RFC-054: RegelRijm, One Unambiguous Dutch Reading of an Action Tree

DraftPartially implemented
Depends on

Context

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:

OperationPitfall for a full reading
ADD, DATE_ADDa plus b is the obvious reading of both
MIN, MAXde kleinste van a en b plus c has two parses without a closing delimiter
MAXmax(0, a−b) and max(a−b, 0) both invite het deel van a boven b
AND, ORunaccented en/of collide with words inside names, and a en b of c has two parses
NOT, EQUALSniet 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
IFa nested IF needs a delimiter, or its anders dangles
FOREACHvoor elke … geldt fits AND only; MIN, MAX, ONLY, OR and no combine are not universal claims
DATE_DIFFhet aantal dagen tussen a en b drops the sign (the result is signed, RFC-021)
ROUNDa 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.

Decision

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.

Reading a rule

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):

[hoogte zorgtoeslag] wordt: afgerond op hele centen ( het deel van ( 2 maal [standaardpremie] als [heeft toeslagpartner] waar is, anders [standaardpremie] ) boven ( ( ( [percentage drempelinkomen partner] als [heeft toeslagpartner] waar is, anders [percentage drempelinkomen alleenstaande] ) maal A ) plus ( ( [in aanmerking genomen toetsingsinkomen] min A ) maal [percentage toetsingsinkomen] als [in aanmerking genomen toetsingsinkomen] meer is dan A, anders € 0,00 ) ) ) waarbij: A = [drempelinkomen aanvrager met toeslagpartner] als [heeft toeslagpartner] waar is, anders [drempelinkomen alleenstaande]

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.
  • Indentation follows the parentheses. It shows the structure; the parentheses carry it, so the text means the same on one line.

What it guarantees

  1. Complete. Every operation in the latest schema (28) has a form, and there is no fallback form.
  2. Unambiguous. Two trees that differ after normalisation (appendix D) never give the same text.
  3. Proven. A test-only parser satisfies parse(render(t)) == normalise(t) for every action in every corpus in the repository and for generated trees.
  4. Deterministic. The same tree gives the same bytes, independent of map order, locale, time zone or runtime.
  5. Corpus-independent. Without a glossary the reading is complete; a name without a term is humanised and marked.
  6. Literals follow the schema. Dates, money, percentages and null each have one fixed form (appendix C).

How it reads

Choices

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.

[u] wordt: 1 als [leeftijd] ten minste 18 is, 2 als [a] een van ( 1; 2 ) is, anders 0

The cases are tried in order, as in the engine. An IF without cases reads geen geval.

Comparisons

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 clauseAfter 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.

Enumerations

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.

[u] wordt: 1 als aan ten minste één van de volgende voorwaarden is voldaan: • [a] • [b] én [c] • aan alle volgende voorwaarden is voldaan: •• [d] •• [e] •• [f] , anders 0

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.

Names and terms

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.

Legend

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.

Why it is unambiguous

Every construct has a level, and an operand whose level is lower than its position allows is parenthesised:

Level, weakest firstConstructs
choice1 als [a], anders 0
logicén, óf, enumerations
notniet
comparisonis, is meer dan, staat in, …
arithmeticplus, min, maal, gedeeld door, 3 dagen na [d], de som van … over elke …
closednames, 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.
  • Arithmetic operators neither: [a] plus ( [b] maal [c] ). With school precedence one tree would have two readings, with and without the redundant parentheses.
  • SUBTRACT and DIVIDE are n-ary left to right, as in the engine: [a] min [b] min [c] is one SUBTRACT, [a] min ( [b] min [c] ) a nested one.
  • Comparisons bind weaker than arithmetic and do not chain: ( [a] is [b] ) is waar.

Every form has a clear end. Nothing dangles.

  • A function form starts with a keyword and has closed arguments, so it ends where its last argument ends: 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] ).
  • A nested choice is always parenthesised: ( 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.
  • A form that can end in an optional part is not closed. A FOREACH that sums may end in waarvoor F, which inside another FOREACH could belong to either. The generated-tree test found this one.
  • A DATE_DIFF unit that is a variable comes last (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.

  • Shorthand is exact: 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 ).
  • Forms that would collide get different words: DATE_ADD reads 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.
  • An AND, OR or arithmetic operation with fewer than two operands has its own form (én ( [a] ), plus ( )), so that AND([a]) does not read as [a].

The grammar is in appendix A.

Layout

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:

  • each operand goes on its own line, one tab deeper, with the connective (én, plus) at the start of the line;
  • the inside of parentheses goes one level deeper and the closing parenthesis returns to the margin of the line that opened it; parentheses inside parentheses always break, one operand per line;
  • each case of a choice goes on its own line, with 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.

Folding

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:

[hoogte zorgtoeslag] wordt: afgerond op hele centen ( het deel van ⟨bedrag uit [heeft toeslagpartner] en [standaardpremie]⟩ boven ⟨bedrag uit [heeft toeslagpartner] en [in aanmerking genomen toetsingsinkomen]⟩ )

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.

In the editor

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).

  • Every rule of the article reads in full, under the name of its output, and follows edits at once. A half-edited tree reads with holes (⟨ontbreekt: value⟩), not an error.
  • A tooltip (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.
  • An operation word opens the action sheet at that part. The sheet does not follow a name while it holds an unsaved edit.
  • A rule shows the fragments of the article text that a linking note (RFC-005) ties to it. Nothing is matched on words.
  • Nothing is conveyed by colour alone: operation words are bold, an untranslated name keeps its brackets, a computed name is a token.

Implementation

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:

  • every operation in the latest schema renders with a form of its own;
  • every action in corpus/regulation and corpus/demo reads back to its tree, with exactly one parse;
  • seeded random trees over all operations, with awkward names, terms that contain keywords and every unit, read back too, and render(a) == render(b) implies normalise(a) == normalise(b);
  • the same tree gives the same bytes, also with shuffled map keys.

Measured on 2026-10-22, without a glossary (npm run regelrijm-report -w packages/frontend-shared -- <dir>):

CorpusActionsReads back exactlyOn one lineNames without a termLongest line
corpus/regulation229229185353158
corpus/demo457457310911159

Why

Benefits

  • A jurist reads the same text wherever an action is shown, and a sign-off on that text refers to one thing.
  • Ambiguity is tested, not argued: the round trip fails the day a new form makes two trees read the same, and a new operation fails the build until it has a form.
  • A corpus kept elsewhere gets the full reading with its own glossary.

Tradeoffs

  • Explicit parentheses make some rules longer than the legal sentence they model, and [x] is waar is longer than x.
  • Delimiters around every name are noise in plain text; the editor drops ‹ › and shows a computed name as a token.
  • A comparison has two word orders, so a search for is meer dan misses meer is dan.
  • Units come from the article, so a fragment rendered without it loses its money format.

Alternatives Considered

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.

Implementation Notes

  • OPERATION_LABELS, describeSubtitle and derivedTitle still give the action sheet its breadcrumb and titles. Replacing them is the next step.
  • Rulework versions on schema v0.5.0 or later read in full. Older versions use removed operations (SWITCH, NOT_EQUALS, NOT_IN, NOT_NULL, CONCAT, SUBTRACT_DATE), which read as an error token.
  • The optional term field is a schema change with its own version; the reading works without it.
  • A definition whose value is a table has no reading yet.

Appendix A: Grammar

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.

rule = name , "wordt:" , expr , [ "waarbij:" , definition , { definition } ] ; definition = letter , "=" , expr ; letter = "A" | "B" | … | "Z" | "AA" | "BB" | … ; expr = choice | logic ; choice = case , { "," , case } , [ "," , "anders" , logic ] | "geen geval" , [ "," , "anders" , logic ] ; case = logic , "als" , logic ; logic = operand , "én" , operand | operand , "óf" , operand | "aan alle volgende voorwaarden is voldaan:" , items | "aan ten minste één van de volgende voorwaarden is voldaan:" , items | operand ; items = bullet , logic , bullet , logic , bullet , logic , { bullet , logic } ; bullet = "•" | "••" | … ; (* the same throughout one list *) operand = "niet" , closed | comparison | arith ; comparison = arith , cmp_op , arith (* main clause *) | arith , "is niet" , arith | arith , "ontbreekt" | arith , "is bekend" | arith , "is een van" , "(" , [ list ] , ")" | arith , "staat in" , closed | arith , arith , "is" (* subordinate clause *) | arith , ( "meer is dan" | "minder is dan" ) , arith | arith , ( "ten minste" | "ten hoogste" ) , arith , "is" | arith , "niet" , arith , "is" | arith , "bekend is" | arith , "een van" , "(" , [ list ] , ")" , "is" | arith , "in" , closed , "staat" ; cmp_op = "is" | "is meer dan" | "is minder dan" | "is ten minste" | "is ten hoogste" ; arith = closed , { "plus" , closed }- | closed , { "min" , closed }- | closed , { "maal" , closed }- | closed , { "gedeeld door" , closed }- | offsets , "na" , closed (* DATE_ADD *) | aggregate | closed ; closed = atom | letter | "(" , expr , ")" | function ; list = expr , { ";" , expr } ; function = nary_word , "(" , [ list ] , ")" (* AND/OR/arithmetic, n < 2 *) | "de kleinste van" , "(" , list , ")" (* MIN *) | "de grootste van" , "(" , list , ")" (* MAX *) | "het deel van" , closed , "boven" , closed (* MAX(0, SUBTRACT(a, b)) *) | "de lijst" , "(" , [ list ] , ")" (* LIST *) | quantor (* FOREACH AND/OR *) | "het aantal volle jaren tussen" , closed , "en" , closed (* AGE *) | "geen termijn na" , closed (* DATE_ADD without offsets *) | day , month_word , "van" , closed (* DATE, literal day and month *) | "de datum met jaar" , closed , "," , "maand" , closed , "en dag" , closed (* DATE *) | "de weekdag van" , closed (* DAY_OF_WEEK *) | "het aantal" , diff_unit , "van" , closed , "tot" , closed (* DATE_DIFF *) | "het verschil van" , closed , "tot" , closed , "in" , name (* DATE_DIFF, `in` a $variable *) | ( "het jaartal" | "het maandnummer" | "het dagnummer" ) , "van" , closed (* DATE_PART *) | ( "de eerste dag van het jaar waarin" | "de eerste dag van de maand waarin" ) , closed , "valt" (* START_OF *) | rounding , "op" , precision , "(" , expr , ")" ; (* ROUND/CEIL/FLOOR *) aggregate = ( "de som van" | "de kleinste waarde van" | "de grootste waarde van" | "de enige waarde van" | "de lijst van" ) , closed , "over elke" , name , "in" , closed , [ filter ] ; (* combine ADD/MIN/MAX/ONLY/none *) quantor = ( "voor elke" | "voor ten minste één" ) , name , "in" , closed , [ filter ] , "geldt" , closed ; (* combine AND/OR *) filter = "waarvoor" , closed ; offsets = offset , { "," , offset } , [ "en" , offset ] ; (* years, months, weeks, days *) offset = closed , ( "jaar" | "jaren" | "maand" | "maanden" | "week" | "weken" | "dag" | "dagen" ) ; diff_unit = "dagen" | "maanden" | "jaren" ; rounding = "afgerond" | "naar boven afgerond" | "naar beneden afgerond" ; precision = "hele eenheden" | "1 decimaal" | digits , "decimalen" | "veelvouden van" , power_of_ten | "hele euro's" | "hele centen" ; nary_word = "én" | "óf" | "plus" | "min" | "maal" | "gedeeld door" ; atom = name | literal ; name = term | untranslated | name , "van" , name ; (* $a.b reads "b van a" *) term = "‹" , text , "›" ; untranslated= "[" , text , "]" ; literal = "waar" | "onwaar" | "geen" | number | money | percent | quantity | date_literal | string ; string = "“" , text , "”" ;

Appendix B: Forms per operation

OperationExample YAMLReading
ADD / SUBTRACTvalues: [$a, $b, 10][a] plus [b] plus 10 / [a] min [b] min 10
MULTIPLY / DIVIDEvalues: [$a, 12][a] maal 12 / [a] gedeeld door 12
MIN / MAXvalues: [$a, $b]de kleinste van ( [a]; [b] ) / de grootste van ( … )
MAX (shorthand)values: [0, {SUBTRACT: [$a, $b]}]het deel van [a] boven [b]
AND / ORconditions: [$a, $b][a] én [b] / [a] óf [b]
NOTvalue: $aniet [a]
EQUALSsubject: $a, value: "X"[a] is “X”
GREATER_THAN … LESS_THAN_OR_EQUALsubject: $a, value: 5[a] is meer dan 5, is minder dan, is ten minste, is ten hoogste
IFcases: [{when: $a, then: 1}], default: 01 als [a], anders 0
INsubject: $a, values: [1, 2] / value: $l[a] is een van ( 1; 2 ) / [a] staat in [l]
LISTitems: [$a, $b]de lijst ( [a]; [b] )
FOREACH ADDcollection: $m, as: x, body: $x.b, combine: ADDde som van [b] van [x] over elke [x] in [m]
FOREACH AND / ORcombine: ANDvoor elke [x] in [m] geldt ( … ) / voor ten minste één [x] …
FOREACH MIN / MAX / nonecombine: MINde kleinste waarde van ( … ) over elke [x] in [m] / de lijst van …
FOREACH ONLYcombine: ONLY, filter: …de enige waarde van [x] over elke [x] in [m] waarvoor …
AGEdate_of_birth: $g, reference_date: $phet aantal volle jaren tussen [g] en [p]
DATE_ADDdate: $d, months: 1, days: 31 maand en 3 dagen na [d]
DATEyear: $j, month: 1, day: 11 januari van [j]
DAY_OF_WEEKdate: $dde weekdag van [d] (0 = maandag)
DATE_DIFFfrom: $a, to: $b, in: dayshet aantal dagen van [a] tot [b]
DATE_PARTdate: $d, in: yearhet jaartal van [d]
START_OFdate: $d, in: monthde eerste dag van de maand waarin [d] valt
ROUND / CEIL / FLOORvalue: $a, precision: 2afgerond op 2 decimalen ( [a] ), naar boven afgerond …, naar beneden afgerond …
ROUND (money)value: $bedrag_cent, precision: -2afgerond op hele euro's ( [bedrag cent] )

Appendix C: Literals and units

ValueReading
true / false / nullwaar / onwaar / geen
string“HUWELIJK”, with ” and \ escaped by \
string that is a valid YYYY-MM-DD1 januari 2025
number without unit1500, 0,5, -3: decimal comma, no thousands separator, no exponent
eurocent / euro€ 1.500,00 (eurocent divided by 100 on the digit string)
percentage / ratio21,5% / 0,21
years … days1 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.

Appendix D: Normalisation

The round trip compares against normalise(t), which only removes differences that mean nothing to the reader and nothing to the engine:

RuleWhy it is allowed
An action’s inline operation moves into valueThe schema defines them as the same thing
Object keys in canonical orderYAML maps are unordered
legal_basis on an operation is droppedProvenance, shown next to the reading
FOREACH without as gets as: itemSchema default
A YAML date object becomes its YYYY-MM-DD stringThe engine reads both the same
2.0 becomes 2The 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 emptyAssociative 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.

References

RegelRecht

An exploration into transparent, executable legislation, and one of the three projects in the starting selection of the Nederlandse Digitale Dienst.

Links

GitHub repository
How it works
Stay informed
Roadmap (Dutch)
Documentation
Research

Contact

regelrecht@minbzk.nl

Part of

Nederlandse Digitale Dienst
Ministry of Economic Affairs and Climate Policy