Skip to content

engine: burn down the round-trips plan's deferred issues - #992

Merged
bpowers merged 17 commits into
mainfrom
roundtrips-issue-burndown
Jul 28, 2026
Merged

engine: burn down the round-trips plan's deferred issues#992
bpowers merged 17 commits into
mainfrom
roundtrips-issue-burndown

Conversation

@bpowers

@bpowers bpowers commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Burns down the issues deferred out of the "Deleting the Round Trips" plan (rows A/B/C, PRs #973/#980/#985/#990/#991). Each was filed rather than fixed at the time because folding it in would have swamped the branch it was found on.

Fixes #976
Fixes #972
Fixes #978
Fixes #979
Fixes #983
Fixes #982
Fixes #987
Fixes #981
Fixes #642
Fixes #974
Fixes #986
Fixes #975

What changed, and why it is smaller than the diffstat suggests

The guiding goal was a simpler compiler, so the work is weighted toward deleting mechanisms rather than guarding them:

Silent wrong answers fixed

Four of these produced a number with no diagnostic, which is the worst failure class in this codebase because a degraded link score is indistinguishable from a real one:

Decisions worth reviewing

Disclosed residuals

Review history

Six whole-branch codex-review passes ran after the per-batch work was committed; they found eight P2s that per-batch review could not see, because each needed a whole-tree view. Two were silent wrong link scores in the pinning projection (a map keyed by dimension name where axis identity was needed) and were fixed by extracting the compiler's own implicit-axis allocation so there is no second implementation left to diverge. Four were in the new diagnostic and shared one root -- it re-derived the pipeline between an equation as written and the arms the compiler evaluates, and each pass found a different stage it got wrong; that ended by enumerating the pipeline's six stages and taking five of them from the parsed AST, which deleted four hand-written mirrors. One was an interaction between two batches on this branch (batch 1's deliberate nan quoting residual made a stored equation ambiguous, and the new diagnostic asserted a claim the text cannot support). The last one is worth reading in the commit: the review's stated mechanism was falsified by measurement, the fix it recommended was implemented as a probe and shown to convert a loud zero into a silent wrong row, and the actual defect was a second copy of a spelling rule one line away.

Process changes

Three rules were added to the root CLAUDE.md, each written with the defect that paid for it:

  1. Claims about other tools are facts to check, not premises to reason from. An unverified claim about Vensim survived three rounds of adversarial review — reviewers check code against the stated claim, never the claim against the world — and cost four implementation rounds before the Vensim documentation falsified it outright.
  2. A test that pins one arm of an N-way decision reads exactly like a test that pins the decision. This appeared three times in one batch at three different altitudes.
  3. A test that hand-builds its inputs proves nothing about the inputs production supplies. A fix shipped that could not fire, because both its tests supplied a dependency set the extractor never generates.

bpowers added 17 commits July 27, 2026 12:49
`ast::needs_quoting` is the single "can this canonical name be spelled bare in
an equation" predicate, but it tested only the XID character classes and the
leading-character rule. The lexer resolves a bare word against KEYWORDS before
it ever considers an identifier, so a variable legally named `"if"` -- neither
XMILE nor Vensim reserves anything, and both our readers admit one with zero
diagnostics -- printed BARE and no longer re-parsed as a reference to itself. A
`patch` rename of an unrelated variable reprints every dependent equation
through `print_ident` and persists the result, so an edit that touched neither
the variable nor its reader silently rewrote a valid saved model into an
unparseable one.

That predicate has three consumers, one per producer of equation text, and all
three were reached: `print_ident`, `ltm_augment::quote_ident`, and the MDL
importer's reference formatter. The importer had its own rule that quoted only
on `.`, which left every other unreadable name bare -- `data revenue k$` emitted
a bare `$`, `Marg Capital'Energy per Capital` emitted a bare `'` that our lexer
reads as the TRANSPOSE operator, and seven of the eight keywords imported to an
`UnrecognizedToken` on every reference (all are names in the checked-in corpus).
It now delegates to `needs_quoting` like the other two; the shared
`quoted_space_to_underbar` is untouched, because it spells a definition-side
ident rather than equation text.

`nan` is excluded, and that is a disclosed residual rather than an oversight:
the importer represents Vensim's `A FUNCTION OF(...)` placeholder as the stored
equation text `NAN`, so quoting `nan` would bind that placeholder to any
like-named variable. The writer cannot spell it differently either -- Vensim
documents `A FUNCTION OF` as not intended for use in writing equations, and as
precluding simulation. So a bare `nan` reference naming a declared variable
still reads as the NaN literal: pre-existing, silent, narrower than the seven
this fixes, pinned by a characterization test, and properly fixed by changing
how the importer represents "no equation".

Renaming a variable TO a name containing `"` is refused at the same front door,
because it is the same class arriving through the same entry point.
`Lexer::quoted_identifier` ends at the first `"` and the equation grammar has no
escape, so such a name can be written neither bare nor quoted -- yet both
readers will define one from `<aux name="a&quot;b">`. The rename used to return
Ok and persist reprinted dependents that no longer compile. The check is on the
target name only, so renaming AWAY from such a name still works and remains how
an affected model is repaired; it reuses `ErrorCode::UnclosedQuotedIdent`, the
code recompiling would have produced, and an `x$y` control pins that the
rejection stays narrow.

The new proptest states the completeness property against the LEXER rather than
a second copy of the character rules, which is the check both past holes here --
leading digit, keyword -- would have failed.
The doc on `Ident<State>` said its derived `salsa::Update` delegates to
`CanonicalStorage`'s manual impl. It could not: salsa 0.26's derive adds an
`Update` bound to every type parameter, and the `Canonical`/`Raw` markers had no
impl, so `Ident<Canonical>: salsa::Update` was unsatisfiable and a container's
per-field dispatch resolved the field through salsa's `'static + PartialEq`
fallback instead. That fallback is value-correct here -- `Ident`'s derived
`PartialEq` bottoms out in the same interned comparison -- so this was never a
wrong answer, only a comment describing a path that did not exist.

The markers now derive `Update` themselves. On a field-less struct the derive
generates the "nothing can differ, report no change" body, structurally
identical to salsa's own `PhantomData<T>` impl, so the unsafe trait's obligation
is discharged vacuously and no `unsafe` is hand-written. Scope, stated so nobody
over-reads it: salsa calls a field's `maybe_update` only for tracked STRUCTS,
while a tracked FUNCTION's memo backdates through `values_equal`/`PartialEq` and
an input setter overwrites outright -- and this crate declares no tracked
structs, only tracked functions. So this changes which impl the compiler selects
and changes no runtime behavior at all today; it is a correctness-of-
documentation fix plus forward-compatibility for the first tracked struct.

A static assertion instantiates the bound at both marker states so the doc claim
is a build failure if it stops holding, and a test names
`UpdateDispatch::<Ident<Canonical>>::maybe_update` with `UpdateFallback`
deliberately out of scope, so it can only resolve to the inherent
`Update`-backed method -- direct evidence of which impl runs rather than an
argument that it runs.
Each of these cost real rework on the branch that added them, so they are
written as rules rather than as advice.

"Claims About Other Tools" is the expensive one. A premise about what Vensim
does -- asserted, never checked -- carried a design decision through three
rounds of adversarial review, and every round found genuine defects in the code
built on top of it while none questioned the premise. That is the shape of the
failure: review checks code against the stated claim, not the claim against the
world, so a wrong premise about an external system survives exactly the process
meant to catch it. The section names the primary sources, including the XMILE
spec already checked in at docs/reference/xmile-v1.0.html, which settles a
subscript-precedence question the same branch nearly answered by reasoning from
our own compiler instead.

The test rule generalizes a mistake that recurred three times in one change at
three different altitudes: a test covering one arm of a two-arm gate, then one
position of a two-position decision, then one of six call sites. Each read as
though it pinned the decision, and each left the untested arm free to compute a
wrong number. Deriving the rows from an enumeration -- the variant list, the
call-site list -- makes "no gaps" checkable instead of assumed.

The scope signal is the cheapest of the three to apply: when a second attempt at
the same discovered issue produces a new defect, the understanding behind it has
now failed twice, and further attempts are designed against it. Splitting at
that point would have ended the episode above four rounds earlier.
LTM occurrence identity is a `SiteId`: a path of u16 child indices. Three of
the walk's push AXES take a count the AST node's own shape does not bound --
an Ast::Arrayed target's equation slots, one builtin call's contents (Mean is
the only variadic BuiltinFn), and one subscript's index list, which Expr2
lowering does NOT narrow to the subscripted variable's declared arity. Past
65,536 children, two references share an identity and the ceteris-paribus wrap
holds or freezes the wrong one, emitting a plausible, wrong link score. Only
the builtin axis was handled, by a reserved u16::MAX sentinel, a suppression
depth counter with a deliberately asymmetric two-view contract, and a checked
sentinel-mapping conversion in the consumer -- machinery that also left
module_output_ref_in_document_order unable to distinguish a width-suppressed
module-output reference from "the target reads no output of this module".

Replace all of it with one check run per target equation before the walk
pushes a single path component. A rejected equation records no occurrence at
all, so no recorded SiteId can be a path two children share, and
model_ltm_variables refuses the whole model with a Warning naming the
variable. The refusal is model-level rather than per-edge because the trigger
is a property of a target equation and because consumers read a MISSING
per-edge entry as a real Bare classification -- so a per-site drop would
misclassify rather than decline. The name-keyed, path-free per-edge view stays
complete, so loop detection is unaffected: a refusal costs the scores, not the
structure. One ordering consequence, benign and deliberate: the refusal sits
after the stateless early return, so a stateless model that is also over-wide
returns no variables and no width warning -- it emits no scores either way, so
there is nothing to warn about.

It is a separate pre-pass rather than a counter carried on the walk because
its descent has to be a SUPERSET of the walk's. The walk pushes a path
component for a LOOKUP table argument and then does not recurse, while the
ceteris-paribus wrap and pin_only_source_refs both descend through it with
child_path -- so a builtin nested under a table argument is reachable by
consumer path indices and by no producer path at all. Subscript indices are
the same shape of problem with a different skip predicate on each side.

The check's arms are derived from the walk's own push sites and its Expr2
match is exhaustive with no catch-all, so a new variable-width variant is a
compile error rather than an unchecked axis. Tests cover all eleven descent
edges, the accept side of the boundary on each axis, and that a refusal leaves
model_edge_shapes and model_detected_loops untouched; they use a #[cfg(test)]
limit override and tiny fixtures. The 16 LTM char goldens and 12 fragment
goldens are byte-identical, since the deleted machinery only activated past
65,535.
emit_source_to_agg_link_scores and source_to_agg_hop_polarity recovered the
reducer kind, name, body and polarity by printing an AggNode's reducer
subexpression, re-parsing it, re-lowering it against a freshly built scope and
re-classifying the result -- a closed round trip through our own printer and
parser, run once per (agg, source) pair and once per agg-touching loop link.
Each failed silently in its own way: a failed reconstruction made the emitter
skip the edge's link scores entirely, zeroing the synthetic agg's loop score,
and made the polarity hop return Unknown, degrading every loop through the agg
to Undetermined. AggNode now carries the BuiltinFn<Expr2> the enumerator
classified when it decided the hoist, and reconstruct_ltm_var_lowered is
deleted along with its last helper; nothing on the emission path parses. The
equation-shape reconstruction both call sites documented as a hazard (an
arrayed agg had to be rebuilt as an ApplyToAll over result_dims) is removed
rather than moved: there is no shape to rebuild.

Carrying an Expr2 puts an f64 inside a salsa-cached, PartialEq-compared value,
which has three consequences and the stored form only addresses two. Stripping
Loc is load-bearing: raw, an edit that only moves an equation's byte offsets
stopped enumerate_agg_nodes backdating, which a test now pins. Stripping
ArrayBounds is a guard, inert today because the ASTs this query walks are
lowered against an empty model scope and carry no bounds at all. The f64 itself
cannot be normalized away: a derived PartialEq is not reflexive on NaN, so a
model whose hoisted reducer holds a nan literal never backdates this query --
an incrementality regression this commit introduces, disclosed on the field, in
CLAUDE.md, and in a characterization test asserting today's broken behaviour.
That is the GH #987/#981 class, fixed at the root by making AST float literals
compare by bit pattern, and that fix must invert the test rather than delete it.
The same f64 is why AggNode loses Eq and its unconditional Debug.

The carried builtin is read only for synthetic aggs; the variable-backed arm's
copy is unread today and is kept so AggNode has one shape.

The same change settles GH #982, which read reducer_collapses_to_scalar and
builtin_routes_through_agg as one question answered twice, inverted on SIZE and
RANK. They are two questions: the first is about the reducer's result type (does
the subtree fit in a scalar slot -- SIZE does, RANK does not) and gates the
freeze/capture paths; the second is about LTM routing (did enumerate_agg_nodes
mint a node -- SIZE is Constant and never is, RANK gets an array-valued agg) and
sets OccurrenceSite::in_reducer. Both already derive from the single
reducer_kind_from_name table, so there was no second table to collapse; what was
missing was the argument and a test. Mutating each set in each direction showed
three of the four cells already pinned and one not: the SIZE membership of the
GH #779 decline, whose inertness rests on the freezability gate reading the same
predicate -- move one and it becomes reachable with nothing noticing. A shared
cfg(test) decision table now covers every arm of reducer_kind_from_name against
all three derived predicates, including the agreement gate's own name-keyed
restatement of the routing rule, whose drift would have quietly weakened that
gate rather than failing it. RANK is deliberately not flipped: that would turn a
scored bare source into a loud decline, a user-visible change with no evidence
behind it.
Expr0..Expr3's `Const` held a bare `f64`, so any value containing a NaN
literal was not equal to ITSELF. Salsa decides whether to backdate a
re-executed query's memo by comparing it with its own rebuild -- the
`salsa::Update` derive walks fields and falls back to `PartialEq` for a
field type with no `Update` impl -- so such a memo never backdated and the
whole downstream query cone re-ran on every revision bump. Every stdlib
SMOOTH/DELAY/TREND template declares `initial_value = NAN` and `db::sync`
splices those into every project, so this needed no user action; a user
model with a `nan` upstream of a cached stage paid it per keystroke on the
interactive diagnostics path.

The literal is now an `ast::Literal`, a Copy newtype comparing and hashing
`f64::to_bits`. It has no `salsa::Update` impl on purpose: the derive's
dispatch resolves to salsa's `PartialEq` fallback, so equality is defined
in exactly one place and the crate gains no new `unsafe`. What defends
that is `#![deny(unsafe_code)]`, which stops an `Update` impl being added
at all, plus `-D warnings` on the now-unused `UpdateFallback` import if
someone opts out -- an `Update` impl would otherwise take over silently
through the inherent dispatch candidate. The four AST enums now also
derive `Eq`, which is what keeps the fix structural: a future
float-bearing variant cannot be a bare `f64`, because `Eq` rejects one at
compile time, transitively through intermediate types.

Bit comparison costs this layer nothing, because both distinctions it
draws are unreachable in an AST literal rather than accepted. There is one
`nan` spelling at the language surface yielding one canonical `f64::NAN`,
so no payload is authorable; and the lexer takes no leading sign, so `-0`
is a negation of the literal `0` and never a `Const`. Both premises are
tripwire tests, and the only production code comparing two ASTs
structurally (`mdl::writer`) reads the value out and compares with its own
tolerance.

`float.rs` gains the reason that is the right posture rather than a
shortcut, next to the `:NA:` sentinel it contrasts with: a NaN in a system
dynamics model is a diagnostic, not a value. What a practitioner sees is a
line on a graph that stops, and their next task is tracing it backward
through the dependency graph, because NaN is absorbing and every variable
downstream shows the same symptom. So attributing a NaN to its origin is
worth a lot, a NaN the engine manufactures is noise in a channel debugged
by hand, and nothing needs machinery treating NaN as a structured value.
`ast::Literal` carries the caching half: bit comparison wherever a float
feeds a cache key, and the types still outside it -- the compiled bytecode
pools and `variable::Table`'s lookup points -- keep IEEE comparison as an
accepted cost of not converting them, with #642's false "only consumer is
non-tracked" premise corrected there so it is not re-litigated.

Two tests that had to work around the defect now do not: `db::stages_tests`'
stdlib oracle uses the representative `smth1` rather than `npv`, and its
whole-project oracle covers all twelve synced models instead of the three
user ones. `ltm_agg`'s characterization test of the broken behaviour is
inverted rather than deleted.
Three silent-wrong-answer defects in the LTM per-element machinery, each of
which produced a degraded link score indistinguishable from a real one.

GH #974 -- a per-element link-score partial pinned every arrayed dep of the target with
the target's FULL element tuple, which is only right when the dep declares
exactly the target's dimensions in the target's order. A dep declaring a strict
subset got an over-arity subscript whose fragment failed to compile, so the
score read a constant 0 behind four Assembly warnings; a dep declaring the same
dimensions in another order got a subscript that COMPILED and read the
transposed element, with no diagnostic at all. A bare reference to the live
source had the mirror defect: it projected by dimension name only, so a
positionally-mapped target found no coordinate, left the reference bare, and
froze it into a multi-slot PREVIOUS that cannot compile in a scalar fragment.

Both arms now go through one projection, which asks the existing single row
derivation which element each of the DEP's own axes reads -- so an identity axis
and a positionally-mapped one are one arm, and the rule accepts exactly the
mapped pairs the occurrence-driven pin does. A dep whose every axis resolves may
subscript a bare reference; a dep with an unresolved axis still gets the
dimension-name indices of an already-subscripted reference substituted (that
only needs the axes the reference spells) but keeps its bare references bare,
which fails loudly rather than reading a wrong element. The pin table also
replaces deriving the dimension names by parsing the separator out of our own
printed element tuple.
-----
The ceteris-paribus wrap held a LOOKUP's table argument verbatim, which is right
for the table HEAD -- a graphical-function table has no value slot, so wrapping
it cannot compile and zeroes the whole fragment -- but wrong for its subscript
INDEX expressions, which codegen evaluates at the current step to select the
element. Every partial of such a target therefore varied with the index's own
movement, attributing it to whichever source the partial isolates.

The indices now go through the wrap's own index pass, which already carries the
element and dimension-name guards, so only genuine value reads change spelling.
That pass freezes an ident only when it is in the wrap's dep set, and the
dependency extractor never walks a table expression -- so an index variable
referenced ONLY inside a table argument is not a dependency and would have been
left live. The descent therefore widens its own dep set with the idents under
these indices, scoped to that argument, with the element and dimension-name
guards running first so a selector still cannot wrap. Leaving the extractor
itself alone is deliberate: an index dropped from a variable's dependencies is a
runlist-ordering question rather than an LTM one.

Because the freeze now covers any index ident, and an enclosing freeze covers the
descents the arm does not reach, the per-element path's separate REFUSAL of a
runtime table index is deleted along with its frozen plumbing, one verdict
variant and the static-index predicate it needed -- a refusal that also threw
away the scoreable sites on the same edge. The descent uses an empty occurrence
lookup because the IR records nothing under a table argument by design, so the
desync guard's premise does not hold there. Widening the dep set also made an
existing gap reachable: an already-qualified dim-element index had no standalone
guard on the paths that suppress qualification, so it froze; the guard now sits
beside the element and dimension-name ones.

Note for anyone re-reading this later: a table argument carrying a runtime index
does not compile on this engine at all -- the target's own fragment fails with
DoesNotExist first -- so the wrong score this prevents is not reachable today.
Five constructions were tried and all five fail that way, which is why the
model-level fixture asserts generated text rather than a score series, and
asserts that upstream refusal alongside it.
-----
The LTM per-axis classifier asked whether a subscript index named one of the
target's iterated dimensions BEFORE asking whether the axis declares it as an
element, the opposite of what the compiler does. XMILE lets a dimension declare
an element whose name is also a dimension name, and there the orders disagree:
without a mapping the classifier declined and the reference fell to the
conservative cross-product, but WITH one it described the axis as iterated over a
dimension the compiler never iterates there, so read slices and element edges
named rows the simulation does not read.

The precedence moves into one shared function that both the classifier and the
per-element pin rule read, so a colliding name cannot be resolved two ways; the
pin rule's rustdoc no longer documents the divergence because there is none.
Element-first is what the engine executes, and the binding evidence is a run
rather than the spec: a new oracle test compiles the collision and asserts the VM
reads the element, then asserts the reference-site IR describes that same read.
The spec points the same way but settles only the adjacent variable-vs-element
pair outright, and the shared function's rustdoc says which passages are a rule
and which are an argument from the namespace rule.

ltm_agg.rs crossed the per-file line cap, so its test module moves to a
#[path]-mounted sibling, matching ltm_augment's four.
Two independent defects in how the LTM ceteris-paribus wrap treats a
subscript index, both silent wrong numbers. GH #977's collapse was
implemented alongside these and withdrawn; see the PR body and the issue.

-- GH #975: seed a frozen subscript index with the un-lagged index --

The ceteris-paribus wrap emitted a UNARY PREVIOUS around a frozen dynamic
subscript index, which the parser desugars to PREVIOUS(idx, 0). The XMILE spec
(section 3.5.6) makes that second parameter the value returned in the first DT,
and zero is a sound default for a value but is out of range for every 1-based
subscript -- so at t=0 the synthesized capture helper read past the end of its
source and computed NaN, and the outer PREVIOUS served that NaN as the score's
first live step. A NaN the engine manufactures is indistinguishable on a graph
from the modeller's own division by zero, which is what makes this a defect
rather than a wart.

An index-position freeze now names its own un-lagged operand as its first-DT
initial value: at the first DT "the index one step ago" IS the current index,
and since it is the index the target's own equation reads at that step, the
freeze is never out of range where the model's own read is not. Value positions
keep the unary spelling, because 0 is a valid VALUE -- not because it is
unobservable; a value-position freeze inside a capture helper is read at step 1
by exactly this route. Only the two walkers that descend into subscript indices
need it; the other two document that they never do. Note the issue's own
diagnosis was wrong on two counts, both checked rather than assumed: the capture
helper already participates in the initials phase, and PREVIOUS does not read a
prev_values snapshot seeded from initials -- it returns its declared initial
value, exactly as the spec says.

The head-lag pin gets a strictly stronger discriminator and its own fixture.
The NaN used to be the only evidence the lag existed at all; now the guard
checks the helper is finite at t=0 AND reads the LAGGED element at every step,
against the model's own simulated series. That needs two Age rows that actually
differ, which the shared fixture does not have.

-- GH #986's third consumer: resolve against the axis it indexes --

The ceteris-paribus wrap decided whether a bare-identifier subscript index was a
static element selector or a runtime read with two PROJECT-WIDE predicates --
dimension_uniquely_containing_element and is_element_of_any_dimension, which
range over every dimension in the project. The compiler does not:
compiler::subscript::normalize_subscripts3 resolves such an index against the
indexed variable's OWN axis, which is what dimensions::resolve_axis_index_name
implements and what GH #986 unified two other LTM consumers onto. This was the
third consumer and it was missed.

The disagreement is a wrong number, not a missed optimization. A model variable
whose canonical name happens to be an element of an UNRELATED dimension -- a
Scenario = [base, high, low] beside a variable named base -- is a runtime read
to the simulation and a static selector to the wrap, so the partial leaves it
LIVE and moves with it: a link score reporting real influence for an edge with
no causal dependence at all. The qualification step then rewrites the index to
an otherdim-qualified form, naming an element of a dimension the indexed
variable is not declared over, which still compiles and reads a different slot
than the PREVIOUS(target) anchor did. Declaring a dimension that NO equation
references is enough to change a score, with no diagnostic in either mode.

THE UNIFICATION IS PARTIAL AND THIS DOES NOT PRETEND OTHERWISE. Resolving an
index needs the declared Dimension at that index's position, and the wrap takes
it from IteratedDimCtx::dep_dims -- the dep-to-declared-dimensions table the db
layer already threads for the GH #526 other-dep correspondence check, so this
adds a consumer rather than a second table built by a second route. But dep_dims
is a table of the TARGET'S ARRAY DEPENDENCIES, not of declared dimensions, and
reading "absent from dep_dims" as "no axis exists" is the convenient reading
rather than the sound one. Two production paths therefore still take the
project-wide fallbacks and still reproduce the defect, and both are named at
their call sites rather than left to be rediscovered: every LOOKUP table index,
because a graphical-function holder is by construction absent from dep_dims
(classify_dependencies records it in referenced_tables and keeps it off the
dependency graph, GH #606), so the axis lookup can never resolve there -- it is
passed a literal None, because a call that could only ever return None reads as
coverage for a path that is not fixed; and generate_per_element_link_equation,
which threads dep_dims: None on a comment that was true when other_dep_verdict
was that table's only consumer. GH #984 stays open on the first, with its
reproduction on the issue. Closing either needs the wrap to get its dimension
truth from where the compiler gets it, which is a decision about a function
family that deliberately carries none, and so is its own change.

What IS fixed is pinned on the emitted text AND on the simulated series, over
the arrayed / scalar / A2A / stock-flow emitters, with a control proving the fix
does not degenerate into freezing every bare index. The test says in its own
rustdoc which path it covers and which it does not, because an earlier revision
of it claimed a LOOKUP fixture it never had -- and that false claim is how the
LOOKUP gap shipped unnoticed in the first place.

One older defect at this site is disclosed rather than folded in: an index
frozen inside an already-frozen head is DOUBLE-lagged, reading the head at t-1
indexed at t-2 where the anchor indexed at t-1, so such a partial is not fully
ceteris-paribus even once every name resolves. That behaviour is pinned but was
never adjudicated -- the pin that freezes it was written to catch a blanket skip
of the whole index pass and uses the lag only as its discriminator, while this
function's own GH #759 comment calls reading an index two steps back
"semantically wrong for a genuinely-dynamic index". It is deferred because it is
a semantics question that interacts with GH #975's head-lag pin, not because the
current answer is settled.
Batch 4 shipped a fix that could not fire: both its tests supplied a
dependency set the extractor never generates, so they passed on an input
that does not occur and the defect survived untouched. The rule sits beside
the N-way-decision rule because they are the same failure wearing different
clothes -- a test that reads as coverage without being coverage.
A Vensim model built with the Sketch tool carries `A FUNCTION OF(...)` for
every variable the modeller has drawn but not yet written a formula for. Per
Vensim's documentation -- the quote already recorded in keyword_ident_tests.rs
-- that construct "precludes simulation", and Vensim refuses to run the model;
our MDL importer stores it as the equation text `NAN`, which we compile,
simulate, and hand back as NaN. So we returned garbage where the source tool
declined to answer at all, and the cost lands on the practitioner: per
src/float.rs a NaN is absorbing, so what they see is a line on a graph that
stops -- identically for every variable downstream of the origin -- and their
next task is a hand search backward through the dependency graph. This is the
case where the engine knows the origin structurally before the model runs, so
a warning naming the variable replaces that whole search.

Emitted engine-side rather than in the MDL reader: open_vensim has no warnings
channel, while db::diagnostic's accumulator already reaches the CLI, MCP, the
FFI and pysimlin -- and it covers a hand-authored XMILE <eqn>NAN</eqn>, which
is the same claim however the variable got that way. Warning, not Error, so
FormattedErrors::push cannot raise the failure flags a passing CLI run or
get_errors check depends on.

Two shapes are deliberately NOT reported, because for them the warning would
assert something the simulation contradicts. A module input port's own
equation is dead once a caller binds the port -- the compiler substitutes the
binding, so the port is not NaN and neither is anything downstream --
recognized by equation_is_a_module_input_fallback. That predicate needs two
clauses and the second is not redundant: can_be_module_input covers XMILE
access="input", while our five SMOOTH/DELAY/TREND templates declare
initial_value = NAN yet carry Compat::default(), because they detect binding
with the isModuleInput() builtin instead. Since db::sync splices those
templates into every project, dropping either clause misreports. Likewise an
EXCEPT default is read only when has_except_default marks it live, since the
MDL converter keeps a dead default as round-trip metadata and the compiler
never evaluates one. The accepted cost is an under-report: an input port that
no caller binds falls back to its own equation, and a NaN one now goes
unwarned.

The atom is decided by lexing rather than by comparing against the string
"nan", so the literal's spelling stays in lexer::KEYWORDS alone -- which also
makes a quoted reference to a variable named `nan` an ordinary equation rather
than a missing one. An arrayed variable with only some unfilled arms is
reported too, and named per arm: its elements are separate series, so an
unfilled x[b] stops x[b]'s line exactly the way an unfilled scalar stops its
own. It stays at most one finding per variable. Four checked-in Vensim sketch
fixtures newly warn, 94 findings in total; each file's count equals its
A FUNCTION OF count exactly.
Two corrections to the UnfilledEquation advisory added in b283d2b, both
found by review, both cases where the warning stated something the
simulation contradicts.

First, the verdict turned on (which arms are unfilled, what the EXCEPT
default says) when it also needs to know whether the arms COVER the
variable's dimension. An armless slot's value is not something the arms'
text can answer, and both directions were wrong on models that run today.
A NaN default that no slot can reach was reported: with arms a=1,b=2 over
D=[a,b] and default NAN, expand_arrayed_with_hoisting never consults the
default and the compiled model holds 1 and 2 with no NaN anywhere. And a
sparse array whose listed arms were all NaN was reported as WHOLLY
unfilled, though its omitted slots compile to a finite 0. unfilled_arms
now takes a variable::ArmCoverage; db::diagnostic computes it, since
resolving dimension names against the project is a database read and the
classifier stays pure. The membership test is the compiler's own
whole-string element lookup rather than the sibling advisory's union
rule, because that lookup IS the fact being asked about. The two arrayed
advisories now share one dimension resolver instead of two copies.

Second, "NaN is absorbing, so every variable downstream is NaN too" is
false, and b283d2b asserted it in the emitted message, in the ErrorCode
doc, in src/float.rs's module documentation and in its own commit body.
NaN is absorbing through ARITHMETIC; every IEEE comparison against a NaN
is false, so `IF x > 0 THEN 1 ELSE 0` reading a NaN x returns a finite 0
and everything below it is finite. That is not a footnote -- it is
exactly what bites during the backward search for a NaN's origin that
float.rs argues is the real cost of a NaN, so the module doc now states
the two consequences a modeller needs: a finite variable does not clear
its inputs, and a NaN can vanish partway down a chain instead of reaching
the output that would have revealed it. The message now scopes the spread
to arithmetic.

The decision table gains coverage as a third axis (2 + 2 + [4 x 4 x 2 -
4 impossible] = 32 cells). Its fixtures are now generated from the axis
states and checked to land in the cell they are filed under, while the
verdicts stay authored as flat literals -- a grouped expectation would be
a second copy of the rule under test. Worth recording why neither the
original 20-cell product nor the review's 9-cell analysis caught this:
both enumerated the axes the code branched on, and the code branched on
two because the third was never passed in. The corpus warning count is
unchanged at 94 across 4 files.
The per-element pin projection decided which target axis supplied each axis of a
bare arrayed dependency by searching the target's dimension NAMES, and a name is
not an axis identity. Two shapes came out wrong, both compiling and both reading
a row the simulation does not: a target that repeats a dimension collapsed to one
map entry, so `target[D,D] = driver * w` over a `w[D]` pinned the second axis
where the simulation reads the first; and two dependency axes that could each map
to either target axis both claimed the first, because an independent per-axis
search tracks no used set. Neither had a diagnostic.

The compiler already resolves exactly this reference, so its allocation moves
into `dimensions::allocate_implicit_axes_partial` and both callers read it --
positional, and one-to-one because each active axis is consumed once. The
compiler takes the total projection of that answer and errors without it; LTM
takes the partial one, because a subscripted reference spells some of its own
axes. The explicit over-arity bail and the equal-arity reordering fast path are
deleted rather than moved: the first is pigeonhole and the second can only fire
where the general first pass agrees.

What could not be shared is declined instead. The other per-element row
derivations are asked their question as a dimension name -- that is what an
AxisRead::Iterated carries -- so a repeated dimension is not expressible in their
interface, and making it so means changing the reference-shape IR. That edge now
gets a Warning and no score, which is the trade this area has taken every time a
plausible wrong number was the alternative. Three checked-in models declare a
repeated dimension, so this is a shape users write.

Also fixes a test helper that built element indices 0-based where production is
1-based; it went unnoticed until the projection started calling get_offset on a
fixture dimension and read the wrong mapped partner.
Third and last correction to the UnfilledEquation advisory, and the same
root as the second: the gap between the arms AS WRITTEN and the arms that
REACH A SLOT. ae71ed0 handled an arm the compiler never selects because
the other arms already cover the dimension; this handles one it never
selects because the subscript names nothing. Given arms a=1, b=2 and
typo=NAN over D=[a,b], expand_arrayed_with_hoisting drops the typo'd arm
and both slots compile finite, yet the advisory claimed typo simulates as
NaN -- on top of the UnknownElementSubscript warning that correctly
reported the subscript itself.

Both facts come from one place. arm_coverage already built the declared
combination keys and the arm keys to answer "does every slot have an
arm"; the effective-arm test is that same lookup read the other way, so
ArmCoverage now carries the effective set alongside the coverage bit and
unfilled_arms classifies over the arms the compiler will select. No
second scan and no second membership rule -- the rule stays
expand_arrayed_with_hoisting's own element lookup. The
UnknownElementSubscript advisory is untouched: a subscript naming nothing
is still worth reporting, and what was wrong was the second warning
claiming a value that no slot has.

Ineffective arms are deliberately NOT a fifth decision-table axis. They
are not a state the verdict depends on but a thing that must not matter,
so the table stays a 32-cell product and one invariant test asserts that
appending an ignored arm -- NaN or filled -- changes no verdict in any
cell. That is a stronger claim than the 24 rows a fifth axis would have
added, and a smaller one.

The corpus count is unchanged at 94 findings across 4 files: exactly one
checked-in model has unknown-subscript arms at all, and none of them is a
NaN literal, so the defect was reachable by construction rather than by
any fixture. ArmCoverage's Debug derive is feature-gated like
CanonicalElementName's -- an unconditional one compiles under the test
profile and breaks the release build pysimlin uses.
Two review findings on the UnfilledEquation advisory, one a defect class
and one an interactive-path cost.

The class first. Three findings on this diagnostic were the same
mistake: an arm shadowed because the others already cover the dimension,
an arm whose subscript names nothing, and an arm a later duplicate
overrides. Each was patched as a case. They are one question -- which arm
does the compiler SELECT for a slot -- so the classifier now takes an
ArmSelection and db::diagnostic::arm_selection answers it by performing
expand_arrayed_with_hoisting's own walk: build the arm map, then for each
declared element combination look it up and fall to the EXCEPT default
only on a miss. Arms are identified by index rather than canonical key,
which is what makes the duplicate case fall out instead of needing
handling: a=NAN and A=1 share a key, so a key-based set marks the key
selected and leaves both source entries to be classified, reporting the
shadowed arm though the compiled slot holds 1. Note parse_equation's map
alone is not the answer -- it is canonical-keyed and last-wins but keys
unknown subscripts too, so the drop of those happens only in the
declared-slot walk.

The cost second. emit_unfilled_equation_warnings ran that walk for every
arrayed variable before checking whether any arm could report, so an
ordinary per-element array paid an O(declared slots) expansion on every
diagnostics pass -- and model_all_diagnostics runs on the interactive
path. variable::may_have_unfilled_arms is a cheap superset test that now
gates it. Measured with a fresh database per iteration, since within one
salsa revision the accumulated diagnostics come from cache and a warm
loop measures DFS overhead instead of the emitters: on a 40-variable
20x20x20 model (320,000 declared slots) the pass drops from ~6.77s to
~6.06s, about 2.2us of walk per slot; on C-LEARN, the largest arrayed
corpus model at 3,255 slots, ~40ms or 1.5%. The corpus was never going
to show this; a modeller's own 3-D array on every keystroke would.

Probing the gate found that its superset property was unpinned -- every
end-to-end fixture with a NaN default also had a NaN arm, so a version
scanning only the arms passed everything. A variable whose only NaN is
its EXCEPT default now has a test. The corpus warning count is unchanged
at 94 across 4 files: the duplicate shape needs subscripts differing in
spelling but not canonically, which no checked-in model has.
The UnfilledEquation advisory read a stored equation of `NAN` and
asserted the modeller had written no formula. In a model that declares a
variable NAMED `nan` that assertion is undecidable, and it was wrong on a
model this branch itself makes reachable: importing MDL `nan = 3` /
`b = nan` stores b's equation as the bare text, so `b has no equation`
named a formula the modeller did write.

This is two changes on this branch meeting rather than a defect in
either. Batch 1 deliberately excluded `nan` from the MDL importer's
keyword quoting, because quoting it would bind Vensim's
`A FUNCTION OF(...)` placeholder -- which we store as `NAN` -- to any
like-named variable, and a round-tripped model would compute a value for
a variable that has none. That trade is disclosed and pinned by
keyword_ident_tests::a_bare_nan_reference_in_mdl_is_still_the_literal.
Its consequence is that the two shapes are stored identically.

So the diagnostic declines to make a claim: may_have_unfilled_arms
returns false when the model declares a variable named `nan`. Note this
is not the declared-name resolution rule batch 1 rejected -- that
resolved the ambiguity in favour of one reading and would have shipped a
wrong value. This ships no claim at all, which is the correct behaviour
for a warning whose entire content is an assertion about the model: its
value is that a practitioner can trust it and skip the backward hunt
through the dependency graph, so a warning that might be false is worse
than one that is absent. Only the bare literal is affected, and only
within the model that declares the name, since that is the scope a bare
reference resolves in; a sub-model's `nan` cannot silence its parent.

The corpus count is unchanged at 94 findings across 4 files -- no
checked-in model declares a variable named `nan`. Note the cost of
declining: in such a model a genuine placeholder now goes unwarned.
Recovering it would need import provenance carried in the datamodel.
Fourth review finding on the UnfilledEquation advisory, and the first
FALSE NEGATIVE: an arm with an empty equation was treated as covering its
slot, so a live NAN default looked unreachable and nothing was reported
while the slot really did simulate as NaN. A spurious warning costs a
moment; a missing one costs the backward hunt this diagnostic exists to
remove.

The three earlier findings were each fixed by re-deriving one more stage
of the pipeline between the arms as written and the arms the compiler
evaluates. That approach is what failed here -- the hand-built selection
was missing a stage, and missed it silently. So this stops re-deriving.
unfilled_arms now takes the parsed Ast<Expr0>, which IS the result of
that pipeline: empty and unparseable arms dropped, duplicate canonical
subscripts collapsed last-wins, dimensions resolved. ArmSelection and
arm_selection are deleted; the only stage still performed here is the
declared-slot walk, which cannot come from the Ast because the parser
does not know about slots, and which is the compiler's own
SubscriptIterator/elements.get/default-on-miss sequence.

Reading the parse costs nothing: model_all_diagnostics already triggers
compile_var_fragment for every variable, which parses under the same
module-ident context key, so this is a memo read. The cheap text gate
added for the interactive path still runs first, so an ordinary variable
reaches neither the parse nor the slot walk.

One behaviour change to note: arrayed element names in the message are
now canonical and in row-major declared order, because that is how the
parsed map is keyed. Recovering the as-written spelling would require
re-deriving the last-wins rule to know which spelling survived, which is
exactly what this removes. The corpus count is unchanged at 94 findings
across 4 files -- no checked-in model contains an empty arm.
The ceteris-paribus wrap qualified a resolved subscript element as `dim·elem`
whatever kind of dimension the axis was. DimensionsContext::lookup resolves that
spelling only for a NAMED dimension, so an index into an indexed axis came out as
`d·1`, which resolves to nothing: the PREVIOUS-capture helper holding it failed to
compile and both of that target's link scores read a constant 0 behind Assembly
warnings. The qualification now goes through qualify_axis_element, the one
qualifier, which leaves an indexed axis's position verbatim -- and verbatim is
what resolves.

The resolution itself was right and is unchanged, which is worth recording
because it is easy to read the other way. A quoted numeric-named variable used as
an index -- `q["1"]` -- is not a runtime read of that variable:
normalize_subscripts3 declines it, but declining routes the subscript to the
dynamic lowering, whose first move is Dimension::get_offset, and that parses an
indexed axis's identifier text into a position. Measured on a running model, with
the variable holding two different values and the result unchanged. Refusing to
resolve it instead would make the wrap freeze the index, and a frozen index is a
dynamic one -- the partial would read whatever the variable held last step where
the equation reads position 1, turning a loud zero into a silent wrong row.

DimName.N remains a deliberate gap in the shared resolver, now pinned by a test
asserting that shape does not compile at all, with instructions for the day it
does.

ltm_augment.rs sat exactly on the per-file line cap, so the wrap's
subscript-index pass moves to a #[path]-mounted sibling -- a cohesive unit and a
pure move.
@codecov

codecov Bot commented Jul 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.39847% with 88 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.70%. Comparing base (a42654b) to head (eea33c7).

Files with missing lines Patch % Lines
src/simlin-engine/src/ast/expr2.rs 75.67% 18 Missing ⚠️
src/simlin-engine/src/ast/mod.rs 87.50% 15 Missing ⚠️
src/simlin-engine/src/ltm_augment.rs 92.66% 11 Missing ⚠️
src/simlin-engine/src/ast/literal.rs 90.72% 9 Missing ⚠️
src/simlin-engine/src/common.rs 70.00% 6 Missing ⚠️
src/simlin-engine/src/db/ltm/link_scores.rs 95.08% 6 Missing ⚠️
src/simlin-engine/src/db/fragment_compile.rs 0.00% 5 Missing ⚠️
src/simlin-engine/src/builtins.rs 55.55% 4 Missing ⚠️
src/simlin-engine/src/compiler/dimensions.rs 96.00% 3 Missing ⚠️
src/simlin-engine/src/ltm_augment_index.rs 97.67% 3 Missing ⚠️
... and 7 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #992      +/-   ##
==========================================
- Coverage   91.72%   91.70%   -0.03%     
==========================================
  Files         241      244       +3     
  Lines      157779   157154     -625     
==========================================
- Hits       144729   144114     -615     
+ Misses      13050    13040      -10     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@bpowers
bpowers merged commit 30637f4 into main Jul 28, 2026
15 checks passed
@bpowers
bpowers deleted the roundtrips-issue-burndown branch July 28, 2026 21:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment