A jurist who checks a translation reads the law and the YAML side by side. The
YAML can be read by a person, but not comfortably: a rule is spread over
nested keys and operation codes, and in a longer rule it is hard to keep the
overview. The editor shows an action as a tree of operations
(frontend/src/utils/operationTree.ts), labelled with OPERATION_LABELS and a
one-line describeSubtitle. That is a summary by design: past three operands
it says “5 condities”, and nobody reads a whole rule from it.
Writing a full reading is easy to get subtly wrong, because the obvious Dutch for several operations gives two different trees the same text:
| Operation | Pitfall for a full reading |
|---|---|
| ADD, DATE_ADD | a plus b is the obvious reading of both |
| MIN, MAX | de kleinste van a en b plus c has two parses without a closing delimiter |
| MAX | max(0, a−b) and max(a−b, 0) both invite het deel van a boven b |
| AND, OR | unaccented en/of collide with words inside names, and a en b of c has two parses |
| NOT, EQUALS | niet x is also the obvious reading of EQUALS(x, false); reading EQUALS(x, true) as x hides a difference the engine makes when x is null |
| IF | a nested IF needs a delimiter, or its anders dangles |
| FOREACH | voor elke … geldt fits AND only; MIN, MAX, ONLY, OR and no combine are not universal claims |
| DATE_DIFF | het aantal dagen tussen a en b drops the sign (the result is signed, RFC-021) |
| ROUND | a postfix x, afgerond op n decimalen leaves the scope of “afgerond” unclear |
The editor’s tree has gaps of its own. It does not descend into subject,
date_of_birth, reference_date, year/month/day or the DATE_ADD
offsets; it shows ROUND, CEIL and FLOOR as their codes; it reads DATE as
“(leeg)”; humanize makes PERCENTAGE_LID_5 and percentage_lid_5 read the
same; and money goes through Intl.NumberFormat, whose output depends on the
runtime.
There is one reading of an action tree, RegelRijm, implemented once, in the frontend. It is generated from the YAML every time a rule is shown, so it cannot drift from what the engine runs. It is for people who read; it is not an input format. Modellers write YAML.
This is article 2 of the Wet op de zorgtoeslag as the corpus has it
(corpus/regulation/nl/wet/wet_op_de_zorgtoeslag/2025-01-01.yaml):
Read from the top:
[hoogte zorgtoeslag] wordt: names what the rule computes. A name in square
brackets has no term yet and reads as its YAML name with spaces.afgerond op hele centen ( … ) rounds everything inside the parentheses.het deel van X boven Y is what X exceeds Y by, and nothing when it does
not: MAX(0, X − Y).2 maal [standaardpremie] als [heeft toeslagpartner] waar is, anders [standaardpremie] is a choice: the value first, then when it applies.A stands for the threshold income, which occurs three times and is spelled
out once, under waarbij:, as a formula in a law is.parse(render(t)) == normalise(t)
for every action in every corpus in the repository and for generated trees.An IF reads value first: waarde als voorwaarde, waarde als voorwaarde, anders waarde. The reader sees what a rule can give before the conditions, and each
case reads as one line of a table of outcomes.
The cases are tried in order, as in the engine. An IF without cases reads
geen geval.
After als and waarvoor Dutch has a subordinate clause, with the verb at the
end. A comparison there reads that way; everywhere else, including an item of
an enumeration, it keeps main-clause order.
| Main clause | After als / waarvoor |
|---|---|
[a] is waar | [a] waar is |
[a] is meer dan [b] | [a] meer is dan [b] |
[a] is ten minste 18 | [a] ten minste 18 is |
[a] is niet 3 | [a] niet 3 is |
[a] is bekend / [a] ontbreekt | [a] bekend is / [a] ontbreekt |
[a] is een van ( 1; 2 ) | [a] een van ( 1; 2 ) is |
[a] staat in [b] | [a] in [b] staat |
[x] is waar stays as it is and is not shortened to [x]: EQUALS is false
on a null x, while a bare x in a condition is not.
Two conditions read [a] én [b] or [a] óf [b], with accents so they never
merge with a word in a name. Three or more read the way RegelSpraak, the rule
language of the Belastingdienst, lists them: a head, then one bulleted item per
line, with •• for a list inside an item.
The form depends on the number of conditions only, never on the width. The
bullets delimit the items, so an item may itself be an én or a list without
parentheses. Whether all or one must hold is said once, in the head.
A reference reads as its term if one is known (‹de peildatum›), otherwise
as its humanised name ([hoogte zorgtoeslag]). Humanising only replaces
_ with a space, so [PERCENTAGE LID 5] and [percentage lid 5] stay
different. $medebewoner.inkomen reads [inkomen] van [medebewoner].
Terms come from a built-in list for engine context ($referencedate →
‹de peildatum›), from an optional term field on an input, output, parameter
or definition, or from a glossary passed in by the caller, so a corpus kept
elsewhere keeps its words out of this repository. Because every name is
delimited, a term may contain is, en or van. A glossary that gives two
names the same term is refused.
A long part that occurs more than once in a rule reads once, under waarbij:.
Which parts get a letter follows from the tree: a candidate is a text of at
least 40 code points that occurs at least twice; candidates are taken longest
first, while they still occur twice outside parts already taken; letters go in
order of first use, and a definition may name later letters but never earlier
ones. Expanding every letter gives back exactly the reading without a legend,
so the legend cannot make two trees read alike.
Every construct has a level, and an operand whose level is lower than its position allows is parenthesised:
| Level, weakest first | Constructs |
|---|---|
| choice | 1 als [a], anders 0 |
| logic | én, óf, enumerations |
| not | niet |
| comparison | is, is meer dan, staat in, … |
| arithmetic | plus, min, maal, gedeeld door, 3 dagen na [d], de som van … over elke … |
| closed | names, literals, letters, function forms, anything in parentheses |
On top of that, three principles. Each rule under them closes a gap that a looser reading would leave.
No hidden precedence. A reader never has to know a convention.
én and óf have no precedence over each other: [a] én ( [b] óf [c] ).
Legal Dutch “a en b of c” is ambiguous, and readers know it.[a] plus ( [b] maal [c] ). With school
precedence one tree would have two readings, with and without the redundant
parentheses.[a] min [b] min [c] is one SUBTRACT, [a] min ( [b] min [c] ) a nested one.( [a] is [b] ) is waar.Every form has a clear end. Nothing dangles.
het aantal volle jaren tussen [a] en [b] plus 1 is AGE(a, b) + 1.niet takes one closed operand: niet ( [a] is meer dan [b] ).( 1 als [b], anders 2 ) als [a], anders 3. Only a choice that is a whole rule or a whole legend definition
goes without. Layout never decides, so a copy into an e-mail keeps its sense.waarvoor F, which inside another FOREACH could belong to either.
The generated-tree test found this one.het verschil van [a] tot [b] in [eenheid]), because a name followed by van continues the name path.One tree, one text. Two trees never share a reading.
is niet, ontbreekt, is bekend and het deel van … boven … each stand for one tree shape, which never renders in the general
form. MAX(SUBTRACT(a, b), 0) reads de grootste van ( … ; 0 ).3 dagen na [d], because ADD of a value in days reads [d] plus 3 dagen; a literal date
reads 1 januari 2025, the DATE operation 1 januari van 2025.én ( [a] ), plus ( )), so that AND([a]) does not read as [a].The grammar is in appendix A.
Layout does not change the token sequence. A construct that fits in 160 code points stays on one line, counting a tab as four. Otherwise:
én,
plus) at the start of the line;als one level under its
value; after a bulleted condition the comma opens the next line at the margin
of the value, so it does not read as one more item.The reading that is tested and signed off is always the full one. A fold is a
view on it: renderFocus shows a rule to a chosen depth and folds what lies
deeper into a signature, which says what a part gives and what it reads,
not how it computes it:
The kind comes from the operation (a comparison is a voorwaarde) or from the
unit of the part’s position (bedrag, percentage, aantal jaren), else
getal or waarde. The inputs are the free references of the part in reading
order; names the part binds itself and constants of the article are not
inputs. At most three are named and the rest counted (en nog 2). A part whose
reading is no longer than its signature is not folded.
The editor does not fold. The action sheet uses renderFocus without a depth
limit, to read one part.
RegelRijm is at the top of the editor’s Machine pane, as the way into an
article’s actions, behind the feature flag panel.regelrijm (off by default).
⟨ontbreekt: value⟩), not an error.nldd-tooltip) on each name says where its value comes from. A
name that another rule computes is a token (nldd-token) that leads to that
rule.packages/frontend-shared/src/regelrijm/ holds pure TypeScript modules without
Vue or browser dependencies. A rule renders into a token stream (keyword,
term, name, literal, letter, fold, hole, error, each with its
path in the tree); the editor renders the tokens as HTML, tests and reports use
the plain text. No Intl, no locale, no clock: money moves its decimal point
on the digit string. It works on the raw tree, so a half-edited tree still
renders.
renderAction(action, { article, glossary }) gives the full reading,
renderFocus(action, path, { depth }) one part. The parser
(test-support/parse.ts) returns every parse of a text and is used only by
tests, which check:
corpus/regulation and corpus/demo reads back to its tree,
with exactly one parse;render(a) == render(b)
implies normalise(a) == normalise(b);Measured on 2026-10-22, without a glossary
(npm run regelrijm-report -w packages/frontend-shared -- <dir>):
| Corpus | Actions | Reads back exactly | On one line | Names without a term | Longest line |
|---|---|---|---|---|---|
corpus/regulation | 229 | 229 | 185 | 353 | 158 |
corpus/demo | 457 | 457 | 310 | 911 | 159 |
[x] is waar is longer than x.‹ ›
and shows a computed name as a token.is meer dan misses
meer is dan.Condition first: als [a] dan 1 anders als [b] dan 2 anders 0, the form of
a programming language and of the first version of this RFC. It needs dan
and anders als as keywords and makes the reader take in every condition
before learning what the rule gives.
The enumeration of the Aanwijzingen voor de regelgeving: a. …; b. …; en c. …. Citable as “onderdeel c”, but an item that is itself an én would need
parentheses to show where it ends. From RegelSpraak, RegelRijm takes the
bulleted lists only, not its quantifiers ten minste n and precies n, which
have no operation, nor reading an empty value as 0 in a sum, which the engine
does not do (RFC-036).
Extending operationTree.ts: it is a summary by design, descends into only
part of the tree, and its number format depends on the runtime.
Terms without delimiters (de peildatum): injectivity would depend on every
term avoiding every keyword, which a glossary written by jurists will not do.
A Rust crate through the engine’s WASM build: the reading belongs where people read rules and should render at once, also for a half-edited tree, without waiting for the engine. A pipeline step runs the same module under Node.
A writable notation: a second source language with its own error messages. Jurists read; modellers write YAML.
OPERATION_LABELS, describeSubtitle and derivedTitle still give the
action sheet its breadcrumb and titles. Replacing them is the next step.SWITCH, NOT_EQUALS, NOT_IN, NOT_NULL, CONCAT,
SUBTRACT_DATE), which read as an error token.term field is a schema change with its own version; the
reading works without it.The reading is a token sequence; whitespace carries no meaning. { … }- means
one or more. Keywords are matched longest first (geen geval before geen,
, anders before ,, is ten minste before is). The subordinate forms of a
comparison may occur anywhere for the parser; the round trip still demands
exactly one parse per text.
| Operation | Example YAML | Reading |
|---|---|---|
| ADD / SUBTRACT | values: [$a, $b, 10] | [a] plus [b] plus 10 / [a] min [b] min 10 |
| MULTIPLY / DIVIDE | values: [$a, 12] | [a] maal 12 / [a] gedeeld door 12 |
| MIN / MAX | values: [$a, $b] | de kleinste van ( [a]; [b] ) / de grootste van ( … ) |
| MAX (shorthand) | values: [0, {SUBTRACT: [$a, $b]}] | het deel van [a] boven [b] |
| AND / OR | conditions: [$a, $b] | [a] én [b] / [a] óf [b] |
| NOT | value: $a | niet [a] |
| EQUALS | subject: $a, value: "X" | [a] is “X” |
| GREATER_THAN … LESS_THAN_OR_EQUAL | subject: $a, value: 5 | [a] is meer dan 5, is minder dan, is ten minste, is ten hoogste |
| IF | cases: [{when: $a, then: 1}], default: 0 | 1 als [a], anders 0 |
| IN | subject: $a, values: [1, 2] / value: $l | [a] is een van ( 1; 2 ) / [a] staat in [l] |
| LIST | items: [$a, $b] | de lijst ( [a]; [b] ) |
| FOREACH ADD | collection: $m, as: x, body: $x.b, combine: ADD | de som van [b] van [x] over elke [x] in [m] |
| FOREACH AND / OR | combine: AND | voor elke [x] in [m] geldt ( … ) / voor ten minste één [x] … |
| FOREACH MIN / MAX / none | combine: MIN | de kleinste waarde van ( … ) over elke [x] in [m] / de lijst van … |
| FOREACH ONLY | combine: ONLY, filter: … | de enige waarde van [x] over elke [x] in [m] waarvoor … |
| AGE | date_of_birth: $g, reference_date: $p | het aantal volle jaren tussen [g] en [p] |
| DATE_ADD | date: $d, months: 1, days: 3 | 1 maand en 3 dagen na [d] |
| DATE | year: $j, month: 1, day: 1 | 1 januari van [j] |
| DAY_OF_WEEK | date: $d | de weekdag van [d] (0 = maandag) |
| DATE_DIFF | from: $a, to: $b, in: days | het aantal dagen van [a] tot [b] |
| DATE_PART | date: $d, in: year | het jaartal van [d] |
| START_OF | date: $d, in: month | de eerste dag van de maand waarin [d] valt |
| ROUND / CEIL / FLOOR | value: $a, precision: 2 | afgerond op 2 decimalen ( [a] ), naar boven afgerond …, naar beneden afgerond … |
| ROUND (money) | value: $bedrag_cent, precision: -2 | afgerond op hele euro's ( [bedrag cent] ) |
| Value | Reading |
|---|---|
true / false / null | waar / onwaar / geen |
| string | “HUWELIJK”, with ” and \ escaped by \ |
string that is a valid YYYY-MM-DD | 1 januari 2025 |
| number without unit | 1500, 0,5, -3: decimal comma, no thousands separator, no exponent |
eurocent / euro | € 1.500,00 (eurocent divided by 100 on the digit string) |
percentage / ratio | 21,5% / 0,21 |
years … days | 1 jaar, 5 jaren, 3 dagen |
A literal has no unit of its own; it takes the unit of its position, the same
way when rendering and parsing. A comparison operand takes the unit of the
other side; an operand of ADD, SUBTRACT, MIN or MAX, a branch of IF and an item
of LIST take the unit of the first sibling that has one, else that of their
parent; the root takes the unit of the action’s output. Operands of MULTIPLY
and DIVIDE, conditions, offsets and date arguments have none. Declared units
come from type_spec.unit.
The round trip compares against normalise(t), which only removes differences
that mean nothing to the reader and nothing to the engine:
| Rule | Why it is allowed |
|---|---|
An action’s inline operation moves into value | The schema defines them as the same thing |
| Object keys in canonical order | YAML maps are unordered |
legal_basis on an operation is dropped | Provenance, shown next to the reading |
FOREACH without as gets as: item | Schema default |
A YAML date object becomes its YYYY-MM-DD string | The engine reads both the same |
2.0 becomes 2 | The engine’s Value has no integer-valued decimal |
| AND in AND, OR in OR, ADD in ADD, MULTIPLY in MULTIPLY are flattened, unless the inner one is empty | Associative in the engine, also under Kleene logic (RFC-036); an empty inner ADD or MULTIPLY is an engine error and stays visible |
Not normalised, because the engine can tell the difference: operand order,
EQUALS(x, true) versus x, a choice nested in anders versus extra cases
(an unknown condition stops the chain, RFC-036), and SUBTRACT and DIVIDE
nesting.
schema/latest/schema.json, definitions/operationTypefrontend/src/utils/operationTree.ts, frontend/src/utils/outputFormat.tspackages/frontend-shared/src/regelrijm/, frontend/src/regelrijm/• / ••An exploration into transparent, executable legislation, and one of the three projects in the starting selection of the Nederlandse Digitale Dienst.
GitHub repository
How it works
Stay informed
Roadmap (Dutch)
Documentation
Research
Nederlandse Digitale Dienst
Ministry of Economic Affairs and Climate Policy