Skip to content

engine: delete the LTM input-side round trip and the last Expr0 classifiers#985

Open
bpowers wants to merge 16 commits into
roundtrips-track-afrom
roundtrips-track-a2
Open

engine: delete the LTM input-side round trip and the last Expr0 classifiers#985
bpowers wants to merge 16 commits into
roundtrips-track-afrom
roundtrips-track-a2

Conversation

@bpowers

@bpowers bpowers commented Jul 20, 2026

Copy link
Copy Markdown
Owner

Part 2 of Track A, stacked on #980. #980 deleted LTM's AST -> text -> AST round trip on the
output side (generated equations stored as typed ASTs) and killed the classify-twice contract
that produced silent-zero link scores. This deletes the round trip on the input side: the
ceteris-paribus wrap no longer parses the target equation's printed text.

Part of #965. Does not close it — see "What remains" below.

The result worth leading with

expr2_to_string is literally print_eqn(expr2_to_expr0(e)). So every one of the eight
Expr0::new sites in ltm_augment.rs was parsing our own printed output of a tree we already
held in that exact shape
. Deleting them is "pass the Expr0 instead of the String".

The consequence is stronger than the parse count. db::ltm_ir computes every occurrence's
SiteId on the Expr2 tree; the wrap addresses occurrences by the child-index path it tracks as
it descends. Feeding the wrap the direct lowering makes those two path spaces the same tree by
construction
. Before, they coincided only because print-then-parse happened to round-trip — a
property the corpus alignment sweep could sample but never establish. Given that five of the
six defects #980's review found were silent zeros arising from exactly this class of
"coincides in practice" assumption, moving it to by-construction is the point of the change.

Parse-site tally: 5 deleted, 1 relocated to a legitimate source-format boundary, 2 remain
(with reasons, below). ltm_augment.rs: 5921 -> 5644 lines.

Commits

c57cca0c lower LTM target equations to Expr0 without a reparse
391bc3c1 pin LTM per-element rows inside the wrap, not after it
0d730b75 delete the LTM occurrence fields nothing reads
b151a60c prove the LTM test occurrence axes against the IR
f54919cd carry LTM reducer bodies as ASTs, not printed text
8d3ee4ab decline loudly when an LTM row pin has no occurrence
123a2cd0 correct the LTM augmentation map in CLAUDE.md
5ce2aa69 pin LTM table-argument rows structurally, not loudly
0f1f0e9b descend expression indices in the LTM pin-only walk
db6dc850 decline an LTM table-argument index that reads at runtime
fb0863b0 gate the LTM table-index refusal on the enclosing freeze
fa34e26b enumerate the LTM table-index verdicts, not just the found cases
b11c182a resolve LTM table indices by the shared derivation
566088a4 row the fifth LTM table-index variant too
172abc1b look inside a compound LTM table index
902b1f27 break an LTM index name collision the compiler's way

The crux: row pinning moved inside the wrap

391bc3c1 is the load-bearing commit. Deleting the last two production Expr0-side classifiers
required relocating PerElement row pinning, and the obvious approach — a shape-preserving
pre-pass on the original AST — is wrong, which we established from the corpus rather than by
argument.

Row pinning emits two spellings of the same site shape: the occurrence the wrap left live
gets the row with bare element names; every occurrence it froze gets the same row qualified
(region·boston), so the frozen read compiles to a direct LoadPrev instead of an ambiguous bare
element name. That live-vs-frozen fact exists only in the wrapped tree, and it is not derivable
from the occurrence — two occurrences with identical axes can land on opposite sides.
per_element_index_nested_occurrence.txt already pins both spellings inside one equation. On a
model whose element names are ambiguous across dimensions — C-LEARN's regions — the bare spelling
fails to compile, so a pre-pass's divergence would be a silent zero, not cosmetic.

So the pin runs inside the wrap, which is the one traversal that knows both the occurrence (by
path) and whether it is about to freeze. This reverses a documented decision in #980's
86df68bf, whose rustdoc rejected wrap-time occurrence tagging — that needs a tag-only descent
or a Loc-keyed side map, i.e. a second representation with its own drift surface. A pin-only
descent writes no tag and carries no map; it reads the one IR by path and performs the actual
lowering. Different thing, admissible for that reason.

Pinned narrowly rather than incidentally: mutating child_frozen = frozen || will_wrap down to
frozen — the exact information loss a pre-pass suffers — fails precisely one golden, the one
named in advance. Ignoring live/frozen entirely fails eight.

One classifier family, and the surviving mirror is gated

classify_expr0_per_element_axes and expr0_iterated_axis_lines_up are out of production. Per-axis
equivalence is exact, not approximate: the retired classifier consulted the same
ltm_agg::classify_axis_access the IR uses, gate for gate.

A #[cfg(test)] Expr0 mirror survives, moved beside its only consumer in
ltm_augment_wrap_test_support.rs. Full deletion is not achievable: three wrap unit tests cannot
be db-backed fixtures — two feed deliberately unparseable and empty text (they pin the loud
parse-failure contract), and the third is SUM(w[from]) + from, which the engine rejects as a
model
(ArrayReferenceNeedsExplicitSubscripts) yet still needs an occurrence stream.

Per the plan's own rule — mirrors are acceptable when drift is loudly gated; where one must stay,
the gate is the deliverable
b151a60c extends the corpus gate from comparing shape to
comparing axes as well, so it now proves every field of the reconstruction the production
wrap reads (SiteId path, shape, axes, in_reducer). An IndexExpr0 -> IndexExpr2 lift was
rejected: it would add a mirror faithful only to the forms classify_axis_access happens to
inspect, in order to delete a mirror.

OccurrenceSite: four fields deleted, one kept

target_element, routing, reducer_keys, already_lagged had no production reader. The
strongest case is routing, which was not merely unread but a duplicated derivation:
model_ltm_reference_sites held two copies of the #793 agg-narrowing rule, and deleting the field
deletes one of them. Duplicate logic inside the IR built to remove duplicate logic.

in_reducer stays — its reader is the corpus gate's reducer-context assertion, and deleting it
would silently narrow that gate. Its rustdoc says so explicitly, and records why it is not
consumed by the #779 bare-feeder decline: reducer_collapses_to_scalar includes SIZE and
excludes RANK while builtin_routes_through_agg does the opposite, so wiring it up would flip a
bare source inside RANK(...) from scored to loudly declined. Filed as #982.

Deleting already_lagged also removed a parameter still threaded through every recursive call of
walk_all_in_expr computing only itself — dead code the compiler cannot see, because it is
genuinely "used" at each hop.

A regression introduced and fixed inside this branch

391bc3c1 introduced a silent zero, and the story is disclosed rather than tidied because the way
it was found matters.

A PerElement source occurrence inside a LOOKUP table argument came out un-pinned:
lookup(pop[region, young], ...). That dimension-name subscript cannot resolve in a scalar
fragment, so the fragment is dropped, the variable keeps a layout slot with no bytecode, and the
score reads a constant 0. The cause is a genuine tension: db::ltm_ir records no occurrence under
a table argument ("static data, not a causal edge") and it is right — but the pin such a
reference needs is about compilability, not attribution. The pre-391bc3c1 pass pinned it
anyway because it re-classified the wrapped tree and so needed no occurrence. The IR-driven
pinning correctly refused to guess, and then said nothing, which was the defect.

8d3ee4ab made it loud. That was the wrong fix, on a premise that turned out false: the
claim was that a table head can only be a lookup-only variable (no value slot, never a causal
source) or the WITH-LOOKUP self-reference. In fact compiler/codegen.rs::extract_table_info
resolves a table argument by ident against any table-carrying variable and has an explicit
Expr::StaticSubscript arm deliberately supporting LOOKUP(g[<single element>], x); and
var_is_lookup_only requires an empty/sentinel equation, so a table-carrying variable with an
equation is value-bearing and can be a causal source. A review pass then produced the case that
shows the blast radius:

target[Region] = effect[Region,young] + LOOKUP(effect[Region,old], TIME)

That edge is scoreable from the first term alone — but because the same target reads effect in a
table argument, the whole partial returned UnfreezablePartial and every per-element score for
the edge was dropped
.

5ce2aa69 replaces the decline with a structural pin (pin_dimension_name_indices): an index
naming one of the source's own declared dimensions becomes this element's coordinate, and a literal
element index is qualified by its axis. It consults no occurrence — enforced by its signature,
which takes only indices and a context — infers no shape, makes no live/frozen decision, and
cannot make a table argument live-selectable. The loud net is kept behind it: an index naming a
different dimension still sets missing_occurrence, because resolving that needs the mapping
classifier the IR owns. 0f1f0e9b completes it — a table reference may carry a dynamic element
index (LOOKUP(pop[Region, pop[Region,young]], x)), which extract_table_info computes an offset
for on purpose, so the reference inside the index needs the same pin. The IndexExpr0::Expr arm
the closure's own comment already claimed to have.

Then two more review passes each found a defect in the previous fix, and the end state is worth
more than the individual fixes. 0f1f0e9b made the shape compile without making it correct:
the table argument is the only one of the three pin-only descents not inside a freeze — the
wrap holds it verbatim precisely because a PREVIOUS of a table has no value slot — so a runtime
index left live there stays live in the emitted partial, and a sibling occurrence's partial then
varies with it. db6dc850 declined runtime indices; fb0863b0 then found that over-declined,
because a LOOKUP can itself sit inside a PREVIOUS/INIT or a whole-frozen reducer, where the
index is already lagged and refusing drops a valid score.

The resolution is a three-way split, which is the real shape of the problem:

verdict question it answers behavior
pin a lowering resolve the index to this element's coordinate
Unspellable about the fragment (compilability) loud everywhere — no freeze can make a dimension-name subscript compile
RuntimeRead about the freeze (ceteris paribus) loud in a bare table argument, kept inside a freeze

Both earlier defects were the same mistake: one predicate trying to answer two independent
questions. A lowering has two obligations — it must compile and it must preserve ceteris
paribus — and the pin-only descent satisfies the second only because the enclosing node freezes
everything. The table argument is the one site where that premise fails, which is exactly where
both defects landed. That premise is now in pin_dimension_name_indices' rustdoc in those terms.

The verdict enumeration

fa34e26b adds per_element_pin_index_verdict_enumeration: 13 index kinds × {bare table
argument, inside a freeze} = 26 cells
, each stating the expected verdict. Every cell matched the
implementation on the first run, so it is test-only.

It exists because both of the findings above were cells — a shape in the wrong bucket — not
logic errors, and each was supplied by a reviewer's counterexample. An enumeration states a verdict
for every shape rather than the ones someone thought of.

The table's shape is itself the result: the four static rows and four unspellable rows are
identical across both columns, and only the three runtime-read rows differ — so the
compilability-vs-ceteris-paribus split is a visible property of the table rather than a claim in a
comment. Three of the unspellable rows were not previously tested at all (an own dimension at a
position the target doesn't project, transposed own dimensions, and an over-arity index).

It is a gate, not documentation: four mutations of the verdict logic fail it independently of
the seven per-shape tests, so an edit that moves a cell goes red even without breaking a named
shape test. Non-vacuity is asserted per cell — every case re-checks that the IR records nothing
for the table argument — so no cell can drift into testing the occurrence walker instead of the rule.

Two cells are marked uncertain rather than asserted. A range and a wildcard table index
are unreachable from a compilable model: codegen's extract_table_info rejects them with
BadTable, so the target's own equation fails to compile before its link scores exist. The rule
currently accepts them when frozen. If they ever became reachable, the failure would surface as a
fragment-compile Warning rather than a warned skip — loud either way, different channel. They were
left marked rather than guessed into Unspellable, because no reachable shape distinguishes the two
choices and a speculative production change was not worth the blast radius. One further caveat: the
numeric literal cell asserts the rule's verdict (leave a static selector alone), not downstream
compilability — a numeric index into a named dimension does not resolve, but that is a property of
the target's own equation, which carries the same index.

Verification

  • C-LEARN numeric gate, green on every production HEAD of the LOOKUP arc: 236.39s (0f1f0e9b), 236.73s (db6dc850), 236.72s (fb0863b0) — three runs within 0.4s of each other. Also green at 242.30s on 391bc3c1, the commit that could falsify the pinning design, on the model whose element names are ambiguous across dimensions. Used as a negative control as well as a smoke test: had any real C-LEARN partial contained a runtime table index, the refusal in db6dc850 would have dropped its score and the pinned-loop assertion would have gone to zero. It didn't, so the tightening is narrow in practice.
  • cargo test -p simlin-engine --lib: 5280 passed / 0 failed / 3 ignored; integration 637 passed / 0 failed.
  • All 16 char goldens byte-identical at every commit. Zero golden regenerations on this branch.
  • scripts/lint-project.sh, cargo clippy --all-targets, and cargo check -p simlin-engine --lib --no-default-features all clean; full pre-commit hook green on all 9 commits.
  • Every fix mutation-verified; probes restored byte-for-byte with the editor, never git checkout.

What remains on #965

Criteria 1-4 are met. Criterion 5 is substantially met with one documented exception, so this does
not close the issue:

  • subscript_idents_at_element's re-parse (site 2147) is byte-load-bearing. The wrap constructs
    PREVIOUS uppercase; four goldens carry lowercase previous( only because print -> re-parse
    lowercases function names, while arrayed_target_slot_scores.txt carries the uppercase spelling
    on a path where no re-parse happens. Both spellings are pinned in the corpus today. Deleting the
    site therefore requires normalizing the generators to one spelling and recapturing those goldens —
    a case-only change, mechanically checkable, and the right end state since it makes print -> parse a
    genuine no-op. Deferred deliberately rather than done at the end of a long branch; the decision is
    made and the four conditions (case-fold-identical diff, one uniform spelling, semantic-inertness
    proof, recorded as the single intentional golden change) are recorded.
  • The two AggNode feeders (sites 1916/1981) are blocked on a design question, not effort.
    Expr0's PartialEq includes Loc, and AggNode is salsa-cached with its equation_text
    documented as "the canonical (Loc-insensitive) key the node was deduped on". Storing an Expr0
    beside it makes the cache entry position-sensitive where the key is deliberately
    position-insensitive — an incremental-invalidation change. Decide explicitly whether to store
    it loc-stripped.
  • Generation-side guard forms stay format! templates by choice. Converting them to AST builders
    would re-parenthesise and re-space every emitted equation, churning all 16 goldens to produce
    equivalent text — trading the corpus's entire value for an aesthetic.

Filed from this work

The table-argument rule: six findings, and what closed the class

The LOOKUP table-argument pin absorbed six review findings. Reported in full because the shape of
the iteration is the useful part, and because a reviewer should know which residual remains.

The findings, in order: not pinned at all (silent zero); a runtime index left live (misattribution);
over-declining inside a freeze; @N and a mapped dimension name misfiled as runtime reads; a missing
StarRange row (self-found); a constant-expression index (1 + 1) misfiled; and subscript
name-collision precedence. Every one after the second was a cell — a shape in the wrong bucket —
not a flaw in the design.

Three structural changes closed the class, rather than the individual cells:

  1. The catch-all is gone. Unrecognized shapes were being silently filed as RuntimeRead. The
    match over Expr0 is now exhaustive with no catch-all, so a new variant is a compile error
    instead of a silent refusal.
  2. Rows are derived from the type, not from what reviewers found. IndexExpr0 has exactly five
    variants; the table now names all five, which makes "no gaps" checkable. Subscript arity beyond
    the over-arity row is a documented non-row: codegen::extract_table_info accepts a table
    argument only when the subscript selects exactly one element, so an under-arity subscript is
    BadTable before link scores exist, and rowing it would state a verdict about a shape no
    compilable model can produce.
  3. Precedence is anchored to the compiler. See the thesis note below.

Residual risk, stated plainly: this rule may still loudly refuse a statically-resolvable index no
row covers. The consequence is a warned skip and a dropped score, never a wrong number. The
silent direction — a runtime read left live, which misattributes — is guarded by the RuntimeRead
verdict plus the freeze gate and is pinned in both directions by mutation. Two deliberate boundaries
are stated rather than hidden: a 0-arity builtin index stays loud (telling TIME from PI inside
App would be a fourth copy of the builtin classification that builtins/compiler::invariance
own), and three unreachable rows (range, wildcard, star-range) are marked uncertain for one traced
reason rather than three hand-waves.

Verdict enumeration: 3 tables, 52 cells. Main 18 rows × 2 freeze columns, mapped 6 row-runs × 2
(3 rows across both declaration directions), collision 2 × 2. Ten mutation probes, all caught, and
seven of the ten are caught by rows that already existed — the table doing the job a per-shape
test cannot. Every emitted cell also asserts the live site kept its bare row, so a fixture that is
not a genuine PerElement instantiation cannot pass. The collision table carries a control row
(the same axis's non-colliding element) so a fix that merely special-cased the colliding name fails
one of the two.

Single-sourcing eliminates drift; it does not establish truth

This branch's thesis is that one derivation should serve every consumer. One finding sharpened it,
and the correction matters more than the fix.

Subscript index precedence was resolved dimension-name-first, on the stated grounds of mirroring
classify_axis_access
. But XMILE lets a dimension declare an element whose name is also a
dimension name, and compiler/subscript.rs's normalize_subscripts3 is element-first — its own
comment says "takes priority". So the analysis-layer classifier disagrees with the compiler, and
"mirror the shared classifier" is what propagated the defect into the new rule.

A shared derivation that disagrees with the compiler is a single source of a consistent error.
The authority for what an equation means is the compiler; the correct instruction is not "match the
shared derivation" but "match the compiler, and share that." Fixed here by ordering the rule the
compiler's way. classify_axis_access itself is left wrong and filed as #986 — it feeds reducer
hoisting, the reference-shape IR, element-edge emission and link-score naming, so reordering it can
move goldens and needs its own diff and verification. Today its dimension-first reading is
accidentally safe (it finds no mapping, declines, and falls to the conservative path); #986 records
that the collision-plus-mapping case is a genuine wrong-answer hazard there, not mere imprecision.

What C-LEARN does and does not gate here

Worth being precise, because a green numeric gate is easy to overclaim. clearn_pinned_climate_loop_is_scored
asserts Discovery mode, three partition entries, and that the deterministic series is finite,
not identically zero, and negative where non-zero. It asserts no magnitudes and no warning count.

So for a tightening it is a strong control — a refusal that dropped a real score zeroes the series
and trips the non-zero assertion. For a widening it is nearly blind, and three of the four fixes
here widen what gets scored.

What actually makes it inert is structural, not luck: C-LEARN's 199 subscripted lookup-table calls
(Historical forestry LOOKUP[COP](…) and siblings) all use literal element indices — the one
index form this arc never changed — and those heads are table-only holders, excluded from every
runlist, so they cannot be causal sources and the rule is never reached for them. Five C-LEARN runs
across the arc landed in a 2.3s band (235.78 / 236.39 / 236.72 / 236.73 / 238.03s), all green.

The honest conclusion: C-LEARN does not exercise the shapes these four fixes changed. Their
coverage is the three new end-to-end tests (@N, mapped dimension, name collision), each of which
compiles a real model and asserts zero assembly warnings plus a finite non-zero score. C-LEARN's role
here is a consistency check that nothing else moved.

Method note

Worth recording honestly, because it is the reusable part.

Stage 391bc3c1 created three pin-only descents (a pre-existing PREVIOUS/INIT, a
whole-frozen reducer, and a LOOKUP table argument). Two were probed; the third was not, and that
is where the regression lived. It surfaced only because a review pass left a probe test behind. A
later probe of the PREVIOUS/INIT descent then found that deleting it left all 5274 lib and all
634 integration tests green
— zero coverage on a second descent. The rule: derive the probe count
from the branch's branch structure, not from when probing starts to feel sufficient.

Four test-scaffolding divergences surfaced across this work, every one by mutation rather than by
review:

  1. the whole-reducer descent had no coverage;
  2. the cfg(test) occurrence builder synthesized only Pinned/Dynamic and never Iterated, so those tests leaned on the classifier rather than the occurrence stream they claimed to build;
  3. it had no MismatchedIterated arm, so a non-aligning dimension name came out Dynamic — invisible because every consumer treats the two identically;
  4. expr_reference_idents documented "returns an empty set when the text does not parse", but that result is the freeze set — an empty one emits a partial that freezes nothing, i.e. the target's own equation, scoring a constant |Δz/Δz| = 1. The ltm-augment: parse fallback can silently break ceteris-paribus logic #311 hazard, behind a docstring calling the fallback benign.

Review passes used --base origin/roundtrips-track-a so each read this branch's commits rather
than re-reading #980's 14; the #981 finding in equation.rs belongs to #980's stack and is
disclosed there.

Review is a sampling process, and this branch measured it rather than assuming it. Six passes:

pass result
1 1 × P2 — the LOOKUP pin regression
2 clean
3 1 × P2 — runtime index left live (in the tree pass 2 had just read clean)
4 1 × P2 — the previous fix over-declined inside freezes
5 clean
6 clean

Green here required two consecutive completed clean passes, and that bar is justified by
measurement, not argument: pass 2 read the tree clean and pass 3 found a P2 in it. A single
clean pass in this area has been demonstrated worthless once. (The same thing happened on #980,
where pass 1 read dea42188 clean and pass 3 found a P2 in that same file.) An aborted or
quota-blocked pass counted as neither clean nor dirty — one pass was verified as a genuine
completion via turn.completed and exit code rather than by grepping for a quota string, since the
obvious pattern quota false-matched this branch's own test name unquotable_source_name.

bpowers added 12 commits July 19, 2026 20:00
The ceteris-paribus wrap took the target's equation as printed TEXT and
parsed it -- but patch::expr2_to_string IS print_eqn(expr2_to_expr0(e)),
so that parse was reconstructing a tree the caller already held in
exactly this shape. The wrap and the three per-element / agg generators
now take &Expr0 lowered straight from the target's Expr2, which deletes
two of the eight Expr0::new sites outright (wrap_changed_first_ast, and
shaped_guard_form_text's changed-last re-parse).

The reason this is more than parse cost: db::ltm_ir computes every
occurrence's SiteId on the Expr2 tree, and the wrap addresses those
occurrences by the child-index path it tracks as it descends. Handing
the wrap the direct lowering makes the two path spaces the same tree by
CONSTRUCTION, where before they coincided only because print-then-parse
happened to round-trip -- a property assert_occurrence_stream_aligns
could sample but never establish. The new corpus property
assert_lowering_matches_reparse states the equivalence that makes the
swap byte-neutral: structural equality modulo Loc and modulo raw-ident
SPELLING, the two coordinates no consumer reads directly (every head
goes through canonicalize/Ident::new on read and print_ident on write,
and Ident::to_source_repr renders the module separator back as '.' while
the parser returns a quoted name with its quotes). It runs on every
alignment/agreement fixture plus a new fixture built from the shapes a
printer/parser asymmetry would most plausibly reshape -- negated
constants, zero-argument builtins, the word operators, right-associative
^, If in operand position, the variadic builtins, LOOKUP's table slot,
and every subscript index form.

shaped_guard_form_text's changed-last leg documented its parse as a
deliberate cheap re-parse on a rare doomed path. The cost argument was
correct and is beside the point: that parse was the only reason the
function needed the text at all, and while it stood the two attribution
conventions could in principle walk different trees. Both legs now start
from the one AST the caller owns.

One parse deliberately survives here, moved into
scalar_or_a2a_target_expr: a target that failed to LOWER has no Expr2,
only the user-authored datamodel equation string. That is a genuine
source-format boundary rather than a re-parse of engine output, so it
keeps the loud PartialEquationError::Parse channel -- which is now the
only way that error can fire on the aux/stock path, since the wrap
itself became infallible.
The `PerElement` row-pinning lowering ran as a pass over the ALREADY-WRAPPED
`Expr0`. A `SiteId` is computed on the target's original `Expr2`, and the wrap
inserts `PREVIOUS(...)` nodes, so the paths no longer line up and the occurrence
IR could not drive that pass -- which is why it re-derived every occurrence's
per-axis access with its own Expr0 classifier
(classify_expr0_per_element_axes / expr0_iterated_axis_lines_up), the last two
Expr0-side classifiers left in production. The pinning now runs FROM the wrap as
it descends, reading OccurrenceSite::axes by path, and those two are gone from
production along with IteratedDimCtx::dim_ctx, the duplicate DimensionsContext
that existed only to feed them.

The obvious alternative -- pin FIRST, on the original AST, where the paths do
line up -- is wrong, and the corpus already says so. Pinning is shape-preserving
(it rewrites leaf Var names inside subscript indices), so it looks like it should
be fully occurrence-addressable. But the lowering emits two spellings: the
occurrence the wrap leaves LIVE gets the row with BARE element names, and every
occurrence the wrap FREEZES gets the same row QUALIFIED (region·boston), so the
frozen read compiles to a direct LoadPrev rather than an ambiguous bare element
name. Which side an occurrence lands on is not a function of its axes:
per_element_index_nested_occurrence emits both spellings of ONE site shape in one
equation, because subtree_has_live_shape excludes index-nested occurrences.
Pin-first cannot see the difference and would spell the nested one bare, and on a
model whose element names are ambiguous across dimensions (C-LEARN's regions)
the bare spelling is the one that fails to compile -- a silent zero, not a
cosmetic diff.

So the wrap threads a `frozen` flag beside `path`, and at the two points where it
deliberately stops descending -- a pre-existing PREVIOUS/INIT call, and the
GH #517 whole-frozen reducer -- it runs a pin-only descent: same path cursor,
same occurrence IR, pins (always qualified, since it is inside a freeze) and
wraps nothing. That is the shape `86df68bf`'s rustdoc rejected, and the rejection
was aimed at something else: it rejected wrap-time occurrence TAGGING, which
needs either a descent whose only purpose is to write tags or a Loc-keyed side
map -- a second representation of the classification, keyed on a coordinate the
wrap rewrites. A pin-only descent carries no map and writes no tag; it reads the
one IR by path and performs the actual lowering.

Per-axis equivalence is exact, not approximate. The retired classifier accepted
an index only when it was an iterated-dimension name lined up with the source's
axis at that position, or a literal element of that axis -- and its lineup check
consulted the SAME ltm_agg::classify_axis_access the IR uses, gate for gate. So
"every axis Iterated or Pinned, arity equal to the source's declared arity"
(axes_as_read_slice) is the same predicate; Reduced, MismatchedIterated and
Dynamic all fall out as not-describable exactly where the old one returned None,
and an over-arity index can never be Iterated because the IR only consults
classify_axis_access where the source has an axis at that position.

All 16 char goldens are byte-identical. Two of the three mutation probes are
caught by them and caught NARROWLY: ignoring the live/frozen distinction fails
eight per-element goldens, and dropping the frozen propagation into a wrapped
node's indices fails exactly per_element_index_nested_occurrence -- the golden
that discriminates the two designs. The third slipped through the entire corpus:
deleting the pin-only descent under a whole-frozen reducer left all 5272 tests
green, because no fixture reaches a PerElement source occurrence nested inside a
reducer that freezes whole. That shape needs an index-nested occurrence (so
subtree_has_live_shape excludes it) inside a reducer that therefore carries no
live reference, and un-pinned it leaves a dimension-name subscript in a scalar
fragment that cannot compile -- the silent zero this track exists to delete.
per_element_pin_reaches_inside_a_whole_frozen_reducer closes it and fails on that
mutation.

The `#[cfg(test)]` Expr0 classifier family moves to
ltm_augment_wrap_test_support, beside the occurrence builder that is now its only
consumer. That makes its status structural rather than an attribute a reader has
to notice, and keeps ltm_augment.rs under the line cap (the in-wrap pinning
pushed it to 6078; it is now 5655). The test occurrence builder also gained the
Iterated arm it was missing: it only ever synthesized Pinned/Dynamic axes, so it
could not have driven the pinning at all -- a divergence from production that was
hiding in the tests, which is the worst place for one.
`OccurrenceSite` carried five classified facts with no production reader,
documented as such and justified by "the remaining GH #965 generation-half work
consumes them". The generation half is this branch, and it consumes exactly one
of them -- so the other four go, along with `OccurrenceRouting` and the
`matching_aggs_for` closure that existed only to populate `routing`.

The decisive one is `routing`, and it is worse than unread: it is a DUPLICATED
DERIVATION. `model_ltm_reference_sites` held two copies of the GH #793 narrowing
-- route a reference only through aggs minted for one of its ENCLOSING reducers
-- one in `matching_aggs_for`, whose only caller was the occurrence finalize, and
one inline in the `ClassifiedSite` loop. Deleting the per-occurrence routing
deletes one of the two. A second implementation of one rule, inside the IR that
exists to end second implementations of one rule, is the exact thing this track
is about.

`reducer_keys` was needed only to compute that routing, so it goes with it (the
coarser `in_reducer` bit it also fed is kept as a bool). `target_element` and
`already_lagged` are per-occurrence duplicates of facts their consumers already
hold: the generators are handed the target element explicitly, and the wrap
recognizes a pre-existing `PREVIOUS`/`INIT` at the node itself. Between them
that is an `Option<String>`, a `Vec<String>`, a `Vec<AggRef>` and a `bool` per
occurrence removed from a salsa-cached per-model map -- which is not nothing
while GH #977 stands.

`in_reducer` stays because it HAS a consumer, not because it might get one: the
corpus gate reads it to exclude reducer arguments from the per-occurrence shape
comparison and to assert both streams agree on reducer context. Deleting it
would silently narrow that gate. It is also the cheap half of what
`reducer_keys` carried, at the only granularity anything asks for.

A prediction this corrects: `already_lagged` was expected to gain its first
production reader when the row pinning moved into the wrap, as the `frozen`
flag. It did not. `frozen` must also cover the freezes the WRAP ITSELF inserts,
which no field of an occurrence can describe, so the wrap computes it
structurally and `already_lagged` stayed unread.

The IR pins for the deleted fields go too -- that is the point of the
"production consumer or IR pin" rule, not a loophole around it. Two of them are
rewritten rather than dropped, because the FACT they pinned still exists at a
different granularity: the hoisted-reducer test now asserts `ThroughAgg` on the
per-EDGE `ClassifiedSite`, where the routing actually lives, and both keep their
assertions that the occurrence preserves the RAW walker shape where the per-edge
view reclassifies `Wildcard` -> `DynamicIndex`. The `already_lagged` walker pin
is deleted outright; the behavior it protected (the wrap never re-wraps
already-lagged content) is pinned end-to-end by `char_already_lagged_other_dep`.

Not done here, deliberately: `in_reducer` is ALSO the natural signal for the
GH #779 bare-reducer-feeder decline, which today re-derives "inside a scalar
reducer" with its own walk. Consuming it there is not behavior-neutral, because
the two decisions use two different reducer SETS --
`reducer_collapses_to_scalar` (includes SIZE, excludes RANK) versus
`builtin_routes_through_agg` (excludes SIZE, includes RANK) -- so a bare source
inside `RANK(...)` would flip from scored to loudly declined. That divergence
between adjacent decisions is a real latent drift surface and gets an issue, not
a silent unification.
Moving the `PerElement` row pinning into the ceteris-paribus wrap made
`OccurrenceSite::axes` load-bearing for the emitted equation: the pinning reads
the per-axis classification to decide which index is a projected coordinate and
which is a fixed literal. The `#[cfg(test)]` occurrence builder synthesizes that
same field so the text-in/text-out wrap unit tests can drive the real wrap
without a db -- and the corpus gate compared only `shape`, so nothing proved the
builder's AXES matched the IR's. The gate now compares them.

Writing the comparison immediately found the divergence it was meant to find. The
builder had no `MismatchedIterated` arm: a target-iterated dimension name that
does NOT line up positionally with the source's axis (the GH #526 case) came out
`Dynamic`. Every consumer treats the two identically -- both are
"not describable per axis", so the pinning declines either way -- which is
exactly why it could sit there unnoticed. It is fixed by mirroring
`classify_occurrence_axes`'s arm order rather than by relaxing the assertion,
because the value of the gate is that the reconstruction is faithful, not that it
is close enough for today's consumers.

The Expr0 classifier family stays `#[cfg(test)]` rather than being deleted, and
this is the reason: at least three wrap unit tests cannot be expressed as db
fixtures at all. Two feed deliberately unparseable and empty text (they exist to
pin the loud parse-failure contract). The third is
`"SUM(w[from]) + from"` -- the Fig. 2 Q4 index-nested reducer-freeze selection --
which the engine REJECTS as a model (`ArrayReferenceNeedsExplicitSubscripts`, a
scalar used as a subscript index; the same reason
`char_reducer_index_nested_freeze`'s own model does not compile). That one needs
an occurrence stream, so there is no db-backed substitute for it. Deleting the
builder would mean deleting the test, and the shape it pins is one where a
mutation was already shown to pass the whole corpus while changing the emitted
text.

So the seam is kept deliberately, and this commit is what makes keeping it
honest: one classifier family in production, a reconstruction confined to test
support, and a corpus gate that now proves every field of that reconstruction the
production wrap actually reads -- `site_id` path, `shape`, `axes`, `in_reducer`.
`ClassifiedReducer` walked the target's `Expr2` to find the reducer applied to
the source and then handed back its array argument as printed TEXT, which every
consumer immediately parsed back. Since `patch::expr2_to_string` IS
`print_eqn(expr2_to_expr0(..))`, that was a parse of our own print of a tree the
classifier was holding in exactly that shape. `body_text: String` becomes
`body: Expr0`, and with it three more of the eight `Expr0::new` sites go:
`pinned_body_row_terms`, `generate_linear_body_partial`, and
`expr_reference_idents`.

Two of those parses had failure modes that are now structurally impossible rather
than merely unreachable, and one of them was SILENT. `expr_reference_idents`
documented "returns an empty set when the text does not parse" -- and its result
is the freeze set (`model_deps` / `arrayed_dep_dims`) the row partial holds at
`PREVIOUS`. An empty freeze set does not fail: it emits a "partial" that freezes
nothing, i.e. the target's own equation, whose score magnitude is a constant
|Δz/Δz| = 1. That is the GH #311 hazard exactly, reachable through a function
whose docstring described the fallback as if it were benign. The other two bailed
to the delta-ratio fallback on a parse failure, which was loud enough but is now
moot. Nothing in the failure handling was load-bearing, because there is no
longer a parse to fail.

`ClassifiedReducer` loses `Eq` and gates `Debug` on `debug-derive`: `Expr0`
carries an `f64` on `Const`, so it is `PartialEq` only, and its `Debug` is
feature-gated. `--no-default-features --lib` still builds, which
`src/simlin-engine/Cargo.toml` documents as the constraint feature-gating hygiene
must be checked against.

Not done here, deliberately: the two remaining agg-equation feeders
(`generate_scalar_feeder_to_agg_equation` /
`generate_iterated_feeder_to_agg_equation`) parse `AggNode::equation_text`, and
carrying an AST there is not the same mechanical change. `AggNode` is
salsa-cached, its `Debug` derive is unconditional, and -- the real obstacle --
`Expr0`'s `PartialEq` includes `Loc`, so storing one would make a salsa cache
entry position-SENSITIVE where the text key it sits beside is deliberately
position-insensitive ("the canonical (`Loc`-insensitive) key the node was deduped
on"). That wants a decision about whether to store the AST loc-stripped, not an
edit.
Regression from the previous commit, found by a reviewer probe. A `PerElement`
source occurrence inside a `LOOKUP` TABLE argument came out UN-PINNED:

    lookup(pop[region, young], PREVIOUS(input))

That dimension-name subscript cannot resolve in a scalar link-score fragment. The
fragment is dropped, the variable keeps a layout slot with no bytecode, and the
score reads a constant 0 -- the silent zero this track exists to delete,
reintroduced by the change that was deleting them.

The cause is a genuine tension, not an oversight in the walk. `db::ltm_ir` records
NO occurrence under a table argument (`BuiltinContents::LookupTable(_) => {}` --
"a graphical-function table reference is static data, not a causal edge"), and it
is right: the table reference carries no causal edge. But the pin such a reference
needs is about COMPILABILITY, not attribution. The previous arrangement pinned it
anyway, because it was a pass over the wrapped tree that re-classified with the
Expr0 classifier and so needed no occurrence. The IR-driven pinning correctly
refuses to guess -- and then said nothing, which is the actual defect.

So it now says so: no occurrence at a SUBSCRIPTED source reference sets
`WrapOutcome::missing_occurrence`, which every wrap caller already converts into
a warned skip. Loud degradation over a silent zero, the same contract the rest of
the wrap follows.

Chosen over the two alternatives deliberately. Pinning it structurally from the
source's declared dimension names would restore the old output exactly, but it
means a small per-axis rule living outside the IR -- reintroducing in miniature
the classifier the previous commit deleted, for a shape whose reachability from a
real model I could not establish (a `LOOKUP` table argument resolves to a
lookup-only variable, which has no value slot and so can never be a link-score
source, or to the WITH-LOOKUP self-reference, whose head is the TARGET rather than
the source). Recording table-argument occurrences in the IR would make them
live-selectable by the wrap, changing what it holds live on unrelated models. If
the warning ever fires on a real model, that is the signal to support the shape
properly rather than to guess at it.

The scope is narrow by construction: the other two pin-only descents (a
pre-existing `PREVIOUS`/`INIT` call, a whole-frozen reducer) ARE walked by the IR,
so they cannot trip this, and a BARE `Var` cannot either -- `pin_bare_source_ref`
pins it structurally from the source's own declared dims and needs no occurrence.
No char golden changes; none has a `LOOKUP` inside a partial at all, which is
exactly why nothing caught this.
The architecture doc still described the ceteris-paribus wrap as working "via
`wrap_non_matching_in_previous` and `classify_expr0_subscript_shape`" -- a
classifier that is `#[cfg(test)]`-only and now lives in a different file. A stale
pointer to a deleted seam in the map is the same drift the seam's deletion was
about, and this file's own maintenance note asks for it to be kept current.

It now says what is true: production never parses a target equation (the wrap
takes an `Expr0` lowered from `Expr2`, and `expr2_to_string` is the print of that
same lowering, so the former round trip was a parse of our own output); every
per-occurrence decision is an occurrence-IR lookup by structural path, which
equals the `SiteId` by construction now that both walk the same tree; there is
ONE access-shape classifier family, on `Expr2`; and the Expr0 mirror is
test-support only, kept because three wrap unit tests cannot be db-backed
fixtures, with the corpus gate proving it matches the IR field for field. It also
records the one place the IR deliberately has nothing to say -- a `LOOKUP` table
argument -- and that a source reference there is declined LOUDLY rather than
pinned by guess.

Also documents the four `#[path]`-mounted `ltm_augment` siblings, which the map
never mentioned at all -- including that the row pinning is called FROM the wrap
as it descends rather than as a pass over its output, since that is the
non-obvious part a reader would otherwise have to reconstruct.
`8d3ee4ab` declined the whole per-element partial when a source subscript had no
recorded occurrence, and the only shape that reaches that is a `LOOKUP` TABLE
argument. The decline is wrong, and worse than the un-pinned emission it
replaced: a target can read the source normally AND in a table argument, and the
decline threw away the direct site's scores too.

    target[Region] = effect[Region,young] + LOOKUP(effect[Region,old], TIME)

The first term is a perfectly good scoreable `PerElement` site. Because the same
target also mentions `effect` in a table argument, the partial returned
`UnfreezablePartial` and EVERY per-element score of the `effect -> target` edge
was dropped with a warning. The shape is also constructible and deliberately
supported: `codegen::extract_table_info` resolves a table argument by offset ->
owning ident with nothing restricting it to lookup-only variables, and carries an
explicit `StaticSubscript` arm for `LOOKUP(g[<single element>], x)`. A variable
with tables AND a real equation is value-bearing (`var_is_lookup_only` wants an
empty/sentinel equation), has a series, and can be a causal source.

The tension `8d3ee4ab` identified is real but resolves the other way. `db::ltm_ir`
records no occurrence under a table argument ("static data, not a causal edge")
and it is RIGHT: no causal edge, no attribution, no score. The lowering is
separately obliged to emit a COMPILABLE scalar fragment, and a dimension-name
subscript cannot resolve in one. Two obligations, and discharging the second does
not require the first -- so `pin_dimension_name_indices` discharges it by NAME,
consulting no occurrence and inferring no shape: an index spelling the dimension
the source declares AT THAT POSITION becomes this target element's coordinate for
it, and an index that axis declares as an element is qualified by that axis.
`pin_bare_source_ref` has always done exactly this structurally for a bare `Var`.
It is a lowering-completeness rule, not a second classifier, and it cannot make
the reference live-selectable: the pin-only descent records no `live_ref` and
spells every pin qualified. The output is byte-identical to the pre-`391bc3c1`
pass's, which is what a regression fix should be.

The loud net stays BEHIND it, for the class the rule provably cannot describe: an
index naming a mapped or transposed dimension (`pop[State, old]` on a source over
`[Region, Age]`) still sets `missing_occurrence`. Deciding that `State` reads
`Region` through a positional mapping rather than being a mismatch IS the per-axis
classification the IR owns, and it recorded nothing. Fixing the reachable shape
while leaving the class silent is how this survived in the first place.

Probing all three pin-only descents (rather than just the one that changed) found
that the pre-existing `PREVIOUS`/`INIT` descent had NO coverage at all: deleting
it left all 5274 lib tests and all 634 integration tests green, the same hole
`391bc3c1` closed for the whole-frozen reducer. Each descent now has exactly one
dedicated test that fails when it is deleted, and the four share one fixture --
per-test copies of the same `(Region, Age)` scaffolding were 60 lines each, which
also pushed `ltm_augment_tests.rs` past the file cap.

All 16 char goldens are byte-identical; none has a `LOOKUP` inside a partial at
all, which is exactly why nothing caught either the regression or the decline.
The new integration test is the end-to-end guard: a per-element-table source read
both ways compiles with zero assembly warnings, emits both per-element scores with
the table argument pinned to each target element's own row, and simulates to real
non-zero values.
The pin-only descent's index closure walked a `Range`'s two endpoints but passed
a plain `Expr` index straight through -- while its own comment said it descended
"exactly as the other-variable arm above does", and that arm handles both. So a
source reference nested in an EXPRESSION index of a `from`-headed subscript was
neither pinned nor flagged: its dimension-name subscript survived into the scalar
link-score fragment, which cannot compile, so the fragment was dropped and the
score read a constant 0. Same silent zero as the table-argument case, reached
through the sibling index kind.

It is reachable through the shape the previous commit fixed, which is why it
belongs here rather than in a follow-up: a table reference may carry a DYNAMIC
element index (`LOOKUP(pop[Region, pop[Region, young]], x)`), which
`codegen::extract_table_info`'s `Expr::Subscript` arm computes an element offset
for on purpose. The IR records nothing anywhere under a table argument, so BOTH
the outer reference and the one inside its index need the structural pin, and
before this only the outer one got it.

Found by probing all three pin-only descents rather than only the one that
changed. The new test fails on the mutation that removes the arm.
Reviewer finding against the previous two commits, and it turns on an asymmetry
those commits missed. Of the three pin-only descents, a `LOOKUP` TABLE argument is
the only one NOT inside a freeze: the wrap holds it verbatim rather than wrapping
it, because a `PREVIOUS` of a table has no value slot. So anything the descent
leaves live in a table argument stays live in the emitted partial.

For a statically-spelled table reference that is fine and is the whole point of
`5ce2aa69`: `LOOKUP(effect[Region, old], TIME)` selects an element by name, which
is not a value read, so pinning it is pure lowering. But an index that READS at
runtime is a different thing:

    LOOKUP(pop[Region, pop[Region, old]], x)
    LOOKUP(pop[Region, idx], x)

`codegen::extract_table_info` evaluates that index to choose the table element, so
the partial isolating `pop[Region, young]` also varies with the current-step value
of `old` -- one row's influence attributed to another. Pinning made those compile
(`0f1f0e9b`) without making them correct, which is the worse failure: a
compilable-but-wrong score is what GH #311 / #661 / #743 all exist to avoid, and it
is invisible where a dropped fragment at least warns.

A descent that wraps nothing cannot make them ceteris-paribus, so the rule now
discharges ONLY statically resolvable indices -- the axis's own dimension name, an
element that axis declares (or its already-qualified spelling), a numeric literal
-- and declines everything else into the existing loud skip. The declined class is
now uniform: a runtime read, another dimension's name, an over-arity index, an
arithmetic index, a range. Previously a bare name the axis did not declare fell
through unflagged, which is exactly how `pop[Region, idx]` slipped past.

The two descents that ARE inside a freeze need no such refusal, and this is why
`0f1f0e9b`'s expression-index descent stays: under a pre-existing `PREVIOUS`/`INIT`
or a whole-frozen reducer everything is already lagged, index reads included, so
pinning is sufficient and a second freeze would read two steps back. That commit's
test moves onto a `PREVIOUS`-nested fixture, where the descent is both reachable
and correct, and asserts the absence of the nested freeze.

The three decline shapes share one table-driven test rather than three near-copies
(also what keeps `ltm_augment_tests.rs` under the file cap). Both tightenings are
mutation-verified: accepting a non-static index, or accepting a bare name the axis
does not declare, each fails it. All 16 char goldens stay byte-identical.

Not addressed here, and pre-existing: the wrap leaves a table argument's indices
untouched for EVERY caller (`ctx.pin` `None` included), so `LOOKUP(g[idx], x)` in
any link-score partial keeps `idx` live. That is the same attribution defect
outside the `PerElement` path, it predates this branch, and fixing it means giving
the wrap's own index pass the table argument. Filed separately.
Reviewer finding against `db6dc850`, whose refusal was keyed on the wrong thing. It
asked "is this a LOOKUP table argument?" when the question that matters is "is this
subtree already inside a freeze?" -- and those differ, because a `LOOKUP` can sit
INSIDE one of the other two pin-only descents:

    growth[Region] = pop[Region, young] + PREVIOUS(LOOKUP(pop[Region, idx], x))

The IR skips a table argument wherever it appears, so this reaches the same
no-occurrence path -- but the enclosing `PREVIOUS` lags everything inside it, index
reads included. Ceteris paribus already holds, and refusing dropped a perfectly
good per-element score plus every loop score through the edge. That is the same
over-decline `5ce2aa69` exists to undo, reintroduced one level down.

So the two loud verdicts are now distinguished, because only one of them depends on
the freeze. `IndexVerdict::Unspellable` -- another dimension's name, an axis this
target does not project, an over-arity index -- is a COMPILABILITY verdict: left
alone it keeps a dimension-name subscript that cannot resolve in a scalar fragment,
so it is loud everywhere, a freeze included. `IndexVerdict::RuntimeRead` -- a
variable or an expression selecting the element -- compiles fine, so it is purely a
CETERIS-PARIBUS verdict: loud in a bare table argument (which stays live), kept
inside a freeze (which lags it). The wrap already threads a `frozen` flag beside
`path` for exactly this kind of question, so the descent takes it too: `true` at the
`PREVIOUS`/`INIT` and whole-frozen-reducer sites, and the wrap's own `frozen` at the
table-argument site, which inherits the enclosing freeze and nothing more.

Naming the four possible verdicts is what makes the split legible; the previous
`Option<String>`-plus-bool encoding is what let two different reasons for declining
get conflated in the first place.

Three mutations, three distinct failures, no overlap: ignoring the freeze (always
decline) fails the frozen test, dropping the refusal entirely fails the bare test,
and gating `Unspellable` on the freeze fails the frozen test's second half. That
third probe initially passed -- nothing covered "unspellable inside a freeze is
still loud" -- so the frozen test carries both halves now.

The pin-descent tests move to their own `ltm_augment_pin_tests.rs`, mounted as a
child of the `tests` module so `use super::*` still resolves every helper. They had
pushed `ltm_augment_tests.rs` past the file cap, and they are a cohesive family:
one test per descent, each failing when its descent is deleted, plus the two
obligations (compile, stay ceteris-paribus) the file header now states.

All 16 char goldens byte-identical.
Every defect this branch fixed in the row-pinning rule was a CELL rather than a
logic error: one index shape landing in the wrong bucket. The `391bc3c1`
regression, and both review findings since, were each fixed after someone supplied
the counterexample -- which means the tests covered the cases people happened to
construct, not the space.

So the space is now stated. Thirteen index kinds x {bare table argument, inside a
freeze} = 26 cells, each with its verdict: pinned to a spelling, or declined into
the warned skip. The four static-selector rows and the four unspellable rows are
identical in both columns; only the three runtime-read rows differ, and that
difference IS the compilability-vs-ceteris-paribus split, now visible as a shape in
the table rather than as prose in a rustdoc.

Every cell matched the current implementation on the first run, so this adds no
production change -- it converts "the reviewed cases are right" into "every case
has a stated verdict". It is load-bearing rather than decorative: all four
mutations of the verdict logic (always-decline a runtime read, never-decline one,
gate `Unspellable` on the freeze, stop qualifying a declared element) fail this
test independently of the seven per-shape tests beside it.

Two rows are marked UNREACHABLE and assert current behavior rather than a derived
requirement, because a compilable model cannot produce them: codegen's
`extract_table_info` rejects a range or wildcard table index with `BadTable`, so
the target's OWN equation fails to compile before its link scores exist. Their
frozen cells are honestly uncertain -- the rule accepts them today, and if they
became reachable the failure would surface as a fragment-compile Warning instead
of a warned skip, loud either way through a different channel. Left as-is
deliberately rather than guessed into `Unspellable`, since no reachable shape
distinguishes the two choices. The numeric-literal row is likewise about the
rule's verdict only: a numeric index into a NAMED dimension does not resolve, but
that is a property of the target's own equation, which carries the same index.

Non-vacuity is asserted per cell, not once: every case re-checks that the IR
records NOTHING for the table argument, so a cell can never silently end up
testing the occurrence walker instead of the rule.
@claude

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

Review — no blocking findings

I reviewed the twelve commits across the six substantive files (db/ltm/link_scores.rs, ltm_augment.rs, ltm_augment_post_transform.rs, db/ltm_ir.rs, and the pin/wrap/agreement test files). Parallelised the pass across the four bundles and re-read the load-bearing spots.

What I checked and cleared:

  1. link_scores.rs signature flip (elem_text: &strelem_eqn: Option<&Expr0>): the String::new() sentinel and the new None arm map 1:1. The empty string was only ever produced by the explicit Arrayed no-slot arm, and the warning text produced via print_eqn(body_eqn) is byte-identical to expr2_to_string(body_text) per patch.rs. No semantics drift.
  2. wrap_changed_first_ast becoming infallible: the only surviving PartialEquationError::Parse channel on this stack is scalar_or_a2a_target_expr, which is a genuine source-format boundary (user-authored datamodel text). Correct.
  3. Occurrence path ≡ wrap child-index path by construction: traced pin_only_source_refs against walk_all_in_expr. Paths match on Subscript (enumerate() matches path.push(i as u16)), Range endpoints, and App(LOOKUP,…) (child 0 table, child 1 index).
  4. pin_dimension_name_indices split (pin / Unspellable / RuntimeRead): the three-way verdict is encoded coherently in verdict_into_index, and the freeze gate is correctly threaded from the three pin-only descent sites. The 26-cell enumeration in fa34e26b is a real gate (four mutation kills asserted independent of the per-shape tests).
  5. OccurrenceSite field removals: target_element, routing, reducer_keys, already_lagged — verified no production readers remain. in_reducer is kept and its rustdoc names its (test-only, gate-side) consumer explicitly.

One minor observation, not a blocker. In db/ltm_ir_tests.rs:1032-1043, hoisted_reducer_occurrence_keeps_the_raw_wildcard_shape moved the routing check from a per-occurrence assert_eq! on both sides (bare = Direct, reduced = ThroughAgg) to a single sites.iter().any(|s| matches!(s.routing, SiteRouting::ThroughAgg { .. })) on the per-edge view. It still catches "the hoisted side isn't ThroughAgg", but a hypothetical regression that promoted the bare numerator to ThroughAgg too would now pass. Existing ClassifiedSite characterization goldens cover this at the edge level, so leaving it as-is is defensible — flagging it in case you want a matching negative assertion (bare site's routing is Direct) to keep the test's positive-and-negative shape.

Overall correctness verdict: correct. The core claim — that the wrap's child-index path space now equals the IR's SiteId path space by construction rather than by a corpus-proven print/reparse isomorphism — checks out. The three fixes to the 391bc3c1 regression (5ce2aa69 / 0f1f0e9b / db6dc850 / fb0863b0) converge on the right invariant (compilability vs ceteris-paribus split by descent context), and the verdict enumeration proves the table shape rather than testing what someone happened to construct.

@bpowers

bpowers commented Jul 20, 2026

Copy link
Copy Markdown
Owner Author

Review pass 7 (over the verdict enumeration itself) found two P2s, both in pin_dimension_name_indices. The PR body's claim that the enumeration "came back all-green" is accurate as to what was run and incomplete as to what was true — being corrected:

  • IndexExpr0::DimPosition (@N) falls into the catch-all and is classified RuntimeRead, so a bare table argument declines and drops every score for the edge — though the compiler resolves @N to a static offset. The enumeration's 13 index kinds did not include DimPosition.
  • A mapped dimension name is classified Unspellable solely because it differs from the source's axis name, so effect[State, young] + LOOKUP(effect[State, old], time) under a positional State -> Region mapping drops the whole edge. The retired mapped-aware classifier handled this.

Root cause is shared: the rule hand-rolls per-axis name resolution that already exists in single-source form (ltm_agg::classify_axis_access, DimensionsContext::mapped_element_correspondence). Being fixed by consulting those rather than by adding arms, with the enumeration extended to cover @N, mapped pairs in both declaration directions, element-mapped pairs, and subdimensions.

Worth recording the methodological point: mutation testing proved the table constrains the code — four verdict mutations fail it independently of the per-shape tests — but it cannot show the table asserts the right verdicts. A cell matching the implementation because both are wrong is invisible to mutation by construction. That gap is exactly what this pass closed, and it is why the enumeration was reviewed rather than trusted.

Not ready to merge until two consecutive clean passes land, at least one after the enumeration extension.

bpowers added 3 commits July 20, 2026 00:12
`pin_dimension_name_indices` -- the lowering-completeness rule that pins a
`LOOKUP` table argument's row, the one source reference the occurrence IR
deliberately records nothing for -- decided each index's verdict by comparing
names itself. Two shapes it could have resolved were therefore filed loud, and a
loud index declines the whole partial, dropping every link score on the edge
rather than just the table argument's (which earns none anyway).

A MAPPED dimension name was judged unspellable purely because it differed from
the source axis's name. `target[State] = effect[State,young] +
LOOKUP(effect[State,old], time)` over an `effect[Region,Age]` source is a valid
model when a positional State/Region mapping exists: the simulation reads
Region's element at the position of the State element being computed. Which
element that is, is exactly what `per_element_row_for_target` answers -- already
the ONE row derivation the score's NAME and the occurrence-driven pin both
consume -- through `DimensionsContext::mapped_element_correspondence`. So the
identity axis and a mapped axis collapse into one arm, gated on the target
actually iterating the dimension, which is `ltm_agg::classify_axis_access`'s own
gate. Both bottom out in the same correspondence, so the rule now accepts
exactly the mapped pairs the IR does, in either declaration direction (GH #757),
and declines an explicit element map for the GH #756 reason. `Unspellable` now
means the shared derivation could not resolve the axis, not that a name differed.

An `@N` POSITION index reached the catch-all and scored a runtime read, which
declines a bare table argument on ceteris-paribus grounds. It selects a FIXED
element and reads nothing at the current step, and `compiler::context` resolves
`DimPosition` to a concrete element offset in scalar context -- which a
link-score fragment is -- so it is kept verbatim. Spelling it out as an element
name here would be a second implementation of position syntax outside the
compiler that owns it; the new end-to-end test compiles a fragment with `@2`
intact rather than taking that on faith.

`classify_axis_access` itself is deliberately NOT the helper consulted. It
consumes `IndexExpr2` where this descent walks the `Expr0` the wrap lowered, and
it answers the hoisting question rather than the compilability one -- which is
why it declines `@N`, correctly there and wrongly here, and why it carries a
`Reduced` verdict a pin cannot use. The shared ANSWER this rule needs is one
level down, at the row derivation and the correspondence.

The enumeration grows from 13 rows to 18 over two tables; the mapped rows need
their own, since a mapped axis requires a target dimension the source does not
declare. Every emitted cell now also asserts the live site kept its bare row, so
a fixture that is not a `PerElement` instantiation can no longer pass. Both
defects were missing ROWS in an all-green table, which is the lesson: a mutation
probe shows that a table constrains the code and nothing about whether its rows
cover the space.
Both defects the last commit fixed were missing ROWS, so "no gaps" should be a
checkable claim rather than an assertion. `IndexExpr0` has exactly five variants
and `StarRange` had no row -- the one variant the enumeration never named. It
lands in the same unreachable class as the range and wildcard rows and for the
same single reason, now stated once: `codegen::extract_table_info` accepts a
table argument only when the subscript selects exactly ONE element (a resolved
`Var`, a `StaticSubscript` of `view.size() == 1`, or a `Subscript` whose every
index is `SubscriptIndex::Single`), so anything wider is `BadTable` before link
scores are generated.

That reason also settles subscript ARITY, which the table deliberately does not
vary beyond the existing over-arity row: an UNDER-arity subscript leaves its
unindexed axes in the view, so it is `BadTable` too. Rowing it would state a
verdict about a shape no compilable model can produce, which is the thing this
table is supposed to stop doing.

A probe confirms the new row is distinguishable rather than a duplicate of the
wildcard row: giving `StarRange` a `Static` arm of its own moves that row and
nothing else.
Third finding of the same shape as the first two: a static selector filed as a
runtime read, so a bare `LOOKUP` table argument declined and every link and loop
score on the edge was dropped. `LOOKUP(effect[Region, 1 + 1], time)` reads no
variable at any step, so it is exactly as static as the bare `2` the rule already
left alone -- the catch-all simply never looked INSIDE a compound index. The
target compiles and so does the fragment; only the score went missing, which the
new end-to-end test reproduced as a warned skip before the fix.

`index_expr_selects_a_fixed_element` is deliberately the narrowest sound
predicate rather than a general invariance test.
`compiler::invariance::exprs_are_invariant` is the engine's run-invariance
derivation, but it consumes LOWERED `Expr` plus an offset-classification callback
-- neither of which exists in this position -- and its notion is WIDER than this
rule's obligation: `Dt`, `StartTime`, and `INIT(x)` of any variable are all
run-invariant, yet whether a table index may read a variable's init buffer is an
attribution question, and attribution is the wrap's vocabulary, not this rule's.
So the predicate decides only the half it can decide alone, and its match over
`Expr0` is exhaustive with no catch-all, so a new variant forces a decision here
instead of silently inheriting a refusal.

The widening stops at a 0-arity BUILTIN index, and that boundary is stated rather
than left as a gap. `reify_0_arity_builtins` turns `time` into an `Expr0::App`
before this rule sees it, and telling `TIME` (varies every step) from `PI` (does
not) inside `App` would mean a fourth copy of the builtin classification
`builtins`/`compiler::invariance` own. The arm stays loud in a bare table
argument -- the conservative direction, a warned skip rather than a confident
wrong score -- and it now has an enumeration row saying so, so the next reviewer
reads a decision instead of an oversight.

18 rows now, and four probes pin the predicate's boundaries: deleting the arm,
widening it to `Var`, widening it to `App`, and making it non-recursive are each
caught, the last three by rows that already existed.
XMILE lets a dimension declare an element whose name is also a dimension name
(`Category = [Region, x]` beside a `Region` dimension), and the table-argument pin
resolved such an index as the DIMENSION first. The compiler does the opposite:
`normalize_subscripts3`'s `Expr3::Var` arm looks the name up in the axis's own
`indexed_elements` and only falls back to the dimension-name `ActiveDimRef`
reading if that misses -- its comment says "takes priority". So for
`target[Region] = ... LOOKUP(effect[Region, Region, old], TIME)` over an
`effect[Region, Category, Age]` source, the pin contradicted the equation's actual
meaning.

Both wrong outcomes matter, in the two directions this branch cares about. With no
`Region`/`Category` mapping the dimension reading finds no correspondence and
declines, dropping every score on a valid edge -- the silent-zero direction. With
such a mapping it would pin the correspondence's element instead of the literal
one: a compilable, confidently WRONG row, which is the outcome worse than none.
The fix is to try the axis-element reading first; `qualify_axis_element` already
tests exactly the axis's own `indexed_elements`, the same membership the compiler
tests, so this is a reordering rather than new knowledge.

The previous ordering was justified in the rustdoc as mirroring
`ltm_agg::classify_axis_access`, which was mirroring the wrong thing --
`classify_axis_access` checks `target_iterated_dims` first and so disagrees with
the compiler in the same way. That is filed as GH #986 rather than fixed here: it
is a precision gap today (its dimension-first reading then finds no mapping and
declines, so the reference falls to the conservative path instead of being
mis-resolved) and it feeds reducer hoisting, the reference-shape IR, and
element-edge emission, so reordering it moves decisions well outside this rule.
The rustdoc now records the divergence as deliberate and points at the issue.

The new enumeration carries a CONTROL row -- the same axis's other, non-colliding
element -- so a fix that merely special-cased the colliding name would not pass
both. The probe is a precise inversion (let the dimension arm claim a name it
shares with an axis element) rather than a deletion, and it moves exactly the
collision rows and nothing else.
@claude

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

Review — no blocking issues found

Two independent review passes across the ~10.8k-line diff, focused on the areas the PR description flagged as having taken multiple rounds:

  • pin_dimension_name_indices — the three-verdict split (pin / Unspellable / RuntimeRead) is correctly gated on frozen; IndexVerdict arms match the described semantics; the numeric-literal cell returns the input unchanged; index_expr_selects_a_fixed_element enumerates all seven Expr0 variants with no _ catch-all.
  • pin_only_source_refs — takes no live/frozen decision by signature; the three call sites pass frozen correctly (PREVIOUS/INIT: true; whole-frozen reducer: true; LOOKUP table arg: inherits parent).
  • assert_lowering_matches_reparse — strips both Loc and raw-ident spelling; fixture list covers LOOKUP's table slot, unary-minus, right-associative ^, IF in operand position, and every subscript index form; non-vacuity gate is slots >= 20.
  • shaped_guard_form_texterr_text = || print_eqn(target_expr) closes over the input AST; both legs .clone() the pristine target_expr, so the changed-first and changed-last paths walk structurally identical trees.
  • wrap_non_matching_in_previous child_frozen threading — computed once as frozen || will_wrap and propagated into recurse_index; mutating it to bare frozen fails the one golden the PR names.
  • OccurrenceSite field deletions (target_element, routing, reducer_keys, already_lagged) — no production reader remains against OccurrenceSite; grep hits are on the distinct ClassifiedSite / ReferenceSite types that legitimately still carry those fields.

Path/SiteId alignment between the wrap's tracked child-index path and walk_all_in_expr's per-arm pushes was also cross-checked (Op1, Op2, If, Subscript, App/LOOKUP) — they agree.

Verdict: correct. No P0/P1/P2 findings.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant