Summary
enumerate_agg_nodes (src/simlin-engine/src/ltm_agg.rs) hoists a whole-extent RANK(pop, 1) subexpression into a scalar synthetic aggregate node $⁚ltm⁚agg⁚0 -- but RANK is an order statistic that returns an ARRAY (the per-element ranks; Vensim's VECTOR RANK), so a scalar agg node is the wrong shape: the agg's own equation rank(pop, 1) is array-valued in a scalar context and its fragment cannot compile. Post-#741 the failure is loud (one synthetic-variable Assembly Warning naming the agg), and every agg-routed loop score through the node is stubbed to 0.
Demonstrated repro
With arrayed pop[Region] and grow[Region] = scale[Region] * RANK(pop, 1) closing a feedback loop, LTM compilation emits exactly one warning naming $⁚ltm⁚agg⁚0 and zeroes the agg-routed loop scores. Demonstrated and pinned loudly by rank_frozen_subtree_link_score_scores_correctly (src/simlin-engine/tests/integration/ltm_array_agg.rs, commit 01181a36 on branch ltm-fix-batch-2) -- the test asserts exactly 1 remaining warning whose variable is the RANK-hoisted agg, and documents this defect as the one degradation deliberately out of #742's scope.
Root cause
reducer_kind_from_name (src/simlin-engine/src/ltm_agg.rs ~139) maps "rank" to ReducerKind::Nonlinear, so reducer_is_hoistable includes RANK and the agg enumerator mints a synthetic node for it exactly as if it were SUM-like. A whole-extent reducer subexpression yields a scalar agg node -- correct for genuinely reducing builtins, wrong for RANK, whose result has the source's shape.
The #742 fix (commit 01181a36) introduced reducer_collapses_to_scalar (ltm_agg.rs ~167) precisely because RANK does not collapse to a scalar -- but only the PREVIOUS-capture consumers (builtins_visitor::arg_has_bare_var_ref, the GH #541 arrayed-capture gate; ltm_augment::expr_is_array_slice_valued, the GH #743 unfreezable-PREVIOUS detector) consult it. The hoisting gate (reducer_is_hoistable, used by reducer_source_vars / the reference-site IR walk to decide which subexpressions become aggregate nodes) still treats RANK as hoistable.
Why it matters
Low-medium. Loud (post-#741, which landed as cd96ffcf on ltm-fix-batch-2) rather than silent, but every loop routed through the RANK agg node scores a flat 0 -- wrong dominance attribution for any model with RANK on a loop edge. The shape is uncommon (no corpus model hits it), but it is one legal equation away.
Components affected
src/simlin-engine/src/ltm_agg.rs -- reducer_kind_from_name / reducer_is_hoistable (the hoisting gate), enumerate_agg_nodes / reducer_source_vars (the enumeration), reducer_collapses_to_scalar (the shape predicate that already encodes the right discrimination but is not consulted here)
src/simlin-engine/src/db/ltm/ -- agg-routed link/loop score emission downstream of the ill-shaped node
Possible approaches
Relationship to existing issues
Discovery context
Surfaced during the #742 fix on branch ltm-fix-batch-2 (commit 01181a36), in the GH #759/#742/#525 cluster work. Part of LTM tracking epic #488.
Summary
enumerate_agg_nodes(src/simlin-engine/src/ltm_agg.rs) hoists a whole-extentRANK(pop, 1)subexpression into a scalar synthetic aggregate node$⁚ltm⁚agg⁚0-- but RANK is an order statistic that returns an ARRAY (the per-element ranks; Vensim's VECTOR RANK), so a scalar agg node is the wrong shape: the agg's own equationrank(pop, 1)is array-valued in a scalar context and its fragment cannot compile. Post-#741 the failure is loud (one synthetic-variable AssemblyWarningnaming the agg), and every agg-routed loop score through the node is stubbed to 0.Demonstrated repro
With arrayed
pop[Region]andgrow[Region] = scale[Region] * RANK(pop, 1)closing a feedback loop, LTM compilation emits exactly one warning naming$⁚ltm⁚agg⁚0and zeroes the agg-routed loop scores. Demonstrated and pinned loudly byrank_frozen_subtree_link_score_scores_correctly(src/simlin-engine/tests/integration/ltm_array_agg.rs, commit01181a36on branchltm-fix-batch-2) -- the test asserts exactly 1 remaining warning whose variable is the RANK-hoisted agg, and documents this defect as the one degradation deliberately out of #742's scope.Root cause
reducer_kind_from_name(src/simlin-engine/src/ltm_agg.rs~139) maps"rank"toReducerKind::Nonlinear, soreducer_is_hoistableincludes RANK and the agg enumerator mints a synthetic node for it exactly as if it were SUM-like. A whole-extent reducer subexpression yields a scalar agg node -- correct for genuinely reducing builtins, wrong for RANK, whose result has the source's shape.The #742 fix (commit
01181a36) introducedreducer_collapses_to_scalar(ltm_agg.rs~167) precisely because RANK does not collapse to a scalar -- but only the PREVIOUS-capture consumers (builtins_visitor::arg_has_bare_var_ref, the GH #541 arrayed-capture gate;ltm_augment::expr_is_array_slice_valued, the GH #743 unfreezable-PREVIOUS detector) consult it. The hoisting gate (reducer_is_hoistable, used byreducer_source_vars/ the reference-site IR walk to decide which subexpressions become aggregate nodes) still treats RANK as hoistable.Why it matters
Low-medium. Loud (post-#741, which landed as
cd96ffcfonltm-fix-batch-2) rather than silent, but every loop routed through the RANK agg node scores a flat 0 -- wrong dominance attribution for any model with RANK on a loop edge. The shape is uncommon (no corpus model hits it), but it is one legal equation away.Components affected
src/simlin-engine/src/ltm_agg.rs--reducer_kind_from_name/reducer_is_hoistable(the hoisting gate),enumerate_agg_nodes/reducer_source_vars(the enumeration),reducer_collapses_to_scalar(the shape predicate that already encodes the right discrimination but is not consulted here)src/simlin-engine/src/db/ltm/-- agg-routed link/loop score emission downstream of the ill-shaped nodePossible approaches
reducer_collapses_to_scalar, so array-valued non-reducing builtins stayDirectreferences. An agg node exists to give a scalar reduction a name; an array-valued builtin has no scalar reduction to name. The frozen-subtree path already scores RANK correctly post-ltm: PREVIOUS-captured array-valued non-reducing builtin (RANK) scalarizes into ill-typed stubbed helpers, corrupting link scores #742 (arrayed capture helpers), so de-hoisting should leave the link scores on the well-tested Direct path. Audit knock-ons: the element-graph walker'sin_reducermarker and ThroughAgg routing key off the same predicate, andgenerate_nonlinear_partial's RANK delta-ratio arm (docs/tech-debt.md entry 27) becomes unreachable from the hoisted path.Relationship to existing issues
PREVIOUS(RANK(...))subtrees inside someone else's scored partial, fixed onltm-fix-batch-2(01181a36) via arrayed capture helpers. This issue is the remaining degradation that fix's pinned test calls out as out-of-scope.cd96ffcfonltm-fix-batch-2); before it this stub was silent.Discovery context
Surfaced during the #742 fix on branch
ltm-fix-batch-2(commit01181a36), in the GH #759/#742/#525 cluster work. Part of LTM tracking epic #488.