js-precedence-climbing-associativity
The bug lives in the climb loop's recursion threshold. For a binary operator of power p, the RHS must be parsed at:
+ - * /): minPrec = p + 1 — i.e. recurse only on operators that bind > (strictly tighter). An equal-power operator is not swallowed by the RHS; it's handled by the loop in the current frame, producing ((8-3)-2).**): minPrec = p — i.e. recurse on operators binding >=. An equal-power operator is consumed by the RHS, producing (2 ** (3 ** 2)).The buggy loop recursed with the wrong threshold (a single >=-style rule, i.e. p for every operator / inverted associativity), so - became right-associative and ** became left-associative; and unary minus was given a power above **, so -2**2 became (-2)**2 = 4 instead of -(2**2) = -4.
Correct binding powers per operator:
const PREC = { '+': 10, '-': 10, '*': 20, '/': 20, '**': 40 }; // higher binds tighter
const UNARY_PREC = 30; // looser than ** (40), tighter than * / (20)
const RIGHT_ASSOC = new Set(['**']);
The fixed climb loop (precedence-climbing.js):
expression(minPrec) {
let lhs = this.prefix(); // handles unary minus at UNARY_PREC
while (true) {
const tok = this.peek();
if (!tok || tok.type !== 'op' || !(tok.value in PREC)) break;
const prec = PREC[tok.value];
if (prec < minPrec) break; // climb while prec >= minPrec
this.next();
// THE FIX: left-assoc -> p+1 (strictly greater ">"), right-assoc -> p (">=")
const nextMin = RIGHT_ASSOC.has(tok.value) ? prec : prec + 1;
const rhs = this.expression(nextMin);
lhs = { type: 'binary', op: tok.value, lhs, rhs };
}
return lhs;
}
prefix() {
if (peek() is '-') {
next();
const operand = this.expression(UNARY_PREC); // absorbs **, not * / — correct unary power
return { type: 'unary', op: '-', operand };
}
return this.primary();
}
Wrote `~/precedence-climbing.js` (tokenizer, fixed parser, tree printer, evaluator) and `~/test.js` (a buggy "before" variant plus 90 assertions). Ran `node test.js` → **90 passed, 0 failed**. Before/after for the reported failures: | expression | buggy (before) | fixed (after) | |---|---|---| | `8-3-2` | `(8 - (3 - 2))` = **7** ✗ | `((8 - 3) - 2)` = **3** ✓ | | `2**3**2` | `((2 ** 3) ** 2)` = **64** ✗ | `(2 ** (3 ** 2))` = **512** ✓ | | `-2**2` | `((-2) ** 2)` = **4** ✗ | `(-(2 ** 2))` = **-4** ✓ | | `2**-3**2` | `((2 ** (-3)) ** 2)` = 0.015625 ✗ | `(2 ** (-(3 ** 2)))` = 0.001953125 ✓ | Edge cases tested (tree shape *and* value, plus a JS cross-check — every printed tree is fully parenthesized, so evaluating it with `Function(...)` must agree): - **Left-assoc chains**: `10-4-3-2` → `(((10-4)-3)-2)` = 1; `100/5/2` → 10; `8/4*2` → 4; `5-2-1` → 2; `1-2+3` → 2. - **Right-assoc chains**: `2**2**3` → 256; `2**3**2**2` → `2**(3**(2**2))` = 2⁸¹. - **Mixed precedence**: `2*3+4*5` → 26; `10/2+3*4` → 17; `2**3*4` → 32; `4*2**3` → 32. - **Unary minus**: `2**-3` → 0.125; `-2*3` → `((-2)*3)` = -6; `2*-3` → `(2*(-3))` = -6; `--3` → 3; `--2**2` → 4; `-3-2` → -5; `3--2` → 5; `(-2)**2` → 4 (parentheses still win). - **Malformed input rejected**: `""`, `2+`, `(2`, `2 3`, `2(3)`, `()` all throw.
{"model": "deepseek-v4-flash", "problem_class": "js-precedence-climbing-associativity", "result": "passed", "tests": 90}