Skip to content

engine: enable LTM (Loops That Matter) on the wasm simulation backend#641

Merged
bpowers merged 37 commits into
mainfrom
wasm-ltm
May 28, 2026
Merged

engine: enable LTM (Loops That Matter) on the wasm simulation backend#641
bpowers merged 37 commits into
mainfrom
wasm-ltm

Conversation

@bpowers
Copy link
Copy Markdown
Owner

@bpowers bpowers commented May 28, 2026

Summary

  • Enables Model.simulate({ engine: 'wasm', enableLtm: true }) to match the bytecode VM (no silent fallback) by threading ltm_enabled / ltm_discovery_mode through the wasm compile path, surfacing the blob's $⁚ltm⁚* series, and running the existing ltm_post / ltm_finding / polarity code over a Results reconstructed from the wasm slab.
  • Adds two new libsimlin FFIs (simlin_analyze_links_from_wasm_results, simlin_analyze_rel_loop_score_from_wasm_results) plus a Results-from-slab + LTM-snapshot reconstruction helper, so both backends funnel through one analytic core. There is no TypeScript reimplementation of the analysis (the structural defense against the divergent-implementation class of ts: two divergent canonicalize implementations (@simlin/core incomplete vs @simlin/engine Rust-faithful) #624).
  • @simlin/engine drops the up-front enableLtm rejection on wasm and the getLinks wasm throw; Run.links is populated for wasm LTM runs; verified through both node DirectBackend and the browser WorkerBackend.
  • Models the backend genuinely cannot lower (e.g. LTM array op exceeding MAX_UNROLL_UNITS) return an explicit WasmGenError::Unsupported rather than panicking or falling back silently.
  • New simulate_ltm_wasm parity harness runs the LTM corpus through both backends with a monotonically rising floor on the number of LTM models that lower, plus arrayed / cross-element / discovery-mode parity gates. Heavy models stay #[ignore]d to respect the 3-minute cargo test cap.
  • Closes ltm: support Loops That Matter on the wasm simulation backend #636.

Design and implementation plan: docs/design-plans/2026-05-26-wasm-ltm.md and the six-phase plan under docs/implementation-plans/2026-05-26-wasm-ltm/.

Acceptance criteria coverage is enumerated in the test-requirements doc; all 16 ACs (wasm-ltm.AC1.1 .. wasm-ltm.AC5.2) have automated coverage.

Test plan

Automated gates (the regression baseline -- all green):

  • cargo test -p simlin-engine --features file_io --test simulate_ltm_wasm -- layout shape, scalar/arrayed/cross-element parity, discovery parity, Unsupported gate, ratcheting floor
  • cargo test -p simlin --test wasm -- new LTM-on-wasm FFI tests (links_from_wasm_match_vm, rel_loop_score_from_wasm_matches_vm, compile_to_wasm_unsupported_ltm_model_surfaces_error)
  • pnpm -C src/engine test -- --testPathPattern='wasm-ltm|worker-wasm' -- node DirectBackend + browser-shaped WorkerBackend parity, Unsupported propagation
  • pnpm -C src/engine exec tsc --noEmit -- no type errors on the new enableLtm shape

Human verification (see docs/test-plans/2026-05-26-wasm-ltm.md):

  • Drive the two #[ignore]d heavy discovery twins (C-LEARN, World3) on demand
  • Four end-to-end Node-REPL scenarios at the @simlin/engine API surface (scalar parity, arrayed parity, discovery, Unsupported-rejection)

bpowers added 30 commits May 27, 2026 20:47
…lity

Round all numeric coordinates the diagram renderer emits to 6 decimal
places (in both the Rust SVG renderer's js_format_number and a matching
TypeScript jsFormatNumber helper). 1-ULP f64 differences from compiler
or hardware variation no longer leak into the SVG output, so the
Rust-vs-TS byte-identical parity at svg-rendering.test.ts and the
diagram::connector::tests::test_render_arc_svg_byte_identical
regression guard hold on any toolchain. Sub-micropixel precision is
far above any visible rendering threshold.

Updated the captured expected string in the byte-identical test to
match the quantized output.
Planning artifacts for bringing Loops That Matter (LTM) analysis to the
wasm simulation backend. Six-phase implementation plan plus the design
plan it derives from, and the test-requirements.md that acceptance
criteria coverage is validated against at final review. No code
changes.
Make the two engine-level wasm compile entry points carry ltm_enabled
and ltm_discovery_mode, so a caller can request an LTM-aware blob.
compile_datamodel_to_artifact and its blob-only wrapper
compile_datamodel_to_wasm now set the two flags on the freshly-synced
SourceProject before calling compile_project_incremental; the wasm
codegen itself stays LTM-unaware, since once the salsa-tracked LTM
queries see the flags, the $\u{205A}ltm\u{205A}* synthetic variables
appear in CompiledSimulation.offsets and therefore (verbatim) in
WasmLayout.var_offsets.

No reset dance is needed here: the SimlinDb is owned by
compile_datamodel_to_artifact and dropped at return, so flag changes
cannot leak across calls. This is the contrast with simlin_sim_new,
whose SourceProject is shared/persistent and must restore prior LTM
state. Behavior is proven by the parity harness in Subcomponent B;
this commit is signature/threading only. The libsimlin FFI caller
(model.rs:157) is updated in lockstep with false, false to keep the
workspace building; Task 2 layers the FFI param threading on top.
Insert ltm_enabled and ltm_discovery_mode as the two parameters after
model on the C FFI, forwarding them to compile_datamodel_to_artifact.
This is the FFI-layer half of the LTM-on-wasm threading -- the engine
half landed in the prior commit. Outputs and return type are unchanged.

The two flags are placed mid-signature (between model and the output
pointers) to keep the call-site convention model | inputs | outputs |
out_error, which matches the rest of the libsimlin FFI. Inserting them
mid-signature shifts every positional argument, so the cbindgen header
is regenerated and three callers are updated in lockstep:

  * src/engine/src/internal/wasmgen.ts: the typed fn signature grows
    to 8 args and the call passes 0, 0 for the two flags. The
    wrapper's external TS API is unchanged; real enableLtm threading
    is Phase 3. This keep-green is mandatory because the pre-commit
    hook rebuilds libsimlin's wasm and runs the TS suite.
  * src/engine/wasm-backend-poc.mjs: the exploratory script's manual
    call is bumped to the new 8-arg form (LTM stays off; the script
    doesn't read LTM series).
  * src/libsimlin/tests/wasm.rs: every call site passes false, false;
    the LTM-on FFI parity test arrives in Phase 2.

No other FFI consumers exist today (CGo/pysimlin don't reach
simlin_model_compile_to_wasm).
Adds three new public items to tests/test_helpers.rs in service of the
LTM-on-wasm parity harness: vm_results_for_ltm and wasm_results_for_ltm
(the VM oracle and its wasm peer, each flipping ltm_enabled on the
freshly-synced salsa SourceProject before compile_project_incremental,
so the db is owned locally and the flag flip can never leak), plus the
LTM_SERIES_TOLERANCE = 1e-6 constant and the assert_ltm_slabs_match
comparator. The 1e-6 tolerance is intentionally far tighter than the
0.05 used in simulate_ltm.rs against an external oracle: both backends
run the same synthetic LTM equations through the same per-opcode lowering
and the same integration loop, so the columns should agree to floating
point round-off.

assert_ltm_slabs_match deliberately compares the entire data slab
element-wise rather than scanning the LTM-named columns. Both Results
share var_offsets (a verbatim copy of CompiledSimulation.offsets), so
slot i denotes the identical variable+element on both sides; a whole-slab
compare therefore covers every link/loop score column including each
element of an arrayed LTM variable (whose elements occupy contiguous
slots), with no per-variable slot-span bookkeeping. This is the single
comparator the Phase 4 arrayed LTM work reuses to carry wasm-ltm.AC2.4.
Adds the simulate_ltm_wasm integration target (file_io-gated, mirroring
simulate_ltm.rs) with the two AC1.1/AC1.5 layout-shape tests:
layout_carries_ltm_series_when_enabled and
layout_omits_ltm_series_when_disabled. Both feed the logistic-growth LTM
fixture through compile_datamodel_to_artifact and scan the emitted
WasmLayout.var_offsets for the canonical $\u{205A}ltm\u{205A}* prefixes
(matching ltm_augment::link_score_var_name and ltm_post::loop_score_ident),
so a future regression that mis-threads ltm_enabled or accidentally drops
synthetic LTM variables from the layout serializer surfaces here rather
than landing inside a downstream numeric harness.

The new test target does not call test_helpers::ensure_results, so the
shared helper picks up #[allow(dead_code)] to match the sibling
wasm-helper idiom; the simulate.rs and simulate_systems.rs callers are
unaffected.
Adds three series_*_matches_vm tests over the scalar LTM corpus -
logistic_growth_ltm/logistic_growth.stmx, arms_race_3party/arms_race.stmx,
and decoupled_stocks/decoupled.stmx - each running the model through
vm_results_for_ltm and wasm_results_for_ltm and asserting whole-slab
equality via assert_ltm_slabs_match within LTM_SERIES_TOLERANCE (1e-6).
This carries wasm-ltm.AC1.2: the LTM synthetic columns the wasm blob
writes must match the bytecode VM the engine treats as the LTM oracle.

assert_ltm_series_match also guards against a silent regression to
LTM-off: it asserts the wasm Results.offsets carries at least one
$\u{205A}ltm\u{205A}* key before comparing slabs, so a future bug that
drops the LTM flag thread cannot pass by comparing two LTM-off runs.
hero_culture_ltm/hero_culture.sd.json is intentionally out of scope for
this phase: a .sd.json loader is a separate follow-up and adding it
would conflate corpus parity with format-loader work.
Adds ltm_corpus_floor_gate and the LTM_CORPUS + MIN_LTM_MODELS_LOWERED
source-of-truth pair. The gate iterates the scalar corpus (the same three
.stmx models the per-model series_* tests cover), counts the models that
lower cleanly through wasm_results_for_ltm, and asserts the count meets
the floor. A model that returns Unsupported is eprintln'd as a skip
during rollout so a single-model regression surfaces without dragging
the whole gate red, but the count threshold prevents a silent slide back
to zero.

The floor value is 3 (the scalar corpus is the entire Phase 1 surface and
all three are expected to lower today). The ratchet contract is that the
constant is only ever raised - Phase 4 brings it up to include arrayed
models - and a drop below the floor must fail the suite (wasm-ltm.AC4.2);
the right response to a regression is to investigate the root cause, not
to lower the constant. Heavy models are reserved for the discovery and
arrayed phases (the #[ignore] pattern in tests/ltm_discovery_large_models.rs);
none of the scalar Phase 1 corpus needs ignoring today.
Three minor documentation and cleanup fixes:

- compile_datamodel_to_artifact: add rustdoc noting what ltm_enabled and
  ltm_discovery_mode do, matching the FFI peer in libsimlin/src/model.rs.

- compile_datamodel_to_wasm: remove the now-inaccurate claim that this
  function is the entry point for wasm-backend-poc.mjs (the script calls
  the libsimlin FFI, not this engine-level function); note instead that
  it is called only from its inline unit test.

- simulate_ltm_wasm.rs: drop the imported-but-unused LTM_SERIES_TOLERANCE
  specifier and the dead _LTM_SERIES_TOLERANCE_REF workaround const; the
  tolerance is applied transitively inside assert_ltm_slabs_match in
  test_helpers.rs, so neither the import nor the const was needed here.
Pulls the structure-resolution and LTM link-score-series scrape out of
the VM-backed FFI into a private analyze_links_core that takes a db
borrow, a SourceModel/SourceProject pair, and an Option<&Results>.  The
existing FFI now drops to thin acquisition: lock db + sync_state +
state, resolve the synced model, call the core, drop locks, then
materialize the OwnedLink vector into the SimlinLinks ABI via the
owned_links_to_ffi helper.

Two cores rather than the one the design proposes because the links
analysis needs only structure + an Option<&Results> while the
relative-loop-score core (next commit) needs the snapshot maps from the
sim; folding both through one signature would have forced links callers
to fabricate empty snapshots.  This satisfies AC5.1 (one engine-level
core per analysis, no per-backend reimplementation) more honestly than
a single over-broad core.

The borrow lifecycle of model_causal_edges (returns &CausalEdgesResult
tied to the db lock) is preserved: the core materializes unique_links
into owned (String, String) pairs before touching results, and the FFI
drops the db / sync_state / state locks the moment the core returns.
compute_link_polarities returns owned data, so the polarity lookup is
lock-independent by construction.
Pulls the per-(partition, slot) denominator caching plus the
per-element / argmax-abs dispatch out of the VM-backed FFI into a
private rel_loop_score_series core driven by &Results, the loop
partition / element-index snapshots, and an external denominator cache.
The slot_partition_at and ensure_denom_for_element helpers are
promoted from FFI-local nested fns to module-level so the core can
share them.

The FFI shell keeps the user-facing surface unchanged: it still parses
subscripted loop ids, resolves a subscript to an element_index via
LoopElementIndex, and maps each engine error (loop unknown, dim count
mismatch, element not found, etc.) to its specific SimlinError code
with the same message text -- those error paths are tightly coupled to
user input and don't belong in a backend-agnostic core.  The core
takes (loop_id, element_index, n_slots) as already-resolved inputs and
returns Option<Vec<f64>>, where None is the engine's missing-data
signal that the FFI maps to DoesNotExist.

Two cores rather than the design's one-core proposal because the
links analysis is driven purely by structure + Option<&Results>
(no snapshots) while this analysis needs both snapshots and a
mutable denominator cache; one over-broad signature would force
links callers to fabricate empty snapshots.  The split-borrow
against &mut state is preserved: cache is borrowed mutably while
results / loop_partitions / loop_element_index are borrowed
immutably, matching how the from-wasm FFI in Subcomponent B will
pass a stack-local empty cache against a freshly-rebuilt Results.
These two pub(crate) helpers in libsimlin's analysis module are
the structural pieces the from-wasm analyze FFIs (added in the
following tasks) need to call the shared analytic cores already
extracted in Subcomponent A.

results_from_layout_and_slab rebuilds an engine::Results from a
host-extracted result slab + serialized WasmLayout, mirroring
Vm::into_results(): the layout's name->offset map becomes the
Results offsets, the slab becomes data, n_slots/n_chunks become
step_size/step_count. The wasm side's results_offset is a
linear-memory byte offset and is irrelevant once the slab has
been lifted out, so callers pass the slab already extracted.
Specs cannot be looked up through a single salsa query (split
between SourceProject::sim_specs and SourceModel::model_sim_specs),
so the helper composes the assemble_simulation branch via
engine::db::source_sim_specs_to_datamodel -- previously private,
promoted to pub here. Neither analytic core reads Specs, but
Results requires a well-formed value.

recompute_ltm_snapshots runs the same salsa queries
simlin_sim_new uses to capture the loop_partitions and
loop_element_index snapshots (model_ltm_variables +
project_datamodel_dims + build_loop_element_index). These
queries only return non-empty data when SourceProject has
ltm_enabled = true, which lives on a salsa input shared with
every other consumer of the project; leaking it past the
snapshot recompute would silently change subsequent operations'
analysis. An LtmEnabledGuard RAII scope guard makes the flag
reset structurally unmissable -- an early return or a panic in
the middle of the queries cannot skip it, unlike an explicit
trailing set_project_ltm_enabled(.., false) line.

The helpers are dead-code-allowed in this commit; tasks 4 and 5
add the from-wasm FFI consumers.
The wasm-backend twin of simlin_analyze_get_links. Both FFIs
funnel through the same analyze_links_core extracted in
Subcomponent A, so the link set and per-link LTM score series
they produce cannot diverge by construction.

The new FFI accepts the host-extracted wasm result slab as a
(*const u8, usize) byte pair plus the serialized WasmLayout
buffer returned by simlin_model_compile_to_wasm. Byte input
matches the convention simlin_project_open_protobuf uses; the
shell validates slab_len is a multiple of 8 (f64 size) and
reinterprets the bytes through f64::from_le_bytes into an
owned Vec<f64>, which sidesteps any alignment concerns wasm
linear memory might present to the host.

The links analysis is structure-driven, not LTM-snapshot driven
(the unique (from, to) edges come from model_causal_edges,
which has no LTM dependency), so this FFI does NOT toggle
ltm_enabled on the salsa db. Only the rel-loop-score
counterpart (task 5) needs the snapshot dance and the
LtmEnabledGuard reset.

Score arrays in the returned SimlinLinks are allocated as
Box<[f64]> -> as_mut_ptr + mem::forget, matching the VM FFI's
allocation shape so the existing simlin_free_links ->
drop_link -> drop_f64_array ownership chain frees them
correctly. The C header is regenerated to expose the new symbol.

Parity test (tests/wasm.rs::links_from_wasm_match_vm): compile
the logistic-growth-LTM fixture to wasm with ltm_enabled=true,
run it under the DLR-FT interpreter (the run_and_stride pattern
already used in this file), extract the full result slab, and
assert the from-wasm FFI's (from, to, polarity, score-series)
output matches simlin_analyze_get_links on the same project
within 1e-6 elementwise on every link with a non-empty score
series. Verified the test catches a defective implementation by
temporarily passing None to analyze_links_core (length mismatch
fails fast).
The wasm-backend twin of simlin_analyze_get_relative_loop_score.
Both FFIs funnel through rel_loop_score_series (extracted in
Subcomponent A) over an engine::Results and the
(loop_partitions, loop_element_index) snapshots, so the per-loop
time series they produce cannot diverge by construction.

Unlike the links twin (task 4), this FFI needs the LTM snapshots
that only model_ltm_variables surfaces when the SourceProject
salsa input has ltm_enabled = true. The new helper
recompute_ltm_snapshots toggles the flag inside an LtmEnabledGuard
RAII scope guard so the reset is structurally unmissable: an
early return or a panic in the middle of the queries cannot
leak the flag past the recompute. The flag lives on a salsa
input shared with every other operation against the project,
so leaking it would silently change subsequent consumers'
analysis -- the same hazard simlin_sim_new defends against with
its bookend set_project_ltm_enabled(.., false) call.

The loop_id parse + subscript resolution is now shared between
the VM and from-wasm FFIs via the new resolve_loop_query helper
so the two surfaces produce identical error messages (issue
#463 message strings). The existing simlin_analyze_get_relative_loop_score
is refactored onto resolve_loop_query in this commit; behavior
is unchanged and the existing rel-loop-score test suite stays
green.

Results-from-slab and the snapshot recompute run against the
salsa db owned by SimlinProject, so the FFI takes a mutable db
lock for the snapshot recompute (the LtmEnabledGuard mutates the
input) and resolves the sync_state to find the SourceModel. The
partition-denominator cache lives on the stack for this call;
the wasm interactive-scrubbing flow re-runs the blob and reaches
this FFI fresh each time, so a persistent cache like SimState's
isn't useful here.

The C header is regenerated to expose the new symbol.

Parity test (tests/wasm.rs::rel_loop_score_from_wasm_matches_vm):
compile the logistic-growth-LTM fixture to wasm with
ltm_enabled=true, run it under the DLR-FT interpreter, enumerate
every loop id with simlin_analyze_get_loops, and assert each
loop's from-wasm rel-score series matches the VM oracle within
1e-6 elementwise. The scalar logistic-growth model has no
subscripted loop ids; the subscripted-id parity is deferred to
Phase 4's arrayed wasm-LTM fixture and documented in the test.
Verified the test catches a defective implementation by
temporarily adding a 0.5 bias to the from-wasm output (rel-score
step 0 mismatch fails fast).
- recompute_ltm_snapshots: replace the dead `let _ = model_name` with a
  `debug_assert_eq!(model.name(guard.db()), model_name)` so the parameter
  documents and enforces the caller-resolution invariant in debug builds.
- slab_from_bytes: add a cross-reference comment noting that the
  element-count geometry check (`== n_chunks * n_slots`) lives in
  `results_from_layout_and_slab`, not here, to prevent a future reader
  from thinking the check is absent.
- LtmEnabledGuard Drop: document the panic-safety reasoning: the setter
  is guarded by an equality check so a no-op restore never touches salsa,
  and on a valid db handle the setter does not panic.
…alysis

Thread enableLtm through the simlin_model_compile_to_wasm TS wrapper so the
8-arg libsimlin export gets a real 0/1 flag instead of the keep-green 0 the
Phase 1 wrapper passed unconditionally. The wrapper now also returns the raw
serialized layout bytes alongside the parsed geometry: libsimlin's from-wasm
analysis FFI takes the serialized form back, so retaining the original bytes
avoids re-serializing the parsed shape.

DirectBackend.simNewWasm drops the up-front enableLtm rejection so the public
facade resolves a Sim for engine:'wasm' + enableLtm:true. The wasm path does
NOT go through simlin_sim_new (which would internally ref the model), so the
caller must keep the model pointer valid for a later from-wasm getLinks --
simNewWasm calls simlin_model_ref before storing the new wasmModelPtr handle
field, and releaseWasmSimState pairs that with simlin_model_unref on dispose.
The three test blocks that pinned the old rejection contract (one each in
wasm-backend.test.ts, wasm-model.test.ts, and worker-wasm.test.ts) are
removed in the same change so the build stays green; the LTM-on-wasm
parity tests land in Task 4.
Add a TS binding for the Phase 2 libsimlin export
simlin_analyze_links_from_wasm_results: it copies the caller's slab and
serialized layout bytes into the libsimlin singleton's linear memory,
invokes the FFI, frees the copies, and returns a SimlinLinksPtr. The
binding mirrors the existing simlin_analyze_get_links pattern but does
the copyToWasm marshalling itself so callers do not have to manage the
two transient allocations and the error pointer.

DirectBackend.simGetLinks's wasm branch now reads the blob's complete
result slab as a fresh Uint8Array, hands it off with the retained model
pointer and serialized layout to the new binding, and runs the returned
ptr through the existing convertLinks (the same one the VM path uses,
no analysis is duplicated between backends). The .slice() on the wasm
memory view is load-bearing: the view aliases the live blob memory,
which may grow and detach the view between the read and the copyToWasm
that follows -- slice forces a fresh, decoupled buffer. Removes the two
now-obsolete getLinks-rejection tests (AC6.1 in wasm-backend.test.ts
and the engine-selection discriminator in wasm-model.test.ts) that
pinned the contract this task inverts; the LTM-on parity tests land in
Task 4.
Drop the `&& this._engine !== 'wasm'` clause from Sim.getRun's wantLinks
guard now that the wasm backend supports getLinks on an LTM sim (the path
reads the blob's results slab and runs the analysis via the from-series
FFI from Phase 2). With this change, a wasm Sim with enableLtm=true
produces a Run whose links carry the same per-step scores as the VM run,
restoring engine parity at the public-API level. LTM-off runs on either
backend still carry empty links, so Model.run({engine:'wasm'}) without
analyzeLtm is unchanged.

The private _engine field on Sim existed only to feed this guard and now
has no other reader; removing it (along with the unused constructor arg)
keeps the class minimal. SimEngine still flows through Sim.create into
backend.simNew, which is the authoritative engine selection point.
Add tests/wasm-ltm.test.ts covering the four end-to-end contracts that
LTM-on-wasm now satisfies: simulate({engine:'wasm', enableLtm:true})
resolves to a Sim (no up-front rejection), Sim.getLinks() on a wasm sim
returns links with per-step score arrays whose length tracks the step
count, the scored link set and per-edge scores match the VM oracle within
1e-6, and Model.run({analyzeLtm:true, engine:'wasm'}) yields a Run whose
links facade matches the VM run. Together these lock down wasm-ltm.AC1.3
and wasm-ltm.AC2.3 through the public Project/Model/Sim/Run facade.

The fixture is the in-tree test/logistic_growth_ltm/logistic_growth.stmx
model -- a small scalar feedback loop (one stock, one flow, three auxes)
whose LTM analysis produces nontrivial per-link scores. Self-loops are
expected to carry no score, so the wasm-vs-VM parity assertions match
each (from, to) edge by key and only compare score arrays when both
engines produced one (with .toBeUndefined() symmetry on the absent half).
The new src/engine/tests/wasm-ltm.test.ts shipped with a stray 0x00 byte
at the supposed-space in 'link.from + " " + link.to' (line 53). Tests
still passed because both backends produced the same 'from\0to' key, but
the source file was technically binary and tripped git's auto-binary
heuristic (its diff rendered as 'Bin 0 -> 6420 bytes'). Replace the NUL
with the intended space character so the file is plain ASCII.
- JSDoc for Model.simulate: drop the stale enableLtm+wasm rejection clause;
  document that both engines support enableLtm (wasm via from-wasm analysis FFI).
- JSDoc for Model.run: replace "wasm never enables LTM / empty links" with the
  current contract: analyzeLtm works on both engines; LTM-off yields empty links.
- wasm-model.test.ts AC6.3 describe: rename to remove the stale "never calls
  getLinks" claim; update the second it() comment to reference the LTM-off path
  and point to wasm-ltm.test.ts for the LTM-on counterpart.
- direct-backend.ts simGetLinks comment: reword the .slice() rationale to
  describe JS-side isolation from libsimlin allocation, not memory growth
  (the blob's memory is non-growing; the comment at :499 was accurate).
- score coalescing: || -> ?? to make null-coalescing intent explicit.
- HandleEntry / HandleExtra: interface -> type (data shapes, not class contracts).
Adds wasm-vs-VM parity tests for the two arrayed LTM fixtures
(arrayed_population_ltm/arrayed_population.stmx, N=3 A2A;
cross_element_ltm/cross_element.stmx, N=2 with a SUM(population[*])
reducer). Phase 1's whole-slab assert_ltm_slabs_match comparator
transparently covers every element of every per-element-arrayed LTM
synthetic (Bare A2A strided slots, FixedIndex name-baked elements,
scalar-to-arrayed targets, and the synthetic-agg $\u{205A}ltm\u{205A}agg\u{205A}*
columns) because both Results share their var_offsets, so no new
per-var comparison logic is needed -- only adding the corpus paths.

Adds an arrayed-multi-slot guard alongside the existing wasm-side
LTM-key check so neither arrayed test can pass vacuously on a scalar
reduction: a regression that collapses an A2A target's link or loop
score to a single slot would still see both backends agree on a scalar
value and pass assert_ltm_slabs_match. The guard reads the
salsa-tracked LtmVariablesResult.vars (the authoritative shape source
driving the ApplyToAll emission slot allocator), asserting at least
one synthetic var carries a non-empty dimensions list. Confirmed the
guard fires when the threshold is raised to an impossible value.
Rewrites ltm_corpus_floor_gate from the Phase 1 'at-least-N-lower'
rollout form to the Phase 4 end-state form: every entry in the new
EXPECTED_SUPPORTED_LTM_MODELS list MUST lower to wasm with LTM enabled,
and any WasmGenError::Unsupported or incremental-compile failure from a
listed model now fails the suite outright (wasm-ltm.AC4.2). The list
contains the three scalar models from Phase 1 plus the two
arrayed/cross-element models covered by Task 1's series_*_matches_vm
tests, so MIN_LTM_MODELS_LOWERED rises from 3 to 5 by being derived
from the list's length.

The redundant floor-count assertion is kept alongside the per-model Ok
check so a *missing* entry (deleted from the list without adjusting the
constant) still fails, not just an Unsupported from a listed entry.
Confirmed the gate fires when any listed model fails to lower or load.
Adds the Phase 4 Subcomponent B coverage for wasm-ltm.AC3.1: a small
XMILE fixture under test/ltm_dynamic_range_unsupported/ combines a one-
stock feedback loop (population/growth/rate, so LTM genuinely emits
link/loop scores) with the non-constant subscript range
SUM(source[lo:hi]) over a size-5 dimension A. The fully-unrolled wasm
emitter cannot express the runtime view range (GH #612), so the
compile path returns WasmGenError::Unsupported cleanly -- the same
trigger the libsimlin TestProject-built compile_to_wasm_unsupported_
model_surfaces_error already uses, but in real XMILE form so the
single fixture serves both Rust tests and the upcoming TS twin.

Two paired assertions land the contract:
unsupported_ltm_model_returns_wasmgen_error in
src/simlin-engine/tests/simulate_ltm_wasm.rs asserts the engine-level
WasmGenError::Unsupported AND that the same model still
simulates fine on the bytecode VM via vm_results_for_ltm -- proving
this is wasm-only, not a structural model error. The FFI-level twin
compile_to_wasm_unsupported_ltm_model_surfaces_error in
src/libsimlin/tests/wasm.rs drives simlin_model_compile_to_wasm with
ltm_enabled=true through the 8-arg signature, asserts a non-null
SimlinError carrying a codegen-failure message, both output buffers
NULL, and no FFI panic. Mirrors the structure of the existing FFI
unsupported test but loaded from the on-disk XMILE so the fixture is
the single source of truth across Rust and TS.
…ack)

Closes the TS half of Phase 4 Subcomponent B (wasm-ltm.AC3.2). The
test loads the test/ltm_dynamic_range_unsupported/model.stmx fixture
committed alongside the Rust assertions in the prior task and drives
Model.simulate twice on it. The wasm path -- engine:'wasm',
enableLtm:true -- must reject: per src/engine/CLAUDE.md the
DirectBackend deliberately surfaces the SimlinError from
simlin_model_compile_to_wasm to the caller rather than falling back to
the VM, so a wasm caller never gets a silently-wrong result slab on a
model the backend cannot lower. The VM path on the same model must
still resolve and produce a step count > 0 plus at least one causal
link, confirming the limitation is wasm-backend-specific (the dynamic
view range is a wasmgen-only restriction; the VM handles it fine) and
that LTM is genuinely on for the fixture rather than the assertion
passing vacuously on an empty link list.

The rejection assertion is intentionally loose (rejects.toThrow with
no message coupling) so the exact codegen error text can evolve
without churning the TS gate; the Rust twin in libsimlin/tests/wasm.rs
is the place the message contract is pinned.
Three doc-precision improvements from code review: (1) the
lowered >= MIN_LTM_MODELS_LOWERED floor check is now documented as
structurally redundant (const is derived from the list) and kept only as
defense-in-depth for a future refactor that pins the const as a hard
literal; (2) the assert_ltm_series_match_arrayed docstring no longer
overclaims coverage -- it names only the forms the current fixtures
actually emit (Bare A2A in arrayed_population; Bare A2A + FixedIndex
name-baked in cross_element) and notes that scalar->arrayed and
synthetic-agg forms would be covered by any future fixture without
changing the comparator; (3) the cross_element test docstring corrects
the claim that SUM(population[*]) hoists to a $:ltm:agg:* node -- it is
variable-backed, so the variable itself is the aggregate and no synthetic
node is emitted; a one-liner near the multi-slot SimlinDb guard records
the Phase-5 sharing deferral in code.
…puts

Adds two test-only helpers to the shared `tests/test_helpers.rs`:
`wasm_results_for_ltm_discovery` lowers a project with BOTH
`ltm_enabled` and `ltm_discovery_mode` set, runs the blob under the
DLR-FT interpreter, and reshapes the slab into a `Results` whose
`specs` are reconstructed from the same compile's datamodel. Specs are
load-bearing for discovery -- not cosmetic -- because
`ltm_finding::discover_loops_with_graph` derives each `FoundLoop.scores`
time axis from `results.specs.start + save_step * step`; a stub-specs
`Results` would produce a degenerate time series that diverges from the
VM peer purely on the time axis. The new `LtmDiscoveryInputs` struct and
`ltm_discovery_inputs` builder extract the body of the existing
`ltm_discovery_large_models.rs::discovery_inputs` so the structural
discovery inputs (`Results` + `CausalGraph` + stocks + ltm-vars + dims)
are assembled in exactly one place across the two test binaries that
exercise discovery. That single-source-of-truth is what makes the
Phase 5 Task 2 wasm-vs-VM parity test honest: both backends drive the
same `discover_loops_with_graph` over the same structural inputs.

`ltm_discovery_large_models.rs::discovery_inputs` becomes a thin
wrapper that returns the file-local `DiscoveryInputs` struct so the
World3 + tractable-companion + C-LEARN tests keep their existing call
shape. The shared builder takes the model name (the prior body
hardcoded "main"), generalising it for any future caller; both
existing call sites in this binary still target "main".
Adds `discovery_arms_race_matches_vm` to the LTM-on-wasm parity
harness: drives `ltm_finding::discover_loops_with_graph` over both a
VM-produced `Results` and a wasm-produced discovery-mode `Results`
(via `wasm_results_for_ltm_discovery` from Task 1), feeding both runs
the same structural inputs assembled by the shared
`ltm_discovery_inputs` builder, and asserts the discovered loop sets
match by canonical edge-sequence key and that every matched loop's
per-timestep score series agrees within 1e-6. Loop identity drops
polarity (runtime-derived from scores, so could flip on the boundary)
and `Loop.id` (assigned post-rank by score-driven sort with order-of-
discovery tie-breaking, not a stable identity surface) -- the
trimmed link sequence under canonical rotation is what
`discover_loops_with_graph` itself uses for internal dedup, the most
natural identity for a directed cycle. Times are checked for exact
equality because both backends' `Results` carry `specs` reconstructed
from the same datamodel compile (the step-to-time formula
`start + save_step * step` is bit-identical); only the score values
are compared with tolerance, and only the slab-comparator dual-NaN
rule (mirroring `assert_ltm_slabs_match`) treats both-NaN as equal so
the LTM PREVIOUS-bootstrap step-0 doesn't false-positive.

The arms-race model is the small parity fixture (~57 lines of XMILE)
and runs in well under a second. Heavy ignored twins
`discovery_clearn_matches_vm_wasm` (C-LEARN v77, 1.4 MB MDL) and
`discovery_world3_matches_vm_wasm` (World3-03, 166-variable) mirror
the precedent in `ltm_discovery_large_models.rs`: both are
`#[ignore]`d -- the wasm compile of those models is slow, and the
World3 twin's VM-side discovery is additionally blocked by GH #540 --
so they do not count against the 3-minute `cargo test --workspace`
cap. Both can be run explicitly with `--ignored`.
DISCOVERY_SCORE_TOLERANCE in simulate_ltm_wasm.rs was numerically
identical to LTM_SERIES_TOLERANCE in test_helpers.rs (both 1e-6) and
served the same wasm-vs-VM parity purpose.  Drop the local constant and
use LTM_SERIES_TOLERANCE as the single source of truth.

Extend LTM_SERIES_TOLERANCE's rustdoc with two additions carried over
from the dropped constant's prose: the relative-or-absolute tolerance
shape (max(1.0, max(|vm|, |wasm|)) * tol), the loop-score chain-length
rationale (small constant chain length in arms_race_3party; both backends
share the salsa-compiled opcode sequence so per-op rounding is
bit-identical), and a new note on transcendental ULP propagation for the
heavy #[ignore]d twins (C-LEARN, World3): math.rs open-codes exp/ln/sin
etc. and may differ from Rust libm by a few ULP; for any link-score
equation routing through a transcendental the propagation through k links
stays within the 1e-6 floor, and the heavy twins are where this would
surface in practice -- they are #[ignore]d, so the risk is contained.
Now that DirectBackend forwards enableLtm + engine:'wasm' end-to-end and the
worker protocol already carries simGetLinks (the VM path used it), the wasm
LTM path is automatically reachable through WorkerBackend with no protocol
change. Pin that promise with a parity test on the same logistic_growth_ltm
fixture wasm-ltm.test.ts uses: drive the worker round-trip, then compare its
links against the node DirectBackend (exact -- same blob and same analytic
core) and the VM (within 1e-6, the documented LTM tolerance). Three-way
agreement covers AC1.4 and guards against a future protocol change silently
dropping link scores on the way back through postMessage.

The test sits as its own describe at the end of worker-wasm.test.ts and
manages its own DirectBackend oracle so the existing Phase 3 suite's lifecycle
is untouched.
bpowers added 5 commits May 28, 2026 00:26
The wasm backend used to list LTM as out of scope (VM-only) and ended its
unrolling-budget rejection with 'falling back to the VM'. Phases 1-5 of
wasm-ltm enabled LTM end-to-end on the wasm engine (the blob carries the LTM
series, simGetLinks runs the shared analytic core off the slab, parity-tested
against the VM within 1e-6), and the no-silent-fallback contract from
engine-wasm-sim is the one the wasm path actually obeys -- an Unsupported
becomes an explicit error to the caller. Sweep the docs and the lower.rs
rustdoc/error-string to match: drop 'LTM (VM-only)' from the simlin-engine
wasmgen bullet, reword the three lower.rs 'falls/falling back to the VM'
sites and one vector.rs sibling around the no-silent-fallback contract,
restate the src/engine/CLAUDE.md wasm-restrictions bullet as LTM-on-wasm-
supported, and add the wasm-ltm follow-up reference to the engine-wasm-sim
design plan and the docs index.

Behavior is unchanged -- the Unsupported message string still contains the
'unrolling exceeds' substring the two cap tests grep for, so the lower-tests
assertions stay green. The worker-wasm 'rejects a wasm-unsupported model'
test name kept the negated 'falling back to the VM' phrase; reword it to
'switching to the VM' so the repo-wide grep returns nothing in src/.
LTM_SCORE_TOL, expectScoresClose, linkKey, and linksByKey were duplicated
between wasm-ltm.test.ts and worker-wasm.test.ts. The two copies also used
different key separators: a literal space in wasm-ltm and the unit-separator
glyph (U+241F) in worker-wasm.

Extract the four helpers into ltm-test-helpers.ts (Functional Core -- pure
functions, no I/O). Standardize on the literal space separator: variable names
in canonical form never contain spaces, so it is safe and more readable. Both
consumer files are Imperative Shell and retain their FCIS pattern comments.
The shared LTM test helpers commit (fcb642f) extracted linkKey into
ltm-test-helpers.ts but the two consumer test files only use linkKey
transitively through linksByKey. tsc with noUnusedLocals (the project's
strict setting) flags TS6133 here; ts-jest's default diagnostics mask it,
but the violation is real. Drop linkKey from the import in both files;
it remains a public export of the helper module for any future direct
consumer.
Reflects contracts that landed on the wasm-ltm branch (the wasm-side
LTM rewrite of phases 1-6) in the per-component context files:

- src/libsimlin/CLAUDE.md: document the new 8-arg signature of
  simlin_model_compile_to_wasm (ltm_enabled / ltm_discovery_mode threaded
  through to the salsa compile, no silent VM fallback on an unlowerable
  LTM); add the two new FFI functions
  simlin_analyze_links_from_wasm_results and
  simlin_analyze_rel_loop_score_from_wasm_results to the analysis
  section, noting that they funnel through the same analyze_links_core
  / rel_loop_score_series cores as the VM FFIs and that the rel-loop
  twin uses an LtmEnabledGuard around recompute_ltm_snapshots; describe
  the new LTM coverage in tests/wasm.rs (the unsupported-LTM
  surface-the-error test plus links_from_wasm_match_vm /
  rel_loop_score_from_wasm_matches_vm).

- src/simlin-engine/CLAUDE.md: add tests/simulate_ltm_wasm.rs to the
  Tests section, noting the wasm-vs-VM whole-slab parity, the
  arrayed/cross-element coverage, the discovery-mode bundle (shared
  ltm_discovery_inputs / LtmDiscoveryInputs in tests/test_helpers.rs
  used by both ltm_discovery_large_models.rs and the new wasm twin),
  the no-VM-fallback Unsupported-LTM test, and the ratcheting floor
  gate.

- src/engine/CLAUDE.md: add tests/wasm-ltm.test.ts and
  tests/ltm-test-helpers.ts to the Tests section.

The earlier in-branch CLAUDE.md updates (the wasm-restrictions bullet
in src/engine/CLAUDE.md and the wasmgen 'out of scope: LTM (VM-only)'
removal in src/simlin-engine/CLAUDE.md, both from Phase 6 Task 2) are
already on this branch and unchanged here.
Manual-verification checklist that re-runs the four automated gates
(simulate_ltm_wasm, libsimlin wasm.rs from-wasm analyze FFIs, wasm-ltm
+ worker-wasm jest suites) plus the ignored heavy discovery twins
(C-LEARN, World3), and four end-to-end Node-REPL scenarios at the
public API (Model.simulate engine:'wasm' enableLtm:true; Run.links via
analyzeLtm:true; WorkerBackend triple-pin; the no-silent-fallback
contract on the dynamic-range Unsupported fixture). Includes a
traceability table mapping every one of the 16 wasm-ltm.ACx.y
acceptance criteria to its automated test and the manual step that
re-exercises it; no acceptance criterion requires human-judged
verification (the manual scenarios are public-API smoke checks).
Copy link
Copy Markdown

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

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: 6dd1c28338

ℹ️ 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/engine/src/direct-backend.ts Outdated
Comment on lines +712 to +714
const { resultsOffset, nSlots, nChunks } = entry.wasmLayout!;
const slabBytes = new Uint8Array(entry.wasmExports!.memory.buffer, resultsOffset, nSlots * nChunks * 8).slice();
const linksPtr = simlin_analyze_links_from_wasm_results(entry.wasmModelPtr!, slabBytes, entry.wasmLayoutBytes!);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Truncate wasm LTM slabs to saved rows

When a wasm LTM sim is fresh, reset, or only advanced with runTo, this copies the whole nChunks result capacity rather than the live saved_steps count that simGetStepCount and simGetSeries already use. The from-wasm FFI then reconstructs a full-length Results, so getLinks()/getRun() can return link-score arrays containing unsaved or stale tail rows after partial runs/resets instead of matching the completed-step count and VM semantics.

Useful? React with 👍 / 👎.

Comment on lines +38 to +40
// Round to 6 decimal places; renormalize -0 so a tiny negative input that
// rounded down to zero doesn't print as "-0".
let r = Math.round(n * 1e6) / 1e6;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Match Rust rounding for negative halves

This uses Math.round, but the Rust counterpart uses f64::round, which rounds half-way cases away from zero. For negative SVG coordinates exactly halfway at the 6th decimal, e.g. -0.1234565, TypeScript emits -0.123456 while Rust emits -0.123457, breaking the byte-identical renderer parity promised above whenever a diagram has such negative coordinates.

Useful? React with 👍 / 👎.

@claude
Copy link
Copy Markdown

claude Bot commented May 28, 2026

Reviewed the LTM-on-wasm wiring (libsimlin from-wasm FFIs, direct-backend.ts model ref management, sim.ts wantLinks gate), the shared analytic cores (analyze_links_core / rel_loop_score_series), the LtmEnabledGuard reset semantics, the simulate_ltm_wasm.rs parity harness (scalar / arrayed / discovery + Unsupported gate + ratcheting floor), the worker-boundary tests, and the js_format_number coordinate quantization. The borrow/lifetime juggling around the shared SourceProject salsa input is sound (the RAII guard restores the flag even on panic), the model-refcount pairing in simNewWasm/releaseWasmSimState is symmetric, and the slab-vs-layout geometry validation in results_from_layout_and_slab catches mismatches. The two shared analytic cores really do funnel both backends through one path, satisfying the structural defense against divergence. No bugs found to flag.

Overall correctness: correct.

bpowers added 2 commits May 28, 2026 08:29
The check-docs policy job treats a backtick-quoted token containing
'/' as a path reference and resolves it against the working tree.
'target/' is the cargo build directory: present locally, absent in
the CI workspace before any cargo build runs.  Rewording the mention
without backticks describes the same destination directory while
satisfying the documentation link check.
The wasm blob allocates its results region for the full nChunks
capacity but tracks how many rows the simulation has actually written
in the live G_SAVED counter (exposed as the 'saved_steps' wasm global,
which simGetStepCount already reads).  simGetLinks was marshaling the
full nChunks*nSlots*8 bytes of the slab back to libsimlin's from-wasm
analysis FFI, so getLinks()/getRun() on a fresh, just-reset, or
partially-run (runTo-only) wasm sim would return link-score arrays
containing uninit/stale tail rows -- diverging from simGetSeries,
which already truncates to saved_steps.

The DirectBackend now sizes the marshaled slab by simGetStepCount,
so the analytic core only sees rows the blob has actually computed.
results_from_layout_and_slab on the Rust side relaxes its strict
'slab.len() == n_chunks * n_slots' check to require the slab be a
multiple of n_slots and not exceed that capacity; the resulting
step_count is slab.len() / n_slots.  The invalid-length cases
(non-multiple-of-n_slots, over-capacity) still surface a
SimlinError rather than reconstructing a malformed Results.

The Rust test links_from_wasm_truncated_slab_matches_prefix pins
the bit-exact equivalence of (truncated-slab call) and (first half
of full-slab call), and links_from_wasm_rejects_invalid_slab_lengths
covers the two error paths.  The TS test 'wasm getLinks after partial
run matches VM prefix' runs the wasm sim halfway via runTo(50),
asserts each scored link's array length equals getStepCount() (not
nChunks), and compares the partial-run wasm scores against the first
savedSteps elements of the VM oracle to rule out stale-tail data.
@claude
Copy link
Copy Markdown

claude Bot commented May 28, 2026

Code review

Reviewed the PR carefully, focusing on the new wasm-LTM code paths in libsimlin (analysis.rs, model.rs), the engine wasmgen plumbing (module.rs), the TypeScript surface (direct-backend.ts, internal/wasmgen.ts, internal/analysis.ts), and the new parity harness (simulate_ltm_wasm.rs).

The design holds up well — funneling both backends through one analyze_links_core + rel_loop_score_series is the structural defense against #624's class of bug, and the LtmEnabledGuard RAII pattern correctly bounds the salsa flag toggle. The slab-length-from-saved_steps contract in simGetLinks and the partial-run regression test (no stale tail) pin the right invariant. The ratcheting floor gate + per-model series_* symmetry is a clean way to keep the corpus honest.

I have no blocking findings. A couple of minor observations that don't rise to bug-level:

  • simlin_analyze_rel_loop_score_from_wasm_results toggles ltm_enabled via the guard but does not save/restore ltm_discovery_mode. If a caller had set the persistent project's discovery mode (currently no libsimlin entry point does), the recomputed snapshots' empty loop_partitions would surface as a "loop unknown" error rather than silently wrong scores — so this is safe-by-default, just worth a docstring note.
  • The JS simGetLinks wasm path always copies the full slab through the from-wasm FFI even for LTM-off sims (where every score will come back None). The VM-side path skips that work via the enable_ltm short-circuit. Not incorrect, just a minor cost for non-LTM engine: 'wasm' callers.

Overall correctness: correct.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 28, 2026

Codecov Report

❌ Patch coverage is 81.30312% with 198 lines in your changes missing coverage. Please review.
✅ Project coverage is 83.75%. Comparing base (d1b2492) to head (6994075).
⚠️ Report is 6 commits behind head on main.

Files with missing lines Patch % Lines
src/simlin-engine/tests/simulate_ltm_wasm.rs 66.78% 90 Missing ⚠️
src/libsimlin/src/analysis.rs 78.19% 70 Missing ⚠️
src/libsimlin/tests/wasm.rs 93.37% 24 Missing ⚠️
src/simlin-engine/tests/test_helpers.rs 83.90% 14 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #641      +/-   ##
==========================================
+ Coverage   83.58%   83.75%   +0.16%     
==========================================
  Files         275      281       +6     
  Lines       75395    77385    +1990     
==========================================
+ Hits        63019    64812    +1793     
- Misses      12376    12573     +197     

☔ 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.

@bpowers bpowers merged commit 414c6a6 into main May 28, 2026
13 of 15 checks passed
@bpowers bpowers deleted the wasm-ltm branch May 28, 2026 20:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ltm: support Loops That Matter on the wasm simulation backend

1 participant