Skip to content

engine: LTM fidelity fixes from the paper audit - #1062

Merged
bpowers merged 11 commits into
mainfrom
ltm-fidelity
Sep 10, 2026
Merged

bpowers merged 11 commits into
mainfrom
ltm-fidelity

Conversation

@bpowers

@bpowers bpowers commented Sep 10, 2026

Copy link
Copy Markdown
Owner

Why

A four-way audit of the Loops That Matter implementation against the five papers (reports and re-runnable experiments in the session scratchpad; summary in the memory note ltm_audit_2026_09_07) found that the instantaneous link scores, loop and relative scores, cycle partitions and module composites reproduce the papers exactly, and that eight things do not. This PR fixes the eight, one hook-verified and adversarially reviewed commit each, and adds the design plan for the next work package.

What changed

  • Flow-to-stock link scores off a net-flow aux (05e4dae4). The score was the 2023 paper's Eq. 3 written over stock second differences, which put it one step late, broke the paper's aggregation invariance at fixed t (identical split-flow and net-flow models disagreed by up to 0.86 in link score), and put a loop's links on two time windows (GH ltm-augment: instantaneous and flow-to-stock link scores use inconsistent time windows within a loop #544; the dominant loop differed at 7 of 229 steps on a two-stock oscillator at dt 0.25). Every flow-to-stock score is now the instantaneous partial of $⁚ltm⁚net⁚{stock}, no dt factor, no stock history, first value at start+dt. Also fixes a scalar flow into an arrayed stock being scored against the stock's initial-value equation (two edges in C-LEARN). The golden fixture's one-step shift is a labeling convention for every link (the reference tool labels the [t, t+dt] score with t), verified by inverting the closed-form share on each golden column; the docs no longer attribute it to flow-to-stock timing.
  • LTM under every integration method (3afd6376). The Euler-only refusal (GH ltm: emit compile-time error when ltm_enabled is true and integration is non-Euler #486) was justified only by the stock-history numerator. Scores are ratios of integration-step deltas reported at saved steps; a proportional-flow model scores identically under Euler, RK2 and RK4 (pinned), and a save_step = 2 dt run pins the dt-step form.
  • The clock is a frozen input in the ceteris-paribus partial (f5e03257, GH ltm: should a ceteris-paribus partial freeze TIME? (today it does not, and the target's autonomous time evolution is attributed to the link) #1016). TIME, STEP, RAMP and PULSE were live inside partials, so an input with zero influence scored 1 and exogenous forcing was credited to every link (41 percent of C-LEARN's arms carried a live time term). One rule at both changed-first freeze sites; the changed-last duals keep the clock live because it cancels; a call holding an occurrence of the isolated input's live shape stays live (documented residual). C-LEARN's 153 loops, relative series and 565 dominant periods are unchanged; 146 link-score series change, 86 to an exact zero.
  • Relative loop scores per partition, one owner (8cbcf0cf). Normalization was per (partition, slot) bucket, which produced wrong dominance verdicts on every coupled arrayed model (cross_element's births loop scored 1/2 and 2/3 instead of 1/3). One function now serves discovery, the FFI, pysimlin and the layout; the de-subscripting oracle's relative-score rows are exact on all 22 audit models.
  • Input-to-module link polarity from the sub-model's pathways (6a90255e). The edge was hard-coded positive, so loops through a DELAY3 or SMTH1 delay-time port were labelled reinforcing while scoring negative at every step.
  • Runtime polarity classified on the relative series (95f848ff). Raw-sum confidence let three inflection steps outweigh two hundred balancing steps. A loop alone in its partition now has a confidence equal to the time share of its sign; the lone-loop Rux fixtures re-baseline to Undetermined and competing-sibling fixtures keep the Rux and Bux arms covered at every surface.
  • One owner for what an element subscript names (e5319fbd). A JSON subscript written "nyc, young" never matched the compiler's key and silently zeroed every element with no diagnostic. CanonicalElementName::from_subscript is the one key owner across the compiler, the readers and the advisory; a declared element with no equation arm is a Warning naming it (kept a Warning because Vensim's own exports omit undefined elements; the reader-side fix is GH engine: MDL reader declares partially defined subscripted variables over the full dimension and fabricates zeros for undefined elements #1059).
  • Loop node sequences from the element circuit (e85914f5). Mixed loops through a named reducer reported growth -> pop[boston] -> total -> growth[boston]; the sequence now comes from one owner reading the circuit.
  • Pins name themselves when they cannot be scored (b27550ae). The hero_culture fixture's fourteen pins were all rejected at sync because the fixture used view-element uids where loop metadata means variable uids everywhere else; the fixture is regenerated, a pin that cannot be scored reports its name, uids and the unresolved ones, and a pin that drops a stale uid but still scores warns and names the cycle it scored.
  • Design plan for single-mode LTM (765b7a3b, docs/design-plans/2026-09-07-ltm-single-mode.md). The audit's architectural verdict: both hero models auto-flip, so the structural loop surface is empty on every model that matters; post-simulation loop scores match in-simulation ones to one ulp; single mode deletes the exhaustive path, the fallback and static polarity. Its acceptance criteria and five embedded product decisions are drafted for the owner's validation, not yet executed.

Evidence

  • Every commit passed the pre-commit hook (rustfmt, clippy, the full Rust suite, TS lint/build/tests, the wasm build, pysimlin) and a written adversarial review whose blockers were fixed before the commit.
  • Hand-calculated pins through the real pipeline: the 2023 paper's table (1.25 / -0.25 at the step of the change), aggregation-invariance twins (1e-15), isolated loops at four dt values, births/deaths 2/3 : -1/3 from t=1, the RAMP loop's 0.500..0.658, z = x*TIME, the frozen-index model, cross_element shares 1/3, 1/3, -1/6, +1/6, the competing-sibling Rux/Bux fixtures at 0.9994, the yeast loop at 0.967 with 147 positive and 53 negative steps on the merged tree.
  • The de-subscripting oracle (arrayed model vs its scalar expansion) re-run on the merged tree over all 22 audit models: simulated values bit-exact everywhere; relative-score mismatches went from 9 models to 0 on every model outside the two defect classes left for follow-ups (the cross-aggregate loop universe, which the single-mode plan settles, and split partials, GH ltm: one causal link read through two reference sites is scored as two split partials (isolated loop scores 0.667, not 1) #1061); the reducer-corpus loops (bare_reducer, variable_backed, scalar_cofactor) now match the scalar model loop for loop.
  • Wasm-versus-VM parity on all corpus models including the ignored C-LEARN and World3 runs; C-LEARN's loop set, relative series and dominant periods unchanged across the clock freeze.

What the evidence does not establish

Fixes #544
Fixes #1016

🤖 Generated with Claude Code

https://claude.ai/code/session_011UGXEWqCKwNEVU75MUduNR

A relative loop score is a loop's share of its cycle partition: its raw
score over the sum of |score| across every loop of the partition
(reference section 4.4). The exhaustive path grouped by (partition, slot)
instead -- an arrayed loop's slot k competed only with other loops' slot
k, and a scalar loop was broadcast into every slot bucket -- so on any
model whose elements are coupled the denominators missed most of the
partition's members. On test/cross_element_ltm the births loop read 1/2
at NYC and 2/3 at Boston where the partition holds four active members
and the share is 1/3; on the arrays investigation's sliced_sum corpus
model the dominant-period verdict was wrong for the whole run. A slot
index is a position in one loop's own dimension space, so two arrayed
loops over different dimension lists, or a scalar loop, attach no shared
meaning to "slot k"; the partition is the only sound group.

ltm_post::compute_rel_loop_scores is now the single owner: every
(loop, slot) with a loop_score column is one member of its slot's
partition (an unresolved slot is a Solo member of its own), the
denominator is the partition-wide sum, a scalar loop has exactly one
series and an arrayed loop one per slot, laid out step-major with the
loop's own slot count as stride. The stride/broadcast machinery, the
slot-0 convenience view, and the FFI's streaming per-element pair are
deleted; libsimlin caches the owner's whole output per SimState and
reads the requested slot (or the argmax-abs collapse the layout also
uses) out of it, and discovery's rank_and_filter accumulates its
partition totals and divides through the same group_totals /
relative_series pair, so the two surfaces cannot disagree on the rule.
The link-level normalization shares the same accumulator (NaN excluded,
Inf kept, finite overflow saturating).

Numbers pinned by tests, through the real pipeline on cross_element_ltm
at every checked step: births 1/3 at both elements, the NYC
migration_out loop -1/6, the cross-element migration_in loop +1/6,
partition magnitudes summing to 1 -- from the engine owner, the
libsimlin FFI (subscripted and bare ids), and pysimlin (Sim and
Run.loops). A property test over generated per-slot partition vectors
checks the owner bit-for-bit against a naive per-member reference and
the partition identity at every step.

The arrays investigation's de-subscripting oracle (report B), rerun on
this tree with its dumper example rebuilt against the new owner: the
relative-score rows that were wrong become exact on cross_element
(4 -> 0 bad), sliced_sum (12 -> 0), per_element_eqns (4 -> 0),
disjoint_dim (5 -> 0), mapped_dims (2 -> 0), share_sum (3 -> 0),
bare_reducer, scalar_cofactor, variable_backed and dt_quarter (3 -> 0
each); raw scores and edges are unchanged on every model. The rows
still wrong (cross_agg, cross_agg4, and the reducer-split models
mean/min/max/stddev_loop, scalar_cofactor_inline, two_d,
migration_matrix) are the stitching and split-partial defects, not the
normalization. The oracle's summary lines:

  cross-agg-ltm: values 6 checked/0 bad | edges ok=12 bad=0 missing=0 split=0 | loops matched=7 raw_bad=0 rel_bad=7 only_arrayed=0 only_scalar=1
  cross-element-ltm: values 11 checked/0 bad | edges ok=16 bad=0 missing=0 split=0 | loops matched=8 raw_bad=0 rel_bad=0 only_arrayed=0 only_scalar=0
  arrayed-population-ltm: values 15 checked/0 bad | edges ok=12 bad=0 missing=0 split=0 | loops matched=6 raw_bad=0 rel_bad=0 only_arrayed=0 only_scalar=0
  a2a_diagonal: values 15 checked/0 bad | edges ok=12 bad=0 missing=0 split=0 | loops matched=6 raw_bad=0 rel_bad=0 only_arrayed=0 only_scalar=0
  bare_reducer: values 7 checked/0 bad | edges ok=12 bad=0 missing=0 split=0 | loops matched=3 raw_bad=0 rel_bad=0 only_arrayed=3 only_scalar=3
  cross_agg4: values 8 checked/0 bad | edges ok=20 bad=0 missing=0 split=0 | loops matched=15 raw_bad=0 rel_bad=15 only_arrayed=0 only_scalar=9
  disjoint_dim: values 10 checked/0 bad | edges ok=14 bad=0 missing=0 split=0 | loops matched=6 raw_bad=0 rel_bad=0 only_arrayed=0 only_scalar=0
  dt_quarter: values 9 checked/0 bad | edges ok=18 bad=0 missing=0 split=3 | loops matched=13 raw_bad=0 rel_bad=0 only_arrayed=0 only_scalar=1
  fixed_broadcast: values 7 checked/0 bad | edges ok=5 bad=1 missing=0 split=0 | loops matched=3 raw_bad=1 rel_bad=0 only_arrayed=0 only_scalar=0
  mapped_dims: values 9 checked/0 bad | edges ok=12 bad=0 missing=0 split=0 | loops matched=6 raw_bad=0 rel_bad=0 only_arrayed=0 only_scalar=0
  max_loop: values 6 checked/0 bad | edges ok=11 bad=1 missing=0 split=3 | loops matched=10 raw_bad=4 rel_bad=6 only_arrayed=0 only_scalar=1
  mean_loop: values 6 checked/0 bad | edges ok=9 bad=3 missing=0 split=3 | loops matched=10 raw_bad=6 rel_bad=10 only_arrayed=0 only_scalar=1
  migration_matrix: values 21 checked/0 bad | edges ok=27 bad=36 missing=0 split=9 | loops matched=283 raw_bad=274 rel_bad=283 only_arrayed=0 only_scalar=503
  min_loop: values 6 checked/0 bad | edges ok=11 bad=1 missing=0 split=3 | loops matched=10 raw_bad=4 rel_bad=6 only_arrayed=0 only_scalar=1
  per_element_eqns: values 6 checked/0 bad | edges ok=8 bad=0 missing=0 split=0 | loops matched=4 raw_bad=0 rel_bad=0 only_arrayed=0 only_scalar=0
  scalar_cofactor_inline: values 7 checked/0 bad | edges ok=12 bad=6 missing=0 split=3 | loops matched=13 raw_bad=9 rel_bad=13 only_arrayed=0 only_scalar=13
  scalar_cofactor: values 8 checked/0 bad | edges ok=14 bad=0 missing=0 split=0 | loops matched=3 raw_bad=0 rel_bad=0 only_arrayed=4 only_scalar=4
  share_sum: values 9 checked/0 bad | edges ok=18 bad=0 missing=0 split=3 | loops matched=13 raw_bad=0 rel_bad=0 only_arrayed=0 only_scalar=1
  sliced_sum: values 17 checked/0 bad | edges ok=24 bad=0 missing=0 split=0 | loops matched=12 raw_bad=0 rel_bad=0 only_arrayed=0 only_scalar=0
  stddev_loop: values 6 checked/0 bad | edges ok=9 bad=3 missing=0 split=3 | loops matched=10 raw_bad=6 rel_bad=10 only_arrayed=0 only_scalar=1
  two_d: values 12 checked/0 bad | edges ok=30 bad=12 missing=0 split=6 | loops matched=84 raw_bad=64 rel_bad=84 only_arrayed=0 only_scalar=352
  variable_backed: values 7 checked/0 bad | edges ok=12 bad=0 missing=0 split=0 | loops matched=3 raw_bad=0 rel_bad=0 only_arrayed=3 only_scalar=3

Discovery's ranking statistic (mean_relative_contribution) divides
through relative_series too, and the enumerated universe's totals
(retain_circuits) go through add_to_total, so the summand rule has one
owner on every path.
The flow-to-stock link score was the 2023 paper's Eq. 3 written over
stock differences, `time_step * (PREVIOUS(flow) - PREVIOUS(PREVIOUS(flow)))`
over the stock's second difference. Under Euler that is the paper's value
one dt late, on a different window from every other link in the loop
(GH #544); it breaks the paper's aggregation invariance at a fixed t; and
it cost two nested-lag capture helpers per link and a two-step startup
guard.

Every stock with a scored flow-to-stock edge now gets a synthetic
net-flow aux `$⁚ltm⁚net⁚{stock} = (inflows) - (outflows)`, shaped like the
stock and minted beside the score by `shaped_link_score` (deduplicated by
name), and the score is the ordinary instantaneous link score of that aux
with respect to the flow: the paper's implementation option (b), section
4.4. The aux is a linear sum, so the partial is closed-form,
`sign * |Δflow / Δnet|`, read over the same [t - dt, t] window as every
other link, with no dt factor, no stock history, and a first value at the
step after the start. The aux is not a causal node (it is in no loop and
no link) and sorts ahead of every score in the evaluation order.

A structural flow-to-stock edge has one emission owner: the Bare shape of
the per-shape emitter, routed before the shape-driven emitters and without
consulting the reference-site IR. That closes two wrong-number paths: a
scalar flow into an arrayed stock took the scalar-to-arrayed emitter and
was scored as a partial of the stock's initial-value equation (a stock's
ast() is its initial); and a stock whose per-element initial reads its own
flow had that initial's sites taken as the edge's shapes, minting one
identical arrayed score per element, or, for a pinned-element read in a
two-dimensional initial, diverting the edge to the per-element arm and
dropping every loop through the stock. C-LEARN has two such scalar-flow
edges (CH4 and CO2 emissions into their arrayed atmosphere stocks).

Pinned in tests/integration/ltm_flow_to_stock.rs (new): the paper's table
(in 5 -> 10, out 4 -> 5) scores 1.25 / -0.25 at the step of the change;
a stock with separate nonlinear flows and its twin with one net flow agree
on LS(flow -> stock) and on the relative loop scores to 1e-12 at the same
t; births 0.1a / deaths a/20 split 2/3 : -1/3 from t = 1; a scalar flow
into an arrayed stock scores 5/5.45 and 5/7.4 per element, the loop through
it +1 with its drain loop at -2; an outflow-only stock scores exactly -1;
a per-element initial reading its own flow yields one arrayed score with no
warning; the net aux is absent from every loop and causal edge. The
isolated-loop invariant holds at dt in {1, 0.5, 0.25, 0.125} with a
one-step startup guard. The logistic golden keeps its +dt relabel: the
shift is a labeling convention for every link score (the reference tool
labels the score computed over [t, t + dt] with t), not a flow-to-stock
timing effect, and the design and reference docs now say so.

Re-baselined, each deliberately: the two LTM character goldens (only the
flow-to-stock arms move, to the new guard form); the value-gate golden
(growth -> pop is exactly 1 from t = 1, the net aux's slots appear); the
two fragment goldens (fixture 10 gained a dynamic-index read so it still
reaches the implicit-helper emission site, since the retired nested lag was
its only helper -- the same reason five other fixtures now mint their
helper through `PREVIOUS(weight[PREVIOUS(idx, idx)])`); the DELAY3
bound-port pin, 0, 1, 2, 4, 8 (was 0, 0, 1, 2, 4: the one-step relabel);
and C-LEARN's LTM guardrail, 6,193 -> 6,224 variables (+35 net auxes, +2
arrayed scores, -6 per-element scalars) and 29,447 -> 28,725 slots (-888
nested-lag helper columns, +166 net-aux slots), derived in its rustdoc from
`ltm_var_dump` and a column diff of `simlin simulate --ltm`.

The Euler-only refusal keeps firing on flow-to-stock emission; its docs no
longer cite the retired numerator as the reason.
The static polarity of an edge into a module instance was Positive for
every input port, so a loop closed through a DELAY3's delay-time port
was labelled r1 while its runtime score is negative at every step, and
every structural-only surface (libsimlin and WASM get_loops, the layout,
the r/b/u ids themselves) carried the wrong sign. A module's input port
has no sign of its own: the sign the parent sees is the sign of the
sub-model's internal pathways from that port to the output the parent
reads, exactly the collapse the macro treatment applies to magnitudes.

CausalGraph::module_input_polarity composes those pathways. Each link
on a pathway is signed by the same get_link_polarity rules a top-level
link gets (recursively, so a hop into a nested instance composes the
same way), so there is one owner of what a link's sign is; the helper
only multiplies signs along each pathway and compares. The edge is
Positive or Negative when every pathway from every entry port the
source feeds, to every output port the parent reads (the sub-model's
sinks when it reads none), agrees; it is Unknown when any pathway
carries an Unknown link, two pathways or two read ports disagree, the
port reaches no read output, or the enumeration was truncated (a
dropped pathway could disagree). Read ports are the union over every
parent reader, loop or not, because the sign is a property of the edge
(the per-exit-port link-score override already scores each loop against
the pathway it traverses); a reporting aux that reads a second,
opposite-signed port therefore turns the edge and every loop through it
Unknown. A fed port that reaches no read output cannot carry a loop and
is ignored. Matching the entry port through normalize_module_ref also
signs a module-to-module edge wired from an output reference, which
fell through to Unknown before, and the stock enrichment uses the same
match. Sub-graphs now carry their own instances' graphs, recursively,
so a user module wrapping a SMOOTH composes one level down; the
recursion is bounded by the module-cycle gate model_detected_loops
applies, and compute_link_polarities carries that gate too.

Cost: signing a module edge re-enumerates the sub-model's pathways per
call, so a graph build pays O(module edges) enumerations instead of one
per instance (+28% on a 41-loop DELAY3 model in the reviewer's
measurement). A per-(instance, exit set) memo would make it O(instances);
it is a known cost, not fixed here.

Pinned through model_detected_loops: DELAY3(inp, tau) with tau = 2 +
0.02*s is b1 (every delay_time -> output pathway is Negative:
stock/(delay_time/3) on each); SMTH1(0.1*s, 4) stays r1; SMTH1(inp,
tau) is b1, following the analyzer's division convention for
(input - output)/delay_time (the true sign depends on the gap's sign;
the runtime series decides Rux/Bux/U, and narrowing that convention
would relabel every gap/adjustment_time link, so it is left alone). One
test per arm of the rule in ltm/module_polarity_tests.rs, and pysimlin
checks Model.loops and Run.loops agree on b1 for the DELAY3 loop. The
module-tests expectation of u1 for a level -> m -> SMTH1 -> inflow loop
encoded the old Unknown; every hop of that loop is positive and it is
now r1.
The overlay was refused under RK2/RK4 whenever a model emitted a
flow-to-stock link score (GH #486). The only justification was the old
score's stock-history numerator, which aligned to the Euler update; the
score is now a ratio of flow deltas over the stock's net-flow aux and reads
no stock history, so the refusal has no premise left.

A link score is a ratio of integration-step (dt) deltas reported at the
saved steps: `PREVIOUS` reads the state the previous dt step ended in,
because the VM snapshots `prev_values` on every dt iteration before the
save/advance logic decides whether the row is recorded, so with
save_step > dt a recorded score is the last dt step's ratio and not a
re-differencing over the saved interval. That is the 2020 paper's form
(Schoenberg, Davidsen and Eberlein, section 6.1: "computed at each dt").
Under RK2/RK4 the VM re-evaluates the flows at the restored end-of-step
state before snapshotting it (the RK stages' trial-point evaluations are
overwritten; wasm mirrors this), so the ratio is taken over the method's
own trajectory. The paper puts Runge-Kutta compatibility as "in principle";
the design doc's note states this form and its boundary: cross-method
equality holds for flows proportional to their stock, and in general the
scores follow the method's trajectory.

Pinned (tests/integration/ltm_integration_method.rs): an isolated
single-flow loop and the births/deaths model score identically under Euler,
RK2 and RK4 -- every link and loop score and every relative loop score equal
to 1e-9 at every saved step -- while their stock trajectories differ; a
nonlinear outflow (0.02 * s ^ 1.3 against births 0.1 * s) scores -22.744
under Euler and -22.749 under RK4 at t = 2; with save_step = 2 * dt the
recorded scores are the dt-resolution run's at the saved times, and the
ratio over the whole saved interval is a different number by more than 0.1
(a change to when prev_values is snapshotted fails this). Elsewhere: an RK4
model's LTM columns agree between wasm and the VM; mark2.mdl (a Vensim RK4
model) compiles and runs with the overlay and every model series is the
LTM-free run's; through libsimlin an LTM sim on an RK4 model reports
exhaustive mode and leaves the trajectory untouched; the MCP read_model
reports an RK4 model's loop with no analysisError; pysimlin's run() on an
RK4 model neither warns nor falls back to a run without loop analysis, and
analyze() reports its loop.

Removed with the guard: `effective_non_euler_method`,
`model_emits_flow_to_stock_score`, `ltm_non_euler_diagnostic_message`, the
GH #486/#663 test block in ltm_unified_tests (its per-element decorated-name
probes included), and the refusal-expecting tests in libsimlin, MCP core,
pysimlin and the wasm harness. The GH #660 "compile failure surfaces
analysis_error" arm keeps its mechanism on a model whose flow reads an
undefined variable at every surface (engine, libsimlin, MCP read_model on
the wire, pysimlin); the message names the variable that failed to compile,
never the missing reference. TestProject gains the `with_save_step` its
`with_sim_time` comment already pointed at. The C header is regenerated
from the FFI docs; tech-debt item 31 is resolved.
The Rux/Bux/U classification summed a loop's RAW loop scores over time.
Raw scores are unbounded: at a dominance inflection every raw score in
a partition diverges together, so a handful of inflection steps can
outweigh the whole run, and an exogenous change that swamps a target's
change shrinks one phase's raw scores to nothing. A series balancing
for 200 steps and reinforcing for three inflection steps read
confidence 0.23 on the raw base; a lone loop balancing for 40 of its
439 active steps read Rux at 0.9999 only because a ramp had made those
40 raw magnitudes tiny.

Both callers -- reclassify_loops_from_results on the exhaustive path
and rank_truncate_and_id on the discovery path -- now feed the
classifier the loop's partition-relative series from the one
normalization owner (compute_rel_loop_scores; the rel_scores discovery
already attaches). Each sample is bounded to [-1, 1] and is the loop's
share of its partition at that step, so the confidence is the
dominance-weighted time share of each sign, and for a loop alone in
its partition (relative score exactly +1/-1 while active) the plain
time share of its sign: Mostly* then needs the minority sign on at
most half a percent of the active steps. The papers define the ratio
on instantaneous pathway scores and the base a reference tool uses for
loops is undocumented, so this is a recorded judgment (types.rs and
the design doc), not a reproduction. Discovery classifies inside
rank_truncate_and_id, once the partition totals exist and before ids
are assigned. A never-active loop is not a fallback case there: its
zero/NaN series never passes retention, so discovery does not report
it at all (the exhaustive surface keeps its structural label).

Numbers pinned through the real pipeline (exhaustive reclassification,
discovery parity, libsimlin Rux and Bux, pysimlin): the GH #679
lone-loop fixture reads Undetermined at its time share, 0.8178 with
399 reinforcing and 40 balancing steps (it was Rux 0.9999 on the raw
base; three tests re-baselined with the reason, and the tests read the
step counts off the series rather than pinning the number of startup
steps); a competing-sibling fixture -- an inflow loop with gain +0.02
flipped to -0.02 for eight steps while the outflow loop's gain rises
from 0.01 to 2 and carries the partition -- is MostlyReinforcing at
0.9994 (share 2/3 at about 430 steps, about 0.0099 at 8), and its
outflow mirror MostlyBalancing at 0.9994; the yeast alcohol model's
growth loop is Undetermined at 0.9655 with 146 positive and 53
negative steps; the synthetic 200-plus-3-step series reads 0.9706 on
the relative base against 0.2308 on the raw one; an A2A loop with one
reinforcing and one balancing element reads Undetermined at exactly 0
under the all-slots reading.
A per-element arm spelled "nyc, young" (or "NYC,Young") simulated every
element of its variable as 0 with no diagnostic. The compiler keyed the
arm by canonicalizing the whole subscript, which turns the space after
the comma into an underscore ("nyc,_young"), a key no combination of
canonical element names ever equals, so the arm matched nothing and the
element took the arrayed expansion's fabricated zero; the GH #905
advisory accepted the union of a per-part rule and the compiler's
whole-string rule, so the spelling one side resolved and the other
dropped was reported by neither.

CanonicalElementName::from_subscript (and its from_parts twin for a
subscript already split by SubscriptIterator) is now the one owner of
the key: split on the commas outside double quotes, trim and
canonicalize each part, join with ",". Every side of the match uses it
-- the arm keys parse_equation builds, the combination keys
expand_per_element, lower_arrayed_arms, unfilled_arms, the
builtins visitor's per-element instance expansion, the LTM lookup slot
classifier and the per-element GF table layout expand against, the
conveyor init-list key (delegates), the two advisories -- and the JSON,
protobuf and XMILE readers store the canonical spelling. The MDL reader
stores the source spelling; the datamodel invariant is only that every
consumer re-keys through the owner, stated on it, along with the
standing limitation that an element name containing a comma keys like
the combination of its pieces.

Severity follows the consequence, uniformly across sources. An arm
whose subscript names no element of the variable's dimensions is an
unused arm: the existing UnknownElementSubscript warning, now by the
one key. A declared element that no arm names and no EXCEPT default
applies to evaluates to the fabricated 0: a new MissingElementEquation
warning on the variable names every such element in row-major order
and says they evaluate to 0. Coverage is read off the parsed shape the
compiler expands (variable::elements_without_an_arm), never the entry
text: an entry with an empty equation is no arm, an empty EXCEPT
default covers nothing, and a gf-only entry is an arm because the
expansion wraps its table around the fabricated input. It is a
warning, not an error, on Vensim
ground truth rather than reasoning: Vensim defines a subscripted
variable on part of its range as a matter of course (h[DimA] :EXCEPT:
[SubA] = 8 defines h[A1] only), and its own export for the sdeverywhere
except model (test/sdeverywhere/models/except/except.dat) lists no
h[A2] at all -- the element does not exist there, so the 0 the MDL
import gives it is a value nothing in a valid Vensim model reads. As an
error it refused five corpus tests (except, except2, subscript,
directconst and the inline except_basic, whose test now pins the
warning naming a2 and a3 on h). The fidelity fix for imported models,
the MDL reader declaring a partially defined variable over the subrange
its arms cover, is GH #1059.

Pinned through the real pipeline: a proptest that any spacing and
capitalization of a one- to three-dimensional combination keys the
canonical join (and the join is a fixed point); the sliced_sum fixture
through Simlin JSON, where "nyc, young" and " Nyc , Young " now store
the canonical spelling and simulate bit-exact equal to the canonical
run across every saved series; the three arms -- a typo'd arm (one
UnknownElementSubscript warning naming nyc,yung and one
MissingElementEquation warning naming nyc,young on pop), a dropped
la,old arm (the warning names it and pop[la,old] is 0 at every step
while its siblings are untouched), an EXCEPT default covering the
dropped arm (no finding, simulates), and an arm for an element of
another dimension (warning only, simulates); the empty-equation entry,
the empty EXCEPT default and the XMILE element with neither equation
nor gf (each reported, each simulating as 0), and the gf-only element
(unreported, gf(0)); a protobuf-reader pin for a spaced, capitalized
subscript. The unfilled-equation test for a sparse array now also
asserts that the new warning names its armless slot. RealBeer4 and the
rest of the metasd and sdeverywhere corpora load and simulate
unchanged.
A per-element growth loop closed through a variable-backed reducer
(growth[r] = pop*0.02*(1 - total/1000), total = SUM(pop)) was reported
as growth -> pop[boston] -> total -> growth[boston]: four nodes for a
three-link loop, growth twice, once bare. The reported sequence was
read from links[0].from plus every link's to, and Link.from doubles as
the link-score name-resolution flag: build_element_level_loops strips
it to the variable level on a same-element A2A hop (a bracketed from
selects the FixedIndex / cross-dimensional score name, an unbracketed
one the Bare or per-target-element form), while to keeps the element
subscript whenever the target is arrayed. Raw scores were right;
identity was not, so the de-subscripting oracle could not match these
loops to the scalar model's circuits.

loop_node_sequence in db/analysis.rs is now the one owner of the
reported sequence: the enumerated loops of model_detected_loops, a
pin's detected_loop_from_loop, and discovery's FoundLoop summaries
(analysis.rs loop_variables, whose from-led reading equals the to-led
one on discovery's element-level links) all read through it. It reads
the circuit off each link's to, starting from the last link's (the
first node), never from from: every node of an elementary circuit is
the to of exactly one link, and a stitched cross-agg loop visits only
its agg twice, which the existing synthetic-agg trim absorbs (the
stitcher takes pairwise node-disjoint petals, db/ltm/loops.rs, so no
other node can repeat; a debug assertion states that). Only the mixed
branch of build_element_level_loops strips from; the cross-element
builder keeps it subscripted. Links, ids and the pin-dedup rotation
are untouched, so the loop-score equations are the same text as
before: the LTM dumper's every synthetic-variable equation and every
saved series on bare_reducer, variable_backed and scalar_cofactor are
identical before and after.

Pinned: the three mixed loops of bare_reducer (SUM(pop)) and
variable_backed (SUM(pop[*])) report growth[e] -> pop[e] -> total, each
node once, beside the A2A loop pop -> growth, through Simlin JSON in
the engine and through pysimlin Model.loops and Run.loops;
scalar_cofactor's reducer loops report growth[e] -> pop[e] -> weighted
with every sequence visiting each node once; a pin naming {growth, pop,
total} in discovery mode reports its three element-level instances
(pin1:1..3) as the same circuits through the pin path; and the owner's
unit tests cover the same-element A2A hop, bare and cross-element
links, a slotted agg hop and an empty loop. On the de-subscripting oracle every
loop of the three models now matches the scalar model's circuit (6, 6
and 7 matched, none unmatched on either side; before, the three mixed
loops of each were unmatched on both sides).
The changed-first partial froze model dependencies only, so TIME, STEP,
RAMP and PULSE advanced inside it: a source with no influence on its
target scored +/-1 whenever an exogenous forcing moved the target, and the
forcing was credited to every link into it (GH #1016; 41% of C-LEARN's
link-score arms carried a live time term). The 2020 paper's partial
f(x_t, y_{t-1}) - z_{t-1} reads every input but the isolated one at the
previous step, and the clock is an input of f like any other; the identity
"a source f does not read scores 0" only closes with the clock among the
"all other inputs". This is the reading of the formula; what Vensim and
Stella do here is unverified and the decision does not rest on it.

One rule, at both changed-first freeze sites (the wrap walker, whose index
pass and table-index freeze route through it, and the reducer-body freeze):
a call of a builtin the compiler itself classifies Invariance::TimeDependent
(the one owner of "reads the clock") is read at the previous step -- a bare
TIME through the per-model helper $⁚ltm⁚freeze⁚time = PREVIOUS(TIME), any
other call lagged whole with its arguments verbatim (a capture lags the call
once; lagging a frozen dep inside it as well would read it two steps back)
-- unless the call holds an occurrence of the isolated input's live shape,
the occurrence IR's verdict, in which case the call stays live, clock
included: the isolated input and the clock are then inseparable in one
call, and its whole change is attributed to the input. That residual is
documented and pinned, not diagnosed; a read of another element of an
arrayed input is not the live shape and freezes with the call. Inside a
frozen dependency's subscript index the call is left verbatim, because the
enclosing freeze already lags the whole read once (PREVIOUS(arr[TIME]) is
arr_{t-1}[TIME_{t-1}]); a model dependency in that position is lagged
twice today, the pre-existing residual the zero-slot predicate documents,
and the clock does not join it. The run constants DT, INITIAL_TIME and
FINAL_TIME are Pure and untouched. The changed-last duals leave the clock
live deliberately: their numerator reads every other input at t on both
sides, so the clock's motion cancels there. The guard form's own
TIME = INITIAL_TIME arm is outside the partial.

The helper exists because a frozen clock is one value for the whole model:
spelled inline, PREVIOUS(TIME) is a capture per arm and per occurrence,
which on C-LEARN v77 cost 9,632 result slots (a third of the row). With
the helper, minted once by model_ltm_variables when any arm reads it and
registered as a freeze helper so it sorts ahead of every score, the width
moves 28,725 -> 28,980 (+255: 36 helper instances, their 36 captures, and
183 captures of time-dependent calls frozen whole) and the emitted count
6,224 -> 6,227 (the helper in main, the ramp_from_to macro model and the
stdlib npv template). The zero-slot predicate recognizes the helper as the
one-step lag it is, so an arm whose only varying content was the clock is
now provably PREVIOUS(target) and omitted to an exact zero.

Pinned (tests/integration/ltm_frozen_clock.rs): x = 5 + STEP(2, 2) + 0*s
scores LS(s -> x) = 0 at every step (was 1 at t = 2); the isolated loop
f = 0.1*s + RAMP(1, 0) scores LS(s -> f) = 0.1*Δs/|Δf| = 0.500, 0.545,
0.587, 0.624, 0.658 at t = 1..5 with the loop score equal to it (was 1);
z = x * TIME scores the changed-first numerator x_t * TIME_{t-1} - z_{t-1},
distinguishable from the clock-live form (a constant 1) at every step; the
GH #763 repro grow = 1 + MIN(pop[*] * TIME) keeps the PREVIOUS(agg) anchor,
so the row that is never the argmin scores 0; z = x + arr[TIME] over
arr = [1, 2, 4, 8, 16] scores 0.1667, 0.1379, 0.1213, 0.1118 at t = 2..5,
the once-lagged form, where a twice-lagged clock flips the sign from t = 3;
and f = 1 + STEP(0.1*s, 2) pins the residual. The text-level rows
enumerate every Invariance class a partial can meet, both changed-last
duals keep their clock live, and the helper is minted once per model and
only when read.

Measured on the LTM corpus and C-LEARN v77 with a simlin simulate --ltm run
on the previous and the new CLI: the five corpus models (logistic_growth,
hero_culture, arrayed_population, cross_agg, cross_element) change no
link-score or loop-score series. C-LEARN changes 146 of 5,998 link-score
series, 86 of them to an identical zero; every one lies on an edge no
retained loop uses, and analyze() reports the same 153 loops, identical
relative-score series and identical 565 dominant periods before and after,
so no dominant loop changes at any step. The remaining changes replace
the clock-credited +/-1 with the paper's value: rs_global_pfc ->
proportion_of_global_to_cop_pfc[..] (ZIDZ of a lookup of Time over the
global sum) goes from an alternating +/-1 to the large negative ratio of a
target that barely moves while its inputs cancel, the "very large values"
case section 3.1 of the reference describes.

The C-LEARN slot-maxima digest is re-pinned with its derivation split
across the two commits that moved it: the net-flow aux commit (05e4dae)
moved slots 20,221 -> 20,337 and nonzero slots 3,106 -> 3,198 without
re-pinning this #[ignore]d release-only gate, and the frozen clock moves
them 20,337 -> 20,338 and 3,198 -> 2,876 (the omitted clock-only arms).
The lookup-pin and value-gate expectations are re-derived under the frozen
clock; the reference doc's section 3.1 note, the design doc, the engine
CLAUDE.md and the performance doc state the rule.
The 2026-09-07 LTM audit established that both hero models auto-flip to
discovery, so the exhaustive path's compile-time loop list is empty on
every model that matters and the TS engine has no discovery binding; that
the post-simulation product of a loop's recorded link scores reproduces
the in-simulation loop_score column to one ulp on all seven fixtures; and
that all-edge instrumentation costs no more than loop-edge instrumentation
plus per-loop score variables. This plan makes the post-simulation
pipeline the only loop-scoring path, keeps Johnson only for the structural
preview and ids, injects pins into the pipeline, treats an inline reducer
as a real loop node, deletes static polarity and the shortest-path
fallback, and gates sub-model pathway emission on SCC membership.

The plan embeds five product decisions (ids without a polarity letter,
element-level loops as the reported unit, named-aggregate reducer
semantics, static polarity deleted, synthetic CLI columns hidden) and its
acceptance criteria are drafted, not yet validated with the owner; both
are called out in the document.
Every one of the hero_culture fixture's fourteen loopMetadata entries
was rejected at sync ("a pinned loop must name at least two
variables"), so the fixture never exercised a pin and the rejection
named only the loop's name, five of which read "Technical Debt Spiral".

The fixture was the wrong side. LoopMetadata.uids are VARIABLE uids
everywhere else: the TypeScript types say so, SetLoopName mints a uid
on each named variable, and the MCP open path assigns every variable a
uid so that SetLoopName can resolve it. The fixture's entries were
written with the uids of the variables' diagram elements while no
variable carried a uid at all, so the projection resolved nothing.
Variable and view-element uids share one number space
(patch::next_available_uid takes the maximum over both), so the
regenerated fixture gives each of the 27 variables the uid of its own
element and leaves the entries untouched; all fourteen now resolve,
validate against the causal graph, and score.

A pin that cannot be scored is now reported with the entry's own
identity: PinnedLoopSpec carries the entry's uids as written, the
subset that matched no variable, and whether any variable of the model
carried a uid at sync (uids live only on the datamodel, so the fact
travels on the spec); every rejection arm goes through one reject
site that appends the unresolved-uid clause, so the two-variable gate
reads "'Ghost' (uids [7, 4]) names 0; uids [4, 7] match no variable's
uid; no variable in the model carries a uid (a pin names variables by
their uid, which SetLoopName assigns)", the not-a-cycle arm names the
entry's uids the same way, and the no-stock and failed-expansion arms
name a dropped uid too. A pin that drops a stale uid but whose
survivors still form a cycle scores that cycle and says so, naming the
dropped uids and the cycle scored instead ("... dropped uids [99],
which match no variable's uid, and scored the loop its remaining
variables form instead: births -> population"): a renamed or
re-created variable must never silently turn a pin into a different
loop under the same name. The name alone was never an identity.

Pinned: the fixture's fourteen pins produce no warning and, in
discovery mode, fourteen non-trivial pin{n} loop scores whose
relative scores are finite, bounded and non-zero; a pin on a model
whose variables carry no uid is reported with its name, its uids, the
unresolved uids and the no-uid cause; a pin with one stale uid beside
a resolving one names the stale uid and not the no-uid cause; a pin
whose survivors are no cycle names its uids and the dropped one; a pin
with a stale uid beside two resolving ones scores their cycle under
pin1 and warns naming it, and that warning has its row in the
once-per-project warning-family test.
The eight LTM fidelity fixes were implemented on two branches with
disjoint file ownership so two implementers could work in parallel:
ltm-fidelity carried the scoring-generator fixes (net-flow flow-to-stock
scores, LTM under every integration method, the frozen clock) and
ltm-fidelity-post the post-simulation and structural ones (per-partition
relative scores, input-to-module polarity, the relative-series polarity
base, the element-subscript key owner, loop node sequences, pin identity).
The only textual conflict was the integration harness's module list.

Two runtime-polarity fixtures from the post-simulation branch pinned exact
active-step counts that assumed the pre-merge two-step startup; on the
merged tree the first scored step is start + dt, so the yeast test now
reads its counts off the series and the balanced-loop fixture flips at
TIME < 12 so its sign split (48/32) is unbalanced by a margin rather than
by an odd step count.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 10, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ⚠️ Failed 2026-09-10T04:08:12.593393Z 1cc10e2 PR opened
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@codecov

codecov Bot commented Sep 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.06667% with 22 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.22%. Comparing base (d7cabaf) to head (1cc10e2).

Files with missing lines Patch % Lines
src/simlin-engine/src/variable.rs 76.59% 11 Missing ⚠️
src/simlin-engine/src/ltm_augment.rs 95.69% 4 Missing ⚠️
src/simlin-engine/src/ltm/graph.rs 97.14% 2 Missing ⚠️
src/libsimlin/src/analysis.rs 91.66% 1 Missing ⚠️
src/libsimlin/src/lib.rs 0.00% 1 Missing ⚠️
src/simlin-engine/src/common.rs 98.27% 1 Missing ⚠️
src/simlin-engine/src/db/analysis.rs 98.94% 1 Missing ⚠️
src/simlin-engine/src/serde.rs 97.29% 1 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff            @@
##             main    #1062    +/-   ##
========================================
  Coverage   93.22%   93.22%            
========================================
  Files         253      253            
  Lines      153504   153270   -234     
========================================
- Hits       143098   142892   -206     
+ Misses      10406    10378    -28     

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

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

@bpowers
bpowers merged commit 1996601 into main Sep 10, 2026
18 checks passed
@bpowers
bpowers deleted the ltm-fidelity branch September 10, 2026 04:20
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