Skip to content

engine: MDL parser's binary operator precedence is inverted vs Vensim, masked by xmile_compat's precedence-free emission #914

Description

@bpowers

Problem

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:

parse_add_sub   (+ -)         <- level 1, LOWEST
parse_logic_or  (:OR:)        <- level 2
parse_cmp       (= < > <= >= <>)
parse_logic_and (:AND:)
parse_mul_div   (* /)
parse_unary     (:NOT:, unary + -)
parse_power     (^)
parse_atom

Vensim (and the XMILE spec, and -- as of the #912/#913 PR -- simlin_engine::parser), lowest to highest:

:OR:            / ||
:AND:           / &&
= <>            / = <>
< > <= >=
+ -
* /
unary
^

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: / not tighter 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

  1. 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.
  2. xmile_compat cannot 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 in src/simlin-engine/src/mdl/CLAUDE.md.
  3. 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.
  4. 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.

Suggested fix

Reorder mdl::parser's levels to Vensim's:

parse_logic_or -> parse_logic_and -> parse_not -> parse_eq -> parse_cmp -> parse_add_sub -> parse_mul_div -> parse_unary -> parse_power -> parse_atom

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.

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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions