cld-toys › Toys › tiny-interpreter

Commentary: tiny-interpreter

Tokens, a tree, a number. Move one line in the parser and 2-3-4 becomes 3 instead of −5 — but 324 of 432 expressions come out identical, and the same edit is the fix for ^. A study guide for interp.py.

tiny-interpreter/ on GitHub·the source with line numbers, for reading rather than downloading
How to read this This is the only documentation the toy has — read it with interp.py open beside you. interp.py is the toy itself (224 lines, 112 of them executable); demo.py runs the same expression space through two parsers and counts the disagreements; test_interp.py is 13 tests and 54 assertions, pinning every number on this page — including the 432-, 5,184- and 62,208-expression sweeps, against an enumerator written independently of the demo's. Every transcript was captured from a real run on macOS 26.5.2 (Darwin 25.5.0, arm64 Apple Silicon), Python 3.15.0a8 (main, Apr 14 2026, 14:20:41) [Clang 22.1.3], stdlib only.
cd tiny-interpreter
python3 demo.py            # the aha (§6) -- about 3s
python3 demo.py --pipeline # just the one-expression walkthrough
python3 test_interp.py     # 13 tests, 54 assertions, ~3s
Contents
  1. Orientation
  2. The problem this mechanism exists to solve
  3. Background you need
  4. The mental model
  5. Reading the source
  6. The demo, and what it proves
  7. Design decisions and roads not taken
  8. What's simplified vs. the real thing
  9. Check yourself
  10. Further reading

1. Orientation

This toy is the three classic stages, at their smallest: a tokenizer that turns characters into tokens, a recursive-descent parser that turns tokens into a tree, and a tree-walk evaluator that turns the tree into a number.

There is only one parser. That is the thing to hold on to. Parser reads a table of precedence levels and, for each level, a fold direction, so the left-folding and right-folding versions are the same object with a different argument. The tokenizer is shared, the node classes are shared, the evaluator is shared. When two runs disagree, there is exactly one place the disagreement can have come from.

The aha Flip the fold in both arithmetic levels and 2-3-4 evaluates to 3 instead of −5 — but 324 of 432 three-operand expressions come out identical. The bug is real, total, and almost invisible.

By the end you should be able to:

The space, up front, because the whole page rests on it. Every percentage below is over the same enumerated set: a op b op c where a, b, c ∈ {2,3,4} and op ∈ {+, -, *, /} — no parentheses, no prefix minus. That is 33 × 42 = 432 expressions. §6.4 stretches it to four and five operands; §6.6 shrinks the operator set until the bug becomes impossible.


2. The problem this mechanism exists to solve

A tokenizer hands you a flat list. 2 - 3 - 4 is five tokens in a row, and a list has no shape. But arithmetic is a tree: something has to be computed first. Nothing in the token stream says which.

So every language invents two conventions and writes them into its parser:

Both are pure convention. Neither is discoverable from the tokens, and neither is checked by anything downstream — the evaluator will happily walk whichever tree it is handed. Wikipedia's Associative property article puts the convention plainly: subtraction and division are non-associative, so "mathematical convention treats them as left-associative by default", x − y − z = (x − y) − z.

The competing goals that make more than one design defensible:


3. Background you need

ConceptWhere it's used in the toyOne link
Tokenizer / lexertokenize, lines 38–61 — one pass, no lookahead, no regexWikipedia: Lexical analysis
Recursive descentParser.binary, lines 152–166 — one function call per precedence level, and the call stack is the grammarWikipedia: Recursive descent parser
Operator precedencethe index into LEVELS, line 111. Level 0 is loosest; tighter walks one row downWikipedia: Order of operations
Associativitythe assoc tuple, lines 113–115, consumed at lines 155–164. This is the whole pageWikipedia: Operator associativity
Precedence climbingthe shape of binary(level): the same algorithm as the classic one, with the level passed down instead of a minimum-precedence integerEli Bendersky: Parsing expressions by precedence climbing
Abstract syntax treeNum / Neg / Bin, lines 66–104. Three node kinds, and Bin.__repr__ prints the grouping that is the toy's whole subjectWikipedia: Abstract syntax tree
Tree-walk interpreterevaluate, lines 190–216 — post-order, and notably free of any precedence logicCrafting Interpreters: Evaluating Expressions
Exact rational arithmeticFraction everywhere; §6.10 shows what floats would have done to the boundary claimPython: fractions

The two starred rows carry the result. Recursive descent is why there is a single line to point at — the grammar is literally the call graph, so a grammar property like associativity has to be encoded as a choice of which function to call. Associativity is the property being encoded, and the only reason the bug is subtle is that most operators do not care.


4. The mental model

Three stages, and only the middle one has an opinion:

"2-3-4" | | tokenize no opinion: a flat list, in source order v num:2 - num:3 - num:4 eof | | Parser.binary ALL the opinion lives here | +-------------------------+ | | fold LEFT fold RIGHT | | - - / \ / \ - 4 2 - / \ / \ 2 3 3 4 | | | evaluate no opinion: post-order, bottom-up v v -5 3

The fold itself, in one picture. A chain a - b - c - d arrives as four operands and three operators. The parser must bracket them, and there are only two ways to do it consistently:

LEFT (((a - b) - c) - d) the loop keeps the tree in a variable and accumulates leftward re-wraps it: node = Bin(op, node, rhs) RIGHT (a - (b - (c - d))) the function re-enters itself and lets the accumulates rightward recursion build the right spine

Which one you get is decided by what you put on the right-hand side of that one assignment: one operand (and the loop folds), or the whole rest of the chain (and the recursion folds).


5. Reading the source

5.1 The tokenizer, and the two shortcuts that keep the parser clean

interp.py · lines 38–61
def tokenize(source):
    """`2-3-4` -> [('num', 2), ('-', '-'), ('num', 3), ('-', '-'), ('num', 4), ('eof', None)]

    Symbols carry themselves as their kind, so the parser can test a token
    directly against an entry in `LEVELS` without a second lookup table. The
    trailing `eof` makes `peek` total: no bounds check anywhere in the parser.
    """
    tokens, i = [], 0
    while i < len(source):
        char = source[i]
        if char.isspace():
            i += 1
        elif char.isdigit():
            start = i
            while i < len(source) and source[i].isdigit():
                i += 1
            tokens.append(("num", int(source[start:i])))
        elif char in SYMBOLS:
            tokens.append((char, char))
            i += 1
        else:
            raise SyntaxError(f"stray {char!r} at offset {i}")
    tokens.append(("eof", None))
    return tokens

The docstring names the two shortcuts; what it does not say is what each one buys downstream.

A symbol is its own kind. ("-", "-") rather than ("op", "-"). That means self.peek() in LEVELS[level] is a direct membership test against the precedence table, with no second lookup mapping token kinds to operator strings. The precedence table becomes the only place operators are named.

The eof sentinel. Without it every peek needs a bounds check, and a bounds check in a recursive-descent parser is the kind of thing that gets forgotten in one branch out of five. With it, peek is total: there is always a token, it is just sometimes the one that matches nothing.

The tokenizer has no opinion about grouping. Feed it 2-3-4 and it hands back the same five tokens no matter what the parser is about to do with them — which is exactly why §6.1's transcript can print one token line above two different trees.

5.2 The precedence table: precedence is an array index

interp.py · lines 111–115
LEVELS = (("+", "-"), ("*", "/"), ("^",))

CORRECT = ("left", "left", "right")   # what every real language does
FLIPPED = ("right", "right", "right")  # the bug, in both arithmetic levels
ADD_FLIPPED = ("right", "left", "right")  # the bug in `expr()` only

Loosest first, so the index is the precedence: binary(0) parses a whole expression, binary(2) parses only a power chain. This is the entire precedence mechanism. Nothing else in the file mentions precedence at all.

Putting assoc beside it as a parallel tuple is the load-bearing design decision of the toy. Associativity is usually spelled as a shape — which function you recurse into — which makes it invisible, unnameable and untestable. Hoisting it into data makes it a value you can pass, print, and sweep over. CORRECT and FLIPPED are the two arguments the demo runs; ADD_FLIPPED is §6.3's third.

Note CORRECT is not ("left", "left", "left"). Level 2 asks for a right fold on purpose — §6.9.

5.3 THE LINE

interp.py · lines 144–166
    def tighter(self, level):
        """Whatever binds one notch tighter than `level`. Below the last infix
        level there is no next table row, so control drops out to prefixes.
        """
        if level + 1 < len(LEVELS):
            return self.binary(level + 1)
        return self.unary()

    def binary(self, level):
        node = self.tighter(level)
        while self.peek() in LEVELS[level]:
            op = self.take()[0]
            if self.assoc[level] == "right":
                # Re-enter the SAME level: this call consumes the entire rest
                # of the chain, so the `while` never gets a second turn and
                # the tree leans right.
                rhs = self.binary(level)
            else:
                # One operand only. The loop does the folding, and each turn
                # buries the tree built so far one node deeper on the left.
                rhs = self.tighter(level)
            node = Bin(op, node, rhs)
        return node

self.tighter(level) against self.binary(level). One method name.

The reason this is worth a whole toy is that neither form looks wrong. rhs = self.binary(level) is not a typo and not a slip; it is the standard, documented way to implement a right-associative operator. Crockford's Top Down Operator Precedence does it by calling expression(bp - 1) instead of expression(bp); precedence climbing does it by not adding one to min_precedence before recursing. Same idea, three spellings. Someone adding ^ to this parser has to write self.binary(level) somewhere, and the mistake is applying it one row too far up the table.

Two things follow, and both were checked by running them rather than by reading.

The while is dead code under a right fold. If the recursive call has already eaten the rest of the chain, the loop condition is false when it returns. Replacing while with if therefore costs the right fold nothing — and breaks the left fold in half the space (verify_cf.py, CF2):

under CORRECT (left fold): 216 of 432 stop parsing, 0 change value the 8 operator pairs that still parse: *+ *- +* +/ -* -/ /+ /- (exactly the pairs that never repeat a level) under FLIPPED (right fold): 0 of 432 differ

216 = the 8 mixed-precedence pairs × 27 operand triples. The other 8 pairs put two operators at the same level, one if consumes one of them, and parse finds a trailing token. Note the left fold never quietly changes a value — it raises. The loop is not an optimisation; it is where left-folding lives.

The loop is where the left spine is built. node = Bin(op, node, rhs) re-wraps the accumulated tree as the left child of the new node, so each turn buries everything built so far one level deeper. The right fold never executes that line more than once per call.

5.4 The second precedence trap, which is not this one

interp.py · lines 168–174
    def unary(self):
        if self.peek() == "-":
            self.take()
            # Not `self.unary()`: prefix minus binds LOOSER than `^`, so
            # `-2^2` is -(2^2) = -4, matching Python's `-2**2`.
            return Neg(self.binary(len(LEVELS) - 1))
        return self.atom()

Prefix minus sits below the last infix level, so -2*3 is (-2) * 3. But it must bind looser than ^, because -2^2 means -(2^2). Python's language reference states the rule directly — "The power operator binds more tightly than unary operators on its left; it binds less tightly than unary operators on its right" — and gives the same example: -1**2 results in -1.

Write the obvious Neg(self.unary()) instead and the toy disagrees with Python on two of three test expressions (verify_cf.py, CF4):

-2^2 shipped -4 variant 4 python -4 -2^3 shipped -8 variant -8 python -8 -3^2 shipped -9 variant 9 python -9 over the 432-expression space the two agree 432 of 432: the space has no '^' and no prefix minus, so this second precedence trap is invisible to the headline sweep entirely.
The instrument has a blind spot The headline sweep contains no ^ and no prefix minus, so a second, equally real grouping bug scores 432 of 432 against it. A number like "324 of 432 agree" is only ever a statement about the space you enumerated.

JavaScript's designers refused to pick a side here at all — MDN notes that a unary operator "immediately before the base number" is a SyntaxError, so -2 ** 2 does not compile and you must write -(2 ** 2) or (-2) ** 2.

5.5 The evaluator, and what is conspicuously absent

interp.py · lines 190–216
def evaluate(node):
    """Post-order walk. Note what is NOT here: no precedence, no
    associativity, no operator table. By the time a tree exists, every
    grouping question has already been answered by the parser -- which is why
    a one-line parser change can move an answer without the evaluator
    changing at all.
    """
    if isinstance(node, Num):
        return node.value
    if isinstance(node, Neg):
        return -evaluate(node.operand)
    left, right = evaluate(node.left), evaluate(node.right)
    if node.op == "+":
        return left + right
    if node.op == "-":
        return left - right
    if node.op == "*":
        return left * right
    if node.op == "/":
        if right == 0:
            raise ZeroDivisionError("division by zero")
        return left / right
    if node.op == "^":
        if right.denominator != 1:
            raise ValueError("fractional exponent would leave the rationals")
        return left ** right
    raise ValueError(f"unknown operator {node.op!r}")

The docstring makes the claim; the shape of the code is the evidence. evaluate dispatches on node.op — a string it reads off the tree — and never once asks what that operator's precedence or associativity is, because there is nowhere left to put the question. Grouping is not data the evaluator consults. It is the tree, and the tree already happened.

That asymmetry is the reason the bug is so quiet. An evaluator can be exhaustively unit-tested — every operator, division by zero, the fractional-exponent guard — and pass all of it while the parser hands it the wrong tree. The tests are not weak; they are aimed at the wrong stage.

This is why the toy can make its claim cleanly. test_evaluator_is_not_where_grouping_lives hands the same function two trees built from the same token list and gets two different numbers. Nothing was configured, patched or monkeyed with; the parser simply handed over a different shape.


6. The demo, and what it proves

python3 demo.py, whole output reproduced across the subsections below.

6.1 One input, two parsers, two answers

==================================================================== 1. ONE INPUT, TWO PARSERS, TWO ANSWERS ==================================================================== source '2-3-4' tokens num:2 - num:3 - num:4 eof assoc = CORRECT (left, left, right) tree ((2 - 3) - 4) value -5 shape - |-- - | |-- 2 | `-- 3 `-- 4 assoc = FLIPPED (right, right, right) tree (2 - (3 - 4)) value 3 shape - |-- 2 `-- - |-- 3 `-- 4 The tokens are identical. The evaluator is identical. One line in Parser.binary chose -5 or 3.

The arithmetic: (2 − 3) − 4 = −1 − 4 = −5. 2 − (3 − 4) = 2 − (−1) = 3. The gap is 8, which is 2 × 4 — flipping the fold flips the sign of the last operand's contribution, so the answer moves by twice it. That is the general shape of the error for a three-term subtraction chain, and it is why the witnesses in §6.2 come in ± pairs.

