Skip to content

engine: LTM cross-element aggregate scoring (aggregate nodes) - #519

Merged
bpowers merged 37 commits into
mainfrom
ltm-503-cross-element-agg
May 10, 2026
Merged

bpowers merged 37 commits into
mainfrom
ltm-503-cross-element-agg

Conversation

@bpowers

@bpowers bpowers commented May 10, 2026 •

Copy link
Copy Markdown
Owner

Implements GH #503 (which was closed manually as part of this work — the repo forbids auto-close keywords). Design: docs/design-plans/2026-05-09-ltm-503-cross-element-agg.md. Updated user-facing behavior doc: docs/design/ltm--loops-that-matter.md ("Aggregate Nodes" section). Manual verification plan: docs/test-plans/2026-05-09-ltm-503-cross-element-agg.md.

Fixes: #516
Fixes: #517

Summary

LTM ("Loops That Matter") scored cross-element feedback loops from the diagonal apply-to-all link scores instead of the actual per-element path, and lumped inlined array reducers into a single "Wildcard" link score that omitted each source element's fractional contribution to the aggregate's velocity. This branch fixes that:

  • Aggregate nodes. Each maximal inlined array-reducer subexpression (SUM(pop[*]), MEAN(...), SUM(m[D1,*]), ...) is hoisted into a synthetic auxiliary $⁚ltm⁚agg⁚{n} (enumerate_agg_nodes, new src/simlin-engine/src/ltm_agg.rs). Causality routes source[d] → agg → target (O(N+M)) instead of an all-pairs source[d] → target[e] cross-product; both halves get real per-element link scores that compose by the chain rule; the agg node is trimmed from a loop when the loop is reported (like a macro's hidden internal nodes). A variable whose entire RHS is one reducer maps to itself — no synthetic minted. Model equations are not rewritten. This resolves the diagonal conflation (share[r] = pop[r]/SUM(pop[*]): the numerator path and the SUM path are now distinct loops, scored separately) and fixes heterogeneous-magnitude scoring (the per-element |Δpop[d]/Δagg| factor is present and non-constant across d).
  • Ast::Arrayed (per-element-equation) link-score targets now carry meaningful per-element partial equations derived from each element's own equation, instead of a "0" placeholder. LtmSyntheticVar.equation changed from String to datamodel::Equation.
  • Cross-element loops are scored on the element-level path with element-subscripted link-score references ("$⁚ltm⁚link_score⁚{from}→{to}"[e]), in both the exhaustive and discovery modes.
  • Scalar→arrayed link scores are emitted as per-target-element scalar variables ($⁚ltm⁚link_score⁚{from}→{to}[{elem}]), so discovery's per-timestep search graph traverses the correct element graph (previously it invented a scalar_source[elem] phantom node and the loop was unreachable).
  • Partial reduces (agg[D1] = SUM(matrix[D1,*])) are supported by the reducer link-score machinery — per-(reduced-elem, result-elem) scalar link scores.
  • Cleanup: the now-obviated ⁚wildcard/⁚dynamic per-shape link-score path is retired (link_score_var_name suffix arms, strip_to_shape_suffix_with_rank, ShapeRank::Wildcard/::DynamicIndex, shape_aware_source_ref's TODO). The RefShape::Wildcard/DynamicIndex enum variants survive as the reference-site walker's "route through an agg node" markers. Also fixed a latent identical-zero bug where shape_aware_source_ref spelled a bare arrayed source in a scalar guard equation (a dimension error → uncompilable fragment).
  • Agg-hop link polarity is now derived (GH ltm: cross-element loops through aggregate nodes always classify as Undetermined (agg-hop link polarity is always Unknown) #516). A loop that routes through a synthetic $⁚ltm⁚agg⁚{n} node used to classify Undetermined: the loop builders derive every link's polarity from the variable-level causal graph, but agg nodes exist only in the element graph, so the hop came back Unknown and forced the whole loop to Undetermined. model_ltm_variables now patches these hops on the detected loops — source[d] → agg is Positive for a monotone reducer (SUM/MEAN/MIN/MAX); agg → consumer is the polarity of the consumer's equation w.r.t. the reducer subexpression, computed by substituting the reducer with the agg name and running the ordinary static polarity analysis (CausalGraph::agg_consumer_polarity) — and re-derives each affected loop's polarity (and re-assigns IDs, whose r/b/u prefix is polarity-derived). STDDEV/RANK aggs stay Unknown (not monotone).
  • Inlined-reducer ceteris-paribus partials no longer zero out (GH engine: SUM(PREVIOUS(arr[*])) silently evaluates to 0.0 under an active A2A dimension (no LoadPrev-of-array-view path) #517). The Bare-reference partial for a target that also contains an inlined reducer (share[r] = pop[r] / SUM(pop[*]) → the pop → share link score) wrapped the array view inside the reducer in PREVIOUS — pop[r] / SUM(PREVIOUS(pop[*])) — which is silently 0.0 at every step under an active apply-to-all dimension because codegen has no LoadPrev-of-array-view path. wrap_non_matching_in_previous now wraps the whole reducer App — pop[r] / PREVIOUS(SUM(pop[*])), which is PREVIOUS of a scalar and evaluates correctly — unless the reducer itself carries the live reference. This is the correct ceteris-paribus partial (Δpop[r] / D(t-1) over Δshare[r]); it also makes the numerator-path loop's link score real, so it correctly competes with the aggregate path in discovery's strongest-path heuristic. (The general codegen fix for LoadPrev-of-array-view stays open for the not-yet-hoisted slice cases.)
  • No regression on scalar / pure-A2A models: simulates_population_ltm (vs test/logistic_growth_ltm/ltm_results.tsv), the WRLD3 LTM smoke, and the arrayed-population LTM tests pass with unchanged expected values; no golden TSV was modified.

Implemented in 6 phases (each individually code-reviewed) plus the two follow-up fixes above; 37 commits. All 22 acceptance criteria (ltm-503-cross-element-agg.AC1.1 .. AC7.2) are covered by passing unit/integration tests or completed verifications; the GH #516 / #517 fixes add their own tests (db_ltm_unified_tests::cross_element_loop_through_agg_is_recovered polarity assertion; ltm_augment::test_partial_equation_* for the whole-reducer PREVIOUS wrapping). cargo test --workspace is green in ~20s, well under the 3-minute cap; the pre-commit hook passed on every commit.

Remaining pre-existing limitations discovered along the way are filed (not fixed — out of scope): #510, #511, #514, #515 (LTM follow-ups, referenced from epic #488 and/or docs/tech-debt.md), #518 (db.rs is at the 6000-line lint cap and could use a split), and #520 (unify the LTM element-graph and link-score reference-site walkers behind one shared classification IR — the current "they agree" invariant is enforced by convention + tests rather than shared code). Epic #488's #503 checklist box is ticked; #516 and #517 are addressed here.

Test plan

  • cargo test --workspace green (also the pre-commit hook end-to-end).
  • cargo test -p simlin-engine --features file_io --test simulate_ltm green.
  • cargo clippy --workspace --all-targets -- -D warnings clean; cargo fmt -- --check clean.
  • Manual: follow docs/test-plans/2026-05-09-ltm-503-cross-element-agg.md — build the share[r] = pop[r]/SUM(pop[*]) model with heterogeneous inits, simulate --ltm, and confirm the $⁚ltm⁚agg⁚0 aux, the per-source-element pop[d]→agg link scores (whose |Δpop[d]/Δagg| magnitudes differ markedly between the large and small region), the agg→share[e] link scores, the Bare numerator pop→share link score (now pop[r] / PREVIOUS(SUM(pop[*])) — non-zero), no ⁚wildcard/⁚dynamic columns, and the loop scores = product over the un-trimmed agg-traversing path.
  • Manual: confirm model_detected_loops surfaces no $⁚ltm⁚agg⁚{n} node in any reported loop (agg trimmed from reporting), and that a cross-element-through-agg loop classifies R/B (not U) when the reducer is SUM/MEAN/MIN/MAX.
  • Manual: WRLD3 LTM smoke + logistic-growth golden — unchanged.

test added 30 commits May 9, 2026 10:57
The LTM synthetic-variable record carried its equation as a bare `String`
plus a separate `dimensions: Vec<String>` field. Phase 1's feature work
needs a link-score variable to be able to hold an `Equation::Arrayed`
(per-element partial equations for arrayed-target link scores), so the
`equation` field becomes a `datamodel::Equation`.

This is a representation prerequisite only -- no behavior change. The
link-score generators now return `datamodel::Equation` (Scalar for scalar
targets, ApplyToAll for arrayed targets; Arrayed is added in Task 2), the
shared link-score-guard wrapping is factored into one helper, and every
`LtmSyntheticVar` constructor keeps the equation's dimension names in
lockstep with the retained `dimensions` field (which `compute_layout` and
`parse_link_offsets` still key off of). `parse_ltm_equation` /
`compile_ltm_equation_fragment` take the typed equation instead of a
`(text, dims)` pair. `LtmSyntheticVar`/`LtmVariablesResult` drop `Eq` and
gate `Debug` on `debug-derive` (the embedded `GraphicalFunction` carries
f64 points and datamodel types only derive `Debug` under that feature);
salsa only needs `PartialEq` and nothing uses either type as a hash key.
When a link-score target's AST is `Ast::Arrayed` (per-element-equation
form), the link-score generator previously fell through to a `"0"`
placeholder partial -- so a cross-element aux like
`migration_pressure[NYC] = (population[NYC] - population[Boston]) * 0.01`
got an `ApplyToAll` equation whose partial was the constant `0`, and the
link score was meaningless.

Now `generate_auxiliary_to_auxiliary_equation` and
`generate_stock_to_flow_equation` route an `Ast::Arrayed` target through
`build_arrayed_link_score_equation`, which emits an `Equation::Arrayed`
over the target's dimensions whose per-element slot equation is the
standard link-score guard form with that element's *own* partial: the
slot for `migration_pressure[NYC]` carries
`(population[nyc] - PREVIOUS(population[boston])) * 0.01` when the link
score's shape is `FixedIndex(["nyc"])`. An element whose equation doesn't
reference the source with this shape gets its source refs frozen, so that
slot evaluates to ~0 -- correct, because that source-element's influence
flows through a different `(from[other], to)` link-score variable.

The per-element dep set is computed by walking each slot's expression
alone (the union over all slots would over-freeze) with the target's
dimensions passed in so literal element-name subscripts are not mistaken
for variable references. The shared "wrap a partial in the guard form"
logic is `link_score_guard_form`; the scalar/A2A equation-text extraction
is factored into `scalar_or_a2a_target_equation_text`. Output slots are
sorted by element name so the salsa-tracked result is deterministic.
`test_stock_to_flow_link_score_handles_apply_to_all` pinned the
`Equation::ApplyToAll` flow case; this adds the sibling for the
`Ast::Arrayed` (per-element-equation) flow case that the prior commit
fixed. A `population[Region]` stock with a per-element-equation
`births[Region]` inflow now yields an `Equation::Arrayed` link score
whose every per-element slot references the flow's actual equation
contents and carries no `(0)` placeholder partial.

The non-shaped `link_score_equation_text` scalarizes its result, so the
test goes through `link_score_equation_text_shaped` with a `FixedIndex`
shape (each `births[e]` references `population[e]`, which is how the
per-shape emission keys these link scores).
End-to-end check on the `cross_element_ltm` fixture: the
`migration_pressure[boston] -> migration_in` link score (where
`migration_in` is a per-element-equation flow) now carries a meaningful
per-element partial instead of the pre-fix `"0"`-partial-derived value
that was far from 1.

`migration_in[NYC] = MAX(migration_pressure[Boston] * -1, 0)`, so the
partial w.r.t. live `migration_pressure[boston]` is exactly all of
`migration_in[NYC]` -- `Δpartial == Δmigration_in[NYC]` and
`ABS(SAFEDIV(Δ, Δ)) == 1` at every step where `Δ != 0` (which holds for
t >= 2, since `migration_pressure[Boston] < 0` throughout and the
population gap keeps growing under the uniform birth rate). The Boston
slot references only `migration_pressure[nyc]` (frozen at PREVIOUS under
the `FixedIndex(boston)` shape) and `migration_in[Boston]` is constantly
0, so that slot is identically 0.
…est)

Two follow-ups from the Phase 1 code review of the LTM cross-element
aggregate scoring work:

- Document why `scalar_or_a2a_target_equation_text` falls back to the
  raw datamodel `eqn` text (and ultimately `"0"`): the path is reached
  only when the target variable failed to lower its equation to an AST,
  in which case using whatever scalar equation text it still carries is
  strictly more useful than a `"0"` partial -- the stock-to-flow
  generator has always done this for the same variable shape. The
  duplicated `Variable::{Stock,Var}` match arm is extracted into
  `scalar_eqn_text_or_zero` so the rationale lives in one place.
- Add unit tests for `Equation::source_text()` covering the scalar,
  Apply-to-All, and Arrayed variants, including the Arrayed-with-EXCEPT-
  default branch that no prior test exercised.

No behavior change.
`retarget_ltm_equation_dims`'s doc comment promises that empty `dims`
collapses the equation to `Scalar`, and the call site in
`emit_per_shape_link_scores` documents that an incompatible-dimensions
arrayed-target edge produces a scalar link score. The `Arrayed` arm,
however, unconditionally rebuilt an `Arrayed` with `dims.to_vec()` -- so
for empty `dims` it yielded a degenerate zero-dimension `Arrayed` rather
than a `Scalar`. That arm assumed a per-element link-score equation is
only ever emitted for a target with non-empty dims, but the
arrayed-target / incompatible-source-dims edge (whose target also has
per-element equations) reaches it: `try_cross_dimensional_link_scores`
declines non-scalar targets, `link_score_dimensions` returns `[]` for
the incompatible-dims case, and `build_arrayed_link_score_equation`
produces an `Equation::Arrayed`.

Route the empty-`dims` `Arrayed` case through the existing
`scalarize_ltm_equation` helper so the function matches its own
contract. A zero-dimension `Arrayed` is meaningless -- its per-element
partials have no target dimension to index -- so collapsing is the
correct choice.
Adds a direct unit test for the previously untested Equation::Arrayed arm
of scalarize_ltm_equation (multi-slot picks the first slot, no slots with
an EXCEPT default picks the default, no slots and no default falls back to
"0"), plus quick guards for the Scalar/ApplyToAll arms. The function now
has a second caller (retarget_ltm_equation_dims, for collapsing a
degenerate zero-dimension Arrayed); its rustdoc previously named only the
legacy link-score path, so add a note explaining why that second caller
collapses to scalar.
`resolve_link_score_name_for_loop` gains a `target_element` parameter and
`generate_loop_score_equation` learns to emit a subscripted reference
`"$⁚ltm⁚link_score⁚{from}→{to}"[e]` when a loop link's `to` node carries
an element subscript -- which happens (after Phase 2's loop-builder
rewrite) when the target link-score variable is A2A and the cross-element
loop visits a single element of it. A bracketed `from` is now resolved by
trying the per-source-element FixedIndex name first and the variable-level
(Bare / Wildcard / DynamicIndex) name as a fallback, so the structural
flow-to-stock link score on a cross-element loop edge resolves to the
variable-level A2A name it is actually emitted under. `find_fixed_index_
emitted_name` prefers an exact `{from}[{e}]->{to}` match when the visited
element is known instead of guessing alphabetically.

No behavior change on its own: with `target_element = None` everywhere
(the only callers until the loop builder produces element-subscripted
`Link.to`s) the output is byte-identical to before.
`build_element_level_loops`'s `is_cross_element` branch no longer collapses
a cross-element circuit onto the diagonal apply-to-all link scores. The
"shortest unique cycle" stripping and the diagonal `circuit_to_links`
call are gone; instead each circuit becomes its own scalar `Loop` whose
`Link`s keep the element subscripts (`population[nyc]`,
`migration_pressure[boston]`, ...) -- `from` is kept subscripted when the
source node is subscripted, and `to` is kept subscripted when the target
node is subscripted and its variable is dimensioned (so the loop visits a
single element of an A2A link score). `generate_loop_score_equation` then
emits `"$⁚ltm⁚link_score⁚{from}->{to}"[e]` for each such hop, so a loop
like `population[nyc] -> migration_pressure[boston] -> migration_in[nyc] ->
population[nyc]` is scored as the product of the actual per-element link
scores along its path. A new `build_element_subscripted_links` helper
factors out the per-link rule; the element-level stock collection (needed
by `partition_for_loop`, which keys element-level) is retained.

The link-score emission loop for exhaustive mode strips the subscript off
both ends of each loop `Link` before calling `try_cross_dimensional_link_
scores` / `emit_per_shape_link_scores` (which key on variable-level
names), so the full per-element link score for each `(var_from, var_to)`
edge is emitted and the loop-score equation's `[elem]` subscript picks the
slot the loop visits.

Pure-A2A loops (the dimensioned-loop-score branch) and the mixed/scalar
branch are unchanged. Cross-element circuits are no longer deduped into a
single Loop, so a model like the `cross_element_ltm` fixture gets one
loop-score variable per cross-element circuit; the postscript-measurement
fixtures count element-level circuits (unchanged), not Loops.
The Phase 2 rewrite left two minor papercuts. The `build_element_level_loops`
visibility comment still claimed unit tests inspect a per-link `shape` field;
there has never been one -- the element-subscripted access is encoded in the
`Link.from` / `Link.to` strings -- so the comment now describes that
accurately. And the rewrite added a second copy of `strip_subscript` (plus a
sibling `split_node_subscript`) in `ltm_augment.rs` alongside the existing one
in `db::db_ltm`. Both modules already lean on `crate::ltm`, which is where the
small LTM string/cycle helpers live (`canonical_rotation`,
`lex_smallest_rotation_start`), so the two subscript helpers move there as
`pub(crate)` and both call sites import them. The `db_analysis` mirror-comment
is updated to point at the new home.
These two pure helpers moved into crate::ltm in the Phase 2 cross-element
aggregate work and previously had no direct coverage (they were exercised
only via callers in db_ltm / ltm_augment). Add example-based tests next to
the existing canonical_rotation helper tests covering bare names,
single-element subscripts, and multi-dimensional subscripts so the
identifier-parsing semantics are pinned independently of any caller.
A scalar-source -> arrayed-target link score (e.g. `total_pop -> migration`
where `total_pop` is scalar and `migration[Region]` is arrayed) was emitted
as a single Bare-A2A `LtmSyntheticVar` with `dimensions = [target_dims]`.
That form is undiscoverable: `parse_link_offsets`'s `expand_a2a_link_offsets`
subscripts *both* sides over `target_dims`, inventing a phantom
`total_pop[nyc]` node that doesn't match the unsubscripted `total_pop` node
the reducer edges (`pop[d] -> total_pop`) produce, so any loop through
`total_pop` is unreachable in the strongest-path search graph.

Emit one scalar `LtmSyntheticVar` per target element instead, named
`$:ltm:link_score:{from}->{to}[{elem}]` with `dimensions: vec![]`, mirroring
the arrayed->scalar `{from}[{elem}]->{to}` convention from
`try_cross_dimensional_link_scores`. `parse_link_offsets`'s `[`-in-`to`
single-passthrough branch parses these to `(from, to[elem])` with no parser
change. New `try_scalar_to_arrayed_link_scores` in `db_ltm.rs` is routed
ahead of `emit_per_shape_link_scores` in both link-score emission loops;
`link_score_dimensions` now returns `vec![]` for the scalar-source case so
the routing-bypass fallback degrades to a harmless scalar Bare var rather
than a phantom-node-bearing A2A one. Per-element equations come from
`generate_scalar_to_element_equation` / `subscript_idents_at_element` in
`ltm_augment.rs`: the partial of `to[elem]`'s equation w.r.t. `from` live,
arrayed deps pinned to `elem`, wrapped in the standard link-score guard
form. `assemble_module` compiles any link score whose name carries a `[`
on either side of the arrow directly from `ltm_var.equation` rather than
through the `(from, to)`-keyed salsa path (which can't round-trip a
bracketed name back to a user variable).

So the system stays consistent, this also threads the per-target-element
name through `build_element_level_loops`'s mixed/scalar branch (keep the
`to[e]` subscript when the target is arrayed) and the loop-score-equation
generator (`loop_link_score_ref`: reference the per-element scalar variable
bare when it exists, else fall back to the dimensioned-A2A subscript-after-
quote form); the Bare-A2A link-score name is no longer emitted for these
edges, so loop-score equations that used to reference it now reference the
per-element scalar variables instead.
The per-target-element emission and the loop-score-equation wiring landed
together in the previous commit (a green build requires both: emitting the
new `$:ltm:link_score:{from}->{to}[{elem}]` names without updating
consumers leaves loop-score equations referencing a no-longer-emitted
Bare-A2A name). This adds the dedicated coverage:

- `scalar_reducer_loop_score_uses_per_element_link_scores` (unified tests):
  the loop `population[nyc] -> total_pop -> migration[nyc] ->
  population[nyc]` -- a scalar reducer (`total_pop = SUM(population[*])`)
  factored out of the per-element migration flow -- has its loop-score
  equation built from exactly the three per-element link-score references
  along its element-level path: `"...population[nyc]->total_pop"`,
  `"...total_pop->migration[nyc]"` (the new scalar->arrayed per-target-
  element name, referenced bare), and `"...migration->population"[nyc]`
  (the structural flow->stock A2A link score, subscripted-after-quote).
  It also asserts no loop score references the retired Bare-A2A
  `total_pop->migration` name.

- `test_scalar_reducer_loop_score_value_matches_hand_calc` (simulate_ltm):
  the same loop's `loop_score` series equals the product of its three
  per-element link-score slots at every step t >= 2 (within 1e-6) and is
  non-zero at some step.
Discovery-mode end-to-end coverage for the cross-element loop-finding the
preceding two commits enable (no new production logic):

- `test_cross_element_ltm_discovery` (tightened): on the `cross_element_ltm`
  fixture, assert that some discovered loop carries the genuine
  cross-element edge `population[nyc] -> migration_pressure[boston]` (or
  the symmetric `population[boston] -> migration_pressure[nyc]`) -- the
  FixedIndex-source A2A link score `population[nyc]->migration_pressure`
  expands via `expand_fixed_from_a2a_link_offsets` to per-target-element
  edges, keeping that edge in the search graph -- not merely "some
  subscripted loop".

- `test_scalar_reducer_loop_discovery` (new): on a model that factors a
  scalar reducer (`total_pop = SUM(population[*])`) out of the per-element
  migration flow, assert discovery finds the loop `population[*] ->
  total_pop -> migration[r] -> population[r]` with `total_pop` *unsubscripted*
  on both incident edges (`(population[nyc], total_pop)` and `(total_pop,
  migration[nyc])`), and that no loop endpoint is a phantom `total_pop[nyc]`.
  Pre-fix this loop was silently undiscoverable: `total_pop -> migration`
  was a Bare-A2A link score with `dimensions = ["Region"]`, so
  `parse_link_offsets`'s `expand_a2a_link_offsets` subscripted both sides
  and the invented `total_pop[nyc]` node never matched the unsubscripted
  `total_pop` from the reducer edge.

`discovery_loops_have_link` / `discovery_loops_debug` test helpers added
for these assertions. No defensive guard added in `expand_a2a_link_offsets`:
it only has the link-score var's `dimensions`, not the source variable's
actual shape, so it can't tell a true Bare-A2A edge from a scalar->arrayed
one -- and Task 1 stops emitting Bare-A2A names for scalar->arrayed edges,
so the mis-expansion path is unreachable from emission.
Two Minor items from the Phase 3 (LTM cross-element aggregate scoring)
code review:

- src/simlin-engine/CLAUDE.md described only the arrayed-source ->
  scalar-target reducer link score helper (try_cross_dimensional_link_scores)
  in the db_ltm.rs paragraph; add the symmetric clause for the new sibling
  try_scalar_to_arrayed_link_scores (scalar source -> arrayed target,
  element in the `to` side), including why a single Bare-A2A var would be
  undiscoverable.

- try_scalar_to_arrayed_link_scores recomputed the target's identifier set
  (and its equation text) inside the per-element loop for the ApplyToAll
  arm, where both are element-invariant. Split the per-element loop by AST
  variant: the ApplyToAll case computes body/deps/deps-to-pin once before
  the loop; the Arrayed case keeps per-element recomputation, since each
  element genuinely has its own slot expression.
The reducer link-score machinery in `try_cross_dimensional_link_scores`
previously handled only full reduces -- an arrayed source feeding a
*scalar* target through SUM/MEAN/MIN/MAX/STDDEV/RANK -- and early-returned
None whenever the target was arrayed. That left partial reduces (`agg[D1]
= SUM(matrix[D1,*])`, which collapses only the D2 axis) unscored even
though `link_score_dimensions` already contained the partial-collapse
branch.

A partial reduce is recognized by the target's dims being a strict subset
of the source's (matched by name); the implied reduced axes are the
difference, so the co-reduced `matrix[d1,*]` slice for each row is derived
directly from the source element tuples without re-deriving the implicit
`[D1,*]` subscript. For each `(d1, d2)` pair the relevant target is only
`agg[d1]`, so the link score is a per-`(d1, d2)` *scalar* variable named
`$⁚ltm⁚link_score⁚matrix[d1,d2]→agg[d1]` (both axes ride in the source
subscript, only the surviving axis in the target subscript),
`dimensions: vec![]`. That naming is consistent with the existing
full-reduce per-source-element naming and rides `parse_link_offsets`'s
element-level-on-both-sides single-passthrough branch, so no parser change
is needed -- the alternative (`...→agg` with `dimensions = ["D1"]`) would
route through `expand_fixed_from_a2a_link_offsets`, broadcasting over D1
and producing wrong edges.

`generate_element_to_reduced_equation` is a sibling of
`generate_element_to_scalar_equation` that subscripts the target
reference by the result-axis element; both now share the wrapping body, so
the full-reduce path is byte-identical. STDDEV/RANK and nested reducers
keep their delta-ratio fallback against `agg[d1]`, unchanged (out of
scope: #483).
End-to-end coverage for AC4.6: a `matrix[D1,D2]` stock feeds `row_sum[D1]
= SUM(matrix[D1,*])` (a partial reduce) which feeds back into the stock, so
the loop `matrix[d1,d2] -> row_sum[d1] -> ... -> matrix[d1,d2]` runs over
the reduced axis. The test asserts the per-`(d1,d2)` partial-reduce link
scores are present with non-degenerate values (a SUM partial reduce splits
the row delta, so the per-element magnitudes are a fraction strictly
between 0 and 1 and sum to ~1), a loop-score equation references them, and
a hand-calc value relation holds.

Two gaps in loop participation surfaced and were fixed (the phase plan
anticipated these as minimal fixes the integration test would surface):

- The mixed/scalar branch of `build_element_level_loops` stripped the
  source subscript whenever the target node was arrayed, which collapsed
  `matrix[d1,d2] -> row_sum[d1]` to `matrix -> row_sum[d1]` -- but the
  partial-reduce link score is named with both axes in the source
  subscript. A new `is_partial_reduce_edge` predicate (same dims-difference
  rule `try_cross_dimensional_link_scores` uses) keeps both subscripts for
  that edge.

- `loop_link_score_ref` only matched the per-target-element scalar link
  score name (`$⁚ltm⁚link_score⁚{from}→{to}[{e}]`) when `link.from` was
  unsubscripted (the scalar-source -> arrayed-target case). The
  partial-reduce link score has the same name shape but an element-level
  `link.from`, so the guard was dropped -- the name is now matched
  verbatim for any `link.from`, which is safe because FixedIndex ->
  arrayed edges never emit that element-in-name form.

The conservative full-cross-product element graph for `SUM(matrix[D1,*])`
still produces spurious cross-element loops (Phase 5 tightens that); the
assertions only require that a real partial-reduce link score is emitted,
non-degenerate, and referenced -- not that the loop set is exactly the four
clean 3-cycles.
The part-(b) comment in test_partial_reduce_cross_element_loop described
the loop through row_sum as a 3-cycle (matrix -> row_sum -> growth ->
matrix), but growth references the scalar full-reduce total, not row_sum
directly, so the actual cycle is the 4-cycle the helper's doc comment
already describes (matrix -> row_sum -> total -> growth -> matrix).
Correct the comment to match, and reword the analogous mention earlier in
the test to not pin the cycle length -- the assertions only require that
some loop_score references a partial-reduce link score, independent of
which cycle it lands in. Comment-only change; no test logic touched.
Phase 5 of the LTM cross-element-aggregate-scoring design treats each
maximal inlined array-reducer subexpression (SUM(pop[*]), MEAN(...), ...)
as an implicit "aggregate node" so causality can route source[d] -> agg ->
target instead of all-pairs source[d] -> target[e]. This adds the shared,
salsa-tracked enumerator that both consumers (model_element_causal_edges
and model_ltm_variables, wired in later subcomponents) will use to agree on
a deterministic agg name for each subexpression.

A variable whose entire dt-equation is exactly one reducer call is its own
aggregate node (no synthetic minted); a reducer used as a sub-expression
mints $:ltm:agg:{n}; AST-identical subexpressions dedupe via printed
equation text (Expr2 is not Eq). Deterministic by construction: variables
visited in canonical-sorted order, AST walked left-to-right depth-first,
synthetic names assigned 0, 1, ... in first-encounter order.

Reducers over slices/partial reduces used as sub-expressions are
deliberately not recognized yet (the conservative full-cross-product
element graph stays in place for those); whole-RHS partial reduces
(row_sum[D1] = SUM(matrix[D1,*])) are recognized as variable-backed aggs
carrying their result dims.

No consumers yet -- the enumerator is registered in lib.rs and exported for
the following Phase 5 subcomponents.
…nt graph

Phase 5 of the LTM cross-element-aggregate-scoring work: `model_element_causal_edges`
now consults `enumerate_agg_nodes` so a Wildcard/DynamicIndex reducer reference to
`from` inside target `to` -- where `to`'s equation hoists a synthetic aggregate
node reading `from` -- emits `from[d] -> $⁚ltm⁚agg⁚n` (per source element) and
`$⁚ltm⁚agg⁚n -> to[e]` (per target element / scalar) instead of the all-pairs
cross-product. A reference to `from` that is not inside a hoisted reducer (a bare
numerator `pop[r]`, a FixedIndex `pop[NYC]`, a non-hoisted slice) still emits its
own edges normally; variable-backed aggs like `total_population = SUM(pop[*])`
are already real nodes and are not rerouted.

`model_loop_circuits_tiered`'s slow-path subgraph projection now keeps synthetic
agg nodes (they have no variable-level counterpart, but a cross-element loop
through a hoisted reducer genuinely traverses the agg, so dropping it would hide
that loop).

A slice-reducer subexpression (`x[r] = ... + SUM(pop[NYC, *])`) is deliberately
not hoisted as a synthetic agg: the agg descriptor only carries the source
variable name, not which elements the slice reads, so the reroute and the
per-element reducer link scores would over-approximate the unread rows with
nonzero garbage. Such a subexpression stays conservatively Wildcard-classified
(tracked as tech debt). A whole-RHS slice/partial reduce (`agg[D1] =
SUM(matrix[D1,*])`) is still recognized, but as a variable-backed agg.
Phase 5: `model_ltm_variables` now emits one `$⁚ltm⁚agg⁚{n}` synthetic
auxiliary per synthetic aggregate node from `enumerate_agg_nodes` -- a plain
computed aux whose equation is the maximal inlined reducer subexpression
(`SUM(pop[*])`, ...). The simulation evaluates it each timestep (so
`PREVIOUS(agg)` is available via the regular `prev_values` snapshot) and the
two link-score halves (`source[d] -> agg`, `agg -> target`) reference it.
Model equations are not rewritten -- the agg aux yields the same value as the
inline reducer. A whole-RHS reducer (`total_population = SUM(population[*])`)
mints no synthetic; the variable already is the aggregate node.

The agg vars get a sort category strictly before link-score vars in the
returned `vars` list, and the category function uses the `$⁚ltm⁚agg⁚` prefix
(not a `⁚agg⁚` substring search) so the `agg -> target` link score
(`$⁚ltm⁚link_score⁚$⁚ltm⁚agg⁚{n}->{to}`, which contains `⁚agg⁚`) stays a
link score that runs after the agg aux. `compute_layout` section 3 re-sorts
LTM vars purely by name -- `$⁚ltm⁚agg⁚{n}` < `$⁚ltm⁚link_score⁚...` lexically
-- so the agg gets its layout slot first there too; the runlist order (the
same-timestep ordering hazard) comes from the `model_ltm_variables` sort and
the `ltm_flow_names` iteration that follows it.

(The `agg -> target` link score equation emitted here still goes through the
generic per-shape fallback, which yields a zero partial because the reducer
subexpression isn't substituted by the agg name yet -- fixed in the next
commit.)
Phase 5: a Wildcard/DynamicIndex reducer reference to `from` inside target
`to` -- where `to` hoists a synthetic aggregate node `$⁚ltm⁚agg⁚n` reading
`from` -- now produces two link-score halves instead of a `…→to⁚wildcard`
per-shape link score:

  (a) `$⁚ltm⁚link_score⁚{from}[{d}]→{agg}` per source element of `from`: the
      source element's fractional contribution to the aggregate's velocity.
      The agg's own equation is exactly the reducer, so the "bare" algebraic
      shortcut applies (varying `from[d]` changes the agg by exactly its own
      delta regardless of what else the reducer combines).
  (b) `$⁚ltm⁚link_score⁚{agg}→{to}[{e}]` per target element (or a single
      scalar `$⁚ltm⁚link_score⁚{agg}→{to}` for a scalar `to`): the partial of
      `to`'s equation w.r.t. `agg` held live, with every hoisted reducer
      subexpression in `to` first AST-substituted by its agg name (so `agg`
      appears live where `SUM(...)` was and any other hoisted reducers end up
      as `PREVIOUS(agg_j)`).

The AST-subtree substitution (`substitute_reducers_in_equation`) matches on
the parsed-AST subtree, not a substring of the equation text, so a reducer
text that is a textual prefix of a different reducer subexpression
(`sum(p[*])` vs `sum(p[*] + 1)`) is never falsely matched.

The Bare numerator / FixedIndex references of `from` in `to` still get their
own (non-reducer-shape) link scores via `emit_per_shape_link_scores`. The
agg-routed loop links (`X -> agg`, `agg -> Y` -- the original `(X, Y)` causal
edge never appears in an element-level loop once routed) drive the same
emission from the loop-iteration path. No `…⁚wildcard`/`…⁚dynamic` link-score
vars are emitted when the reference routes through an agg (the `RefShape`
suffix arms and `link_score_var_name`'s wildcard/dynamic cases survive as
code -- Phase 6 deletes them; a non-hoisted slice/dynamic reference with no
agg keeps the conservative `…⁚wildcard` emission for now).
Phase 5 (Task 5): a cross-element feedback loop that runs through a hoisted
reducer visits the aggregate node more than once, so Johnson -- which
enumerates only elementary circuits -- never emits it directly. Each
agg-touching elementary circuit contributes one "petal" `agg → ... → agg`;
`build_element_level_loops` now combines `k ≥ 2` petals of the same agg whose
internal nodes are pairwise disjoint into the non-elementary cross-agg loops
(capped at MAX_AGG_PETALS=8 petals → ≤ 219 recovered loops per agg, to keep
the combinatorics bounded on wide arrays). The recovered loops' links and
polarities come from `build_element_subscripted_links`, so their loop-score
equations reference the per-element link scores along the un-trimmed path --
including the `pop[d]→agg` and `agg→share[e]` halves with `d ≠ e` (the
cross-element coupling through the aggregate, exactly what the design's AC4.2
asks for). The polarity of an agg hop is currently `Unknown` (the
variable-level polarity graph doesn't carry agg nodes), so a recovered loop
classifies as Undetermined -- improving that means teaching polarity analysis
about reducer monotonicity, which is out of scope (tech-debt #21).

The user-facing reported loops (`model_detected_loops`, variable-level) never
include synthetic agg nodes -- the aggregate is "trimmed" from the displayed
loop in the sense that the only graph that surfaces it is the element-level
one used internally to build the loop-score equations (and discovery's
element-level graph, where the agg node stays visible -- AC4.7's test checks
discovery finds a loop traversing it). A standalone cosmetic
`[X→agg, agg→Y] → [X→Y]` collapse of the element-level `Loop.links` is not
implemented (it would break the loop-score equations, which need the agg
halves, unless `Loop` carried a separate untrimmed-links field); the
"trim for reporting" contract is met by the variable-level report not showing
agg nodes.
Fixes the five items from the Phase 5 review of the reducer-hoist /
aggregate-node implementation:

- Trim synthetic `$⁚ltm⁚agg⁚n` nodes out of *reported* loops in
  `discover_loops_with_graph` (AC4.2). A chain `[X→agg, agg→Y]` collapses
  to `[X→Y]` with composed polarity; only synthetic agg names are trimmed
  (variable-backed whole-RHS-scalar reducers are real nodes). The loop-
  score equation still references the un-trimmed agg-traversing path, so
  the trim is reporting-only. `LinkPolarity::compose` replaces the two
  ad-hoc sign-multiplication matches in `ltm::polarity`. The AC4.7
  discovery test now asserts both that the cross-element loop is still
  found and that the reported FoundLoop's links are the trimmed form.

- Fix the `agg → target` link score when a target hoists 2+ reducers
  (`x = SUM(a[*]) / SUM(b[*])`): the other aggs in the reducer-
  substituted equation must be in the dep set so they get PREVIOUS-
  wrapped, otherwise the numerator collapses to Δtarget and the magnitude
  pins to ±1. Also fixes the Pass-3 fragment dispatch: an
  `agg → scalar_target` link-score name has no bracket or shape suffix, so
  the legacy `(from, to)`-keyed salsa path used to claim it,
  `reconstruct_single_variable` the synthetic agg name, get `None`, and
  emit a degenerate equation against the target's original (reducer-
  bearing) equation -- collapsing the score to zero. Any agg-node link
  score now compiles from its already-substituted `ltm_var.equation`.

- Dedupe agg link-score emission in the exhaustive loop-link path: when a
  target references a source both directly and via a hoisted reducer, the
  loop-link emitter visited the agg-routed loop links *and* re-emitted the
  agg halves through the direct edge's `emit_link_scores_for_edge`,
  pushing duplicate `LtmSyntheticVar`s into the `Vec`. The loop-link
  caller now passes `skip_agg_halves`.

- Derive an aggregate node's `result_dims` from the reducer's result
  shape, not the owning variable's: `share[Region] = SUM(pop[*])` is a
  full reduce broadcast to `[Region]`, so `result_dims` is `[]`; only a
  partial slice-reduce that genuinely varies per element keeps the
  variable's dims.

- `substitute_reducers_in_expr0` now recurses into subscript index
  expressions (`stock[SUM(idx[*])]`) -- such a reducer is hoisted by the
  agg walker, so the substituter must mirror that descent -- and the
  `slot.unwrap()` in the arrayed-target branch is restructured to thread
  the slot expression through directly.

The Pass-3 LTM fragment dispatch in `compile_project_incremental` is also
de-duplicated (one `compile_direct` closure for the four verbatim-compile
cases) to keep `db.rs` under the per-file line cap.
…-score product)

The Phase 5 review flagged `test_discovery_finds_cross_element_through_agg_loop`
as a near-no-op: its `has_trimmed_agg_loop` assertion only checked that *some*
discovered loop contained a `pop[d] -> share[?]` link and an `update[?] -> pop[?]`
link, which the plain numerator-diagonal loop `pop[big] -> share[big] ->
update[big] -> pop[big]` already satisfies whether or not the `SUM(pop[*])`
reducer (hence the synthetic aggregate node) is present. The accompanying
comment ("a model without the agg-traversal would not produce this loop") was
therefore false: both models produce that loop; only its *score* differs.

Replace it with `test_discovery_loop_through_agg_scored_on_untrimmed_path`,
which compiles the heterogeneous-share fixture in discovery mode, simulates it,
runs `discover_loops_with_graph` directly, and then (1) keeps the strong AC4.2
trim assertion (no reported `FoundLoop.link` references `$:ltm:agg:0`), and
(2) reproduces the discovered `pop[big] -> share[big] -> update[big] ->
pop[big]` loop's `loop_score` series, at every step, as the product of the
four *un-trimmed* per-element link scores read straight from the result
matrix -- `pop[big]->agg * agg->share[big] * share->update[big] *
update->pop[big]` -- with a `saw_nonzero` guard so the equality is not vacuous
and so it pins that discovery routed through the aggregate node (the bare
numerator product is identically zero for this fixture). Update phase_05.md's
AC4.7 wording and done-when checklist to note that strongest-path discovery
reports one loop per stock node, so AC4.7's intent is "loop-finding is rerouted
through the agg node and scored on the un-trimmed path; the agg node is trimmed
from the reported links" -- not "the cross-element loop is the reported strongest
loop".

No production code changed.
Phase 5's aggregate-node reroute (each maximal inlined reducer is hoisted
into a synthetic $⁚ltm⁚agg⁚{n} auxiliary; pop[d]→agg→target is scored by
the chain rule) made the ad-hoc ⁚wildcard / ⁚dynamic per-shape link-score
machinery dead. This removes it:

  - link_score_var_name no longer appends the ⁚wildcard / ⁚dynamic
    suffixes; the LINK_SCORE_WILDCARD_SUFFIX / LINK_SCORE_DYNAMIC_SUFFIX /
    LINK_SCORE_SHAPE_SUFFIXES constants are gone.
  - ShapeRank shrinks to Bare / FixedIndex (still needed for the
    Bare-beats-FixedIndex dedup tie-break in parse_link_offsets);
    strip_to_shape_suffix_with_rank and its callsite are gone.
  - resolve_link_score_name_for_loop loses its Wildcard / DynamicIndex
    lookups; the assemble pass loses the LINK_SCORE_SHAPE_SUFFIXES check.
  - shape_aware_source_ref's TODO about the aggregate denominator is
    replaced with a note that the agg node now realizes it (the body is
    unchanged -- it already special-cased only FixedIndex).

The RefShape::Wildcard / DynamicIndex enum variants survive: they remain
the reference-site walker's "route through an agg" markers, and the
conservative-slice reducer (SUM(pop[NYC,*]) inside a larger expression)
that enumerate_agg_nodes deliberately doesn't hoist still classifies as
Wildcard. emit_per_shape_link_scores now dedups by the resulting name so
such a slot collapses onto the canonical Bare link score rather than
minting a duplicate.

Dead tests pinned to the suffix machinery are removed; a positive AC5.1
guard (no ⁚wildcard / ⁚dynamic var emitted for a few reducer-bearing
fixtures) is added.
Updates the LTM docs to describe the aggregate-node treatment (each
maximal inlined reducer hoisted into a synthetic $⁚ltm⁚agg⁚{n} aux,
pop[d]→agg→target scored by the chain rule, the agg trimmed from reported
loops) and the element-level cross-element loop scoring landed by the
2026-05-09-ltm-503-cross-element-agg design plan:

  - docs/design/ltm--loops-that-matter.md: adds $⁚ltm⁚agg⁚{n} and the
    per-element link-score names to the naming table; rewrites the
    element-graph truth table's Wildcard/DynamicIndex rows for the agg
    reroute; adds an "Aggregate Nodes" subsection; updates the Link Score
    Classification, Loop Scores, and Discovery sections; retires the
    per-shape Wildcard link-score description.
  - src/simlin-engine/CLAUDE.md (= AGENTS.md): adds ltm_agg.rs to the
    module map; updates ltm_augment.rs / db_analysis.rs / db_ltm.rs /
    ltm/types.rs bullets for enumerate_agg_nodes, the retired Wildcard
    path, LtmSyntheticVar.equation: datamodel::Equation, element-
    subscripted cross-element loops; adds a "Last updated" freshness date.
  - docs/design-plans/2026-04-25-ltm-per-ref-elem-graph.md: adds a
    re-measured post-Phases-1-5 measurement postscript subsection
    (cross_element_ltm, arrayed_population_ltm, hero_culture_ltm, WRLD3-03,
    plus share/mean reducer-with-feedback fixtures showing the N²→N+M
    element-edge win).
  - simulate_ltm.rs: updates the measurement_postscript_* rustdocs to
    match the re-measured numbers (the loose asserts are unchanged).
  - docs/tech-debt.md: forward-pointers + implementing commit hashes on
    items #20 / #26 / #34 (the per-shape Wildcard link score was
    superseded; cross-element assertions tightened).
  - db_ltm.rs: a doc-comment on is_partial_reduce_edge noting the agg
    reroute only covers scalar synthetic aggs (the whole-RHS arrayed-result
    reducer is a variable-backed agg the predicate still handles).
Phase 6 retired the per-shape wildcard/dynamic link-score path, but in
doing so it dropped the routing that sent a non-Bare-shaped *scalar* link
score through direct (non-salsa) compilation. For a DynamicIndex/Wildcard
reference into a scalar target -- e.g. `total = arr[idx]` inside a
feedback loop -- the `(from, to)`-keyed salsa path (link_score_equation_text,
hard-coded to RefShape::Bare) re-derives the partial with the whole
subscript wrapped in PREVIOUS(), collapsing the ceteris-paribus numerator
to ~0 and zeroing the link score (and any loop score that multiplies it).
Untested, so the suite stayed green.

Fix: add a `compile_directly` flag to LtmSyntheticVar; emit_per_shape_link_scores
sets it for any non-Bare shape, and assemble_module routes those vars to
the verbatim-equation compile path. Separately, the DynamicIndex/Wildcard
partial that path produces was itself uncompilable: shape_aware_source_ref
spelled the bare *arrayed* source in the scalar guard equation
(`source - PREVIOUS(source)`), a dimension error -> no bytecode -> still
zero. Capture the live source slice the partial isolates
(`arr[PREVIOUS(idx)]`, `pop[NYC,*]`) during the PREVIOUS-wrapping
transform and wrap it in SUM() for the guard's source side; SUM of a
scalar is the identity and SUM of a slice is scalar, and the result only
feeds the SIGN factor and the =0 guard, so substituting SUM for the
reducer's own algebra is harmless. Applied to the aux->aux, stock->flow,
and arrayed-per-element link-score generators (same latent bug in all
three).

Also retires a stale db_ltm_unified_tests section comment that still
described the removed `⁚wildcard` naming convention.
…r_name

The post-Phase-6 `link_score_var_name` bullet said only the not-hoisted
conservative-slice reducer reaches that function as a Wildcard/DynamicIndex
shape. The bare-dynamic-index case (`arr[idx]`, `arr[i+1]`) is a sibling
that also reaches it -- `source_ref_for_guard` handles both -- so spell
both out to match the code's own docstrings.
The doc comment claimed strip_element_subscript "mirrors"
crate::ltm::strip_subscript, but it truncated at the first `[` (find)
while strip_subscript truncates at the last `[` (rfind). For the
single-comma-subscript element-node names LTM actually produces (e.g.
"x[a,b]", "population[nyc]") and the bracket-free synthetic agg names,
the two are equivalent; they would only diverge on a hypothetical
nested-bracket name the codebase never generates. Switch to rfind so
the "mirrors" claim is literally true and behavior matches
strip_subscript's documented "last `[` is the truncation point", and
note the nested-bracket semantics in the doc comment.
Human verification plan for the completed 6-phase GH #503 work (aggregate
nodes, element-level cross-element loop scoring, discovery alignment,
partial-reduce reducer machinery, the retired wildcard/dynamic link-score
path). Backend engine change with no UI, so the plan is a manual
verification procedure: build the share[r]=pop[r]/SUM(pop[*]) motivating
case, simulate with --ltm, and inspect the synthetic LTM variables to
confirm per-element chain-rule attribution; plus the WRLD3 /
logistic-growth no-regression checks. Includes a traceability table
mapping each acceptance criterion to its automated test and manual step.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3efb982044

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/simlin-engine/src/db_analysis.rs Outdated
Comment on lines +1643 to +1645
let route_through_agg = !routed_aggs.is_empty()
&& matches!(site.shape, RefShape::Wildcard | RefShape::DynamicIndex);
if route_through_agg {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Route only hoisted reducer references through agg nodes

Guarding reroute on !routed_aggs.is_empty() makes every Wildcard/DynamicIndex site from from_name to to_name go through synthetic aggs whenever the target equation contains at least one hoisted reducer, even when a given site is not inside that reducer. In mixed equations (for example one hoisted SUM(pop[*]) plus a non-hoisted slice/dynamic reference to pop), the non-hoisted reference loses its direct from->to edges and is incorrectly rewritten as from->agg->to, which changes the element graph and can drop or distort loop detection/scoring for those terms.

Useful? React with 👍 / 👎.

@claude

claude Bot commented May 10, 2026

Copy link
Copy Markdown

Review: LTM cross-element aggregate scoring

Reviewed the core engine changes (ltm_agg.rs, db_analysis.rs element-edge reroute, db_ltm.rs loop builder + link-score emission, db.rs LTM compile path, ltm_finding.rs agg-trimming, ltm_augment.rs equation generators). The change is well-tested and heavily documented. One finding below; otherwise it looks correct.

[P2] Agg-node reroute also captures non-hoisted Wildcard/DynamicIndex references to the same source

src/simlin-engine/src/db_analysis.rs (around lines 1642-1644)

let route_through_agg = !routed_aggs.is_empty()
    && matches!(site.shape, RefShape::Wildcard | RefShape::DynamicIndex);

The comment just above (lines 1633-1636) states that a reference to from in to that is not inside a hoisted reducer — "a non-hoisted slice/dynamic index" — "still emits its own edges normally". But the condition reroutes every Wildcard/DynamicIndex site for from as soon as to hoists any synthetic agg that reads from; it does not check whether this site is the one that was hoisted. So for a target equation like to[r] = SUM(pop[*]) + pop[@2] (a full reduce of pop hoisted into $⁚ltm⁚agg⁚n, plus a separate dynamic-index access of pop), the pop[@2] reference is rerouted through the SUM's agg node instead of emitting its own (conservative cross-product) edges, conflating its causal contribution with the reducer's and losing the direct edge. It requires the unusual pattern of the same source appearing in a target's equation both inside a hoisted full reducer and via a separate slice/dynamic index, so impact is limited — but either the condition should be narrowed to sites actually inside a hoisted reducer, or the comment should be corrected to match.


Overall correctness: correct — the one issue above is an edge case; the patch otherwise appears sound and existing tests/behavior are preserved.

@claude

claude Bot commented May 10, 2026

Copy link
Copy Markdown

Review

I went through the LTM cross-element aggregate-scoring change in depth — ltm_agg.rs (new), the db_ltm.rs link-score / loop-builder rework, db_analysis.rs element-graph reroute, ltm_augment.rs equation generators, ltm_finding.rs discovery + agg trimming, and the LtmSyntheticVar (String → datamodel::Equation, compile_directly) plumbing through db.rs.

I traced the major paths end-to-end:

  • Agg enumeration → element-graph reroute → loop builder → link-score emission → loop-score equations → discovery → trim all line up on the synthetic-name conventions ($⁚ltm⁚agg⁚{n}, pop[d]→agg, agg→to[e], the compile_directly / bracket / is_synthetic_agg_name dispatch in assemble_module, the category sort keeping the agg aux ahead of link scores while the agg→target link score stays a link score).
  • trim_synthetic_aggs_from_loop_links cycle merging (incl. the wraparound branch), recover_cross_agg_loops petal combination and disjointness, is_partial_reduce_edge shape check, and the parse_link_offsets simplification (retiring the ⁚wildcard/⁚dynamic suffixes) all check out.
  • Non-LTM / scalar / pure-A2A paths are unaffected (enumerate_agg_nodes returns empty, routed_aggs/keep_node/category all degrade to prior behavior).

A couple of accepted trade-offs are clearly documented in comments rather than being defects: cross-element loops routed through an agg report Undetermined polarity (the element-level CausalGraph has empty variables, so compose over those hops is always Unknown — same as the pre-existing discovery behavior), and recover_cross_agg_loops only enumerates one ordering per petal subset (the 2^k - k - 1 count, capped at MAX_AGG_PETALS).

No blocking issues found.

Overall correctness: correct

test added 4 commits May 9, 2026 19:11
`70f34eae` ("doc: remove implementation plan") deleted the phase_NN.md
files but left `test-requirements.md`, which linked to phase_01.md and
phase_06.md -- so `scripts/check-docs.py` (and the pre-commit hook) failed
on a clean tree. Rephrase line 10 to refer to "phases 1 through 6 of the
now-removed implementation plan" instead of linking the deleted files.
The element-graph reroute (`model_element_causal_edges`) and the
link-score suppression (`emit_per_shape_link_scores(skip_reducer_shapes)`)
both keyed off the access *shape* (`RefShape::Wildcard | RefShape::DynamicIndex`)
to decide whether a reference to `from` in `to` should collapse into a
hoisted `$⁚ltm⁚agg⁚{n}` aggregate node. But a `to` equation that mentions
`from` both inside a hoisted reducer (`SUM(pop[*])`) *and* via a direct
dynamic index (`pop[idx]`) produces a `DynamicIndex` site for the *direct*
`pop[idx]` reference too -- so that direct reference was wrongly folded
into the agg (losing its conservative `pop[d] -> to` element edges) and its
`pop -> to` link score was dropped, leaving the dynamic-index dependency
unscored.

Add `ReferenceSite::in_reducer`, set true iff the site is syntactically
inside an array-reducing builtin argument (the same `SUM`/`MEAN`/`MIN`/
`MAX`/`STDDEV`/`RANK` recognition set `enumerate_agg_nodes` hoists; `SIZE`
and the 2-arg `MIN`/`MAX` are excluded). The reroute now keys on
`in_reducer`, and `skip_reducer_shapes` only suppresses the `Wildcard`
shape (the hoisted reducer's argument, already scored by the agg's two
halves) -- `DynamicIndex` keeps its conservative Bare-named link score.
`register_agg` deduped aggregate nodes purely by canonical reducer text,
regardless of `AggKind`. So when a whole-RHS reducer (`denom = SUM(pop[*])`,
a *variable-backed* agg with `is_synthetic = false`) was registered before
an inline use of the same text (`share[r] = pop[r] / SUM(pop[*])`, which
should mint a *synthetic* `$⁚ltm⁚agg⁚{n}`), the inline use reused the
variable-backed agg -- no synthetic was minted, so the downstream
`is_synthetic` filters in `model_element_causal_edges` and
`model_ltm_variables` skipped it, and whether the inline reducer got proper
aggregate-node routing depended on canonical variable name order.

Make the by-key dedup apply only to `Synthetic` aggs; `VariableBacked` aggs
are never deduped (two scalars can each be `SUM(pop[*])` and remain
distinct nodes). Rename `AggNodesResult.by_key` to `synthetic_by_key` and
narrow `agg_for_key` to resolve only synthetic aggs (variable-backed aggs
are found via `aggs_in_var`); `by_var` still indexes both kinds.
The doc comments on `emit_per_shape_link_scores` (db_ltm.rs) and the
Phase-5 reroute block (db_analysis.rs) cited `SUM(pop[idx, *])` /
`SUM(pop[NYC, *])` as the construct that produces a `DynamicIndex`
reference site. That's wrong: `classify_subscript_shape` checks for a
wildcard first and returns `Wildcard` for any index list containing one,
regardless of a sibling dynamic index. The construct that actually
yields a `DynamicIndex` site is a single non-literal index with no
wildcard -- a direct `pop[idx]`, or `SUM(pop[idx])` inside a reducer
(which isn't a full reduce, so `enumerate_agg_nodes` doesn't hoist it
anyway). The claim each comment supports -- a `DynamicIndex` site
reaching that code is from a non-hoisted construct, so its conservative
link score must survive -- was already correct; only the worked example
was mislabeled.

Also add `ref_site_bare_arrayed_arg_is_in_reducer` to the
`collect_reference_sites_tests` module: a bare arrayed argument
`SUM(pop)` is a full reduce that gets hoisted, and its AST reference is
a bare `Var`, so the site must come back as `RefShape::Bare` with
`in_reducer == true` -- the existing tests covered `SUM(pop[*])`
(Wildcard) but not this bare-arg form.
@claude

claude Bot commented May 10, 2026

Copy link
Copy Markdown

Review: PR #519 — LTM cross-element aggregate scoring

I reviewed the new ltm_agg.rs, the substantial changes to ltm_augment.rs, db_ltm.rs, db_analysis.rs, db.rs, ltm_finding.rs, and the smaller ltm_post.rs / ltm/polarity.rs / ltm/types.rs / datamodel.rs changes. The implementation is careful and well-commented; the determinism contract for enumerate_agg_nodes (canonical-sorted variable iteration, first-encounter synthetic numbering, dedup-by-text only for synthetic aggs) holds, the assemble_module link-score routing (has_element_subscript / touches_synthetic_agg / compile_directly) selects the direct-compile path for every shape that needs it, parse_link_offsets handles the new bracketed/agg-name endpoint shapes, and trim_synthetic_aggs_from_loop_links cannot panic on any rotation (aggs never chain, so the cycle can't be reduced below length 1 going into a merge). Loop scores are computed from the un-trimmed link chain before trimming, as intended. I did not find any P0/P1 issues — the known limitations (agg-hop loops classify Undetermined; set-vs-sequence dedup of cycles) are already tracked in docs/tech-debt.md (#516/#480, #22).

One minor observation:

[P3] Distinct loops that trim to the same edge chain are reported as separate loops with identical links

src/simlin-engine/src/ltm_finding.rs (discover_loops_with_graph: the all_paths dedup near L737-746 and the post-trim found_loops.push near L835-857). all_paths is deduped on the un-trimmed element-node rotation, but trim_synthetic_aggs_from_loop_links runs afterward. When a feedback path runs through two distinct inlined reducers over the same source(s) in the same target equation (e.g. y[r] = SUM(x[*]) + MEAN(x[*]) with x[d] = f(y[d])), the two cycles x[r]->$agg(sum)->y[r]->x[r] and x[r]->$agg(mean)->y[r]->x[r] survive dedup, then both trim to [x[r]->y[r], y[r]->x[r]] — so two FoundLoops get emitted with byte-identical loop_info.links/stocks/dimensions but different scores, and both get IDs assigned. Niche trigger and arguably "different loops", but the duplicate-looking entries are confusing; deduping found_loops on the trimmed rotation (keeping the max-score representative) would avoid it.

Overall correctness verdict: correct

No blocking issues. Existing behavior and tests are preserved; the patch is free of bugs that would break the build or simulation results, modulo the cosmetic P3 above.

@codecov

codecov Bot commented May 10, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 76.26667% with 89 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.45%. Comparing base (e9cee0a) to head (e095654).
⚠️ Report is 12 commits behind head on main.

Files with missing lines Patch % Lines
src/simlin-engine/src/ltm_agg.rs 83.49% 34 Missing ⚠️
src/simlin-engine/src/ltm/polarity.rs 54.68% 29 Missing ⚠️
src/simlin-engine/src/ltm_finding.rs 31.03% 20 Missing ⚠️
src/simlin-engine/src/db.rs 82.35% 3 Missing ⚠️
src/simlin-engine/src/ltm/graph.rs 66.66% 2 Missing ⚠️
src/simlin-engine/src/db_analysis.rs 96.87% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #519      +/-   ##
==========================================
+ Coverage   82.38%   82.45%   +0.06%     
==========================================
  Files         241      242       +1     
  Lines       61200    63290    +2090     
==========================================
+ Hits        50419    52185    +1766     
- Misses      10781    11105     +324     

☔ View full report in Codecov by Sentry.
📢 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.

Two LTM (Loops That Matter) follow-ups surfaced while reviewing the
cross-element aggregate-scoring work.

GH #516: a feedback loop that routes through a synthetic $⁚ltm⁚agg⁚{n}
node always classified as Undetermined. The loop builders derive every
link's polarity from the variable-level causal graph, but agg nodes exist
only in the element graph, so analyze_link_polarity found no reference to
the agg's name in either endpoint and the hop came back Unknown -- which
forces the whole loop to Undetermined. The polarity is derivable for the
common cases: source[d] -> agg is Positive whenever the reducer is monotone
non-decreasing in each element (SUM/MEAN/MIN/MAX), and agg -> consumer is
the polarity of the consumer's equation with respect to the reducer
subexpression, computed by substituting the reducer with the agg name and
running the ordinary static polarity analysis
(CausalGraph::agg_consumer_polarity). model_ltm_variables now patches these
hops on the detected loops and re-derives each affected loop's polarity (and
re-assigns IDs, whose r/b/u prefix is polarity-derived). STDDEV/RANK aggs
are not monotone and stay Unknown.

GH #517: the ceteris-paribus partial for a Bare reference whose target also
contains an inlined reducer (share[r] = pop[r] / SUM(pop[*]) -> the
pop -> share link score) wrapped the array view *inside* the reducer in
PREVIOUS -- pop[r] / SUM(PREVIOUS(pop[*])) -- which is silently 0.0 at every
step under an active apply-to-all dimension because codegen has no
LoadPrev-of-array-view path. wrap_non_matching_in_previous now wraps the
whole reducer App -- pop[r] / PREVIOUS(SUM(pop[*])), which is PREVIOUS of a
scalar and evaluates correctly -- unless the reducer itself carries the live
reference (the test-only Wildcard live_shape). This is the correct partial
(Delta pop[r] / D(t-1) over Delta share[r]); the general codegen fix for
LoadPrev-of-array-view stays open for the not-yet-hoisted slice cases. With
the numerator path's link score now real, it correctly competes with the
aggregate path in discovery's strongest-path heuristic, so the
test_discovery_loop_through_agg_scored_on_untrimmed_path assertion is
relaxed to accept whichever path discovery surfaces.

References GH #516 and GH #517.
@claude

claude Bot commented May 10, 2026

Copy link
Copy Markdown

Review

I reviewed the cross-element aggregate-node changes (ltm_agg.rs, the element-graph reroute in db_analysis.rs, the link-score/loop-score emission in db_ltm.rs/ltm_augment.rs, and the trim_synthetic_aggs_from_loop_links collapse in ltm_finding.rs). The design is coherent and the edge cases I traced through (loop-link trimming wraparound, agg-half emission dedup via seen_links, the in_reducer/route_through_agg keying, scalar-source/per-target-element naming) all hold up. No blocking issues found.

One minor doc-comment note (P3):

[P3] is_array_reducer_name doc claims to mirror enumerate_agg_nodes's reducer set, but it intentionally differs

src/simlin-engine/src/ltm_augment.rs (is_array_reducer_name, ~lines 20-31)

The doc says "Mirrors the reducer set enumerate_agg_nodes hoists," but this function additionally includes SIZE ("sum" | "stddev" | "size" | "rank"), whereas ltm_agg::reducer_source_vars and db_analysis::builtin_is_array_reducer both deliberately exclude SIZE. Including SIZE here is actually load-bearing — it's what makes a SIZE(arr[*]) subexpression get PREVIOUS(SIZE(arr[*])) instead of being recursed into as SIZE(PREVIOUS(arr[*])) (the GH #517 failure mode) — so the code is correct, but the comment undersells why and a future maintainer "fixing" it to match the other two sets would reintroduce that bug. Suggest noting it's a superset (the hoisted set ∪ {SIZE}) and why.


Overall correctness: correct. Existing code/tests should not break and I didn't find functional bugs in the patch.

@bpowers
bpowers merged commit 29d4d70 into main May 10, 2026
13 checks passed
@bpowers
bpowers deleted the ltm-503-cross-element-agg branch May 10, 2026 04:50
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