Conversation
…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.
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).
There was a problem hiding this comment.
💡 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".
| 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!); |
There was a problem hiding this comment.
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 👍 / 👎.
| // 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; |
There was a problem hiding this comment.
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 👍 / 👎.
|
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. |
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.
Code reviewReviewed the PR carefully, focusing on the new wasm-LTM code paths in libsimlin ( The design holds up well — funneling both backends through one I have no blocking findings. A couple of minor observations that don't rise to bug-level:
Overall correctness: correct. |
Codecov Report❌ Patch coverage is 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. 🚀 New features to boost your workflow:
|
Summary
Model.simulate({ engine: 'wasm', enableLtm: true })to match the bytecode VM (no silent fallback) by threadingltm_enabled/ltm_discovery_modethrough the wasm compile path, surfacing the blob's$⁚ltm⁚*series, and running the existingltm_post/ltm_finding/ polarity code over aResultsreconstructed from the wasm slab.simlin_analyze_links_from_wasm_results,simlin_analyze_rel_loop_score_from_wasm_results) plus aResults-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/enginedrops the up-frontenableLtmrejection on wasm and thegetLinkswasm throw;Run.linksis populated for wasm LTM runs; verified through both nodeDirectBackendand the browserWorkerBackend.MAX_UNROLL_UNITS) return an explicitWasmGenError::Unsupportedrather than panicking or falling back silently.simulate_ltm_wasmparity 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-minutecargo testcap.Design and implementation plan:
docs/design-plans/2026-05-26-wasm-ltm.mdand the six-phase plan underdocs/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 floorcargo 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'-- nodeDirectBackend+ browser-shapedWorkerBackendparity, Unsupported propagationpnpm -C src/engine exec tsc --noEmit-- no type errors on the newenableLtmshapeHuman verification (see
docs/test-plans/2026-05-26-wasm-ltm.md):#[ignore]d heavy discovery twins (C-LEARN, World3) on demand@simlin/engineAPI surface (scalar parity, arrayed parity, discovery, Unsupported-rejection)