Skip to content

ltm: scalar module output feeding an arrayed reader emits a single scalar constant-0 link score, dropping all arrayed loops through modules #716

Description

@bpowers

Summary

A SCALAR module output feeding an ARRAYED reader gets its input -> module (really module-output -> arrayed-reader) link score emitted as a single scalar $⁚ltm⁚link_score⁚{m}→{growth} variable that evaluates to a constant 0. There are NO per-target-element $⁚ltm⁚link_score⁚{m}→{growth}[{elem}] variables. As a result, every arrayed feedback loop that runs through a module is dropped by LTM discovery (the loop product is 0, so the loop never forms) and mis-scored in exhaustive mode. This is an arrayed-link-score emission gap for the module-output-as-source case, not a settled equilibrium.

Empirical evidence

Probed on an arrayed fixture: arrayed stock s[Region]; a scalar multi-output module m (pos/neg producer); per-element reader growth[Region] = f(m·pos); loop closes s[e] -> m -> growth[e] -> s[e].

  • The element-level causal graph correctly carries the subscripted edges (s[nyc] -> m, s[boston] -> m; exit reader growth[nyc]).
  • But the m -> growth link score is emitted as a single scalar $⁚ltm⁚link_score⁚m→growth whose value is constant 0. There are NO $⁚ltm⁚link_score⁚m→growth[nyc] per-element variables.
  • The module internals are demonstrably alive (m·$⁚ltm⁚composite⁚input_val = -1, internal input_val→pos = +1, input_val→neg = -1), so the 0 is an emission gap, not an equilibrium.
  • Consequence: discovery returns loops: [] for the arrayed loop s[e] -> m -> growth[e] -> s[e]. The per-exit-port recompute (the GH ltm: discovery mode can report the wrong loop polarity (sign error) through a multi-output module #698 family, PR ltm: discovery-mode correctness fixes from deep review #705) can never apply to arrayed loops through a module because they never form.

Root cause direction

Ordinary scalar-source -> arrayed-target edges get per-target-element link scores via try_scalar_to_arrayed_link_scores (src/simlin-engine/src/db/ltm/link_scores.rs), named $⁚ltm⁚link_score⁚{from}→{to}[{elem}]. But the module-output-as-source case takes a different path: module-involved links are built by module_link_score_equation (src/simlin-engine/src/db.rs ~line 580 / the dispatch at ~756-778) and its per-shape twin in src/simlin-engine/src/db/ltm/compile.rs, which produce a single scalar LtmSyntheticVar with dimensions: vec![]. So a module-output -> arrayed-reader edge never reaches the per-target-element emission that an ordinary scalar -> arrayed edge does.

Likely fix: route module-output -> arrayed-reader links through the per-target-element emission (mirroring try_scalar_to_arrayed_link_scores), emitting one $⁚ltm⁚link_score⁚{m}→{to}[{elem}] per reader element, so the arrayed loop product is per-element and the loops form. The discovery parser specifically needs the element on the to side (a single Bare-A2A var would be undiscoverable, the same reasoning that motivated try_scalar_to_arrayed_link_scores putting the element on to).

Why it matters

Correctness/completeness. Any model with an arrayed reader of a module output (a common shape -- a scalar policy/macro feeding per-region dynamics) silently loses its entire arrayed feedback-loop class through the module, with no diagnostic. The modeler sees loops: [] (or a loop list missing the module loops) and is taught the wrong mental model ("no feedback here"). Discovery is also the regime large/auto-flipped models fall into, so this bites exactly where it is least visible.

Distinct from existing tracking

Components affected

  • src/simlin-engine/src/db.rs (module_link_score_equation, the module-involved link dispatch at ~756-778)
  • src/simlin-engine/src/db/ltm/compile.rs (link_score_equation_text_shaped, the per-shape module twin)
  • src/simlin-engine/src/db/ltm/link_scores.rs (try_scalar_to_arrayed_link_scores -- the per-target-element emission the module path should mirror; emit_link_scores_for_edge dispatch)
  • Downstream: src/simlin-engine/src/ltm_finding.rs (discovery, the arrayed loop never forms), exhaustive loop-score builder (mis-scored loop product)

Secondary observation (possibly distinct)

Feeding a scalar module input from an arrayed source (src "s") or a single element (src "s[nyc]") fails to compile with failed to compile fragments for variables: m, m, m. This is the mirror direction (arrayed/element-as-source into a scalar module input) and may be a separate validation/compile gap rather than an LTM-emission gap; noting it here for context. If it proves orthogonal during the fix, it warrants its own issue.

Discovery context

Empirically discovered during PR #705 review follow-up (LTM implementer), 2026-06-03, via probes on the arrayed pos/neg-module fixture described above. Part of LTM tracking epic #488. Related discussion: PR #705 review thread r3353758167.

Activity

  1. added
    ltmLoops that Matter (LTM) analysis subsystem
    on Jun 4, 2026
  2. bpowers commented on Jun 10, 2026

    @bpowers
    OwnerAuthor

    Mechanism note from the GH #758 work (branch ltm-fix-batch-2, commit 5c9c2d41): the constant-0 here is plausibly the same broken-by-construction species #758 fixed for non-module edges. module_link_score_equation (src/simlin-engine/src/db.rs) always returns Equation::Scalar; for an arrayed reader, both the ceteris-paribus path (via scalarize_ltm_equation) and the black_box_unit_transfer_equation fallback produce a scalar equation referencing the arrayed target bare -- which never compiles in scalar context, so the fragment fail-warns and is stubbed to 0 (or, if it compiles, is dimensionally wrong). The #758 gate deliberately excludes module endpoints (arrayed_non_module in src/simlin-engine/src/db/ltm/link_scores.rs), so module->arrayed-reader edges remain on this path; this issue's per-target-element emission fix is what covers them.

  3. bpowers commented on Jul 30, 2026

    @bpowers
    OwnerAuthor

    Mechanism and a measured count for this, from the LTM fragment-compile investigation in #994.

    This is the largest remaining class of LTM fragment-compile failures: 340 generating roots on C-LEARN, and it is the top bucket left after that PR (which takes the corpus from 1,603 failures to 449; 333 of the 449 are this).

    Two code sites produce the symptom described here:

    1. link_score_equation_text_shaped (src/simlin-engine/src/db/ltm/compile.rs:254) short-circuits on from_is_module || to_is_module into module_link_score_equation, which ends in LtmEquation::scalarize. That call is a shape relabel only — src/simlin-engine/src/db/ltm/equation.rs:203 drops the dimensions and keeps arm 0's text verbatim. So an arrayed-target body whose references are in apply-to-all form gets relabelled Scalar and then cannot compile, which is why the emitted variable is a single scalar rather than a per-element family.

    2. Independently, try_scalar_to_arrayed_link_scores — the machinery that already emits one scalar score per target element for a scalar source — bails on a module source at src/simlin-engine/src/db/ltm/link_scores.rs:1149. A module output is a scalar node feeding an arrayed target, so the one mechanism built for exactly this shape excludes it.

    So the fix has a shape: route a module-to-arrayed-target edge through the existing per-target-element path instead of returning None at the module guard, resolving the module's output reference via module_output_ref_in_document_order (which exists) as the live source. That reuses existing machinery and adds no new abstraction.

    The cost worth knowing before starting: the module guard exists in three places — link_score_dimensions, try_scalar_to_arrayed_link_scores, and the compile.rs short-circuit — and they have to agree.

    One approach that looks attractive and is not viable: emitting the module link score as apply-to-all over the target's dimensions instead of scalarizing. link_scores.rs:1090 documents why a scalar from must not get an A2A score — expand_a2a_link_offsets invents a phantom from[elem] node, and loops through from become undiscoverable. The current vec![] is deliberately avoiding that.

    Diagnosing this is now much cheaper than it was: cargo run --release -p simlin-engine --example ltm_fragment_failures (added in #994) buckets every failure by compiler error and cross-tabulates against endpoint shape, and runs on any model via LTM_FAIL_MODEL.

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

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