Skip to content

engine: delete the LTM classify-twice round trip and type its synthetic equations#980

Open
bpowers wants to merge 14 commits into
mainfrom
roundtrips-track-a
Open

engine: delete the LTM classify-twice round trip and type its synthetic equations#980
bpowers wants to merge 14 commits into
mainfrom
roundtrips-track-a

Conversation

@bpowers

@bpowers bpowers commented Jul 20, 2026

Copy link
Copy Markdown
Owner

Track A of the "Deleting the Round Trips" plan: the LTM subsystem decided each reference's
access shape twice — once on Expr2 for causal-edge emission, once on a reparsed Expr0
to pick which occurrence stays live — and the two implementations were held together by a
docstring saying they "must stay in sync". This deletes that round trip and stores LTM
synthetic equations as typed ASTs instead of text.

The failure mode is why this was worth doing: when the two classifier families disagreed, the
intended live reference got wrapped in PREVIOUS() and the link score came back a clean
zero. No error, no panic, no diagnostic — a plausible number. Two shipped bugs (#759, #913)
came through that seam.

Part of #965. Fixes #971.

What changed

commit
f44097b7 broaden the LTM site IR to per-occurrence with transform context
f057ef38 pick the module-output live dep in document order (#971)
b7898692 run ceteris-paribus wraps on original equations
6993b9b6 single-source the other-dep verdict; prove occurrence alignment corpus-wide
86df68bf drive live-ref selection from the occurrence IR — deletes the sync contract
dea42188 store LTM synthetic equations as typed ASTs
390939642b72c67d six review-loop fix commits, below

After 86df68bf, classify_expr0_subscript_shape, resolve_literal_element_index,
is_live_source_iterated_dim_subscript, is_literal_element_index and
expr0_contains_live_match are gone from production, and
classify_other_dep_iterated_dim_subscript is deleted outright. Decisions now come from the
reference-site occurrence IR, threaded through the wrap by a structural path cursor whose
alignment with the IR walker is proven corpus-wide by the gate's occurrence-alignment sweep.
A missing or misaligned occurrence degrades loudly (skip-and-warn) at every decision site,
never to a silent guess.

dea42188 makes LtmSyntheticVar.equation a typed LtmEquation holding parsed Expr0 arms.
Printing survives only as a diagnostics projection (warnings, debug dumps, the goldens).

Design decisions worth knowing

  • Transform-first inversion (b7898692). Wraps run on original equations; row-pinning and
    agg-substitution became post-transform lowerings. This is what makes original-AST SiteIds
    addressable by the wrap at all.
  • Two lowering-only classifiers survive, behind a rustdoc'd seam:
    classify_expr0_per_element_axes and expr0_iterated_axis_lines_up classify the
    already-wrapped AST for row pinning, which original-AST SiteIds cannot address. They do
    not mirror an Expr2 classifier for the same decision, so the dangerous mirror is gone
    even though these remain. Wrap-time occurrence tagging was rejected: it would need a
    tag-only descent into PREVIOUS/INIT content the wrap never visits, plus a Loc-keyed
    parallel map with its own drift surface.
  • UNADDRESSABLE_CHILD is a reserved sentinel, not a widened type. Builtin arity is the
    one child count the AST shape does not bound. Rather than widen the path element to u32
    doubling a boxed-path footprint on every model in a salsa-cached IR to serve an arity no real
    model reaches — one child index out of 65,536 is reserved as a value the producer never
    emits, so a path containing it provably cannot equal any recorded SiteId. Producer and
    consumer both derive from that single constant.
  • The two accumulator views are asymmetric, and this is now documented at the code.
    Absence in the name-keyed per-edge view is defaulted to Bare by consumers, so skipping a
    site misclassifies the reference; absence in the SiteId-keyed occurrence view is safe and
    sometimes required. That is why suppression gates only push_occurrence. Collapsing the two
    is the trap that produced one of the review findings below.
  • A debug_assert was removed rather than kept (17d4e7c0). Its premise — that a
    generated equation is a print_eqn re-print, so a parse failure can only be an augmentation
    bug — was falsified by a valid model. Aborting inside a salsa query on ordinary user input is
    the wrong failure mode for a library whose debug builds reach pysimlin/MCP/serve consumers.
    The loud path is warn-and-skip.

Defects found and fixed during review

Six review passes over the branch diff found six real defects, five of them the silent-zero
class this track exists to delete. None would have thrown or failed a test.

  1. Debug-build panic on valid models (17d4e7c0). quote_ident spelled any
    all-alphanumeric name bare, which a leading digit satisfies — but the lexer only starts an
    identifier on XID_Start, so a stock legally named "1stock" produced one equation
    carrying both "1stock" and bare 1stock, unparseable. Fixed at the root by consulting
    ast::needs_quoting too. Requiring both predicates can only ever add quotes, which is why
    every existing golden stayed byte-identical.
  2. Arrayed arm parse errors silently dropped (b0a2a6f0). One unparseable arm among
    parseable siblings: the error became None, the slot was zero-filled, the fragment kept its
    bytecode, and no diagnostic fired — a silent per-element zero. LtmArm now retains the
    error, distinguishing a failed arm from a legitimately empty one.
  3. Quadratic occurrence lookup (b0a2a6f0). SlotOccurrences groups once and is now the
    only route to an OccurrenceLookup, so the borrow forces callers to hoist it out of their
    per-element loop. See the honest note on its measured effect below.
  4. 16-bit SiteId child index overflow (b0a2a6f0, completed in d5eed63b). Both halves
    mattered: the consumer aliased an over-arity child onto an earlier sibling's path (so a
    lookup could succeed with an unrelated shape and freeze the wrong reference), and the
    producer's early return suppressed push_ref_site alongside the occurrence, which
    misclassifies rather than omits.
  5. Range endpoints skipped while pinning per-element sources (2f56c512).
    other[pop[region]:3] passed through untouched, leaving a recorded occurrence un-pinned —
    an internal inconsistency, since the sibling lowering in the same file already descended
    both endpoints.
  6. generate_per_element_link_equation did not doom on missing_occurrence (2f56c512).
    It checked only other_dep_mismatch, so a live source the IR could not record looked
    non-live, got frozen at PREVIOUS, and the score came out a clean zero. Every other wrap
    caller already doomed on it; the contract is now uniform.

New deterministic gates

2ae6cb33 lifts the zero-fragment-compile-failure assertion into the shared char-golden
harness. A char golden pins generated text, so it is structurally blind to a stably
unparseable equation: no bytecode, variable reads constant 0, golden green forever. Only 5 of
15 fixtures asserted the runtime consequence.

The expectation is now a required argument to assert_char_fixture, so a new fixture cannot
forget to declare it. Thirteen declare AllCompile; two declare their real failures exactly,
each with a why. Comparison is an exact set, not a subset — a new failure is a regression,
a disappeared one means the annotation went stale. Non-vacuity was demonstrated, not assumed.

2b72c67d adds the sixteenth fixture: an existing per-element shape with its source renamed
pop -> 1pop, a legal quoted XMILE name the lexer cannot read bare. Both halves of its
assertion were verified load-bearing separately — with the golden temporarily recaptured so
the text assertion could not mask it, the AllCompile half independently catches the runtime
consequence. Deliberately a leading digit and not a keyword: a keyword-named source would fail
today against open #976, and a fixture that is red on correct code is worse than no fixture.

Verification

  • C-LEARN LTM numeric gate on the final production tree: clearn_pinned_climate_loop_is_scored ... ok, 241.47s. Real arrayed Vensim model, production SetLoopName patch path, discovery-mode auto-flip, pinned climate loop scored finite/non-zero/negative.
  • cargo test -p simlin-engine --lib: 5271 passed / 0 failed / 3 ignored (baseline 5262; +9 new tests).
  • All 15 pre-existing char goldens byte-identical across every commit in this PR; the two new golden files are behavior records. No previously-recorded LTM behavior changed anywhere on this branch.
  • cargo clippy --all-targets -- -D warnings clean; scripts/lint-project.sh passed; full pre-commit hook green on every commit.
  • Every fix mutation-verified. Three mutation probes initially slipped through, each exposing a real coverage gap that was then closed — including the corpus guard's own annotations.

Disclosed residuals

Stated here rather than left for the next person to discover.

What remains on #965

This does not close #965. Criteria 1 (one authoritative representation) and 3 (typed
storage, no print/reparse to compile) are met. What remains is the generation half: eight
Expr0::new text-parse sites in ltm_augment.rs, text-level pinned_body_row_terms, the two
lowering-only classifiers above, and two cfg(test) mirrors. That is parse cost and
cleanliness — the correctness payoff is banked here — and it is deliberately a separate PR
rather than more diff on top of this one.

Follow-ups filed

A note on how this was reviewed

Six scripts/codex-review passes, each over the whole branch diff. The pass history was
0, 1, 3, 2, 3, 0, 0 findings — pass 1 read dea42188 and reported no regressions, and pass 3
then found a P2 silent-zero in that same file; the range-endpoint bug lived in b7898692,
which four passes read and missed. Sampling review is not a proof, which is why green here
required two consecutive clean completed passes, and why the deterministic in-repo gates above
are the load-bearing evidence rather than the review itself.

dc20563e (pinning scripts/codex-review to gpt-5.6-sol) is an unrelated tooling commit that
landed on this branch; it can be dropped or kept at your preference.

bpowers added 14 commits July 18, 2026 08:53
The reference-site IR (model_ltm_reference_sites) classified access
shapes per (from, to) edge, which serves causal-edge emission but not
the ceteris-paribus transform: live-ref selection re-derives shapes on
printed-and-reparsed Expr0 via the mirror classifier family. The
settled Fig. 2 analysis of the round-trips plan established that both
consumers ask one per-occurrence question, provided the IR carries
context the transform needs and the edge emitter ignores.

This extends the walker (one pass, unchanged edge view) with a
per-occurrence view over the whole target equation: stable structural
SiteIds (deterministic child-index paths), reducer-enclosure context
surfaced rather than folded into routing, already-lagged markers
(inside PREVIOUS/INIT; suppresses re-wrapping, not selection),
index-position markers (the occurrences the transform structurally
excludes from live selection), a position-mismatched-iterated per-axis
arm distinct from a dynamic index (so the other-dep
Collapse/Mismatch/NotIterated verdict is derivable from the IR), and
document-order enumeration of module-output composites including
implicit SMOOTH/DELAY expansions (the deterministic source A2b needs
to fix GH #971's arbitrary HashSet pick).

One walker-side correction: a subscript index token that names an
element of the source's own dimensions is an element selector, not a
causal reference, matching execution (normalize_subscripts3 resolves
element-first, position-strict). No consumer-visible edge changes --
variable-level dep extraction already filtered such idents, so the old
site was an orphan no keyed consumer read; the fix ends the
walker/transform disagreement so A2b can select live occurrences from
the IR.

Consumers are unchanged in this step; the switch to the occurrence
view (and deletion of the Expr0 mirror family) is the follow-on.
Track A step 2a of the round-trips plan; part of the #965 arc.
module_link_score_equation's module-to-variable branch holds one
output of the module instance live in the ceteris-paribus partial and
freezes the rest. The live output was chosen by .find() over
identifier_set's HashSet, whose iteration order is per-process random,
so when a target reads two outputs of one module instance the emitted
link score -- and every loop score through it -- flapped between
processes (GH #971).

The reference-site IR now enumerates module-output composites as
per-occurrence ModuleOutput records in stable left-to-right walk
order, so the pick becomes the document-order-first output that names
the module: deterministic across processes, over the same candidate
set the old scan considered. The identifier_set scan survives only as
a defensive fallback when the IR recorded no matching occurrence,
preserving the historical set of edges that receive a real partial;
the typed-equation work is expected to delete it.

The pin builds two models differing only in the order the target
reads the two outputs and asserts they hold different sources live,
distinguishing document order from both an alphabetical sort and the
old order-independent hash pick.
The ceteris-paribus transform was applied to three categories of
input text, two of them DERIVED: generate_per_element_link_equation
wrapped a row-rewritten equation against a synthesized FixedIndex(row)
live shape, and the agg-to-scalar / scalar-to-element generators
wrapped agg-substituted text where hoisted reducers had already been
replaced by synthetic names. Derived text has no reference-site
occurrence stream, which blocked moving live-ref selection onto the
per-occurrence IR: a partial switch would have left three
classification paths (see the Category A/B/C analysis recorded in the
track-A PR).

This inverts the composition to rewrite-of-a-transform: every wrap now
runs on the target's own equation (or its own Arrayed slot text) with
the site's actual shape as the live shape, and the row-pinning and
reducer-substitution rewrites become free-standing post-transform
lowerings (rewrite_per_element_source_refs on the wrapped AST,
substitute_reducers_in_expr0 mapping frozen PREVIOUS(SUM(..)) and live
reducer subexpressions to their synthetic agg names in place). The
lowerings live in a new sibling module, ltm_augment_post_transform.rs,
both because they are the coherent post-wrap unit and to keep
ltm_augment.rs under the per-file line cap. Net new Expr0
classification call sites: zero -- the follow-on step can swap the
classifier family for occurrence lookups wholesale.

Behavior is pinned by a new characterization suite (ltm_char_tests.rs
with checked-in goldens plus runtime guards): generated texts are
byte-identical to the prior composition for the entire battery except
one adjudicated case -- a frozen occurrence reached through another
reference's subscript index is now element-qualified
(other[pop[region-dot-boston, ..]]) where the old ordering left the
bare element spelling; both resolve to the same element and the full
compiled series is bitwise identical. Guards also deliberately pin two
pre-existing degradations (a first-live-step NaN from an uninitialized
PREVIOUS(index) capture helper, and loud warned-zero on nested live
reducers) so this change provably preserves rather than fixes them;
they are tracked separately.
…gnment

Groundwork for retiring the Expr0 mirror classifier family. Two
pieces, both mechanism-verified:

The other-dep iterated-subscript verdict (Collapse / Mismatch /
NotIterated, which decides whether a non-live dep's subscript
collapses to a bare PREVIOUS(dep), dooms the changed-first partial, or
wraps normally) now has one implementation: the transform delegates to
db::ltm_ir::derive_other_dep_verdict, derived from the occurrence
IR's per-axis vocabulary. The delegation is arm-by-arm equivalent to
the deleted duplicate, including the arity-dominance corner where the
two families labeled an over-declared-arity axis differently: the
arity guard returns Mismatch before any per-axis arm is consulted, so
the labeling difference was unobservable (pinned by
verdict_ignores_over_arity_axis_labeling).

The classifier-agreement gate additionally asserts, for every fixture,
that the occurrence IR's per-occurrence stream aligns one-to-one with
the wrap walk's reference visits on the printed-and-reparsed text --
the premise a SiteId-keyed selection bridge depends on -- with new
fixtures covering already-lagged, index-nested, LOOKUP, reducer, and
module-output shapes. Misalignment fails with a diagnostic naming the
path, turning the printer/parser-asymmetry class loud in the gate
before any production switch. Two Fig. 2 selection semantics
(index-nested exclusion from live capture, PREVIOUS/INIT
verbatim-return) gain direct mutation-verified pins.

The wrap itself still decides shapes on reparsed Expr0; the follow-on
flips those decisions onto the IR and deletes the remaining
classifiers. The GH #971 identifier_set fallback now marks itself
loudly if it ever fires (corpus-unreachable today), so its planned
deletion can proceed with confidence.
The ceteris-paribus wrap machine re-derived every per-occurrence
decision -- live-shape matching, literal/element resolution,
iterated-dim recognition, already-lagged skipping, element-selector
and dimension-name index handling -- by classifying the
printed-and-reparsed Expr0, mirroring the Expr2 classifiers under a
"must stay in sync" docstring contract. Those decisions now come from
the reference-site occurrence IR, threaded through the wrap with a
structural path cursor whose alignment with the IR walker is proven
corpus-wide by the gate's occurrence-alignment sweep. A missing or
misaligned occurrence at any decision site degrades loudly
(skip-and-warn), never to a silent guess.

The sync contract is deleted. classify_expr0_subscript_shape,
resolve_literal_element_index, is_live_source_iterated_dim_subscript,
is_literal_element_index, and expr0_contains_live_match are gone from
production (the first four survive cfg(test)-only, feeding synthetic
non-compilable fixtures that cannot be built through a db);
classify_other_dep_iterated_dim_subscript is deleted outright. The
two lowering-side classifiers (classify_expr0_per_element_axes,
expr0_iterated_axis_lines_up) survive lowering-only -- they classify
the already-wrapped AST for row-pinning, which original-AST SiteIds
cannot address -- behind a rustdoc'd seam that the typed-equation
step deletes; wrap-time occurrence tagging was rejected because it
would need a tag-only descent into PREVIOUS/INIT content the wrap
never visits plus a Loc-keyed parallel map with its own drift
surface.

Every wrap call site threads a real occurrence stream; the empty-
lookup shortcut and its incorrect justification are deleted. That
also aligns the legacy and shaped link-score queries on the
bare-live-source-inside-reducer class, where they had derived
different equations (the compiled fragment came from one, the
reported equation from the other): both now take the changed-last
form, which is what shipped from both queries before this arc (the
changed-first freeze form never shipped; pinned by
legacy_and_shaped_bare_score_agree_when_source_bare_in_reducer).
Un-hoisted-reducer and module-output-inside-reducer shapes keep their
loud warned-skip degradation, pinned against the silent-zero variant.

The characterization goldens are unchanged; the classifier-agreement
gate becomes single-family classification pins plus the alignment
sweep, with every expected-shape fixture retained.
LTM synthetic variables were stored as datamodel::Equation text: the
transform built a wrapped Expr0 AST, printed it, and
db/ltm/parse.rs minted a transient datamodel aux and re-parsed the
text for compilation -- 2-3 parses per equation and a printer/parser
asymmetry surface at the compile boundary. LtmSyntheticVar now
carries the typed equation (a new LtmEquation in db/ltm/equation.rs
holding the wrapped Expr0 arms for scalar, apply-to-all, and arrayed
forms); producers hand over the AST they already built, and
compilation consumes it directly. Printing survives only as a
diagnostics projection (warnings, debug dumps, the characterization
goldens), which is why the goldens are byte-identical.

Two long-lived seams die at the same boundary. The legacy
link_score_equation_text query is deleted: compile.rs's sub-case (a)
previously compiled fragments from the legacy text while emitters
reported the shaped query's text (with a known dim_ctx divergence
between them); compiled and reported equations now come from one
stored derivation by construction. module_link_score_equation's
identifier_set fallback (and its loud marker) is deleted: the
occurrence IR is the only source for module-output live selection,
and a missing occurrence takes the loud missing-occurrence path.

Salsa incrementality is preserved: the shaped text query backdates on
the PartialEq-equal typed equation, so unrelated edits reuse the
expensive fragment (pinned by the existing per-link caching test);
the units-context dependency was dropped from the LTM parse path
entirely. Mutation probes confirm the compiled representation is
test-bound: a perturbed stored AST trips numeric pins even though
text goldens are blind to it.

This is the storage/compile half of the GH #965 typed-equation work;
the generation half (the transform still parses the target equation
from printed text once at the source boundary) is the follow-on.
Comment-only. Inserting other_dep_verdict between
wrap_non_matching_in_previous and its rustdoc left the wrap
undocumented and gave the verdict helper a doc block describing
live_source / other_deps / iter_ctx / out.live_ref / dims_ctx -- none
of which are its parameters. The same accident already existed for
classify_expr0_subscript_shape, whose doc sat on the bool-returning
is_literal_element_index (so that predicate was documented as "Rules:
any Wildcard -> RefShape::Wildcard"); this branch's edits appended new
cfg(test) rationale into that wrong block, so it is corrected here
rather than left to rot.

Both blocks are also brought in step with the code they now describe:
the wrap takes a &WrapCtx rather than positional parameters, its
iterated-dim normalization is driven by the occurrence IR's shape
(iter_ctx now supplies only the other-dep arity/mapping facts and the
dimension-name index guard's fallback), and it threads a structural
path; the shape classifier's rule list was missing the #511 Bare and
#525 PerElement arms it actually implements.

Two narrower staleness fixes ride along. A link_scores comment still
pointed at `substituted`, the caller-side local this branch deleted
when reducer substitution became a post-transform lowering. And
OccurrenceSite's "every field has a named A2b/A3 consumer" claim was
false once the wrap turned out to reach the already_lagged and
in_reducer decisions structurally: target_element, routing,
in_reducer, reducer_keys, and already_lagged have no production reader
today. Saying so plainly -- and recording that each is pinned at the
IR level, which is what keeps a walker regression catchable without
one -- is more useful to the next reader than a claim they would
disprove in a grep.
quote_ident spelled an identifier bare whenever every character was
alphanumeric or underscore. A leading digit satisfies that but cannot
START an identifier: the equation lexer reads `1stock` as the number 1
followed by the identifier `stock`. `"1stock"` is a legal quoted XMILE
name, so a valid model produced a link score whose partial carried
print_eqn's quoted `"1stock"` beside a bare `1stock` in the guard
form's SIGN factor -- one equation, two spellings of one name, and the
whole thing unparseable. Before the typed-equation work that meant a
silently-degraded score; after it, the eager arm parse also tripped
the augmentation-bug debug_assert, so a debug build aborted inside a
salsa query on ordinary user input.

quote_ident now also consults ast::needs_quoting -- the
leading-character rule print_ident already applies on the print_eqn
path -- so the two spellings cannot disagree. It deliberately does NOT
delegate outright: U+00B7 IS XID_Continue, so needs_quoting alone
would spell a module-output composite bare (`mod·out1`). That parses,
but it would rewrite the emitted text of every module-composite link
score for no bug. Requiring BOTH predicates can only ever add quotes,
which is why every characterization golden is byte-identical.

The debug_assert in LtmArm::new is removed rather than kept alongside
the root-cause fix. Its premise -- a generated equation is a print_eqn
re-print, so a parse failure can only be an augmentation bug -- was
false, and asserting on it converted the pre-existing warn-and-skip
degradation into an abort on a valid model. The loud path is the
diagnostic that was already there: no AST means no bytecode, so
model_ltm_fragment_diagnostics warns that the variable holds a layout
slot reading constant 0. Generator bugs still fail loudly in tests via
the numeric pins and the goldens.

The regression pin asserts the arm has a parsed AST, not merely that
the text contains a quote, so it catches any unparseable generated
equation rather than this one spelling; mutation-verified by restoring
the old predicate.
Three findings from a review pass over the typed-equation boundary.

Arrayed arm parse errors were silently dropped, and this completes the
degradation story 17d4e7c claimed. That commit deleted a debug_assert
on the grounds that "no AST means no bytecode, so
model_ltm_fragment_diagnostics warns". True for a scalar equation;
FALSE for an arrayed one. LtmArm::new collapsed a parse failure into a
bare expr: None, to_flow_ast's slot map dropped it, and any sibling arm
that parsed kept the fragment alive with bytecode -- so the compiler
zero-filled the missing slot and no diagnostic fired at all. A silent
per-element zero: the exact class this track exists to remove,
reintroduced at the new boundary. LtmArm now retains the parse error,
which is what distinguishes a FAILED arm from a legitimately EMPTY one
(the latter must still drop, as variable::parse_equation drops it), and
to_flow_ast rejects the whole equation before any shape-specific
assembly. The pin covers both shapes and both mutation directions.
Only the first error is kept: the diagnostic names the variable and its
text, not a parse position, so a Vec inflated LtmSyntheticVar past
clippy's large_enum_variant threshold for data nothing reads.

Occurrence lookup was quadratic in an arrayed target's element count:
for_slot rescanned the whole occurrence stream once per slot and
allocated N temporary vectors. SlotOccurrences groups once and indexes
per slot, and is now the ONLY way to obtain an OccurrenceLookup -- the
lookup borrows from the index, so the type system forces callers to
hoist it out of their per-element loop instead of rebuilding it inside.

Measured, because the win is not where the finding implied. Timing
model_ltm_variables on a width-N per-element target (the checked-in
#[ignore]d harness per_element_generation_scaling):

  N=50   37.7ms -> 37.3ms      N=200  564ms -> 568ms
  N=100  141ms  -> 141ms       N=400  2.317s -> 2.304s

About 2%, still 4x per doubling. The rescan was a genuine asymptotic
term and is gone, but a LARGER pre-existing quadratic dominates: this
shape emits N+1 link scores each arrayed over the target's N+1 slots,
so (N+1)^2 equation arms per edge -- 160,401 at N=400. The queries it
reads are all linear and total ~7ms of that 2.3s, so the cost is the
emission shape, not classification. Filed separately; not this
branch's to fix, and the harness prints the arm counts that show it.

A SiteId child index could overflow on a variadic builtin. Builtin
arity is the one child count the AST shape does not bound, so a
65,536-argument MEAN panicked in debug and WRAPPED in release --
re-using an earlier child's SiteId, which makes the wrap's path lookup
return a different occurrence and freeze the wrong reference: a
plausible wrong score, silent. site_child_index returns None past the
addressable range rather than a wrapped value, and the walker records
no occurrence for such a child, which routes into the existing loud
path (a live-source subscript with no occurrence sets
missing_occurrence, so the partial is abandoned and the emitter warns).
The per-edge ReferenceSite view still gets its site, so no causal edge
is lost. Widening the path element to u32 was rejected: every
occurrence carries a boxed path in a salsa-cached per-model IR, so it
would double that footprint on every model to serve an arity no real
model reaches, and it would move the boundary rather than remove it.
Pinned on the boundary function, not on a 65,536-argument fixture that
would strain the suite's wall-clock cap.

The occurrence read side (SlotOccurrences / OccurrenceLookup) moves to
its own file: ltm_augment.rs had reached the 6000-line cap, and this is
the coherent unit to extract.
b0a2a6f guarded only the PRODUCER. Two halves of the same defect
survived, both of them the silent-corruption class the guard exists to
prevent.

The consumer still aliased. ltm_augment::child_path built its path with
`i as u16`, so a builtin child at index 65,536 wrapped to child 0's
path. The IR walker deliberately records no occurrence for that child,
but the wrap would then look up child 0's -- and if that sibling HAS an
occurrence, the lookup succeeds with an unrelated shape,
missing_occurrence never fires, and the wrap freezes the wrong
reference and emits a plausible wrong score. Omitting the occurrence on
one side is only safe if the other side cannot manufacture a colliding
path.

So the sentinel is now reserved rather than merely hoped-for:
UNADDRESSABLE_CHILD is a value site_child_index NEVER returns (it costs
one child index out of 65,536), and child_path maps an over-arity child
to it with a checked conversion. A path containing it therefore cannot
equal any recorded SiteId, so every lookup at or below such a child
provably misses instead of aliasing. The App arms additionally set
missing_occurrence at the overflow point, so the partial is abandoned
where the problem is rather than depending on a miss further down.

The producer dropped too much. The early return skipped the whole
contents match, so it suppressed push_ref_site along with the
occurrence -- which my commit message for b0a2a6f explicitly claimed
it did not do. That claim was wrong. The two views are not
interchangeable: the occurrence view is SiteId-keyed and must stay
silent for a child it cannot address, but the per-edge view is
name-keyed and feeds model_edge_shapes, where a MISSING entry is not
read as "no reference" -- consumers default it to a single Bare site.
So dropping the edge site would misclassify a FixedIndex/DynamicIndex
reference and emit wrong element edges and link scores. Suppression is
now a depth counter consulted only by push_occurrence, covering the
unaddressable child's whole subtree (a nested occurrence at a truncated
path would alias a sibling) while every reference keeps its real shape
in the edge view.

Both halves needed their own pins, and neither was covered by the
producer-side boundary test: it passes whether or not child_path's
conversion is checked, and the suppression counter is never raised
below 65,536 arguments, so no fixture exercises it. Each is now pinned
on the exact invariant -- child_path's sentinel mapping and
WalkAccum's record-the-edge-anyway contract -- rather than through a
65,536-argument fixture that would strain the suite's wall-clock cap.
Both are mutation-verified: restoring `as u16` fails the consumer pin,
and suppressing ref sites fails the producer pin.
Three findings, two fixed and one documented.

The per-element row-pinning lowering skipped subscript RANGE endpoints.
rewrite_per_element_source_refs matched only IndexExpr0::Expr when
recursing through another variable's subscript, so a source reference in
a range bound -- other[pop[region]:3] -- passed through untouched. The
IR walker DOES record an occurrence there (IndexExpr2::Range pushes
children 0 and 1) and the sibling lowering
substitute_reducers_in_expr0 in the same file already descends both
endpoints, so this was an internal inconsistency rather than a
deliberate boundary: the recorded occurrence was left un-pinned and its
dimension-name subscript survived into the scalar per-element equation,
which either fails to compile (a PREVIOUS-of-dim-name capture helper) or
reads the wrong element. Both index branches now descend range
endpoints. The partially-classifiable branch descends only where no
per-axis substitution applied, since recursing after a substitution
would double-pin the source's own axis.

generate_per_element_link_equation did not doom on missing_occurrence.
It checked only other_dep_mismatch, so a live-source occurrence the IR
could not record left the source looking non-live: the wrap froze it at
PREVIOUS and the score came out a clean ZERO. Every other wrap caller
already dooms on it (shaped_guard_form_text,
generate_scalar_to_element_equation,
build_partial_equation_shaped_with_live_ref); this was the one hole
through which that silent zero could still reach an emitter, so the
doom contract is now uniform across all of them.

module_output_ref_in_document_order cannot distinguish a SUPPRESSED
module-output occurrence (one inside an unaddressable builtin child)
from "the target reads no output of this module": both yield None and
fall through to the signed magnitude-1 unit transfer. That is
documented rather than fixed. The outcome is the same fallback an
unlocatable reference has always taken -- an explicit approximation, not
a plausible-looking wrong number -- and distinguishing the two means
threading an unaddressable marker through the IR and this query to
serve a 65,536-argument builtin, an arity far past where LTM generation
is tractable at all (GH #977). The rustdoc records what the None
conflates and says that if such a marker ever becomes cheap, the honest
behavior is to skip the edge loudly rather than silently approximate it.

The range fix is pinned by a focused test on the lowering and
mutation-verified: removing the descent leaves other[pop[region]:3]
verbatim and fails the pin.
A char golden pins generated equation TEXT, so it is structurally blind
to a generated equation that is STABLY unparseable: such an equation
compiles to no bytecode, the variable reads a constant 0, and the golden
stays green forever. Only 5 of the 15 fixtures asserted the runtime
consequence, and they did it in separate test functions, so the other 10
contributed no coverage of that class. Two independent
unquotable-generation bugs turned up in one day (the 1stock leading
digit, and the bare-keyword class of GH #976), neither of which any text
golden would have caught -- a defect class with a demonstrated
recurrence rate against a check that was 1/3 deployed.

The assertion now rides the shared harness. assert_char_fixture pins the
golden AND the fragment-compile expectation in one call, and the
expectation is a REQUIRED argument, so a new fixture cannot forget to
declare it and quietly contribute nothing. Thirteen fixtures declare
AllCompile. The two that legitimately produce failures declare them
exactly, each with a `why`:

- agg_nested_reducer: the GH #517 live-agg-inside-a-declined-outer-
  reducer degradation b789869 deliberately preserves -- the partial
  freezes a wildcard slice, which has no LoadPrev-of-array-view path.
  Six entries (two target elements, each with its score plus the two
  PREVIOUS-capture helpers its partial synthesizes).
- reducer_index_nested_freeze: this fixture's OWN model is rejected
  (ArrayReferenceNeedsExplicitSubscripts -- it uses a scalar as a
  subscript index, deliberately, to pin the Fig. 2 Q4 index-nested
  selection semantics at the text level), so its PREVIOUS-capture helper
  inherits the un-compilable subscript. Zero failures is not reachable
  here without changing what the fixture pins.

An exact set comparison rather than a subset: a NEW failure is a
silent-zero regression, and a DISAPPEARED one means the annotation went
stale and should be tightened. An annotation nothing can forget to set
beats a global assert with invisible carve-outs.

Non-vacuity was demonstrated rather than assumed: running all fifteen as
AllCompile first, exactly the two genuinely-failing fixtures failed, and
loosening either annotation back to AllCompile is rejected.

One honest limit, since it bears on what this buys. The guard catches a
generation change that stops compiling on the shapes the fixtures cover;
it does NOT catch the quoting class specifically, because no fixture
carries a name the lexer cannot read bare -- verified by re-introducing
the quote_ident regression, which leaves all fifteen green while the
dedicated 1stock pin fails. Covering that class through the corpus would
need a fixture with such a name, which is deliberately out of scope
here. The dedicated pins remain the coverage for it.

Also documents the producer-side asymmetry on both accumulator methods:
absence in the per-edge view is defaulted to Bare by consumers (so
skipping a site MISCLASSIFIES the reference), while absence in the
occurrence view is safe and sometimes required. That is why
suppress_occurrences gates only push_occurrence, and it is the trap
worth naming so the two are not collapsed later.
The corpus was blind to the highest-recurrence generation bug class we
have. Two independent unquotable-generation bugs turned up in one day --
the leading-digit `1stock` fixed in 17d4e7c, and the bare-keyword class
of GH #976 -- and neither was catchable by any of the fifteen existing
fixtures, because a fixture only exercises the class if it CARRIES such a
name. Demonstrated, not assumed: re-introducing the quote_ident
regression left all fifteen green while only the dedicated 1stock unit
pin failed. The corpus is the net that runs on every commit, so a class
with that recurrence rate resting entirely on one unit pin -- which a
future refactor could delete with nothing going red -- was the wrong
place to be.

This is Model A (the PerElement mixed iterated+literal shape) cloned with
the source renamed `pop` -> `1pop`: an existing shape with one identifier
changed, not a new model shape. `"1pop"` is a legal quoted XMILE name
that canonicalizes to `1pop`, which the equation lexer cannot read bare
(it only starts an identifier on XID_Start, so bare `1pop` lexes as the
number 1 followed by `pop`).

A leading digit and deliberately NOT a keyword: a keyword-named source
would fail today against open GH #976 (`ast::needs_quoting` has no
keyword check), and a fixture that is red on correct code is worse than
no fixture.

Both halves of the fixture's assertion are load-bearing, verified
separately under the regression. The golden catches the text change
(`"1pop"` -> bare `1pop`). Independently -- with the golden temporarily
recaptured so the text assertion could not mask it -- the AllCompile
expectation catches the runtime consequence: both per-element scores fail
to compile, so each keeps a layout slot with no bytecode and reads a
constant 0. That second half is the one the fifteen text-only fixtures
could never have provided.

The new golden file is a behavior RECORD, not a behavior change: the
fifteen existing goldens are byte-identical (`git diff dea4218..HEAD --
ltm_char_golden/` is empty).
@codecov

codecov Bot commented Jul 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.96000% with 113 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.61%. Comparing base (43f3d51) to head (2b72c67).

Files with missing lines Patch % Lines
...rc/simlin-engine/src/ltm_augment_post_transform.rs 78.50% 49 Missing ⚠️
...simlin-engine/src/ltm_augment_wrap_test_support.rs 88.08% 23 Missing ⚠️
src/simlin-engine/src/db/ltm_ir.rs 96.12% 17 Missing ⚠️
src/simlin-engine/src/db/ltm/equation.rs 92.80% 10 Missing ⚠️
src/simlin-engine/src/db.rs 80.00% 8 Missing ⚠️
src/simlin-engine/src/db/ltm/parse.rs 93.18% 3 Missing ⚠️
src/simlin-engine/src/db/ltm/mod.rs 71.42% 2 Missing ⚠️
src/simlin-engine/src/db/ltm/compile.rs 92.30% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #980      +/-   ##
==========================================
+ Coverage   91.53%   91.61%   +0.08%     
==========================================
  Files         234      238       +4     
  Lines      156305   157050     +745     
==========================================
+ Hits       143070   143888     +818     
+ Misses      13235    13162      -73     

☔ 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.

@claude

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

Review

Traced the core surfaces where a regression would hide:

  • Walker/wrap SiteId path alignment (db/ltm_ir.rs:809, 822-846, 501-517 vs ltm_augment.rs:665, 924, 985, 1097-1136) — child-index-for-child-index in every arm (Op1/Op2/If/App/Subscript+Range).
  • LtmEquation::to_flow_ast (db/ltm/equation.rs:307-361) — the any-arm-failed short-circuit runs before shape-specific assembly, faithful to the doc.
  • SlotOccurrences::new/for_slot/ArrayedSlotMap — slot numbering matches the walker for both Ast::ApplyToAll and Ast::Arrayed (sorted-element-key order + default at keys.len()).
  • UNADDRESSABLE_CHILD sentinel (db/ltm_ir.rs:615-651, ltm_augment.rs:662-675) — producer/consumer derive from one const; the push_ref_site/push_occurrence asymmetry is documented and load-bearing.
  • generate_per_element_link_equation (ltm_augment.rs:2815) — the missing_occurrence doom (fix 6) is present against actual wrap outputs.
  • quote_ident (ltm_augment.rs:2949-2957) — the needs_quoting-and-alphanum conjunction covers the 1stock class without regressing module-composite spelling.

No blocking issues found. Everything I probed either matched the author's own commit-message reasoning or a disclosed residual (#977/#978/#979/#981). Two non-qualifying observations left unfiled: a test-only walker in ltm_augment_wrap_test_support.rs:219-236 doesn't skip literal subscript indices the way the production/gate walkers do (testing-hygiene only — the corpus alignment gate uses the other walker), and i as u16 at db/ltm_ir.rs:809/501/517 is the same aliasing class as #979 on subscript/Arrayed axes but unreachable in practice.

Overall verdict

Correct. Approve.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant