Skip to content

engine: MDL reader declares partially defined subscripted variables over the full dimension and fabricates zeros for undefined elements #1059

Description

@bpowers

Problem

Vensim legitimately defines a subscripted variable on a subset of its range and never defines the rest. The MDL reader declares such a variable over the full parent dimension(s) and the compiler fills the uncovered elements with 0. In Vensim those elements do not exist: no zero, no error, and any equation that read them would itself be a Vensim error.

Concrete shapes, all from the checked-in corpus:

  • h[DimA] :EXCEPT: [SubA] = 8 with DimA: A1, A2, A3, SubA: A2, A3 -- only h[A1] is defined (test/sdeverywhere/models/except/except.mdl:20).
  • d[SubA, C1] = 4 -- only d[A2,C1] and d[A3,C1] are defined (except.mdl:16).
  • except3[DimE, DimF, DimG] :EXCEPT: [E2, F2, G2] = 3 -- 7 of 8 elements defined (except.mdl:36).
  • d[A1] = b[B1] and e[B1] = b[B1] -- one element each (test/sdeverywhere/models/subscript/subscript.mdl:12,14).
  • d[D1, DimB, DimC] = GET DIRECT CONSTANTS(...) -- one element of the first axis (test/sdeverywhere/models/directconst/directconst.mdl:31).

Ground truth

The Vensim .dat exports list exactly the defined elements and nothing else:

  • test/sdeverywhere/models/except/except.dat: h[A1] only (line 97; no h[A2], no h[A3]); d[A2,C1] and d[A3,C1] only (lines 29, 31); seven except3[...] rows with no except3[E2,F2,G2] (lines 45-57).
  • test/sdeverywhere/models/subscript/subscript.dat: d[A1] (line 25) and e[B1] (line 28) only.
  • test/sdeverywhere/models/directconst/directconst.dat: six d[D1,...] rows only (lines 21-31).

What Simlin does today

src/simlin-engine/src/mdl/convert/variables.rs (build_variable_with_elements):

  • the LHS subranges are normalized to their parent dimensions before the variable is built (comment at ~line 608: "parent_dims are already normalized to parent dimensions when equations span different subranges"), so d[SubA, C1] is declared over DimA x DimC;
  • :EXCEPT: handling filters the excepted keys out of the element list (~lines 466-489) but keeps the full-dimension declaration, and the has_except_default comment (~line 615-618) states the resulting artifact as intent: "excepted elements should remain at 0 (undefined) rather than receiving the default."

The compiler then materializes a slot for every declared element and leaves the uncovered ones at 0. The corpus comparison never reads those elements (they are absent from the .dat), so nothing fails. The inline fixture in src/simlin-engine/tests/integration/simulate.rs (simulates_except_basic_mdl, ~line 2550-2559) asserts h[a2] == 0 and h[a3] == 0 with the message "h[A2] should be 0 (undefined)" -- that pins a Simlin artifact, not Vensim behavior.

Affected variables in the corpus: except.mdl d, except3, except4, f, g, h, k, o, p, q, r, u, w, x, y; subscript.mdl d, e; directconst.mdl d; plus the inline simulates_except_basic_mdl fixture's h.

Relationship to the B3 diagnostic on branch ltm-fidelity-post

Since the B3 fix on that branch (element-key owner), a declared element with no equation arm and no default raises ErrorCode::MissingElementEquation naming the uncovered elements. Its severity was deliberately kept at Warning (the model still compiles) precisely because of these Vensim corpus models -- the diagnostic is correct for a hand-authored XMILE model that forgot an element, but for an MDL import it is reporting a shape the reader itself fabricated. Closed #905 asked for that diagnostic; this issue is about the reader producing the wrong shape in the first place, which the diagnostic cannot fix.

Why it matters

  • Correctness / fidelity: Simlin invents values (0) for elements Vensim says do not exist. Any consumer that reads the variable's full extent -- SUM(h[DimA!]), a downstream apply-to-all equation over DimA, unit inference, LTM's element graph, the layout's per-element sparklines -- sees a shape and values Vensim never had. This is the silent-wrong-number class, hidden from the corpus comparison only because the comparison never reads the fabricated cells.
  • Diagnostics: every partially defined Vensim import now carries a MissingElementEquation warning that the modeler cannot act on, which teaches users to ignore that warning where it is genuinely useful.
  • Writer round-trip: the writer side (MDL writer EXCEPT round-trip changes behavior for excluded elements #350, engine: MDL writer silently drops Equation::Arrayed default_equation (EXCEPT), losing equations for default-only elements #858) has to reconstruct :EXCEPT: lists from a full-dimension declaration plus an element list; a truthful declared shape makes the faithful re-emit straightforward.

Component(s) affected

  • src/simlin-engine/src/mdl/convert/variables.rs -- build_variable_with_elements (subrange-to-parent normalization, :EXCEPT: key filtering, has_except_default)
  • src/simlin-engine/src/datamodel.rs -- Equation::Arrayed / variable dimensions (needs to be able to declare a variable over a covered subrange, including a synthesized one)
  • Consumers that assume a variable spans its declared dimensions: compiler element materialization, unit inference, LTM element graph, layout/sparklines, MDL writer
  • src/simlin-engine/tests/integration/simulate.rs -- simulates_except_basic_mdl (the h[a2]/h[a3] == 0 assertions encode the artifact)

Proposed fix

The MDL reader (and the datamodel) should declare a partially defined variable over the subrange its arms actually cover:

  1. Compute the covered element set per axis from the LHS arms (explicit elements, subranges, and :EXCEPT: subtractions).
  2. If the covered set per axis is a declared subrange (e.g. SubA for d[SubA, C1]), declare the variable over that subrange.
  3. If it is not a declared subrange (e.g. h covers {A1}; except3 covers a non-rectangular 7 of 8), synthesize a subrange dimension for the covered set (a named subrange of the parent so mapping/aggregation against the parent still resolves), and declare over it. Non-rectangular coverage like except3 needs a decision: either a sparse-element shape in the datamodel, or the smallest rectangular cover plus a per-element existence mask. State the choice and its consumers explicitly.
  4. The uncovered elements then do not exist in Simlin either: the compiler allocates no slot, MissingElementEquation no longer fires for imports, and every consumer sees the true shape.
  5. Update simulates_except_basic_mdl to assert h[a2] / h[a3] are absent rather than 0, and add corpus-level coverage over except, subscript, and directconst that asserts the declared shape matches the .dat row set.

This changes variable shapes across consumers, so it is its own item and not part of the LTM fidelity branch. Cost estimate: medium (reader + datamodel subrange synthesis + tests over the except/subscript/directconst corpus models).

Context

Identified during the LTM fidelity work on branch ltm-fidelity-post (2026-09-08). LTM audit report B (arrays oracle) found the spaced-subscript silent-zero bug that led here; the B3 (element-key owner) commit on that branch references this issue. Nearest existing tracking: #905 (closed; asked for the missing-element diagnostic), #350 / #858 (MDL writer side of :EXCEPT:), #356 (default-fill heuristic), #651 (apply-to-all collapse in the same reader function).

Activity

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

    engineIssues with the rust-based simulation engine

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions