Skip to content

ltm: module-internal PREVIOUS lag is uncounted state on every surface (lag-only sub-model loops unscored and unpinnable) #773

@bpowers

Description

@bpowers

Problem

Module-INTERNAL PREVIOUS() state is uncounted on every LTM surface. A loop

driver -> sub(out = PREVIOUS(in)) -> reader -> driver

is genuine feedback -- the sub-model implements a one-DT lag, the parent cycle compiles and simulates as a first-order discrete lag -- but it is entirely unscored and unpinnable:

  • model_is_stateless's lagged leg (model_has_lagged_dt_deps, src/simlin-engine/src/db/ltm/mod.rs) is deliberately parent-level only: it checks previous_only dt deps on the model's own variables, skipping Module kinds.
  • The module leg (modules_carry_state, same file, ~line 288) walks sub-models transitively but tests only sub_edges.stocks.is_empty() -- a sub-model whose only state is a PREVIOUS lag reads as stateless.
  • Pin validation (src/simlin-engine/src/db/ltm/pinned.rs) mirrors the same blindness: it cannot see lagged edges inside a module's pathway, so a pin over the parent loop is rejected.

So on a parent whose only state is a lag-only sub-model, the stateless early return fires and LTM emits nothing (no scores, and pins are dropped without even an invalid-pin warning), while model_detected_loops -- which has no stateless gate -- still enumerates the loop. The detected-vs-scored gap is the same shape #748 describes for module-internal stocks, on the lagged-state leg.

This is a known, documented residual of the #749 fix (branch ltm-fix-batch-2, commit 69e84d8e): that fix made PARENT-level previous_only dt deps count as state in both model_is_stateless and pin validation, and deliberately left module-internal lagged state uncounted on BOTH surfaces so the two surfaces at least agree (both treat the shape as stateless). The rustdoc on model_has_lagged_dt_deps records the scope cut.

Why it matters

Genuine feedback is invisible to LTM for lag-only sub-models -- e.g. a user sub-model implementing a custom delay or smoothing structure via PREVIOUS instead of an INTEG. #749 established (per the LTM reference, sec. 7) that PREVIOUS is state the enumerator legitimately scores; the module boundary should not change that verdict. Severity: low-medium (uncommon model shape today; silent no-output rather than wrong numbers).

Components affected

  • src/simlin-engine/src/db/ltm/mod.rs -- modules_carry_state (~line 288) and/or model_has_lagged_dt_deps (the stateless gate's two legs)
  • src/simlin-engine/src/db/ltm/pinned.rs -- pin validation's view of module-internal lagged edges

Constraint / fix direction

Both surfaces must move together (the #749 invariant): give modules_carry_state a lagged leg (a sub-model variable with a previous_only dt dep counts as state, transitively, mirroring its stock walk) AND give pin validation module-pathway lagged-edge visibility, so a scored loop is also pinnable. Fixing only the gate would recreate the exact enumerable-but-unpinnable disagreement #749 closed.

Cross-references

Discovery context

Identified during the #749 fix on branch ltm-fix-batch-2 (commit 69e84d8e), 2026-06-09: the commit message and model_has_lagged_dt_deps rustdoc record the deliberate scope cut; this issue makes the residual independently trackable.

Metadata

Metadata

Assignees

No one assigned

    Labels

    ltmLoops that Matter (LTM) analysis subsystem

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions