You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
engine: MDL parser's binary operator precedence is inverted vs Vensim, masked by xmile_compat's precedence-free emission #914
mdl::parser's binary operator precedence table is inverted relative to Vensim. The bug is currently invisible because mdl::xmile_compat formats expressions without precedence-aware parentheses, so the XMILE parser re-establishes the intended grouping when the stored equation text is re-read. The wrong AST never reaches the compiler.
That accident is load-bearing. Any change that makes xmile_compat faithfully parenthesize the MDL AST preserves the wrong grouping and breaks the C-LEARN reference gate (simulates_clearn vs Ref.vdf: measured, 49,239 tolerance violations across target_is_active, sorted_target_active, effective_target_year, net_flux_to_biomass, ...).
Correction (2026-07-09). An earlier revision of this issue said the laundering works because "the XMILE parser's precedence is Vensim's". That was false as a general claim, and the falsehood was hiding a real bug: simlin_engine::parser gave && and || a single shared left-associative level, while XMILE 3.3.1, Vensim, and mdl::parser all bind and tighter than or. So the correct MDL tree Or(1, And(0, 0)) (= 1) flattened to 1 or 0 and 0 and re-parsed as And(Or(1, 0), 0) (= 0) -- the laundering destroyed a correct AST. Fixed in the #912/#913 PR (parser::parse_logical split into an or-level wrapping an and-level; ast::BinaryOp::precedence() gives them distinct levels), pinned end-to-end by mdl::tests::logical_precedence_survives_the_mdl_to_xmile_laundering and by adding test/test-models/tests/logicals/test_logicals.mdl to simulate.rs's run set.
The accurate statement is narrower: XMILE's precedence matches Vensim's for the operators xmile_compat emits, and each such operator is an individual commitment, not a consequence of the two tables agreeing.
The two tables
src/simlin-engine/src/mdl/parser.rs, lowest to highest:
So MDL puts +/-below the comparisons and :AND:above them. Note that the two tables do agree on the relative order of :OR: and :AND: -- the bug fixed above was on the XMILE side.
Repro (verified at 8543a230, branch conveyor-engine)
a = 2 > 1 :AND: 0
b = 1 + 2 > 2
MDL AST: a = Gt(2, And(1, 0)), b = Add(1, Gt(2, 2)).
xmile_compat emits the flat 2 > 1 and 0 and 1 + 2 > 2.
Expr0::new re-parses those as (2 > 1) && 0 and (1 + 2) > 2 -- Vensim's meaning.
simlin simulate therefore prints the CORRECT a = 0, b = 1.
Confirm the AST by construction: parse_add_sub calls parse_logic_or for its right operand, so 1 + 2 > 2 builds Add(1, Gt(2,2)); parse_cmp calls parse_logic_and for its right operand, so 2 > 1 :AND: 0 builds Gt(2, And(1,0)).
The two readings genuinely differ: Gt(2, And(1,0)) is 2 > 0 = 1, while Vensim's (2 > 1) :AND: 0 is 0.
Residual that laundering CANNOT fix
x = :NOT: 1 > 2
Both parsers bind :NOT: / nottighter than a relational operator, so both build Gt(Not(1), 2) = 0 > 2 = 0. Vensim binds :NOT:looser than a relational and yields :NOT: (1 > 2) = 1. Because the two grammars agree with each other and disagree with Vensim, the flat re-emission cannot correct it -- this one needs the parser fix below. (mdl::parser::parse_unary conflates :NOT: with the arithmetic unary -; Vensim places :NOT: between the comparisons and :AND:, which is far lower than arithmetic unary.)
Why it matters
Any consumer of the MDL AST other than xmile_compat sees the wrong tree. Today that includes convert/stocks.rs's is_all_plus_minus stock/flow classification, convert/variables.rs, and anything reading Expr::Op2 structure. A stock whose INTEG argument mixes +/- with a comparison would be mis-classified.
It caps the MDL writer's property tests.mdl::writer_proptest's equation_write_reparse_is_fixpoint re-reads the writer's output with mdl::parser, so its generator (expr0_strategy) must stay restricted to arithmetic -- no comparisons, no logical operators. Widening it fails against our own reader rather than against Vensim. The full-operator-set round trip lives in ast::print_eqn_proptest instead, which re-parses with the XMILE grammar.
It is a booby trap: the module reads as if it round-trips faithfully, and the one thing keeping it correct is the absence of parentheses.
Note :NOT: needs its own level between :AND: and the equality operators -- it is lower than the arithmetic unary -, which the current parse_unary conflates it with. Verify against Vensim before committing to the exact placement.
Then xmile_compat can be given the general precedence-aware parenthesization it should have had, mdl::writer_proptest's generator can be widened to the full operator set, and C-LEARN must still reproduce Ref.vdf.
Guard
The regression guard for the arithmetic half of the class is in place: test/test-models/tests/arithmetics/ and arithmetics_exp/ were added to simulate.rs's run set (they were in no run set before). They pin Vensim's unary / ^ / parenthesization interactions cell-by-cell.
For the logical half, test/test-models/tests/logicals/test_logicals.mdl was added to the run set too -- but it only exercises :AND: / :OR: / :NOT: in isolation, never mixed with each other or with a relational, so it does not cover this issue. A corpus fixture with a Vensim reference for mixed comparison/logical/arithmetic precedence (including :NOT: 1 > 2) should be added alongside any fix here.
Problem
mdl::parser's binary operator precedence table is inverted relative to Vensim. The bug is currently invisible becausemdl::xmile_compatformats expressions without precedence-aware parentheses, so the XMILE parser re-establishes the intended grouping when the stored equation text is re-read. The wrong AST never reaches the compiler.That accident is load-bearing. Any change that makes
xmile_compatfaithfully parenthesize the MDL AST preserves the wrong grouping and breaks the C-LEARN reference gate (simulates_clearnvsRef.vdf: measured, 49,239 tolerance violations acrosstarget_is_active,sorted_target_active,effective_target_year,net_flux_to_biomass, ...).The two tables
src/simlin-engine/src/mdl/parser.rs, lowest to highest:Vensim (and the XMILE spec, and -- as of the #912/#913 PR --
simlin_engine::parser), lowest to highest:So MDL puts
+/-below the comparisons and:AND:above them. Note that the two tables do agree on the relative order of:OR:and:AND:-- the bug fixed above was on the XMILE side.Repro (verified at
8543a230, branchconveyor-engine)a = Gt(2, And(1, 0)),b = Add(1, Gt(2, 2)).xmile_compatemits the flat2 > 1 and 0and1 + 2 > 2.Expr0::newre-parses those as(2 > 1) && 0and(1 + 2) > 2-- Vensim's meaning.simlin simulatetherefore prints the CORRECTa = 0,b = 1.Confirm the AST by construction:
parse_add_subcallsparse_logic_orfor its right operand, so1 + 2 > 2buildsAdd(1, Gt(2,2));parse_cmpcallsparse_logic_andfor its right operand, so2 > 1 :AND: 0buildsGt(2, And(1,0)).The two readings genuinely differ:
Gt(2, And(1,0))is2 > 0=1, while Vensim's(2 > 1) :AND: 0is0.Residual that laundering CANNOT fix
Both parsers bind
:NOT:/nottighter than a relational operator, so both buildGt(Not(1), 2)=0 > 2=0. Vensim binds:NOT:looser than a relational and yields:NOT: (1 > 2)=1. Because the two grammars agree with each other and disagree with Vensim, the flat re-emission cannot correct it -- this one needs the parser fix below. (mdl::parser::parse_unaryconflates:NOT:with the arithmetic unary-; Vensim places:NOT:between the comparisons and:AND:, which is far lower than arithmetic unary.)Why it matters
xmile_compatsees the wrong tree. Today that includesconvert/stocks.rs'sis_all_plus_minusstock/flow classification,convert/variables.rs, and anything readingExpr::Op2structure. A stock whose INTEG argument mixes+/-with a comparison would be mis-classified.xmile_compatcannot be made faithful. The natural hardening -- parenthesize an operand whose precedence the reader would not recover -- is correct and yet regresses C-LEARN, because it preserves the wrong tree. This blocked exactly that hardening while fixing engine: MDL writer's unparseable-equation fallback leaks XMILE text into .mdl, silently changing meaning (INT -> LOOKUP(INT, ...)) #912/engine: three more print_eqn/parser asymmetries (unary!,!=, and right-nested^re-parsing to a different AST) #913; only the^guard (paren_exponent_operand, whose grouping does not depend on the disputed table) could be added. See the "xmile_compat does NOT parenthesize by precedence" note insrc/simlin-engine/src/mdl/CLAUDE.md.mdl::writer_proptest'sequation_write_reparse_is_fixpointre-reads the writer's output withmdl::parser, so its generator (expr0_strategy) must stay restricted to arithmetic -- no comparisons, no logical operators. Widening it fails against our own reader rather than against Vensim. The full-operator-set round trip lives inast::print_eqn_proptestinstead, which re-parses with the XMILE grammar.Suggested fix
Reorder
mdl::parser's levels to Vensim's:Note
:NOT:needs its own level between:AND:and the equality operators -- it is lower than the arithmetic unary-, which the currentparse_unaryconflates it with. Verify against Vensim before committing to the exact placement.Then
xmile_compatcan be given the general precedence-aware parenthesization it should have had,mdl::writer_proptest's generator can be widened to the full operator set, and C-LEARN must still reproduceRef.vdf.Guard
The regression guard for the arithmetic half of the class is in place:
test/test-models/tests/arithmetics/andarithmetics_exp/were added tosimulate.rs's run set (they were in no run set before). They pin Vensim's unary /^/ parenthesization interactions cell-by-cell.For the logical half,
test/test-models/tests/logicals/test_logicals.mdlwas added to the run set too -- but it only exercises:AND:/:OR:/:NOT:in isolation, never mixed with each other or with a relational, so it does not cover this issue. A corpus fixture with a Vensim reference for mixed comparison/logical/arithmetic precedence (including:NOT: 1 > 2) should be added alongside any fix here.Component(s)
src/simlin-engine/src/mdl/parser.rs(parse_add_sub/parse_logic_or/parse_cmp/parse_logic_and/parse_mul_div/parse_unary)src/simlin-engine/src/mdl/xmile_compat.rs(format_binary_ctx)src/simlin-engine/src/mdl/writer_proptest.rs(expr0_strategy, restricted because of this)src/simlin-engine/src/mdl/convert/stocks.rs(an AST consumer)