6.2 The headline, derived

==================================================================== 2. HOW MUCH OF THE SPACE NOTICES ==================================================================== space: a op b op c, a,b,c in {2,3,4}, op in {+,-,*,/} no parentheses, no prefix minus 3^3 * 4^2 = 432 expressions FLIPPED both arithmetic levels agree 324 / 432 ( 75.00%) differ 108 ADD_FLIPPED the additive level only agree 378 / 432 ( 87.50%) differ 54 ==================================================================== 3. WHERE THE 108 LIVE ==================================================================== operator pairs that can tell the two parsers apart: -+ 27 of 27 operand triples -- 27 of 27 operand triples /* 27 of 27 operand triples // 27 of 27 operand triples total 108 and every other pair of the 16 differs in 0 of 27 the first six witnesses, in enumeration order: 2-2+2 correct 2 flipped -2 2-2-2 correct -2 flipped 2 2/2*2 correct 2 flipped 1/2 2/2/2 correct 1/2 flipped 2 2-2+3 correct 3 flipped -3 2-2-3 correct -3 flipped 3

Where 432 comes from. Three operand slots from a 3-element set and two operator slots from a 4-element set: 33 × 42 = 27 × 16 = 432.

Where 108 comes from — and it is four numbers, not one. Go through the 16 operator pairs by hand. Write the left fold as (a ⊕ b) ⊗ c and the right fold as a ⊕ (b ⊗ c).

Mixed precedence additive and multiplicative, or the reverse. There is only one operator at each level, so binary never loops at either level and both folds build the identical tree. 8 pairs, 0 differences, by construction rather than by arithmetic.

Same level — the remaining 8 pairs. Take the additive four and multiply out both sides:

pairleft foldright foldequal?
++(a+b)+ca+(b+c) = a+b+calways
+-(a+b)−ca+(b−c) = a+b−calways
-+(a−b)+c = a−b+ca−(b+c) = a−b−ciff c = 0
--(a−b)−c = a−b−ca−(b−c) = a−b+ciff c = 0

The pattern: the first operator decides. If is +, the right fold's inner result is simply added, and the brackets do nothing. If is , the right fold negates the whole inner result, which flips the sign of c's contribution — so the two answers differ by 2c. The multiplicative four are the identical argument with × for + and ÷ for , and "differ by 2c" becomes "differ by a factor of c2".

So the differing pairs are exactly -+, --, /*, // — first operator non-associative, both operators at the same level — which is precisely the list the demo prints. 4 pairs × 27 operand triples = 108, and 432 − 108 = 324 agree.

The "iff c = 0" is a real condition, not a hedge. The operand set {2,3,4} has no zero, which is why all four pairs score 27 of 27. Admit 0 and they drop (verify_cf.py, CF6):

ops {+,-} only, operands {0,2,3,4}, 3 operands: op pair differ of third operand nonzero in ++ 0 64 0 (n/a) +- 0 64 0 (n/a) -+ 48 64 48 (all) -- 48 64 48 (all)

48 of 64 = 3/4, and 64 = 43. The missing 16 are the 4 × 4 combinations with c = 0. Every differing case has a nonzero third operand, as the derivation requires.

6.3 The other shape, and the number this page used to carry

ADD_FLIPPED flips only the additive level and scores 378 of 432 (87.50%) — 54 differences, which is 2 pairs (-+, --) × 27. The multiplicative pairs /* and // are folded correctly, so they vanish from the culprit list.

This matters because it is the number a hand-written parser produces. In the textbook expr / term / factor shape, the fold direction is not a parameter; it is a call, written out once per function. Someone who right-folds expr() has changed one function and left term() correct — so the same conceptual bug is half as visible in the hand-written shape as in the table-driven one.

The two shapes are otherwise behaviourally identical. verify_cf.py's CF1 runs a hand-written expr/term/power/unary/atom parser against the shipped table-driven one over both folds:

checked 11244 (expression, fold) pairs mismatches 0 the same edit in the hand-written shape touches expr() only: flipped in expr() and term() 324 of 432 agree flipped in expr() only 378 of 432 agree

11,244 = the 11,232 flat arithmetic cases (432 + 5,184 expressions, times two folds) plus 12 involving ^. Zero mismatches: the table is a refactor, not a different algorithm.

If you saw an earlier number An earlier one-line summary of this toy quoted 378 of 432. That is the ADD_FLIPPED figure above. The headline moved to 324 of 432 when the toy shipped with the fold as an explicit per-level parameter, which flips both arithmetic levels at once. Both numbers are real and both are printed by demo.py.

6.4 The bug gets louder with length

==================================================================== 4. THE BUG GETS LOUDER WITH LENGTH ==================================================================== operands expressions FLIPPED agree ADD_FLIPPED agree 3 432 324 75.00% 378 87.50% 4 5184 2646 51.04% 3753 72.40% 5 62208 20802 33.44% 36171 58.15%

5,184 = 34 × 43 = 81 × 64. 62,208 = 35 × 44 = 243 × 256.

The agreement rate is not a constant, and this is the practically useful half of the result. Each extra operator is another chance for two same-level operators to land next to each other with a non-associative one first. At three operands the flipped parser is right 75% of the time; at five it is right a third of the time. A bug's invisibility is a property of your inputs, not of the bug. If your test expressions are short — and hand-written test expressions are always short — you are sampling from the most forgiving end of the distribution.

6.5 Would your test suite have caught it

==================================================================== 5. WOULD YOUR TEST SUITE HAVE CAUGHT IT ==================================================================== 18 tests of the kind a person writes for a calculator: 1+2 correct 3 flipped 3 passes 2*3 correct 6 flipped 6 passes 10-4 correct 6 flipped 6 passes 8/2 correct 4 flipped 4 passes 1+2*3 correct 7 flipped 7 passes (1+2)*3 correct 9 flipped 9 passes 2*3+4 correct 10 flipped 10 passes 4+2*3 correct 10 flipped 10 passes 10-2-3 correct 5 flipped 11 CAUGHT 100/10/2 correct 5 flipped 20 CAUGHT 2*(3+4) correct 14 flipped 14 passes 1+2+3+4 correct 10 flipped 10 passes -5+3 correct -2 flipped -2 passes 6/3*2 correct 4 flipped 1 CAUGHT (2+3)*(4-1) correct 15 flipped 15 passes 7 correct 7 flipped 7 passes 2*3*4 correct 24 flipped 24 passes 1+2-3+4 correct 4 flipped -4 CAUGHT 4 of 18 fail under the flipped parser; 14 are blind to it.

Four of eighteen. Look at what the fourteen have in common: they are testing precedence (1+2*3, 2*3+4, 4+2*3), parentheses ((1+2)*3, 2*(3+4), (2+3)*(4-1)), each operator once (1+2, 2*3, 10-4, 8/2), or associative chains (1+2+3+4, 2*3*4). Every one of those is a test someone writes deliberately. None of them can see this bug.

The four that catch it are the accidents: two - in a row, two / in a row, a / followed by a *, a - in the middle of a + chain. Nobody writes 1+2-3+4 as a test of associativity; they write it as "a longer sum".

Contrast the neighbouring knob. Swap the two precedence rows and re-run the same 432 (verify_cf.py, CF3):

LEVELS = (('+', '-'), ('*', '/'), ('^',)) LEVELS = (('*', '/'), ('+', '-'), ('^',)) expressions whose value moves: 204 of 432 (47.22%) expressions that now divide by zero: 9 total no longer behaving: 213 of 432 (49.31%) associativity flip, same space: 108 of 432 (25.00%)

Precedence: half the space, plus nine crashes that were not crashes before. Associativity: a quarter of the space, silently. The first is caught by 1+2*3, which is the second test anyone writes. The second is caught by nothing on the list above except by luck.

6.6 Boundary — where the bug cannot exist

==================================================================== 6. WHERE THE BUG CANNOT EXIST ==================================================================== restrict the operators, re-run the same sweep: operators operands expressions agree {+ } 3 27 27 100.00% {+ } 6 729 729 100.00% {* } 3 27 27 100.00% {* } 6 729 729 100.00% {+,*} 3 108 108 100.00% {+,*} 6 23328 23328 100.00% {- } 3 27 0 0.00% {- } 6 729 0 0.00% {/ } 3 27 0 0.00% {/ } 6 729 0 0.00% {-,/} 3 108 54 50.00% {-,/} 4 648 81 12.50%

This is the row to take away. Restrict the operators to + and * and the flipped parser is correct on all 23,328 six-operand expressions — 36 × 25 = 729 × 32. Not "almost all". All, at every length, forever. + and * are associative, so both trees compute the same number and the parser's opinion is unobservable. If every operator in your language is associative, this entire toy is about nothing, and that is the honest boundary: a language of +, *, min, max, and, or, string concatenation and set union has no associativity bugs to find.

The mirror row: {-} and {/} alone agree on zero expressions at any length, because every adjacent pair is same-level and non-associative-first.

The 12.5% row is worth a second look. {-,/} at four operands is 648 = 34 × 23, and 81 = 34 — exactly one of the eight operator sequences survives. CF8 asks which:

op sequence agree of --- 0 81 --/ 0 81 -/- 0 81 -// 0 81 /-- 0 81 /-/ 81 81 //- 0 81 /// 0 81

Only /-/. Note that -/- — equally "alternating" — does not survive: its two - are both at the additive level and both consumed by one call to binary(0), which folds. In /-/ the lone - splits the two / into separate multiplicative subexpressions, so no single call ever sees two of its own operators, and no fold ever happens.

6.7 Boundary — one pair of parentheses ends the argument

and one pair of parentheses ends the argument: 2-3-4 correct -5 flipped 3 DIFFER (2-3)-4 correct -5 flipped -5 same 2-(3-4) correct 3 flipped 3 same

Explicit grouping is the one thing the fold cannot override, because atom re-enters at binary(0) and returns a finished subtree that no enclosing level can restructure. This is the practical defence, and it is why the convention survives in the wild: people who are unsure parenthesise, and their code is then immune to the whole class.

6.8 Boundary — prefix minus rides along, it starts nothing

prefix minus rides along; it starts nothing: -2-3 correct -5 flipped -5 same ((-2) - 3) -2*3/4 correct -3/2 flipped -3/2 same (((-2) * 3) / 4) 2*-3*-4 correct 24 flipped 24 same ((2 * (-3)) * (-4)) -2-3-4 correct -9 flipped -1 DIFFER (((-2) - 3) - 4) -2/3/4 correct -1/6 flipped -8/3 DIFFER (((-2) / 3) / 4) 2-3--4 correct 3 flipped -5 DIFFER ((2 - 3) - (-4))

-2-3 has one binary operator, so there is nothing to fold and no disagreement is possible. -2*3/4 has two, but at the same level with * first — associative-first, so it agrees, exactly as §6.2's table predicts. The three that differ differ because of their binary chain (- -, / /, - -), not because of the prefix.

2-3--4 is the nicest of the six: correct −5 and flipped 3 are the two values from 2-3-4 in §6.1, swapped. (2−3)−(−4) = −1+4 = 3 and 2−(3−(−4)) = 2−7 = −5.

6.9 The same edit, as the fix

==================================================================== 7. THE SAME EDIT IS THE FIX FOR '^' ==================================================================== LEVELS = (('+', '-'), ('*', '/'), ('^',)) a right fold on level 2 is not a bug; it is what '^' means. expression fold-left fold-right python 2^3^2 64 512 512 2^2^3 64 256 256 4^3^2 4096 262144 262144 Python agrees with the right fold. Same line, opposite verdict: associativity is a per-operator fact, and the parser is where that fact is written down.

This is the point the rest of the page exists to set up.

rhs = self.binary(level) on level 0 is the bug that produced §6.1's wrong −5→3. The identical expression, on level 2, is what makes ^ correct. (23)2 = 82 = 64; 2(32) = 29 = 512. Python prints 512 for 2 ** 3 ** 2; so does JavaScript, whose MDN reference spells out the same three lines this transcript does — 2 ** 3 ** 2 is 512, (2 ** 3) ** 2 is 64.

The sentence to keep There is no such thing as "the correct fold". There is only a per-operator convention, and a parser is the document where that convention is recorded. A bug of this class is not a logic error — it is a transcription error against a spec that lives outside the program. Which is why it survives testing: the code is self-consistent, and only the world disagrees.

Wikipedia's Operator associativity article notes there is "no general agreement" on exponentiation's associativity across languages, and §5.4's neighbouring question is contested too. On the machine these transcripts came from, bash -c 'echo $(( -2 ** 2 ))' prints 4; Python's -2 ** 2 is −4; JavaScript refuses to read it at all. Three languages, three answers, one expression — which is exactly what makes it a convention rather than a fact.

6.10 Boundary of the boundary — + is associative in the rationals

§6.6's headline row says the bug is impossible when the operators are + and *. That claim depends on the toy using Fraction. In floating point it is false (verify_cf.py, CF5):

a=1e+16 b=1.0 c=1.0 (a+b)+c = 1e+16 a+(b+c) = 1.0000000000000002e+16 a=1e+16 b=1.0 c=0.1 (a+b)+c = 1e+16 a+(b+c) = 1.0000000000000002e+16 a=1e+16 b=1.0 c=0.2 (a+b)+c = 1e+16 a+(b+c) = 1.0000000000000002e+16 IEEE 754 doubles, 6 values, all 216 triples: 48 differ (22.22%) the same test in Fraction: 0 differ

Six values, 63 = 216 triples, 48 of them non-associative under +. Wikipedia states it plainly: "addition and multiplication of floating point numbers are not associative, as different rounding errors may be introduced when dissimilar-sized values are joined in a different order."

So the toy's exactness is not fastidiousness — it is what makes the 100.00%-agree row mean something. Had the evaluator used floats, that row would have been measuring the FPU's rounding behaviour on a particular operand set, and "the bug cannot exist here" would have been false in a way that had nothing to do with parsers. It is also why compilers do not reassociate floating-point arithmetic unless you pass a flag that says you do not mind.


7. Design decisions and roads not taken

7.1 A table of levels, not one function per level

The textbook shape is expr() calls term() calls factor(). It reads like BNF and it is what most tutorials teach. It lost here for one reason: it cannot express the fold direction as data. In that shape, right-folding expr means editing expr, so a demo that runs both parsers would need two copies of the parser — and then the reader is entitled to wonder what else differs between the copies. The whole argument rests on there being one parser.

The cost of the table is that the mistake is worse when it happens: one edit hits every level, which is why the headline is 324 and not 378 (§6.3). The cost of the hand-written shape is that the bug is quieter. Neither shape is safer; they fail differently. And they are otherwise indistinguishable — 11,244 checked pairs, zero mismatches.

7.2 Precedence climbing, not Pratt

Pratt parsing / TDOP attaches a binding power and a parse method to each token, which is strictly more powerful: it handles prefix, infix and postfix uniformly, and extends to ?:, function calls and indexing without new machinery. It is also the shape where associativity is least visible — buried in bp versus bp - 1 inside a token's handler.

Precedence climbing over an explicit table puts the same decision in a place you can point at, which is the only reason the toy exists. A Pratt version would be a better parser and a worse commentary.

7.3 Shunting-yard, not used

Dijkstra's shunting-yard would produce the same trees with an explicit operator stack and no recursion. It lost because associativity there is spelled as >= versus > in a stack-popping comparison — a genuinely one-character difference, which sounds ideal, but it is a character inside a loop condition whose meaning takes a paragraph to explain. tighter versus binary is a difference a reader can see. (The sibling toy regex-engine uses shunting-yard, where the same >= appears with a comment noting the operators are left-associative.)

7.4 A tree, not evaluate-as-you-parse

binary could return a number instead of a Bin, which would delete the three node classes (lines 66–104) and the evaluator (lines 190–216) — 66 of the file's 224 lines. It would also delete the demo, because the entire aha is that two shapes produce two numbers, and a parser that returns numbers has no shape to show. §6.1's side-by-side trees are the toy.

7.5 Fraction, not float or int

int would make / lossy and 2/3/4 uninteresting. float would make "these two parsers agree" mean "these two parsers agree to within rounding", which is a different and much weaker claim — see §6.10, where floats break +'s associativity outright. Fraction costs speed the toy does not need and buys exact equality, which is the comparison every number on this page depends on.

7.6 Neg as its own node, not Bin("-", Num(0), x)

Desugaring prefix minus to 0 - x would remove a class. It would also make §6.8 unprovable: the claim there is that prefix minus contributes no associativity difference of its own, and if prefix minus were a subtraction node it would be a same-level - and would join the chain. Keeping it distinct is what lets the demo print ((-2) - 3) and show the prefix sitting outside the fold.

7.7 The parser rejects a fractional exponent

4^(1/2) raises rather than returning 2.0, because leaving the rationals would smuggle a float back in and undermine §6.10. This is the toy defending its own measuring instrument.


8. What's simplified vs. the real thing

No variables, no statements, no functions. A real interpreter's parser has a statement level above the expression level, and the tree has declarations, scopes and control flow. Everything on this page is about one expression, so the environment — the thing that makes an interpreter an interpreter — is absent entirely. Add variables and the next question is evaluation order, which is a different convention from associativity and equally invisible: f() - g() - h() fixes the grouping but not which of the three runs first.

No error recovery. The first SyntaxError ends everything. A production parser must report several errors per run, which means it needs to invent a plausible tree for the broken part and keep going — panic-mode recovery, synchronising on statement boundaries. That machinery is usually larger than the parser it protects.

No source positions. Tokens carry no offsets except in the tokenizer's own "stray character" message, so the toy cannot say where a problem is. Every real front end threads spans through every node, because an error message without a location is worthless and because the same spans drive the debugger.

Recursion depth is the real limit, and the fold decides who hits it (verify_cf.py, CF7):

sys.getrecursionlimit() = 1000 operands parse CORRECT eval CORRECT parse FLIPPED eval FLIPPED 100 ok ok ok ok 500 ok ok ok ok 900 ok ok ok ok 1200 ok RecursionError RecursionError -

Read the 1,200 row carefully. Under the left fold the parser is fine — its loop is flat — and the evaluator blows the stack, because the tree is a 1,200-deep left spine. Under the right fold the parser blows up first and there is no tree to evaluate. Same depth, different component, entirely because of the fold. Real parsers cap nesting depth explicitly rather than letting the stack decide: CPython refuses eval("("*2000 + "1" + ")"*2000) with SyntaxError: too many nested parentheses, having accepted the same expression at 200. The alternative is converting the walk to an explicit stack. A 1,200-term expression is not hypothetical: generated SQL IN lists and machine-written config routinely look like this.

One namespace of operators, hard-coded. Real languages let you define operators (Haskell's infixl 6 <+>, Scala's precedence-by-first-character rule), which means precedence and associativity become runtime data the parser must read before it can parse — the exact table this toy hard-codes, built from declarations. That is also where associativity bugs stop being transcription errors and become genuine ambiguities: two libraries can declare incompatible fixities for the same symbol.

Fraction is not what anybody ships. Real evaluators use machine integers and IEEE doubles, and inherit §6.10's non-associativity as a permanent property. This is why -ffast-math is controversial and why compilers will not reassociate floating-point sums by default.


9. Check yourself

Question 1

The toy uses operands {2,3,4} and gets "27 of 27" for each of the four culprit pairs. What would the four counts become with operand set {0,1,2}, and why is the answer not the same for all four?

Answer

Measured (verify_cf.py, CF9): -+ and -- score 18 of 27, /* and // score 4 of 27 with 15 of 27 raising ZeroDivisionError.

The additive half follows straight from §6.2: the condition is c ≠ 0, and 9 of the 27 triples have c = 0, leaving 18.

The multiplicative half is not symmetric with it, for two reasons. The neutral element of × is 1, not 0, so c = 1 also makes the folds agree — (a/b)*1 and a/(b*1). And zero is not merely neutral for division, it is fatal: a/b*c needs b ≠ 0 on the left and b*c ≠ 0 on the right, so the 9 triples with b = 0 plus the 9 with c = 0, less the 3 counted twice, are 15 crashes. Of the 12 survivors, c = 1 kills 6 and a = 0 kills 2 more, leaving 4.

That is the real lesson of the question. "27 of 27" is a property of the operand set — {2,3,4} was chosen with no 0 and no 1 precisely so that every culprit pair scores its maximum and division stays total. A different operand set is a different measuring instrument, and can make the same bug look four times rarer.

Question 2

demo.py reports 8 mixed-precedence operator pairs that never differ. Would that still hold for a four-operand expression like 2+3*4-5?

Answer

No, and this is why §6.4's rate falls. The "mixed pairs never differ" argument is that each level sees at most one of its own operators, so no fold happens. With four operands there are three operator slots, and + * - puts two additive operators (+ and -) into one call to binary(0) — which loops, and therefore folds.

2+3*4-5: left fold (2 + (3*4)) - 5 = 14 - 5 = 9; right fold 2 + ((3*4) - 5) = 2 + 7 = 9. Equal here, because the first additive operator is +. Swap it: 2-3*4+5 gives left (2 - 12) + 5 = -5 and right 2 - (12 + 5) = -15. The §6.2 rule survives intact — the first operator at the level decides — it just now has more opportunities to apply.

Question 3

§5.3 shows that whileif costs the right fold nothing. Does that mean the right-folding parser is simpler? What has it actually traded?

Answer

It has traded iteration for recursion, one-for-one. The left fold's loop runs k times for a k-operator chain and the stack stays flat; the right fold's loop runs once per call but calls itself k times, so the stack goes k deep. Neither does less work.

The consequence is measured in §8's CF7 table: at 1,200 operands the right fold dies in the parser with RecursionError while the left fold parses fine. The left fold then dies in evaluate instead — because a left-leaning tree is a left spine and the post-order walk is itself recursive. So the honest answer is that the recursion is conserved; the fold only chooses which component pays it.

Question 4

You inherit a language whose parser you cannot read. You get to run 20 expressions. How do you find out whether - is left- or right-associative, and how would you look for the same answer about **?

Answer

One expression is enough, not 20: evaluate 2-3-4. −5 means left, 3 means right. §6.2's table is the proof that no other three-operand shape is needed — -+ and -- are the only additive witnesses, and both are single tests.

For ** you cannot use the same trick with a symmetric operand, so avoid 2**2**2 (16 either way). Use 2**3**2: 512 is right-associative, 64 is left. And test -2**2 separately: −4 means unary binds looser (Python), 4 means it binds tighter (bash), a SyntaxError means the language refused to choose (JavaScript). Three expressions, three independent conventions — which is §6.9's point, that these are per-operator facts and not one global policy.

Question 5

Your team ships a parser for a config DSL with the operators +, *, min, max and || (string concatenation). A code review flags "you should have a test for associativity." What is the correct response?

Answer

That no such test can fail. All five operators are associative: min(min(a,b),c) = min(a,min(b,c)), and string concatenation is the free monoid — ("a"||"b")||"c" and "a"||("b"||"c") are the same string. §6.6's 100.00% rows for {+}, {*} and {+,*} at every length up to six operands are the same statement.

The correct response is therefore: write the test anyway if it is cheap, but know that it is a regression guard against a future operator, not a check on today's parser. The moment someone adds -, /, %, ** or a right-associative assignment or arrow (a -> b -> c), it starts being able to fail — and §6.4 says to write that test with at least four operands, because three is where the bug hides best.

The one caveat worth raising in the review: if + here means floating-point addition, associativity is only approximate (§6.10), so an exact-equality test over large and small magnitudes can fail for reasons that have nothing to do with the parser.

Question 6

demo.py prints "324 of 432 agree". Name two ways that number is misleading about how likely you are to ship this bug undetected.

Answer

It is optimistic about the operators. The space gives +, -, *, / equal weight. Real expressions are not uniform: + and * dominate, which pushes the agreement rate up, so 75% understates the bug's invisibility in practice.

It is pessimistic about length. 75% is the three-operand figure — the most forgiving row in §6.4. Realistic expressions are longer, and by five operands the rate is 33.44%.

A third, from §5.4: the space contains no ^ and no prefix minus, so it scores 432 of 432 on a second, real grouping bug it simply cannot see. Every percentage on this page is a statement about an enumerated set, and the enumerated set is stated at the top for exactly that reason.


10. Further reading

Every link below was fetched and confirmed live when this was written.