diff --git a/docs/README.md b/docs/README.md index 2ca9602da..b01911620 100644 --- a/docs/README.md +++ b/docs/README.md @@ -34,6 +34,7 @@ - [design-plans/2026-05-22-engine-wasm-sim.md](design-plans/2026-05-22-engine-wasm-sim.md) -- Integrate the wasm backend into `@simlin/engine` as a selectable engine (`Model.simulate({engine:'wasm'})`): vm-vs-wasm demux below the `Sim` facade in `DirectBackend`, a resumable blob run ABI for `runTo`, and a node VM-vs-wasm benchmark; 4 phases - [plans/](plans/README.md) -- Implementation plans (active and completed) - [test-plans/](test-plans/) -- Human verification plans for completed features + - [test-plans/2026-05-22-engine-wasm-sim.md](test-plans/2026-05-22-engine-wasm-sim.md) -- Manual verification for the `@simlin/engine` selectable wasm engine (`Model.simulate({engine:'wasm'})`): re-running the automated gates, driving the gated/`#[ignore]`d heavy tests, and the human-judged extras (interactive scrubbing feel, VM-vs-wasm benchmark numbers); all 25 ACs already have automated coverage - `implementation-plans/` -- Detailed phase-by-phase implementation plans, created during plan execution ## Security diff --git a/docs/dev/benchmarks.md b/docs/dev/benchmarks.md index 98830df84..a20021f47 100644 --- a/docs/dev/benchmarks.md +++ b/docs/dev/benchmarks.md @@ -22,11 +22,11 @@ Criterion saves results in `target/criterion/` and generates HTML reports in `ta ## Benchmark suites -| Suite | File | What it measures | -|-------|------|------------------| -| `compiler` | `benches/compiler.rs` | End-to-end compiler pipeline on real models (WRLD3, C-LEARN) | +| Suite | File | What it measures | +| ------------ | ----------------------- | ----------------------------------------------------------------- | +| `compiler` | `benches/compiler.rs` | End-to-end compiler pipeline on real models (WRLD3, C-LEARN) | | `simulation` | `benches/simulation.rs` | VM execution, slider interaction, compilation of synthetic models | -| `array_ops` | `benches/array_ops.rs` | Array sum, element-wise add, broadcasting, multi-ref | +| `array_ops` | `benches/array_ops.rs` | Array sum, element-wise add, broadcasting, multi-ref | ### compiler benchmarks @@ -38,11 +38,40 @@ The `compiler` suite measures each stage of the compilation pipeline independent - **`full_pipeline`** — all stages end-to-end Models used: + - `wrld3` — World3 model (151 KB, ~3,800 lines), a classic system dynamics model - `clearn` — C-LEARN climate model (1.4 MB, ~53,000 lines), a stress test for the compiler C-LEARN currently uses builtins that are not yet implemented in the bytecode compiler, so it is automatically skipped for `bytecode_compile` and `full_pipeline`. It still participates in `parse_mdl` and `project_build`, which are the most allocation-heavy stages. +## Node VM-vs-wasm eval benchmark + +`@simlin/engine` can run a model on two backends: the libsimlin VM or a compiled WebAssembly blob. This benchmark compares their **simulation (eval) time** through the public `Model.simulate({ engine })` API, on fishbanks, WORLD3, and C-LEARN. + +It is a [jest](https://jestjs.io/) test gated behind `RUN_BENCH` so it stays out of the default `pnpm test` (a full C-LEARN run on both engines exceeds the per-test time budget): + +```bash +# Run all three models on both engines +RUN_BENCH=1 pnpm -C src/engine exec jest backend-bench + +# Subset the models (comma-separated: fishbanks, wrld3, clearn) +RUN_BENCH=1 BENCH_MODELS=fishbanks,wrld3 pnpm -C src/engine exec jest backend-bench +``` + +It prints a markdown table of the warm **median** eval time per engine plus the wasm/VM ratio. + +What it measures, and what it deliberately excludes: + +- **Eval only.** The `Sim` for each `(model, engine)` is built once in untimed setup; for wasm that one-time cost is the blob compile and instantiate. Each measured iteration is a `reset()` (also untimed) followed by a timed `runToEnd()`. Result extraction (`getRun`/`getSeries`) is not timed. +- **Median over an explicit warmup.** A discard-only warmup runs first, then the harness collects timings adaptively (until a max iteration count or a per-model wall-clock budget) and reports the median. The pure stats/harness lives in `src/engine/tests/bench-stats.ts` and is always-on unit-tested. +- **Cross-checked before trusted.** Before timing, the benchmark runs each model on both engines and compares a representative series within the engine's tolerance, so a broken run can't masquerade as a fast one. + +Absolute numbers include the async public-API overhead, so the VM/wasm ratio is the figure to compare across runs. + +The Rust counterpart is `src/simlin-engine/examples/backend_bench.rs`, which uses the same eval-vs-eval methodology and median statistic against the lower-level `Vm`/wasm interfaces. + +Results are reported in the PR or chat, not committed: the harness is regenerable, but checked-in numbers go stale and mislead. Do not add a results file. + ## Profiling ### Build a benchmark binary for profiling diff --git a/docs/implementation-plans/2026-05-22-engine-wasm-sim/phase_01.md b/docs/implementation-plans/2026-05-22-engine-wasm-sim/phase_01.md new file mode 100644 index 000000000..8be961a0b --- /dev/null +++ b/docs/implementation-plans/2026-05-22-engine-wasm-sim/phase_01.md @@ -0,0 +1,304 @@ +# @simlin/engine WebAssembly Simulation Backend — Phase 1: wasm resumable run ABI (Rust) + +**Goal:** Extend the emitted wasm "blob" so a run can be advanced incrementally — `run_initials()`, `run_to(time)`, and a resumable `reset()` mirroring the bytecode VM's `run_initials`/`run_to`/`reset` — backed by a persistent step cursor stored in mutable wasm globals. This unblocks the TypeScript engine's `runTo(time)` in Phase 2. + +**Architecture:** The `wasmgen` backend lowers one `CompiledSimulation` into a self-contained `wasm-encoder`-built module. Today its `run` driver (`emit_run_simulation`) computes the whole simulation in one self-resetting call, holding its step cursor in **function locals**. This phase promotes the cursor (`saved`, `step_accum`, `did_initials`) to **mutable wasm globals** so it survives across separate exported calls, factors the per-step loop into a shared driver that both `run` and the new `run_to(target)` use, makes `run_initials` idempotent, and extends `reset` to clear the cursor while keeping constant overrides. The bytecode VM (`vm.rs`) is the correctness oracle: every new behavior is parity-tested against the VM within the engine's existing comparator tolerances. No FFI signature changes; only the blob's exported function set grows. + +**Tech Stack:** Rust; `wasm-encoder` v0.244 (regular dep, emits the module — hand-driven `Function`/`Instruction` builder, not walrus); DLR-FT `wasm-interpreter` + `checked` (pinned git dev-deps, rev `64cedbba603edfd64cbb6b5a19f5fa34530bb03a`, used to execute the blob in tests); `simlin_engine::test_common::TestProject` (in-memory fixtures, no `file_io`). + +**Scope:** Phase 1 of 4 (wasm resumable run ABI). Phases 2–4 (TS node core, browser/worker, benchmark) follow. + +**Codebase verified:** 2026-05-22 + +--- + +## Acceptance Criteria Coverage + +This phase implements and verifies the following at the **blob/VM-parity level** (the public-API/facade realizations of AC2/AC3/AC5 land in Phase 2; this phase proves the blob itself matches the VM): + +### engine-wasm-sim.AC2: `runToEnd`/`runTo` parity (resumable) +- **engine-wasm-sim.AC2.1 Success:** `runToEnd()` (wasm) series equal the VM within tolerance. +- **engine-wasm-sim.AC2.2 Success:** `runTo(t)` then `getValue(name)` (wasm) equals the VM's value at `t`. +- **engine-wasm-sim.AC2.3 Success:** segmented `runTo(t1)` then `runTo(t2)` (`t1 **Blob-level scoping note.** AC5.2 at the blob level means the blob's `set_value` export *returns a nonzero error code* for a non-overridable offset (the TS facade turns that into a thrown error in Phase 2). AC5.4 at the blob level means the *same instantiated module* can be driven through `set_value`/`reset`/re-run without re-instantiation (the `DirectBackend` instance-reuse policy is realized in Phase 2). AC2.2's "`getValue`" is read at the blob level as "the value in the live `curr` chunk (base 0) after `run_to(t)`," since the blob has no `getValue` export — the host reads that variable's slot directly from linear-memory base 0 (matching the VM's `get_value_now`, which reads the current chunk). + +--- + +## Background: what exists today (verified) + +All paths absolute from repo root `/home/bpowers/src/simlin`. + +**The blob's exports (`src/simlin-engine/src/wasmgen/module.rs:1819-1828`), verbatim today:** +```rust +let mut exports = ExportSection::new(); +exports.export("run", ExportKind::Func, run_fn_index); +exports.export("set_value", ExportKind::Func, set_value_fn_index); +exports.export("reset", ExportKind::Func, reset_fn_index); +exports.export("clear_values", ExportKind::Func, clear_values_fn_index); +exports.export("memory", ExportKind::Memory, 0); +exports.export("n_slots", ExportKind::Global, G_N_SLOTS); +exports.export("n_chunks", ExportKind::Global, G_N_CHUNKS); +exports.export("results_offset", ExportKind::Global, G_RESULTS_OFFSET); +wasm.section(&exports); +``` + +**Global index constants (`module.rs:78-81`):** `G_N_SLOTS = 0`, `G_N_CHUNKS = 1`, `G_RESULTS_OFFSET = 2`, `G_USE_PREV_FALLBACK = 3`. The geometry globals are immutable; `G_USE_PREV_FALLBACK` is the **only** mutable global today (declared `mutable: true` at `module.rs:1809-1816`, comment at `module.rs:1807` literally says "the only mutable global"). It is internal (not exported) and gates `PREVIOUS()` fallback: armed to `1` at run start (`module.rs:1047-1048`), cleared to `0` after the first prev-snapshot (`emit_prev_snapshot`, `module.rs:1121-1122`). It is the inverse of the VM's `prev_values_valid`. + +**The run driver `emit_run_simulation` (`module.rs:1014-1097`):** its cursor lives in **function locals**, declared `let mut f = Function::new([(3, ValType::I32), (2, ValType::F64)]);` (`module.rs:1022`): `L_SAVED = 0`, `L_STEP_ACCUM = 1`, `L_DST = 2` (i32) and two f64 RK scratch locals `L_SAVED_TIME = 3`, `L_RK_S = 4` (`module.rs:84-86`). Its body: +1. Seed reserved slots into `curr` chunk (base 0): `TIME_OFF=start`, `DT_OFF=dt`, `INITIAL_TIME_OFF=start`, `FINAL_TIME_OFF=stop` (`module.rs:1034-1037`; reserved-slot constants `TIME_OFF=0`/`DT_OFF=1`/`INITIAL_TIME_OFF=2`/`FINAL_TIME_OFF=3` at `module.rs:60-63`). +2. Arm `G_USE_PREV_FALLBACK = 1`, call root `initials` with `module_off=0` (`module.rs:1047-1050`). +3. `emit_copy_chunk(curr -> initial_values_base, n_slots)` — capture `initial_values := curr` once (`module.rs:1056-1061`). +4. `Block $break { Loop $continue { ... } }` (`module.rs:1063-1095`): + - **Guard:** `if curr[TIME] > stop { br $break }` (`module.rs:1066-1071`). + - **Step:** `emit_euler_step` / `emit_rk4_step` / `emit_rk2_step` (`module.rs:1076-1084`; helpers at `:1101`, `:1415`, `:1544`; `emit_eval_step` `:1108`, `emit_prev_snapshot` `:1119`). + - **Save + advance:** `emit_save_advance` (`module.rs:1090`, body `:1129-1210`): save condition `(step_accum == save_every) | (saved==0 & curr[TIME]==start)` (`:1144-1155`); write `curr[slot]` to `results[saved*stride + slot*8]` for every slot (`:1158-1172`); `saved += 1; step_accum = 0` (`:1174-1180`); `if saved >= n_chunks { br 2 }` (`:1183-1186`); copy `next -> curr` (`:1193-1201`); `curr[TIME] += dt` (`:1203-1209`). + - `Br $continue`. + +**`save_every` is computed identically in both backends** as `max(1, round(save_step/dt))`: wasmgen at `module.rs:590`, VM at `vm.rs:653`. `n_chunks`/`n_slots`/`dt`/`method` come from the single `Specs` value on `CompiledSimulation.specs` (`results.rs:22-32`, `n_chunks` derived in `Specs::from` at `results.rs:35-72`), so the two backends cannot disagree on geometry. + +**Memory is sized once at compile time and never grows** (`compile_simulation`, `module.rs:423-587`; memory `maximum: None` but no `memory.grow` is ever emitted). Result slab is step-major: `n_chunks` rows of `n_slots` f64, time in column 0, at byte offset `results_offset` (= `results_base = stride*2`, `stride = n_slots*8`). + +**Constant overrides** live in a constants-override region (`const_region_base`, f64 by absolute slot offset) plus a validity region (`const_valid_base`, one byte per slot; `module.rs:558-567`), initialized at instantiation from `collect_overridable_defaults` (`module.rs:569`, debug-asserted equal to the VM's overridable set at `:575-585`). The key mid-run mechanism (`lower.rs:1332-1366`): for an overridable offset, generated `AssignConstCurr` code **loads the value from the override region every time it executes** rather than baking in an immediate. **Consequence:** a `set_value` between `run_to` calls already affects only subsequent steps — AC5.3 needs no new mechanism beyond making the run resumable. + +- `emit_set_value` (`module.rs:1252-1288`): returns `1` if `offset < 0 || offset >= n_slots`, returns `1` if `valid[offset] == 0` (not overridable), else writes `const_region[offset]` and returns `0`. +- `emit_clear_values` (`module.rs:1313-1324`): straight-line restore of compiled-default constants. +- `emit_reset` (`module.rs:1298-1304`): **today only** sets `G_USE_PREV_FALLBACK = 1`. It deliberately does not touch the constants region. + +**The VM oracle (`src/simlin-engine/src/vm.rs`):** persistent cursor fields on `struct Vm` (`vm.rs:239-287`): `curr_chunk` (`:248`), `next_chunk` (`:249`), `did_initials` (`:251`), `step_accum` (`:253`), `prev_values_valid` (`:286`). Initialized in `Vm::new` (`vm.rs:619-634`). Behaviors to mirror: +- `run_to_end` (`vm.rs:638-641`): `self.run_to(self.specs.stop)`. +- `run_to(end)` (`vm.rs:644-856`): calls `run_initials()?` first; loops stepping until `curr[TIME] > end` (strict `>`); `use_prev_fallback: !self.prev_values_valid` per call (`:681`); clamping past FINAL_TIME is implicit (the saved-row exhaustion break stops it). +- `run_initials` (`vm.rs:1079-1148`): idempotent via `if self.did_initials { return Ok(()); }` (`:1080-1082`); seeds reserved slots, runs `eval_initials`, captures `initial_values`, sets `did_initials = true; step_accum = 0`. +- `reset` (`vm.rs:989-1002`): sets `curr_chunk=0; next_chunk=1; did_initials=false; step_accum=0; prev_values_valid=false`, clears scratch — **keeps constant overrides** (does not call `clear_values`, does not restore literals). +- `set_value` (`vm.rs:1026-1047`): rejects a non-constant target with `ErrorCode::BadOverride` ("cannot set value of '{}': not a simple constant"); `is_constant(off)` = `constant_info.contains_key(&off)` (`vm.rs:907-909`). +- `set_value_by_offset` (`vm.rs:1050-1065`), `clear_values` (`vm.rs:1068-1075`). + +> **Cursor mapping (important — diverges from the design's framing).** The VM stores results in a chunk-ring of `n_chunks + 2` chunks and advances `curr_chunk`/`next_chunk` through it. The blob has a different layout: one fixed `curr` (base 0), one fixed `next` (base `stride`), and a separate `results` region. So `curr_chunk`/`next_chunk` do **not** translate to the blob. The blob's persistent cursor is `{ saved, step_accum, did_initials }` (today the locals `L_SAVED`/`L_STEP_ACCUM` plus an implicit "initials done" notion); "current time" already persists in `curr[TIME]` linear memory; `prev_values_valid` is already represented by `G_USE_PREV_FALLBACK` (its inverse). Frame the new globals around `saved`/`step_accum`/`did_initials`. + +> **`lower.rs` needs little or no change (diverges from the design's hint).** The design lists `lower.rs` as a Phase 1 component for "loop restructuring," but `lower.rs` contains no whole-simulation stepping loop — its loops are per-opcode runtime constructs (`emit_pulse`, array `BeginIter` unrolling). The entire stepping loop is in `module.rs::emit_run_simulation`. The per-opcode `initials`/`flows`/`stocks` programs are already stateless and re-callable. Do not modify `lower.rs` unless a concrete need surfaces during implementation; if so, document why in the commit. + +**Test harness (verified templates):** +- Engine-internal `#[cfg(test)]` in `module.rs`: `compile_sim` (`:2205`), `run_artifact_results` (`:2213-2243`), `run_artifact_with_overrides` (`:4089-4144`), `set_value_rc` (`:4149`), `layout_offset` (`:4167`), `vm_results_with_override` (`:4202`), and the existing override parity test `compile_simulation_set_value_override_matches_vm` (`:4220`, already covers AC5.1). +- DLR-FT execution helper `run_wasm_results(wasm, layout) -> Vec` (`src/simlin-engine/tests/test_helpers.rs:285`): `validate(wasm)` → `Store::new(())` → `module_instantiate` → `instance_export("run").as_func()` → `store.invoke_simple_typed::<(), ()>(run, ())` → read `n_chunks*n_slots` f64 from `memory` at `results_offset` via `mem_access_mut_slice`. Typed invocation supports args: `store.invoke_simple_typed::<(f64,), ()>(run_to, (target,))`, `::<(), ()>` for `run_initials`/`reset`, `::<(i32, f64), i32>` for `set_value`. +- Single-variable stride read `run_and_stride(wasm, layout, off)` (`src/libsimlin/tests/wasm.rs:281`): `f64 @ results_offset + (c*n_slots + off)*8` for `c in 0..n_chunks`. +- Comparators (same tolerance for wasm-vs-VM and VM-vs-reference): `ensure_results` / `ensure_results_excluding` (`test_helpers.rs:66`/`:75`). +- The fast in-memory fixture `simple_model()` (`src/libsimlin/tests/wasm.rs:28`): `inflow_rate = 2` (constant), `level` stock fed by `inflow = inflow_rate`, sim 0..10 dt 1 (11 chunks). `TestProject` builder in `src/simlin-engine/src/test_common.rs` (`new`/`with_sim_time`/`scalar_const`/`flow`/`stock`/`build_datamodel`/`compile_incremental`). +- libsimlin FFI: `simlin_model_compile_to_wasm` signature at `src/libsimlin/src/model.rs:117` (six args, returns void, malloc-return convention — **must not change**); VM-side resumable FFI for the oracle: `simlin_sim_run_to` (`simulation.rs:204`), `simlin_sim_run_to_end` (`:233`), `simlin_sim_reset` (`:303`), `simlin_sim_set_value` (`:468`), `simlin_sim_get_series` (`:817`). + +**Test-budget rules (must follow):** individual unit tests complete in a few seconds on a debug build; `cargo test --workspace` is under a 3-minute cap. Use tiny in-memory fixtures, not large model files. Whole-model parity twins are `#[ignore]`d and run under `--release`. The fast tasks below use `TestProject`/`simple_model` (no `file_io`); only Task 6 touches the `#[ignore]`d whole-model tests. + +--- + +## Implementation Tasks + + +Subcomponent A: the resumable run ABI in the blob, plus engine-level VM-parity tests, all in `src/simlin-engine/src/wasmgen/module.rs` and `src/simlin-engine/tests/test_helpers.rs`. + + +### Task 1: Resumable run ABI core (mutable-global cursor + `run_initials`/`run_to` exports + resumable `reset`) + +**Verifies:** engine-wasm-sim.AC2.1, engine-wasm-sim.AC2.2 (the foundation the rest build on) + +**Files:** +- Modify: `src/simlin-engine/src/wasmgen/module.rs` (globals `:78-81`/`:1798-1817`, run driver `:1014-1097`, save/advance `:1129-1210`, reset `:1298-1304`, type section `:1751-1766`, function indices `:1782-1785`, exports `:1819-1828`) +- Add test helper + test: `src/simlin-engine/tests/test_helpers.rs` (near `run_wasm_results` `:285`) and a `#[cfg(test)]` test in `module.rs` (near `:4220`) + +**Implementation:** + +1. **Add three mutable i32 globals** for the persistent cursor. Add index constants after `G_USE_PREV_FALLBACK = 3` (`module.rs:78-81`): + - `G_SAVED = 4` — saved-row counter (was local `L_SAVED`). + - `G_STEP_ACCUM = 5` — save-cadence accumulator (was local `L_STEP_ACCUM`). + - `G_DID_INITIALS = 6` — `0` until initials have run (the blob analogue of `Vm::did_initials`). + + Declare each in the global section (`module.rs:1809-1816` pattern) initialized to `0`: + ```rust + globals.global( + GlobalType { val_type: ValType::I32, mutable: true, shared: false }, + &ConstExpr::i32_const(0), + ); + ``` + Update the stale "the only mutable global" comment (`module.rs:1807`) to reflect that the cursor globals are now also mutable. These three are **internal** — do not add them to the export section. + +2. **Factor the stepping loop into a `run_to(target: f64)` driver** that reads/writes the cursor from the new globals instead of locals: + - New emitter `emit_run_to` builds a function of type `(f64) -> ()` (its f64 param is the local-0 `target`). Body: `call run_initials` (idempotent), then the `Block $break { Loop $continue { ... } }` from today's `emit_run_simulation:1063-1095`, with two changes: (a) the guard compares `curr[TIME] > target` (the param) instead of `> stop`; (b) `emit_save_advance` reads/writes `G_SAVED`/`G_STEP_ACCUM` via `GlobalGet`/`GlobalSet` instead of `LocalGet`/`LocalSet` on `L_SAVED`/`L_STEP_ACCUM`. `L_DST` and the RK scratch f64 locals stay function-local (per-step transients). Keep the `if saved >= n_chunks { br 2 }` exhaustion break (now reading `G_SAVED`) — this is what makes `run_to(target)` past FINAL_TIME clamp to the end (AC2.4), exactly like the VM's ring exhaustion. + - New emitter `emit_run_initials` builds a `() -> ()` function: `if G_DID_INITIALS != 0 { return }` (idempotency, mirroring `vm.rs:1080-1082`); else seed the reserved slots (`module.rs:1034-1037` logic), arm `G_USE_PREV_FALLBACK = 1`, call root `initials` (`module_off=0`), `emit_copy_chunk(curr -> initial_values_base)` (`:1056-1061`), set `G_SAVED = 0`, `G_STEP_ACCUM = 0`, `G_DID_INITIALS = 1`. (It does **not** save chunk 0; the first save happens in `run_to`'s loop, matching today's behavior and the VM.) + - **Re-express `run()` to delegate** (DRY, mandated): emit `run` as `call reset; f64.const ; call run_to`, so there is exactly one stepping-loop implementation shared by `run` and `run_to`. **Invariant (the linchpin): `run()` must produce a full from-`t0` simulation on *every* call to a reused instance.** The delegation path satisfies this for free — `reset` clears `G_DID_INITIALS`/`G_SAVED`/`G_STEP_ACCUM` and re-arms `G_USE_PREV_FALLBACK=1`, then `run_to`→`run_initials` (which no longer short-circuits, since `reset` cleared `G_DID_INITIALS`) re-seeds the reserved time slots and runs initials. Use this path. A fallback (keep `emit_run_simulation` as `run`'s body, switched to the global cursor) is acceptable **only if** the encoder makes delegation infeasible — and then `run`'s body MUST, at its top, itself reset `G_DID_INITIALS`/`G_SAVED`/`G_STEP_ACCUM` to 0 and re-arm `G_USE_PREV_FALLBACK=1`, because the now-idempotent `run_initials` (`if G_DID_INITIALS != 0 return`) would otherwise silently skip initials on a second `run`, double-count saves, or resume from stale cursor state. Either way, the existing `wasm_parity_hook` corpus parity (`run` vs VM) plus the new triple-agreement test below catch a faithless re-expression; document the chosen path in the commit message. + +3. **Extend `emit_reset`** (`module.rs:1298-1304`) to clear the cursor: set `G_SAVED = 0`, `G_STEP_ACCUM = 0`, `G_DID_INITIALS = 0`, and keep `G_USE_PREV_FALLBACK = 1`. Do **not** touch the constants-override region (overrides survive reset — AC3.2), mirroring `vm.rs:989-1002`. + +4. **Register the two new functions and exports:** + - Add a function type `(f64) -> ()` to the type section (`module.rs:1751-1766`) for `run_to`; reuse the existing `() -> ()` type (`TYPE_RUN_FN`) for `run_initials`. + - Assign `run_to_fn_index` and `run_initials_fn_index` in the function-index assignment (`module.rs:1782-1785`); emit their bodies. Note `run`'s delegating body references `run_to_fn_index`/`reset_fn_index`, so assign indices before emitting `run`'s body (the existing flow declares indices before bodies). + - Add to the export section (`module.rs:1819-1828`): + ```rust + exports.export("run_to", ExportKind::Func, run_to_fn_index); + exports.export("run_initials", ExportKind::Func, run_initials_fn_index); + ``` + +**Testing:** +Add a DLR-FT helper that drives the resumable exports, then a parity test: +- Helper `run_wasm_results_segmented(wasm, &layout, targets: &[f64]) -> Vec` in `test_helpers.rs` (sibling to `run_wasm_results:285`): instantiate, `invoke_simple_typed::<(), ()>(run_initials, ())`, then for each `t` in `targets` `invoke_simple_typed::<(f64,), ()>(run_to, (t,))`, then read the whole slab (`n_chunks*n_slots` f64 at `results_offset`). +- Test `compile_simulation_run_to_matches_run_and_vm` (in `module.rs` `#[cfg(test)]`): build a small `TestProject` (stock + constant flow, ~11 chunks), compile to artifact, and assert all three agree within `ensure_results`-equivalent tolerance: the full series from (a) the `run` export, (b) `run_initials` + `run_to(stop)`, and (c) the VM (`Vm::run_to_end` over the same `CompiledSimulation`). This proves the re-expressed `run` is faithful and that `run_initials`+`run_to(stop)` equals the VM (AC2.1), and that the per-`t` last-saved value equals the VM at `t` (AC2.2 foundation). + +Tests must verify: +- engine-wasm-sim.AC2.1: wasm `run_to(stop)` (and `run`) full series equals the VM within tolerance. +- engine-wasm-sim.AC2.2: the value at the chunk for time `t` after `run_to(t)` equals the VM's value at `t` (strided read). + +**Verification:** +Run: `cargo test -p simlin-engine wasmgen::module 2>&1 | tail -30` +Expected: the new test passes; all pre-existing `wasmgen` tests still pass (confirms the `run` re-expression is faithful). +Run: `cargo test -p simlin-engine 2>&1 | tail -20` +Expected: green. + +**Commit:** `engine: add resumable run_to/run_initials ABI to wasmgen blob` + + + +### Task 2: Segmented + clamp parity (`run_to` resumes correctly; past-FINAL_TIME clamps) + +**Verifies:** engine-wasm-sim.AC2.2, engine-wasm-sim.AC2.3, engine-wasm-sim.AC2.4 + +**Files:** +- Add tests: `src/simlin-engine/src/wasmgen/module.rs` (`#[cfg(test)]`, near Task 1's test) +- Possible fix: `src/simlin-engine/src/wasmgen/module.rs` (only if a parity gap surfaces) + +**Implementation:** +No new production code is expected — these tests exercise Task 1's driver. If any assertion fails, the cursor/guard logic from Task 1 is wrong; fix it here and explain in the commit. + +**Testing:** +Use a fixture with several save points (e.g. `simple_model`-style, sim 0..10 dt 1, save_step 1 → 11 chunks) so segment boundaries land on and between save points. Drive the VM oracle with the matching `Vm::run_to` segments. +- `run_to_segmented_matches_single_and_vm`: choose `t1 < t2 < stop` (e.g. 3 and 7). Assert `run_initials; run_to(t1); run_to(t2)` produces a slab whose first rows (≤ t2) equal both `run_initials; run_to(t2)` (single) and the VM driven `run_to(t1); run_to(t2)`. (AC2.3) +- `run_to_at_save_and_between_save_points`: assert the saved-row count after `run_to(t)` matches the VM's saved-row count for the same `t`, for `t` exactly on a save point and `t` between save points. (AC2.2) +- `run_to_past_final_time_clamps`: `run_to(stop * 2.0)` equals `run_to(stop)` and `Vm::run_to_end`, and exactly `n_chunks` rows are saved. (AC2.4) + +**Verification:** +Run: `cargo test -p simlin-engine wasmgen::module 2>&1 | tail -20` +Expected: all three new tests pass. + +**Commit:** `engine: parity-test segmented and clamped wasm run_to vs VM` + + + +### Task 3: `reset` parity (defaults reproduced; overrides preserved; instance reused) + +**Verifies:** engine-wasm-sim.AC3.1, engine-wasm-sim.AC3.2, engine-wasm-sim.AC5.4 (blob-level instance reuse) + +**Files:** +- Add tests: `src/simlin-engine/src/wasmgen/module.rs` (`#[cfg(test)]`) +- Possible fix: `src/simlin-engine/src/wasmgen/module.rs` (only if a gap surfaces in `emit_reset`) + +**Implementation:** +No new production code expected beyond Task 1's `emit_reset` extension. Fix here only if a test reveals a gap. + +**Testing:** +All tests reuse a **single instantiated module** (one `Store`/instance) across calls, which is itself the blob-level demonstration of AC5.4 (no re-instantiation needed between `reset`/`set_value`/re-run). +- `reset_then_run_reproduces_defaults`: on one instance, `run` → capture series A; `reset` → `run` → capture series B; assert A == B and both equal `Vm::run_to_end` (with a fresh-then-`reset` VM). (AC3.1) +- `reset_preserves_overrides`: on one instance, `set_value(const_offset, v)` (use `layout_offset:4167`/`set_value_rc:4149` helpers), `run` → series A; `reset` → `run` → series B; assert A == B (the override survived reset) and both equal the VM run with the same override applied and a `reset` in between. (AC3.2, AC5.4) + +**Verification:** +Run: `cargo test -p simlin-engine wasmgen::module 2>&1 | tail -20` +Expected: both new tests pass. + +**Commit:** `engine: parity-test wasm reset (defaults + override-preserving) vs VM` + + + +### Task 4: Mid-run `set_value` parity + non-constant rejection + +**Verifies:** engine-wasm-sim.AC5.1 (reference existing), engine-wasm-sim.AC5.2 (blob-level), engine-wasm-sim.AC5.3 + +**Files:** +- Add tests: `src/simlin-engine/src/wasmgen/module.rs` (`#[cfg(test)]`) + +**Implementation:** +No new production code — `emit_set_value` (`module.rs:1252-1288`) already returns nonzero for non-overridable offsets, and overridable constants are re-read each step (`lower.rs:1332-1366`), so a mid-run override already affects only later steps. These tests lock in that behavior against the VM. + +**Testing:** +- `mid_run_set_value_matches_vm`: on one instance with a constant `inflow_rate`, `run_initials; run_to(t1)` (e.g. t1=5); `set_value(offset_of(inflow_rate), v2)` (assert it returns `0`); `run_to(stop)`. Compare the slab to the VM driven identically (`Vm::run_to(t1)`, `Vm::set_value("inflow_rate", v2)`, `Vm::run_to(stop)`). Assert rows at times `≤ t1` are unchanged from a no-override baseline and rows after reflect `v2`. (AC5.3) +- `set_value_nonconstant_returns_error`: pick a non-constant variable's slot (e.g. the stock `level` or the computed `inflow`) and assert `set_value(that_offset, v)` returns `1`; assert `set_value(offset_of(inflow_rate), v)` returns `0`. This is the blob-level peer of the VM's `BadOverride` rejection (`vm.rs:1036-1044`). (AC5.2) +- AC5.1 is already covered by `compile_simulation_set_value_override_matches_vm` (`module.rs:4220`); reference it in a comment rather than duplicating. + +**Verification:** +Run: `cargo test -p simlin-engine wasmgen::module 2>&1 | tail -20` +Expected: both new tests pass; `compile_simulation_set_value_override_matches_vm` still passes. + +**Commit:** `engine: parity-test mid-run wasm set_value and non-constant rejection` + + + + +Subcomponent B: prove the new exports survive the libsimlin FFI compile path. + + +### Task 5: libsimlin FFI — resumable exports on the compiled blob + +**Verifies:** engine-wasm-sim.AC2.3, engine-wasm-sim.AC5.3 (across the `simlin_model_compile_to_wasm` path) + +**Files:** +- Add test: `src/libsimlin/tests/wasm.rs` (near `compile_to_wasm_returns_blob_and_layout:84`, reuse `simple_model:28`, `parse_layout:48`, `run_and_stride:281`, `vm_series:312`) + +**Implementation:** +No production change. `simlin_model_compile_to_wasm` (`src/libsimlin/src/model.rs:117`) returns the blob bytes + serialized layout; the resumable ABI is reached via the blob's own exports, so the FFI signature is unchanged. This test asserts the compiled-via-FFI blob carries and honors `run_initials`/`run_to`/`reset`. + +**Testing:** +- `compile_to_wasm_blob_supports_resumable_run`: call `simlin_model_compile_to_wasm(simple_model())`, `validate` the blob, parse the layout, instantiate under DLR-FT, and drive `run_initials` → `run_to(t1)` → `set_value(layout offset of inflow_rate, v)` → `run_to(stop)` → read `level`'s strided series. Drive the VM oracle through the **FFI** the same way: `simlin_sim_new` → `simlin_sim_run_to(t1)` → `simlin_sim_set_value("inflow_rate", v)` → `simlin_sim_run_to_end` → `simlin_sim_get_series("level")`. Assert the series match within tolerance. (AC2.3, AC5.3 across the FFI compile path) +- Add a `reset`-across-FFI assertion in the same test (or a sibling): after the above, call the blob's `reset`, re-run, and confirm a fresh full run reproduces the override-applied defaults — peer of `simlin_sim_reset` (`simulation.rs:303`). + +Also assert the blob still has the original exports (`run`, `set_value`, `reset`, `clear_values`, `memory`, `n_slots`, `n_chunks`, `results_offset`) so the export-set growth is purely additive. + +**Verification:** +Run: `cargo test -p libsimlin --test wasm 2>&1 | tail -20` +Expected: the new test passes; `compile_to_wasm_returns_blob_and_layout` and `compile_to_wasm_unsupported_model_surfaces_error` still pass (FFI signature unchanged). + +**Commit:** `libsimlin: exercise resumable wasm exports across the FFI compile path` + + + + +Subcomponent C: real-model segmented coverage without breaking the test-time budget. + + +### Task 6: Real-model segmented `run_to` parity in the `#[ignore]`d whole-model twins + +**Verifies:** engine-wasm-sim.AC2.1, engine-wasm-sim.AC2.3 (on WORLD3 and C-LEARN) + +**Files:** +- Modify: `src/simlin-engine/tests/simulate.rs` (`simulates_wrld3_03_wasm:1867`, `simulates_clearn_wasm:2085`) +- Add helper: `src/simlin-engine/tests/test_helpers.rs` (reuse the `run_wasm_results_segmented` added in Task 1, or add a thin `wasm_results_for_segmented` mirroring `wasm_results_for:224`) + +**Implementation:** +These two tests are `#[ignore]`d and run under `--release`, so adding a segmented pass does not affect the 3-minute debug `cargo test` budget. Extend each twin: in addition to the existing single-`run` parity check, run the same model's blob through a two-segment `run_to` (split near the midpoint of `[start, stop]`) and assert the resulting series equals the single-`run` wasm series (and therefore the existing oracle). Do **not** add a segmented pass to the per-corpus hook (`wasm_parity_hook:1047`) — that would double wasm work across the whole corpus and risk the budget. + +**Testing:** +- Extend `simulates_wrld3_03_wasm`: assert `run_wasm_results_segmented(wasm, layout, &[mid, stop])` equals the existing `run`-export wasm series (which is already compared to the VM via `ensure_results`). (AC2.1, AC2.3 on WORLD3) +- Extend `simulates_clearn_wasm`: same segmented-equals-single assertion (the single run is already checked against the VDF oracle via `ensure_vdf_results_excluding`). (AC2.1, AC2.3 on C-LEARN) + +**Verification:** +Run: `cargo test -p simlin-engine --features file_io --release -- --ignored simulates_wrld3_03_wasm simulates_clearn_wasm 2>&1 | tail -30` +Expected: both ignored tests pass (single and segmented agree, and the single still matches its oracle). +Run (budget guard): `cargo test -p simlin-engine 2>&1 | tail -5` +Expected: green and well under budget (the segmented pass is `#[ignore]`d, so the default run is unaffected). + +**Commit:** `engine: segmented run_to parity for WORLD3 and C-LEARN wasm twins` + + + +--- + +## Phase 1 Done When + +- The blob exports `run_to(f64)->()` and `run_initials()->()` in addition to the unchanged `run`/`set_value`/`reset`/`clear_values`/`memory`/`n_slots`/`n_chunks`/`results_offset`; the cursor (`saved`/`step_accum`/`did_initials`) is held in mutable wasm globals; `reset` clears the cursor while keeping constant overrides. +- Segmented `run_to`, clamped `run_to`, `reset` (defaults + override-preserving), and mid-run `set_value` all match the VM within the engine's existing comparator tolerances (Tasks 1–4), including across the libsimlin FFI compile path (Task 5) and on the real WORLD3/C-LEARN models (Task 6). +- `simlin_model_compile_to_wasm`'s signature and the `WasmLayout` wire format are unchanged (the export-set growth is purely additive). +- `cargo test -p simlin-engine` and `cargo test -p libsimlin --test wasm` pass; the default (debug) `cargo test` stays within the 3-minute budget (heavy real-model coverage stays `#[ignore]`d). diff --git a/docs/implementation-plans/2026-05-22-engine-wasm-sim/phase_02.md b/docs/implementation-plans/2026-05-22-engine-wasm-sim/phase_02.md new file mode 100644 index 000000000..5ecd48278 --- /dev/null +++ b/docs/implementation-plans/2026-05-22-engine-wasm-sim/phase_02.md @@ -0,0 +1,302 @@ +# @simlin/engine WebAssembly Simulation Backend — Phase 2: @simlin/engine node core (TS) + +**Goal:** Make `Model.simulate({ engine: 'wasm' })` (and `Model.run(..., { engine: 'wasm' })`) run under Node with VM parity. All vm-vs-wasm branching lives in `DirectBackend`; the public `Sim`/`Model`/`Run` classes stay structurally unchanged; `'vm'` remains the default so existing callers are untouched. + +**Architecture:** A new functional-core module `internal/wasmgen.ts` wraps the libsimlin FFI `simlin_model_compile_to_wasm` (returns the per-model wasm blob bytes + a serialized `WasmLayout`) and provides two pure functions: `parseWasmLayout` (decode the little-endian layout wire format) and `readStridedSeries` (strided f64 read of one variable's series out of the blob's linear memory into a single `Float64Array`). A complete, Rust-matching `canonicalize` resolves caller variable names to the layout's canonical keys. `DirectBackend.simNew` gains an optional `engine` param: for `'wasm'` it compiles the blob, instantiates it as its own import-free `WebAssembly.Instance` (synchronously), parses the layout, captures the model's stop time, and stores instance+layout+exports on the handle entry; every sim op then branches on the entry's recorded engine. `Sim.create` threads `engine` through `backend.simNew`; `getRun` fetches `getLinks` only when LTM is enabled (so a wasm sim — which never enables LTM — returns empty links and `Model.run` works). Explicit errors (no VM fallback) for unsupported models, `enableLtm` on wasm, and `getLinks` on wasm. + +**Tech Stack:** TypeScript (engine package, `lib: ["es2020", "dom"]` so `WebAssembly.*` types compile); jest + ts-jest (`testEnvironment: node`, `tests/**/*.test.ts`, `internal/wasm` mapped to the node loader); functional-core / imperative-shell pattern; the synchronous `WebAssembly.Module`/`Instance` constructors (the blob is import-free; `DirectBackend` runs off the browser main thread, so sync compile is allowed everywhere it runs). + +**Scope:** Phase 2 of 4. Depends on Phase 1 (the blob's `run_to`/`run_initials`/resumable `reset` ABI). Phase 3 wires the browser worker; Phase 4 is the benchmark. + +**Codebase verified:** 2026-05-22 + +--- + +## Acceptance Criteria Coverage + +### engine-wasm-sim.AC1: Engine selection via `Model.simulate`/`run` +- **engine-wasm-sim.AC1.1 Success:** `Model.simulate({engine:'wasm'})` returns a `Sim` driven by the blob; `simulate()` / `{engine:'vm'}` returns the VM-backed `Sim`. +- **engine-wasm-sim.AC1.2 Success:** `Model.run({engine:'wasm'})` returns a `Run` whose series match `Model.run({engine:'vm'})` within tolerance. +- **engine-wasm-sim.AC1.3 Success:** existing callers passing no `engine` get today's VM behavior (default unchanged). + +### engine-wasm-sim.AC2: `runToEnd`/`runTo` parity (resumable) +- **engine-wasm-sim.AC2.1 Success:** `runToEnd()` (wasm) series equal the VM within tolerance. +- **engine-wasm-sim.AC2.2 Success:** `runTo(t)` then `getValue(name)` (wasm) equals the VM's value at `t`. +- **engine-wasm-sim.AC2.3 Success:** segmented `runTo(t1)` then `runTo(t2)` (`t1` (`:128`), `_nextHandle = 1` (`:127`), `_projectChildren` (`:129`). +- `allocHandle(kind, ptr, extra?: { projectHandle? }): number` (`:131-145`) — the single handle-creation chokepoint. +- `getEntry(handle, expectedKind): HandleEntry` (`:147-159`), `getSimPtr` (`:169`), `getModelPtr` (`:163`), `getProjectPtr` (`:159`). +- Sim ops (exact lines): `simNew(modelHandle, enableLtm): SimHandle` (`:378-384`), `simDispose` (`:386-396`), `simRunTo(handle, time)` (`:398-400`), `simRunToEnd` (`:402-404`), `simReset` (`:406-408`), `simGetTime` (`:410-412`), `simGetStepCount` (`:414-416`), `simGetValue(handle, name)` (`:418-420`), `simSetValue(handle, name, value)` (`:422-424`), `simGetSeries(handle, name): Float64Array` (`:426-429`, computes `stepCount` then calls FFI), `simGetVarNames` (`:431-433`), `simGetLinks` (`:435-438`), `modelGetSimSpecsJson(handle): Uint8Array` (`:372`). +- `simDispose` calls `simlin_sim_unref(entry.ptr)` (`:395`); `projectDispose` (`:235`) and `reset` (`:183-192`) also unref child/all sim ptrs. **All three must skip the unref for wasm entries** (no native alloc; the `WebAssembly.Instance` is GC'd when the entry is dropped). + +**`EngineBackend`** (`src/engine/src/backend.ts`): full interface `:41-97`; `simNew(modelHandle: ModelHandle, enableLtm: boolean): MaybePromise` at `:85` (the **only** interface change). `MaybePromise = T | Promise` (`:39`) — `DirectBackend` returns `T` synchronously; the wasm path stays synchronous. + +**`Sim`** (`src/engine/src/sim.ts`): `static async create(model, overrides = {}, enableLtm = false): Promise` (`:44-57`) → `await backend.simNew(model.handle, enableLtm)`; fields `_handle/_model/_overrides/_disposed/_enableLtm`; getter `ltmEnabled` (`:82-84`). `getRun` (`:198-222`) currently fetches links unconditionally at `:212` (`const [loops, links, stepCount] = await Promise.all([this._model.loops(), this.getLinks(), this.getStepCount()])`). `reset` (`:127`) calls `backend.simReset` then re-applies `_overrides` via `simSetValue`. + +**`Model`** (`src/engine/src/model.ts`): `simulate(overrides = {}, options: { enableLtm?: boolean } = {})` (`:430-434`); `run(overrides = {}, options: { analyzeLtm?: boolean } = {})` (`:448-456`, maps `analyzeLtm` → `enableLtm`); `loops()` (`:314-317`, model-level, engine-agnostic); `timeSpec()` (`:297`) already parses `modelGetSimSpecsJson` → `{ start, stop: simSpecs.endTime, dt, units }` (with a defensive `simSpecs.endTime ?? 10`). + +**`Run`** (`src/engine/src/run.ts`): pure data holder, no WASM. `RunData` (`:18-25`): `{ varNames; results: Map; loops; links: readonly Link[]; stepCount; overrides }`. `convertLinks(0)` returns `[]` (`direct-backend.ts:97-114`), so LTM-off VM runs already carry `links: []`. No change needed to `Run`. + +**FFI marshalling** (`src/engine/src/internal/`): +- `memory.ts`: `malloc(size)` (`:19`, wraps `simlin_malloc`), `free(ptr)` (`:33`, wraps `simlin_free`), `stringToWasm` (`:57`), `copyFromWasm(ptr, len): Uint8Array` (`:132`), `allocOutPtr` (`:142`), `readOutPtr` (`:152`), `allocOutUsize` (`:162`), `readOutUsize` (`:172`), `readFloat64Array(ptr, count): Float64Array` (`:197`, element-wise `DataView.getFloat64(..., true)`). +- `model.ts:349-376` `callBufferReturningFn` — the single-buffer error-ptr + out-buffer template (reads via `copyFromWasm`, frees the returned `bufPtr` with `free()`). **The new `wasmgen.ts` wrapper is this shape with two out-buffers.** +- `internal/index.ts:13-21` aggregates the wrappers (`export * from './sim'`, etc.). Add `export * from './wasmgen';`. +- The raw FFI functions are accessed dynamically via `getExports().simlin_X as (...) => ...` (no generated `.d.ts`). `simlin_model_compile_to_wasm` has **no TS binding yet** — `wasmgen.ts` creates it. Rust signature (`src/libsimlin/src/model.rs:117`): `(model, out_wasm, out_wasm_len, out_layout, out_layout_len, out_error)`; both buffers freed with `simlin_free` (== `free()`); failure stores `SimlinErrorCode::Generic` ("wasm code generation failed: ..."). +- WASM singleton loaders: `internal/wasm.node.ts` (async instantiate; `getExports()`/`getMemory()`), `internal/wasm.browser.ts`. The singleton's `memory` can grow (`maximum: 16384`). The **blob is a separate `WebAssembly.Instance`** with its own non-growing memory. + +**Errors** (`src/engine/src/internal/error.ts:152-161`): `class SimlinError extends Error { code; details }` — the only Error subclass. Facade guards throw plain `Error`. FFI errors surface as `SimlinError` via the error-ptr idiom. + +**Name canonicalization** (the correctness crux): `WasmLayout.var_offsets` keys are **canonical** idents (`module.rs:746`, from `CompiledSimulation.offsets`). The VM path passes raw names to the FFI, which applies `Ident::new` = Rust `canonicalize` (`common.rs:364`). The TS `src/core/canonicalize.ts` is **incomplete** (whole-string quote check; no unquoted-dot → middle-dot, no quoted-inner-dot → sentinel, not quote-aware per-part), so it gives wrong keys for dotted/module/quoted names. A complete, Rust-matching canonicalizer is required. The Rust rules (per `common.rs:364`, verified; sentinel `LITERAL_PERIOD_SENTINEL = '\u{2024}'` at `common.rs:281`): split the trimmed name into `.`-separated parts with a quote-aware iterator (`IdentifierPartIterator`, `common.rs:1534`); for each part, if wrapped in `"..."`, take the inner text and replace each inner `.` with `'\u{2024}'` (U+2024 ONE DOT LEADER), else replace each `.` with `'\u{00B7}'` (U+00B7 MIDDLE DOT, the module separator); then replace `\\` → `\`, collapse whitespace runs (space, `\t`, `\r`, `\n`, U+00A0, and the literal escape sequences `\n`/`\r`) into a single `_`, and lowercase; concatenate parts. Verified vectors (as TS string literals with escapes, never bare glyphs — U+2024/U+2025/U+00B7 are visually indistinguishable in many fonts): `canonicalizeIdent('a.b') === 'a\u{00B7}b'`, `canonicalizeIdent('"a.b"') === 'a\u{2024}b'`, `canonicalizeIdent('a."b c"') === 'a\u{00B7}b_c'`. + +**Layout var-name shapes:** keys include reserved `time`/`dt`/`initial_time`/`final_time` (`db.rs:5367`); arrayed vars are per-element `base[e1,e2]` (canonical base + comma-joined canonical element names, `db.rs:5409`); module outputs `module\u{00B7}subvar`; lookup-only tables excluded; `$`-prefixed LTM vars only when LTM enabled (never for wasm). VM `simlin_sim_get_var_names` (`src/libsimlin/src/simulation.rs:726`) returns the same canonical keys, filters **only** `$`-prefixed names (`is_internal_var`, `simulation.rs:29` = `name.starts_with('$')` — it does **not** filter the reserved names), and `sort()`s by Rust byte order. `simlin_sim_get_stepcount` (`simulation.rs:270`) returns `results.step_count` = saved-row count = `n_chunks`. VM `simlin_sim_get_value` → `vm.get_value_now(off)` reads `data[curr_chunk * n_slots + off]` (`vm.rs:880-887`, the live current chunk) under a `debug_assert!(did_initials)` precondition. + +**Test conventions:** jest (`pnpm test` from `src/engine/`; `jest.config.js` maps `@simlin/engine/internal/wasm`→`wasm.node.ts`). Representative end-to-end test: `src/engine/tests/direct-backend.test.ts` — `new DirectBackend(); backend.reset(); backend.configureWasm({ source: loadWasmBuffer() }); await backend.init();` then `projectOpenXmile → projectGetModel → simNew → simRunToEnd → simGetSeries`. Fixtures by `__dirname` path: `pysimlin/tests/fixtures/teacup.stmx`; `test/test-models/samples/teacup/teacup.mdl`. A wasm-**unsupported** construct: a runtime view range `[start:end]` (`ViewRangeDynamic`, `wasmgen/lower.rs:1530`); author it as a tiny fixture or via `TestProject`-equivalent. Target 95%+ coverage; functional core / imperative shell. + +--- + +## Implementation Tasks + + +Subcomponent A: the functional core — pure parsers + FFI wrapper + a correct canonicalizer. These have no `Sim`/`Model` coupling and are unit-tested in isolation. + + +### Task 1: `internal/wasmgen.ts` — compile FFI wrapper + pure layout parser + strided read + +**Verifies:** engine-wasm-sim.AC4.3 (single-`Float64Array` strided read, at the unit level) + +**Files:** +- Create: `src/engine/src/internal/wasmgen.ts` +- Modify: `src/engine/src/internal/index.ts:13-21` (add `export * from './wasmgen';`) +- Test: `src/engine/tests/wasmgen.test.ts` (unit) + +**Implementation:** +Define the types and three functions: +- `interface WasmLayout { nSlots: number; nChunks: number; resultsOffset: number; varOffsets: Map }`. +- `interface WasmBlobExports { memory: WebAssembly.Memory; run(): void; run_to(time: number): void; run_initials(): void; reset(): void; set_value(offset: number, value: number): number; clear_values(): void; n_slots: WebAssembly.Global; n_chunks: WebAssembly.Global; results_offset: WebAssembly.Global }`. +- `simlin_model_compile_to_wasm(model: SimlinModelPtr): { wasm: Uint8Array; layout: Uint8Array }` — imperative shell. Mirror `internal/model.ts:349 callBufferReturningFn` but with **two** out-buffer/out-len pairs plus one error out-ptr: `allocOutPtr()` ×2 for the buffers, `allocOutUsize()` ×2 for the lengths, `allocOutPtr()` for the error. Call `getExports().simlin_model_compile_to_wasm(model, outWasm, outWasmLen, outLayout, outLayoutLen, outErr)`. If `readOutPtr(outErr) !== 0`, read code/message/details, `simlin_error_free`, and `throw new SimlinError(...)` (this is the unsupported-model path, AC7.1). On success, `copyFromWasm` both buffers, `free()` both returned `bufPtr`s, and `free()` every out-ptr in `finally`. +- `parseWasmLayout(bytes: Uint8Array): WasmLayout` — **pure**. Port the POC parser (`src/engine/wasm-backend-poc.mjs:130-155`): little-endian `DataView`; read `nSlots`/`nChunks`/`resultsOffset` as u64 (`getBigUint64(p, true)` → `Number`), `count` as u32; then `count` entries of `{ nameLen: u32, name: utf8[nameLen], offset: u64 }` into a `Map`. Use `TextDecoder` for names. +- `readStridedSeries(memory: ArrayBufferLike, layout: WasmLayout, slot: number): Float64Array` — **pure** (takes an `ArrayBuffer`, not the instance, so it is unit-testable). Allocate exactly one `Float64Array(layout.nChunks)`; fill via `new DataView(memory).getFloat64(layout.resultsOffset + (c * layout.nSlots + slot) * 8, true)` for `c in 0..nChunks`. No intermediate arrays (AC4.3). + +Register the module in `internal/index.ts`. + +**Testing:** +- `parseWasmLayout`: hand-build a byte buffer (known nSlots/nChunks/resultsOffset + two named entries at known offsets) and assert the parsed struct + map. Round-trip against the documented wire format. +- `readStridedSeries`: build a fake `ArrayBuffer` laid out step-major (nChunks×nSlots f64 at a known resultsOffset), and assert the function extracts a known variable's column exactly, returns a `Float64Array` of length `nChunks`, and allocates nothing else (AC4.3). + +These are pure-function unit tests; they need no WASM instance or libsimlin. + +**Verification:** +Run: `pnpm -C src/engine exec jest tests/wasmgen.test.ts 2>&1 | tail -20` +Expected: all unit tests pass. +Run: `pnpm -C src/engine exec tsc --noEmit 2>&1 | tail -5` +Expected: typechecks (the `WebAssembly.*` types resolve via `lib: dom`). + +**Commit:** `engine: add wasmgen FFI wrapper + pure layout parser and strided read` + + + +### Task 2: Complete, Rust-matching `canonicalize` for name→slot resolution + +**Verifies:** supports engine-wasm-sim.AC4.1 / AC4.4 (correct name resolution into the canonical layout keys) + +**Files:** +- Create: `src/engine/src/internal/canonicalize.ts` +- Test: `src/engine/tests/canonicalize.test.ts` (unit) + +**Implementation:** +Implement `canonicalizeIdent(name: string): string` as a **pure** function reproducing Rust `simlin-engine/src/common.rs:364 canonicalize` exactly: +1. `trim()`. +2. Split into parts on `.` using a **quote-aware** scan (a `.` inside a `"..."` segment does not split). Mirror Rust's `IdentifierPartIterator` (`common.rs:1534`). +3. For each part: if it is wrapped in double quotes, take the inner text and replace each inner `.` with `'\u{2024}'` (U+2024 ONE DOT LEADER, the literal-period sentinel = `common.rs:281`); otherwise replace each `.` with `'\u{00B7}'` (U+00B7 MIDDLE DOT, the module separator). +4. Then on each part: replace `\\` with `\`; collapse any run of whitespace (space, `\t`, `\r`, `\n`, U+00A0) **and** the two-char escape sequences `\n`/`\r` into a single `_`; `toLowerCase()`. +5. Concatenate the parts (no separator — the `'\u{2024}'` / `'\u{00B7}'` substitutions already carry the join). + +> **Why a new module, not `src/core/canonicalize.ts`:** the core helper is incomplete (no dot/quote handling) and is shared by other consumers whose behavior must not shift mid-feature. This module is the engine-local, fully-correct canonicalizer the wasm name lookup needs. + +**Testing:** +Assert the Rust test vectors (from `common.rs` tests at `:412` and `:489-509`), expressed as **TS string literals with explicit `\uXXXX` escapes — never bare glyphs** (U+2024 one-dot-leader, U+2025 two-dot-leader, and U+00B7 middle-dot are visually indistinguishable in many fonts; a copied glyph would silently assert the wrong codepoint and corrupt name resolution): +- `canonicalizeIdent('Hello World') === 'hello_world'` +- `canonicalizeIdent('a.b') === 'a\u{00B7}b'` — unquoted dot → U+00B7 middle dot +- `canonicalizeIdent('"a.b"') === 'a\u{2024}b'` — quoted-inner dot → U+2024 one dot leader +- `canonicalizeIdent('a."b c"') === 'a\u{00B7}b_c'` +- `canonicalizeIdent('model.variable') === 'model\u{00B7}variable'` +- `canonicalizeIdent('"a/d"."b c"') === 'a/d\u{00B7}b_c'` +- `canonicalizeIdent('"a/d".b') === 'a/d\u{00B7}b'` +- `canonicalizeIdent('"quoted"') === 'quoted'` +- `canonicalizeIdent('"b c"') === 'b_c'` +- `canonicalizeIdent('café') === 'café'` — non-ASCII passes through, lowercased +- `canonicalizeIdent('Å\nb') === 'å_b'` — non-ASCII lowercase + literal `\n` escape → underscore +- `canonicalizeIdent(' a b') === 'a_b'` +- idempotency on already-canonical input: `canonicalizeIdent('room_temperature') === 'room_temperature'` + +Include a property test: `canonicalizeIdent` is idempotent (`canonicalizeIdent(canonicalizeIdent(x)) === canonicalizeIdent(x)`). + +**Verification:** +Run: `pnpm -C src/engine exec jest tests/canonicalize.test.ts 2>&1 | tail -20` +Expected: all vectors pass. + +**Then file the canonicalizer-unification debt:** dispatch the `track-issue` agent (Task tool, `subagent_type: "track-issue"`) describing that `src/engine/src/internal/canonicalize.ts` duplicates the incomplete `src/core/canonicalize.ts`, and that the two should later be unified into one Rust-faithful canonicalizer (so we do not silently keep two diverging copies). Confirm the agent reports the issue filed or already-tracked. + +**Commit:** `engine: add Rust-faithful canonicalize for wasm name resolution` + + + + +Subcomponent B: the `DirectBackend` demux — sim creation/disposal, then per-op branching. Tested directly through `DirectBackend` (not yet through `Model`/`Sim`), so each task ends green before the facade is wired. + + +### Task 3: `EngineBackend.simNew` engine param + `DirectBackend` wasm sim creation/disposal + +**Verifies:** engine-wasm-sim.AC1.1, engine-wasm-sim.AC6.2, engine-wasm-sim.AC7.1, engine-wasm-sim.AC7.2, engine-wasm-sim.AC5.4 (instance owned once on the entry) + +**Files:** +- Modify: `src/engine/src/backend.ts:85` (interface `simNew`) +- Modify: `src/engine/src/direct-backend.ts` (`HandleEntry` `:116-124`; `allocHandle` `:131`; `simNew` `:378-384`; `simDispose` `:386-396`; `projectDispose` `:235`; `reset` `:183-192`) +- Test: `src/engine/tests/wasm-backend.test.ts` (integration via `DirectBackend`) + +**Implementation:** +1. **Interface:** change `backend.ts:85` to `simNew(modelHandle: ModelHandle, enableLtm: boolean, engine?: 'vm' | 'wasm'): MaybePromise;`. Because `engine` is optional, `WorkerBackend` (which implements the interface and is untouched until Phase 3) still satisfies it. Add a shared `type SimEngine = 'vm' | 'wasm';` (export it from `backend.ts`). +2. **Widen `HandleEntry`** with wasm-only fields: `engine?: SimEngine; wasmInstance?: WebAssembly.Instance; wasmLayout?: WasmLayout; wasmExports?: WasmBlobExports; wasmStopTime?: number;`. Widen `allocHandle`'s `extra` param to carry them (or set them on the entry immediately after `allocHandle` returns). +3. **`simNew` demux:** + - `engine` defaults to `'vm'`. For `'vm'`: unchanged path (`simlin_sim_new(modelPtr, enableLtm)`, `allocHandle('sim', ptr, { projectHandle, engine: 'vm' })`). + - For `'wasm'`: **reject `enableLtm`** first with a clear `Error` ("LTM is not supported on the wasm engine; use engine:'vm'") — AC6.2, before any compile. Then: `const { wasm, layout } = simlin_model_compile_to_wasm(modelEntry.ptr)` (throws `SimlinError` on an unsupported model → AC7.1, no fallback). `const parsed = parseWasmLayout(layout)`. Capture stop time by parsing the model's sim specs and reading `endTime`, mirroring `Model.timeSpec()` (`model.ts:297`): `JSON.parse(new TextDecoder().decode(this.modelGetSimSpecsJson(modelHandle))).endTime`. `endTime` is a required field of the serialized `SimSpecs`, so it is effectively always present for a compiled model and no magic-number fallback is embedded here; if you choose to mirror `timeSpec`'s defensive `?? 10` (`model.ts:297`), reuse that exact expression rather than introducing a divergent constant. Instantiate synchronously and import-free: `const instance = new WebAssembly.Instance(new WebAssembly.Module(wasm), {})`. Build `wasmExports` from `instance.exports`. `allocHandle('sim', 0, { projectHandle, engine: 'wasm', wasmInstance: instance, wasmLayout: parsed, wasmExports, wasmStopTime })`. (`ptr` is `0`/unused for wasm.) +4. **Dispose guards:** in `simDispose`, `projectDispose`, and `reset`, only call `simlin_sim_unref(entry.ptr)` when `entry.engine !== 'wasm'` (a wasm entry has no native sim; dropping the entry lets the `WebAssembly.Instance` be GC'd). Keep the rest of the disposal bookkeeping unchanged. + +**Testing** (via `DirectBackend` directly, mirroring `direct-backend.test.ts` setup): +- AC1.1: `simNew(modelHandle, false, 'wasm')` returns a sim handle; the entry records `engine: 'wasm'` and holds an instance (assert a subsequent `simRunToEnd` + `simGetSeries` works once Task 4 lands — for this task, assert the handle is created and `simNew(..., 'vm')` / `simNew(...)` still create VM sims). +- AC6.2: `simNew(modelHandle, true, 'wasm')` throws a clear error and creates no sim. +- AC7.1/AC7.2: build a wasm-unsupported model (runtime view range), assert `simNew(modelHandle, false, 'wasm')` throws (a `SimlinError`/`Error`, no VM fallback), and that `simNew(modelHandle, false, 'vm')` on the same model succeeds. +- AC5.4 (creation half): assert the instance is created exactly once and stored on the entry (a later `simReset`/`simSetValue` in Task 4 reuses it). +- Disposal: `simDispose` on a wasm sim does not throw and does not call `simlin_sim_unref` (e.g. spy/verify no native unref on a 0 ptr); `projectDispose` cleans up wasm child sims. + +**Verification:** +Run: `pnpm -C src/engine exec jest tests/wasm-backend.test.ts 2>&1 | tail -25` +Expected: creation/error/dispose tests pass. +Run: `pnpm -C src/engine exec jest tests/direct-backend.test.ts 2>&1 | tail -10` +Expected: existing VM tests still pass (default path unchanged). + +**Commit:** `engine: wasm-engine sim creation and disposal in DirectBackend` + + + +### Task 4: Per-op demux (`run`/`reset`/reads/`setValue`) + `getLinks` rejection + +**Verifies:** engine-wasm-sim.AC2.1, AC2.2, AC2.3, AC2.4, AC3.1, AC3.2, AC4.1, AC4.2, AC4.3, AC4.4, AC5.1, AC5.2, AC5.3, AC6.1 + +**Files:** +- Modify: `src/engine/src/direct-backend.ts` (the sim ops `:398-438`) +- Test: `src/engine/tests/wasm-backend.test.ts` (extend) + +**Implementation:** each sim op fetches the entry via `getEntry(handle, 'sim')` and branches on `entry.engine`. For `'vm'`, the existing FFI call is unchanged. For `'wasm'`, drive the blob (`entry.wasmExports` / `entry.wasmLayout` / `entry.wasmStopTime`): +- `simRunToEnd`: `entry.wasmExports.run_to(entry.wasmStopTime)` (the blob's `run_to` runs `run_initials` internally and is resumable; using the real stop time mirrors the VM's `run_to(specs.stop)`). +- `simRunTo(time)`: `entry.wasmExports.run_to(time)` (resumable; segments accumulate — AC2.3; a `time` past stop is clamped by the blob — AC2.4). +- `simReset`: `entry.wasmExports.reset()` (Phase-1 reset: clears the cursor, keeps constant overrides — AC3.2). The `Sim` facade re-applies overrides after `simReset`, which is harmless/consistent. +- `simSetValue(name, value)`: resolve `slot = entry.wasmLayout.varOffsets.get(canonicalizeIdent(name))`; if absent → throw "unknown variable" (parity with the VM's not-found error). Else `const rc = entry.wasmExports.set_value(slot, value)`; if `rc !== 0` → throw a `SimlinError`/`Error` ("cannot set value of '': not a simple constant"), matching the VM's `BadOverride` (AC5.2). `rc === 0` succeeds (AC5.1, AC5.3). +- `simGetSeries(name): Float64Array`: resolve slot (canonicalize → `varOffsets`); if absent → throw the same not-found error as the VM (AC4.4). Else `readStridedSeries(entry.wasmExports.memory.buffer, entry.wasmLayout, slot)` — one `Float64Array(nChunks)` (AC4.1, AC4.3). +- `simGetValue(name): number`: resolve slot; read the variable's **current value** from the blob's live `curr` chunk (linear-memory base 0): `new DataView(entry.wasmExports.memory.buffer).getFloat64(slot * 8, true)`. This mirrors the VM oracle exactly — `simlin_sim_get_value` → `vm.get_value_now(off)` reads `data[curr_chunk * n_slots + off]` (`vm.rs:880-887`), the live current chunk. **Precondition (same as the VM):** `get_value_now` carries `debug_assert!(did_initials)`; a `getValue` before any `run_to`/`runToEnd` reads stale base-0 memory, which matches the VM's undefined-before-initials behavior — so pre-run `getValue` is out of scope (callers run first, as the AC2.2 test does). The base-0 `curr`-chunk read is the **determined** source of truth (not a guess); the AC2.2 parity test guards it, and only if a one-step offset against the VM nonetheless surfaces should you reconcile against `vm.rs:880` (the VM is the oracle). +- `simGetStepCount(): number`: `entry.wasmLayout.nChunks` (= the VM's saved-row count — AC4.2). +- `simGetVarNames(): string[]`: from `entry.wasmLayout.varOffsets` keys, **filter only `$`-prefixed keys** (matching the VM's `is_internal_var`, `src/libsimlin/src/simulation.rs:29` = `name.starts_with('$')`) and sort by Unicode **code point** (to match Rust's byte-order `sort()`; do **not** use the default JS UTF-16 `Array.sort` for non-ASCII names). **Do NOT filter the reserved names** `time`/`dt`/`initial_time`/`final_time`: the VM's `simlin_sim_get_var_names` (`simulation.rs:726`) filters only `is_internal_var`, so those reserved names DO appear in the VM's output and the wasm path must include them too. The AC4.2 parity test guards this equality — it does not decide it; the behavior is pinned here. +- `simGetTime(): number`: `new DataView(entry.wasmExports.memory.buffer).getFloat64(0, true)` (the `time` slot is slot 0 of the live `curr` chunk at base 0). +- `simGetLinks`: for `'wasm'`, **throw** a clear `Error` ("getLinks is not supported on the wasm engine; use engine:'vm'") — AC6.1. + +> Read the blob's `memory.buffer` freshly per call (the blob's memory does not grow, but reading fresh keeps the pattern uniform with the singleton helpers and avoids a stale-buffer footgun). + +**Testing** (VM-vs-wasm parity through `DirectBackend`; the VM is the oracle, compared within the engine's existing tolerance — use a small supported fixture like teacup, plus at least one fixture with a constant the test overrides): +- AC2.1: `simRunToEnd` then `simGetSeries(name)` (wasm) equals the VM for the model's variables. +- AC2.2: `simRunTo(t)` then `simGetValue(name)` (wasm) equals the VM after the same `simRunTo(t)`. +- AC2.3: `simRunTo(t1)`+`simRunTo(t2)` equals a single `simRunTo(t2)` and the VM. +- AC2.4: `simRunTo(stop*2)` equals `simRunToEnd` and the VM. +- AC3.1/AC3.2: `simReset` then re-run reproduces defaults; with a prior `simSetValue(const)`, reset preserves the override (matches VM). +- AC4.1/AC4.2/AC4.4: `simGetSeries` for every var equals VM; `simGetVarNames()`/`simGetStepCount()` equal VM (the VM `getVarNames` includes the reserved time vars — assert exact array equality); `simGetSeries('definitely_not_a_var')` throws like the VM. +- AC4.3: `simGetSeries` returns one `Float64Array` of length `nChunks` (assert `instanceof Float64Array` and `.length === stepCount`). +- AC5.1/AC5.2/AC5.3: `simSetValue(const, v)` then run matches VM; `simSetValue(nonConstant, v)` throws; mid-run `simRunTo(t1)`+`simSetValue(const,v)`+`simRunTo(t2)` affects only post-`t1` steps (matches VM driven identically). +- AC6.1: `simGetLinks` on a wasm sim throws. + +**Verification:** +Run: `pnpm -C src/engine exec jest tests/wasm-backend.test.ts 2>&1 | tail -30` +Expected: all parity + error tests pass. + +**Commit:** `engine: per-op vm/wasm demux in DirectBackend with VM parity` + + + + +Subcomponent C: thread `engine` through the public facade and gate link-fetching, then test through `Model`/`Sim`. + + +### Task 5: `Sim.create`/`Model.simulate`/`Model.run` engine threading + `getRun` LTM gating + +**Verifies:** engine-wasm-sim.AC1.1, AC1.2, AC1.3, AC6.3 + +**Files:** +- Modify: `src/engine/src/sim.ts` (`create` `:44-57`; `getRun` `:198-222`) +- Modify: `src/engine/src/model.ts` (`simulate` `:430-434`; `run` `:448-456`) +- Test: `src/engine/tests/wasm-model.test.ts` (public-API parity) + +**Implementation:** +- `Sim.create(model, overrides = {}, enableLtm = false, engine: SimEngine = 'vm')`: pass `engine` to `await backend.simNew(model.handle, enableLtm, engine)`. Store `engine` on the `Sim` (a private field) so `getRun`/diagnostics can see it; expose nothing new publicly. +- `Model.simulate(overrides = {}, options: { enableLtm?: boolean; engine?: SimEngine } = {})`: forward `engine` to `Sim.create(this, overrides, enableLtm, engine)`. (The `enableLtm:true && engine:'wasm'` rejection is enforced authoritatively in `DirectBackend.simNew` from Task 3, covering the worker path too; this method just forwards.) +- `Model.run(overrides = {}, options: { analyzeLtm?: boolean; engine?: SimEngine } = {})`: forward `{ enableLtm: analyzeLtm, engine }` to `simulate`. Preserve the existing `analyzeLtm`→`enableLtm` naming asymmetry. +- `getRun` (`sim.ts:212`): gate link-fetching on LTM — replace the unconditional `this.getLinks()` with `this.ltmEnabled ? this.getLinks() : Promise.resolve([])`. This makes `Model.run({engine:'wasm'})` work (a wasm sim never enables LTM, so `getLinks` is never called on it → returns `[]`), and is harmless for the VM path (which already returns `[]` with LTM off). `this._model.loops()` stays unconditional (model-level, engine-agnostic). + +**Testing** (through the public `Model`/`Sim` API; VM is the oracle): +- AC1.1: `model.simulate({ engine: 'wasm' })` returns a `Sim` whose `runToEnd()`+`getSeries()` match the VM; `model.simulate()` and `model.simulate({ engine: 'vm' })` return VM-backed sims. +- AC1.2: `model.run({ engine: 'wasm' })` series equal `model.run({ engine: 'vm' })` within tolerance. +- AC1.3: existing `model.simulate(overrides)` / `model.run(overrides)` calls with no `engine` behave exactly as before (VM); confirm a representative existing test path is unaffected. +- AC6.3: `model.run({ engine: 'wasm' })` resolves to a `Run` with `links` empty (`[]`), and does not throw (no `getLinks` call on the wasm sim). + +**Verification:** +Run: `pnpm -C src/engine exec jest tests/wasm-model.test.ts 2>&1 | tail -25` +Expected: public-API parity + empty-links tests pass. +Run: `pnpm -C src/engine test 2>&1 | tail -15` +Expected: the full engine suite is green (default behavior unchanged). +Run: `pnpm -C src/engine exec tsc --noEmit 2>&1 | tail -5` +Expected: typechecks (incl. `WorkerBackend` still satisfying the widened `EngineBackend`). + +**Commit:** `engine: thread engine selection through Model/Sim and gate getRun links` + + + +--- + +## Phase 2 Done When + +- `Model.simulate({ engine: 'wasm' })` and `Model.run({ engine: 'wasm' })` run supported models under a node `DirectBackend` with series matching the VM within the engine's existing tolerance; `engine: 'vm'` / no `engine` is unchanged (AC1.*). +- `runToEnd`/`runTo` (incl. segmented and clamped), `reset` (defaults + override-preserving), by-name reads (`getSeries`/`getVarNames`/`getStepCount`, single `Float64Array`, unknown-name error), and constants-only `setValue` (incl. mid-run) all match the VM (AC2.*, AC3.*, AC4.*, AC5.*). +- `getLinks()` on a wasm sim throws; `Model.simulate({engine:'wasm', enableLtm:true})` is rejected up front; an unsupported model throws (no VM fallback) yet runs via `engine:'vm'`; `Model.run({engine:'wasm'})` returns empty links (AC6.*, AC7.*). +- The only `EngineBackend` change is the optional `engine` param on `simNew`; `Sim`/`Model`/`Run` are otherwise structurally unchanged; `WorkerBackend` still compiles (Phase 3 wires it). `pnpm -C src/engine test` and `tsc --noEmit` pass. +- The `track-issue` agent has been dispatched (in Task 2) to file the debt of unifying the engine-local `canonicalizeIdent` with `src/core/canonicalize.ts`. diff --git a/docs/implementation-plans/2026-05-22-engine-wasm-sim/phase_03.md b/docs/implementation-plans/2026-05-22-engine-wasm-sim/phase_03.md new file mode 100644 index 000000000..aee0d6acb --- /dev/null +++ b/docs/implementation-plans/2026-05-22-engine-wasm-sim/phase_03.md @@ -0,0 +1,164 @@ +# @simlin/engine WebAssembly Simulation Backend — Phase 3: browser / worker + +**Goal:** Make `engine: 'wasm'` work through the Web Worker path with no structural protocol change — exactly one optional, additive `engine` field on the existing `simNew` message, threaded through the three worker sites, plus a parity test proving a `WorkerBackend`-driven wasm sim matches a node `DirectBackend`. + +**Architecture:** In the browser, `WorkerBackend` (main thread) sends a `simNew` request over a `postMessage` discriminated-union protocol to `WorkerServer` (in a Web Worker), which delegates to an internal `DirectBackend`. Because `WorkerServer` wraps a `DirectBackend`, the entire Phase-2 wasm demux (and the blob's `WebAssembly.Instance`) already lives inside the Worker for free. The only delta is: (1) add an optional `engine?: 'vm' | 'wasm'` field to the `simNew` request variant, (2) forward `request.engine` in the server's `simNew` case, (3) add the `engine?` arg to `WorkerBackend.simNew` and include it in the message. No new message types, no new response shapes; `getSeries` already transfers its `Float64Array` zero-copy and is engine-agnostic. + +**Tech Stack:** TypeScript; the existing postMessage discriminated-union worker protocol; jest with the in-memory loopback harness (`createTestPair`) that wires a real `WorkerBackend` to a real `WorkerServer` via fake transport closures (no real `Worker`/jsdom; `testEnvironment: node`). + +**Scope:** Phase 3 of 4. Depends on Phase 2 (the `engine` param on `EngineBackend.simNew`/`DirectBackend.simNew`, the wasm demux, and `internal/wasmgen.ts`). The `worker-backend.ts` edit only typechecks once Phase 2 has widened `EngineBackend.simNew`. + +**Codebase verified:** 2026-05-22 + +--- + +## Acceptance Criteria Coverage + +### engine-wasm-sim.AC8: Browser/worker parity + minimal protocol +- **engine-wasm-sim.AC8.1 Success:** through `WorkerBackend`, `engine:'wasm'` produces series matching node `DirectBackend` (and the VM). +- **engine-wasm-sim.AC8.2 Success:** the protocol delta is exactly one optional `engine` field on the existing `simNew` message — no new message types/response shapes; `getSeries` still transfers zero-copy. + +--- + +## Background: what exists today (verified) + +All paths absolute from `/home/bpowers/src/simlin`. Cited line numbers match current committed code. + +**The `simNew` request variant** (`src/engine/src/worker-protocol.ts:83`), inside the `WorkerRequest` union (opens `:34`, closes `:94`): +```ts +| { type: 'simNew'; requestId: number; modelHandle: WorkerModelHandle; enableLtm: boolean } +``` +Optional-field precedent in the same union: `:36` `{ type: 'init'; requestId: number; wasmSource?: ArrayBuffer; wasmUrl?: string }` and `:52` `{ ...; includeStdlib?: boolean }`. `WorkerModelHandle`/`WorkerSimHandle` are `number` aliases (`:17-19`). `VALID_REQUEST_TYPES` (`:189`) and `isValidRequest` (`:203-209`) only check the `type` discriminant + `requestId` presence — **no field-level validation**, so adding an optional field touches neither. + +**The response union** (`worker-protocol.ts:98-100`) is fixed and generic: +```ts +export type WorkerResponse = + | { type: 'success'; requestId: number; result: unknown; transfer?: ArrayBuffer[] } + | { type: 'error'; requestId: number; error: SerializedError }; +``` +The handle comes back inside `result: unknown` — **no per-request response shape**, so adding a request field needs no response change (AC8.2). `SerializedError` (`:25-30`) carries `name`/`message`/`code?`/`details?`; `serializeError`/`deserializeError` (`:114-152`) round-trip them, so a thrown `SimlinError`/`Error` from the worker's `DirectBackend` (unsupported model, `enableLtm` rejection) propagates to the main thread intact. + +**The `WorkerServer` simNew case** (`src/engine/src/worker-server.ts:367-377`): +```ts +case 'simNew': { + const modelHandle = this.getModelHandle(request.modelHandle); + const backendSimHandle = this.backend.simNew(modelHandle, request.enableLtm); // :369 + const parentProject = this.modelToProject.get(request.modelHandle); + if (parentProject === undefined) { + throw new Error(`Model handle ${request.modelHandle} not associated with a project`); + } + const workerSimHandle = this.registerSimHandle(backendSimHandle, parentProject); + this.sendSuccess(requestId, workerSimHandle); + return; +} +``` +`this.backend` is a `DirectBackend` (`:31` field, `:44-47` constructor `this.backend = new DirectBackend()`). TypeScript narrows `request` to the `simNew` variant inside the case, so `request.engine` is available once the protocol field is added. + +**Zero-copy `getSeries`** (`worker-server.ts:422-427`, `sendFloat64WithTransfer` `:517-523`, `detachable` `:530-535`): the case calls `this.backend.simGetSeries(handle, name)` (engine-agnostic) and ships the `Float64Array` with transfer. `detachable` `.slice()`s only if the view is partial (`byteOffset !== 0` or `buffer.byteLength !== byteLength`); the Phase-2 wasm `getSeries` returns a freshly-allocated `Float64Array(nChunks)` (byteOffset 0, owns its buffer), so it transfers as-is. Existing buffer-independence coverage: `worker-server.test.ts:771-817`. + +**The `WorkerBackend.simNew` builder** (`src/engine/src/worker-backend.ts:512-519`): +```ts +simNew(modelHandle: ModelHandle, enableLtm: boolean): Promise { + return this.sendRequest((requestId) => ({ + type: 'simNew', + requestId, + modelHandle, + enableLtm, + })); +} +``` +`sendRequest` (`:79-114`) returns `Promise`, enqueues on a FIFO `_queue`, assigns a monotonic `requestId`, and resolves via `handleResponse` (`:61-73`) on `success` (or rejects with `deserializeError` on `error`). The message object literal must structurally satisfy the (Phase-3-widened) `simNew` request variant — adding `engine` to the protocol union makes the literal typecheck. + +**The worker test harness** (`src/engine/tests/worker-backend.test.ts`): `createTestPair()` (`:35-61`) builds a real `WorkerBackend` and a real `WorkerServer` and wires them with fake transport closures (`setTimeout(..., 0)` to mimic async delivery), capturing every `transfer` list in a `transfers: (Transferable[] | undefined)[]` array (`TestPair`, `:24-29`). WASM is loaded from disk: `loadWasmSource()` → `core/libsimlin.wasm` (`:13,:21`); model fixture `loadTestXmile()` → `pysimlin/tests/fixtures/teacup.stmx` (`:15-18`). Existing "sim operations" tests (`:359-398`) show the pattern: `await backend.init(loadWasmSource()); projHandle = await backend.projectOpenXmile(data); modelHandle = await backend.projectGetModel(projHandle, null); const simHandle = await backend.simNew(modelHandle, false); await backend.simRunToEnd(simHandle); const series = await backend.simGetSeries(simHandle, 'teacup_temperature');`. `DirectBackend` is directly constructible in a node test (it backs `WorkerServer` and is used directly in `worker-server.test.ts`). Production wiring (`backend-factory.browser.ts:22-53`, `engine-worker.ts`) ships the whole structured-cloned message via `worker.postMessage`, so an added optional field flows through with no factory/worker-entry change. + +--- + +## Implementation Tasks + + +Subcomponent A: the additive protocol field + a worker parity test. + + +### Task 1: Thread an optional `engine` field through the three worker sites + +**Verifies:** engine-wasm-sim.AC8.2 (the protocol delta is exactly one optional `engine` field; no new message types or response shapes) + +**Files:** +- Modify: `src/engine/src/worker-protocol.ts:83` (the `simNew` request variant) +- Modify: `src/engine/src/worker-server.ts:369` (forward `request.engine`) +- Modify: `src/engine/src/worker-backend.ts:512-519` (add the `engine?` arg + include it in the message) + +**Implementation:** +1. **Protocol** (`worker-protocol.ts:83`): add the optional field, mirroring the `?:` precedent at `:36`/`:52`: + ```ts + | { type: 'simNew'; requestId: number; modelHandle: WorkerModelHandle; enableLtm: boolean; engine?: 'vm' | 'wasm' } + ``` + Use the same `'vm' | 'wasm'` union as Phase 2's `SimEngine` (import the exported `SimEngine` type from `./backend` if it reads cleaner; an inline literal is acceptable and matches the surrounding style). Do **not** touch `VALID_REQUEST_TYPES` or `isValidRequest` (they are field-agnostic). +2. **Server** (`worker-server.ts:369`): forward the field — change the one line to: + ```ts + const backendSimHandle = this.backend.simNew(modelHandle, request.enableLtm, request.engine); + ``` + Nothing else in the case changes (handle translation, project association, `registerSimHandle`, `sendSuccess` are engine-agnostic). +3. **Backend builder** (`worker-backend.ts:512-519`): widen the signature to match the Phase-2 `EngineBackend.simNew` and include `engine` in the message: + ```ts + simNew(modelHandle: ModelHandle, enableLtm: boolean, engine?: 'vm' | 'wasm'): Promise { + return this.sendRequest((requestId) => ({ + type: 'simNew', + requestId, + modelHandle, + enableLtm, + engine, + })); + } + ``` + +This is purely additive: `engine === undefined` (every existing caller, and the VM path) produces the same message shape as before (an absent optional field), so existing behavior is unchanged. + +**Testing:** +This task is a typed, additive protocol change with no new behavior of its own (the wasm behavior is exercised in Task 2). Verify operationally: +- Existing worker tests still pass unchanged (the VM path is untouched). +- The project typechecks (the `worker-backend.ts` message literal satisfies the widened `simNew` variant; this also confirms Phase 2's interface widening is present, as required). + +**Verification:** +Run: `pnpm -C src/engine exec tsc --noEmit 2>&1 | tail -10` +Expected: typechecks (no excess-property error on the `simNew` message literal; `WorkerBackend.simNew` matches `EngineBackend.simNew`). +Run: `pnpm -C src/engine exec jest tests/worker-backend.test.ts tests/worker-server.test.ts 2>&1 | tail -15` +Expected: all existing worker tests pass (additive change, VM path unchanged). + +**Commit:** `engine: thread optional engine field through the worker simNew message` + + + +### Task 2: Worker wasm-engine parity test (matches node DirectBackend; zero-copy preserved) + +**Verifies:** engine-wasm-sim.AC8.1, engine-wasm-sim.AC8.2 + +**Files:** +- Test: `src/engine/tests/worker-wasm.test.ts` (new; reuse the `createTestPair` loopback pattern from `worker-backend.test.ts:35-61` and the WASM/fixture loaders at `:13-18`) + +**Implementation:** +No production code. This test drives `engine: 'wasm'` end-to-end through the real postMessage protocol (real request/response serialization shapes, the FIFO queue, `handleResponse`/`deserializeError`) via the in-memory loopback, and compares against a node `DirectBackend`. + +**Testing:** +- AC8.1: In one node test, build a `WorkerBackend` via `createTestPair()` and a separate `DirectBackend`; `await both.init(loadWasmSource())`; open the same model (teacup XMILE) in each; on the `WorkerBackend` create a wasm sim (`await backend.simNew(modelHandle, false, 'wasm')`), `simRunToEnd`, and `simGetSeries(name)`; on the `DirectBackend` run the same model via `engine: 'wasm'` and via `engine: 'vm'`. Assert the `WorkerBackend` wasm series equals the `DirectBackend` wasm series exactly, and equals the VM series within the engine's tolerance (wasm is not bit-identical to the VM's libm by design). +- AC8.2 (transfer + protocol shape): assert a wasm-sim `simGetSeries` round-trips a `Float64Array` and that the call adds exactly one transfer entry of length 1 to the cumulative `transfers` array — assert on the array-length delta around that single `simGetSeries` call (or that the last appended entry is a one-element `[ArrayBuffer]`), since `TestPair.transfers` (`worker-backend.test.ts:24-29`) accumulates across all calls (mirroring `worker-server.test.ts:771-817`), confirming the zero-copy path is unchanged for the wasm engine. Optionally assert the served request object for `simNew` carries `engine: 'wasm'` and no new message `type` was introduced. +- Error propagation through the worker (reinforces AC6/AC7 across the worker boundary): `await expect(backend.simNew(modelHandle, true, 'wasm')).rejects.toThrow(/LTM .* not supported|wasm/i)` (the `enableLtm` rejection serializes from the worker), and a wasm-unsupported model rejects rather than silently falling back. + +**Verification:** +Run: `pnpm -C src/engine exec jest tests/worker-wasm.test.ts 2>&1 | tail -25` +Expected: the worker wasm series matches the `DirectBackend` (and the VM within tolerance); the transfer assertion passes; the error cases reject across the worker boundary. +Run: `pnpm -C src/engine test 2>&1 | tail -15` +Expected: the full engine suite is green. + +**Commit:** `engine: parity-test the wasm engine through the Web Worker path` + + + +--- + +## Phase 3 Done When + +- A `WorkerBackend`-driven `engine: 'wasm'` sim produces series matching a node `DirectBackend` (and the VM within tolerance) through the real postMessage protocol (AC8.1). +- The protocol delta is exactly one optional, additive `engine` field on the existing `simNew` message — no new message types, no new response shapes, `VALID_REQUEST_TYPES`/`isValidRequest` untouched; `getSeries` still transfers its `Float64Array` zero-copy for the wasm engine (AC8.2). +- Worker-boundary error propagation works for the `enableLtm`-on-wasm rejection and unsupported models (no silent VM fallback). +- `tsc --noEmit` and `pnpm -C src/engine test` pass; existing worker tests are unchanged. diff --git a/docs/implementation-plans/2026-05-22-engine-wasm-sim/phase_04.md b/docs/implementation-plans/2026-05-22-engine-wasm-sim/phase_04.md new file mode 100644 index 000000000..7b5b8cb2a --- /dev/null +++ b/docs/implementation-plans/2026-05-22-engine-wasm-sim/phase_04.md @@ -0,0 +1,153 @@ +# @simlin/engine WebAssembly Simulation Backend — Phase 4: node benchmark + +**Goal:** A repeatable Node benchmark comparing VM vs wasm **simulation (eval) time** for fishbanks, WORLD3, and C-LEARN, through the public `Model.simulate({ engine })` API, with explicit warmup and median reporting. + +**Architecture:** A gated jest test under `src/engine/tests/` reuses the ts-jest-from-source pipeline (no build step, no new dependency, no `tsx`/`ts-node`). The benchmark splits into a small **pure** stats/harness module (`median`, an adaptive warmup+measure loop) that is always-on unit-tested, and an **imperative** runner that loads the three models, builds one `Sim` per `(model, engine)` once (untimed — for wasm this is the blob compile/instantiate), then times only `runToEnd()` in a loop (with `reset()` between runs, outside the clock). The heavy run is gated behind `RUN_BENCH=1` so it stays out of the default `pnpm test` (respecting the project's per-test time budget) while the pure helpers keep coverage. It mirrors `examples/backend_bench.rs`'s eval-vs-eval methodology and median statistic, adds the explicit warmup the AC requires, prints a markdown table, and (per the project's "no stale benchmark data" rule) checks in the harness only — never a results file. + +**Tech Stack:** TypeScript; jest (ts-jest, `testEnvironment: node`); `performance.now()` from `node:perf_hooks`; `fs.readFileSync` + `path.join(__dirname, ...)`; the public `@simlin/engine` API (`Project.open*`, `Model.simulate({ engine })`, `Sim.reset`/`runToEnd`/`getRun`/`dispose`). + +**Scope:** Phase 4 of 4. Depends on Phase 2 (the `engine` demux + `Model.simulate({ engine })`) and Phase 1 (the resumable `reset` export so `reset()`+`runToEnd()` re-runs the wasm blob without recompiling). It does not depend on Phase 3. + +**Codebase verified:** 2026-05-22 + +--- + +## Acceptance Criteria Coverage + +### engine-wasm-sim.AC9: Node benchmark +- **engine-wasm-sim.AC9.1 Success:** a node benchmark reports warm-median simulation (eval) time for fishbanks, WORLD3, and C-LEARN on both engines, via `Model.simulate({engine})`, with explicit warmup. + +--- + +## Background: what exists today (verified) + +All paths absolute from `/home/bpowers/src/simlin`. + +**No TS benchmark exists yet** in `src/engine` (no `bench`/`benchmarks` dir, no `*.bench.ts`, no `examples/`, no `bench` package script). The only Node timing precedent is the throwaway `src/engine/wasm-backend-poc.mjs` (plain `.mjs`, bypasses the package API, mean not median, mismatched iteration counts — **not** the model to copy). The reference methodology is the Rust `src/simlin-engine/examples/backend_bench.rs`. + +**`backend_bench.rs` (the methodology to mirror):** +- Models (`MODELS`, `:243-262`), relative to `src/simlin-engine`: fishbanks `../../default_projects/fishbanks/model.xmile` (XMILE), wrld3 `../../test/metasd/WRLD3-03/wrld3-03.mdl` (Vensim), clearn `../../test/xmutil_test_models/C-LEARN v77 for Vensim.mdl` (Vensim). Selected via `BENCH_MODELS` (default all three). +- Eval region (`:481-512`): the `Vm`/wasm instance is created **once in untimed setup**; the timed body is `vm.reset(); time(vm.run_to_end())` (VM) and `time(run())` (wasm). Compile/build/instantiate are timed in **separate** phases, excluded from eval. +- Harness (`bench`, `:165-203`): adaptive — runs `min_iters` (=1), then until `max_iters` (=100) or a per-phase wall-clock `budget_s` (=2.5, `BENCH_TIME_BUDGET`) elapses. Statistic: **median** (`times[iters/2]` after sort), reported with `min` and iteration count. No discrete warmup (the adaptive median + min absorbs it) — but **AC9.1 requires explicit warmup**, so the TS benchmark adds a discard-warmup loop (a deliberate, AC-mandated divergence). +- Report (`print_summary`, `:644-732`): a markdown eval table `| model | VM reset+run | wasm run | wasm/VM | front-end compile (shared) |`, each cell `median (n=…, min …)`. It also `cross_check`s (`:336-363`) that VM and wasm produce matching series, guarding against benchmarking a broken run. + +**Project conventions:** +- `docs/dev/benchmarks.md` documents only the Criterion Rust benches (`cargo bench`); it predates `backend_bench.rs` and has no Node section (consider adding one — see the optional doc task). The MEMORY rule "No stale benchmark data": **commit the regenerable harness, not the numbers; show results in-chat.** +- Root `CLAUDE.md` hard rule: individual tests complete in a few seconds on debug; `cargo test --workspace` under a 3-minute cap. An ungated C-LEARN×2-engine benchmark would violate this, so the heavy run **must be gated** out of the default `jest` run (mirroring the `#[ignore]`d heavy Rust tests). + +**Public API (current; Phase 2 adds `{ engine }`):** +- `Project.open(xmile, opts?)` (`src/engine/src/project.ts:54`, XMILE — **not** a format sniffer), `Project.openVensim(data, opts?)` (`:103`), `Project.openProtobuf` (`:70`). Each calls `backend.init(opts?.wasm)` internally (idempotent), so **no separate `ready()`/`configureWasm` is needed in Node**; the default WASM source resolves to the bundled `core/libsimlin.wasm` (`internal/wasm.node.ts:39-42`). +- `project.mainModel()` (`project.ts:157`) → the default `Model`. +- `Model.simulate(overrides?, options?)` (`model.ts:430`) — Phase 2 makes `options` `{ enableLtm?; engine?: 'vm' | 'wasm' }`. `Sim.reset` (`sim.ts:127`), `Sim.runToEnd` (`:119`), `Sim.getRun` (`:198`, fetches **all** series + `loops()` + `getLinks()` + `getStepCount` — heavy; exclude from timing), `Sim.dispose` (`:227`). +- High-level usage to mirror: `src/engine/tests/api.test.ts:444-528` (`await Project.open*(bytes); const model = await project.mainModel(); const sim = await model.simulate(...)`). + +**Timer + model files:** +- `performance.now()` from `node:perf_hooks` (the only repo precedent: `wasm-backend-poc.mjs:19`). No `process.hrtime` usage anywhere. `performance.now()` returns fractional ms. +- Model files (all plain bytes, no LFS; verified sizes): `default_projects/fishbanks/model.xmile` (7.8 KB), `test/metasd/WRLD3-03/wrld3-03.mdl` (151 KB), `test/xmutil_test_models/C-LEARN v77 for Vensim.mdl` (1.4 MB). The C-LEARN `.xmile` (393 B) is a header-only stub — **unusable**; use the `.mdl`. From a test at `src/engine/tests/`, the repo root is three levels up: `path.join(__dirname, '..', '..', '..', 'default_projects', 'fishbanks', 'model.xmile')`, etc. +- C-LEARN cost: compile dominates (~3.85s native; happens in untimed setup), eval ~hundreds of ms. Keep its measured iteration count low; the adaptive budget handles this. + +**Test pipeline:** `src/engine/jest.config.js` — `preset: ts-jest`, `testEnvironment: node`, `testMatch: tests/**/*.test.ts`, `moduleNameMapper` rewrites `@simlin/engine/internal/*` to the node source. A non-`.test.ts` module under `tests/` is importable but not auto-run. Run a single file: `pnpm -C src/engine exec jest `. + +--- + +## Implementation Tasks + + +Subcomponent A: a tested pure stats/harness, then the imperative benchmark runner that uses it. + + +### Task 1: Pure benchmark harness (`median` + adaptive warmup/measure loop) + +**Verifies:** (functional core for AC9.1 — the statistic and warmup/iteration policy) + +**Files:** +- Create: `src/engine/tests/bench-stats.ts` (pure, non-`.test.ts`, importable) +- Test: `src/engine/tests/bench-stats.test.ts` (always-on unit tests) + +**Implementation:** +A pure, side-effect-free harness (the timing body is injected, so it is deterministically testable): +- `interface Stat { medianMs: number; minMs: number; iters: number }`. +- `median(times: number[]): number` — sort a copy ascending, return `times[times.length >> 1]` (matching `backend_bench.rs:181`'s `times[iters/2]`); `NaN` for empty input. +- `interface BenchOpts { warmup: number; minIters: number; maxIters: number; budgetMs: number }`. +- `runTimed(opts: BenchOpts, body: () => number, now: () => number = () => performance.now()): Stat` — run `opts.warmup` iterations and **discard** them (the explicit warmup AC9.1 requires); then collect timings: while `times.length < maxIters && (times.length < minIters || now() - start < budgetMs)`, push `body()`. Return `{ medianMs: median(times), minMs: Math.min(...times), iters: times.length }`. (`now` is injectable so tests don't depend on wall-clock.) +- `runTimedAsync(opts: BenchOpts, asyncBody: () => Promise, now: () => number = () => performance.now()): Promise` — the **async** twin of `runTimed` (the benchmark's eval is `async`): identical warmup-discard + adaptive-median policy, but `await`s `asyncBody()` each iteration. This keeps the warmup/median policy in the tested core so Task 2's timed region is single-sourced and tested (not an ad-hoc inline loop). + +`body()` returns the elapsed ms of one measured run; `runTimed` does not itself call `performance.now()` around `body` — the caller times the precise region (see Task 2), exactly as `backend_bench.rs` has the closure return `ms_since(t0)`. + +**Testing** (deterministic, no models, no WASM): +- `median`: odd/even lengths, unsorted input, single element, empty (`NaN`). +- `runTimed`: with a `body` returning a fixed sequence and a fake `now`, assert (a) exactly `warmup` calls are discarded before measurement, (b) it stops at `maxIters`, (c) it stops early when the injected `now` exceeds `budgetMs` after `minIters`, (d) `minIters` is honored even if the budget is already exceeded, (e) the returned `medianMs`/`minMs`/`iters` are correct. +- `runTimedAsync`: with a fake **async** `body` (resolving a fixed sequence) and a fake `now`, assert the same five properties (a)–(e) as `runTimed`, confirming the async twin shares the warmup-discard + median policy. + +**Verification:** +Run: `pnpm -C src/engine exec jest tests/bench-stats.test.ts 2>&1 | tail -20` +Expected: all unit tests pass. + +**Commit:** `engine: pure benchmark harness (median + adaptive warmup/measure)` + + + +### Task 2: VM-vs-wasm eval benchmark runner (gated) over fishbanks / WORLD3 / C-LEARN + +**Verifies:** engine-wasm-sim.AC9.1 + +**Files:** +- Create: `src/engine/tests/backend-bench.ts` (the imperative runner; exports `runBenchmark`) +- Create: `src/engine/tests/backend-bench.test.ts` (gated benchmark + cross-check assertions) + +**Implementation:** +`backend-bench.ts` — the imperative shell, using the public API and the Task-1 harness: +- A model table mapping `'fishbanks' | 'wrld3' | 'clearn'` to `{ path, open }`, where `path` uses `path.join(__dirname, '..', '..', '..', ...)` (repo root is three levels up from `tests/`) and `open` is `Project.open` (fishbanks XMILE) or `Project.openVensim` (WORLD3, C-LEARN `.mdl`). Default to all three; honor a `BENCH_MODELS` comma-list env (mirroring `backend_bench.rs`). +- `async function timeEngine(model, engine: 'vm' | 'wasm', opts): Promise`: + 1. `const sim = await model.simulate({ engine });` — **untimed setup** (for wasm this compiles + instantiates the blob once; for vm it creates the libsimlin sim). + 2. `const stat = await runTimedAsync(opts, async () => { await sim.reset(); const t0 = performance.now(); await sim.runToEnd(); return performance.now() - t0; });` — `reset()` runs inside the async body but **before** the clock is sampled (it is setup, not part of the measured region); only `runToEnd()` is timed. `runTimedAsync` (defined and unit-tested in Task 1) keeps the warmup-discard + median policy in the tested core, so the timed region is single-sourced — do **not** hand-roll an inline measure loop here. Do **not** time `getRun()`/`getSeries()`. + 3. `await sim.dispose()` after measuring. +- `async function crossCheck(model): Promise`: outside any timing, run the model on both engines to completion and compare a representative variable's series (via `getRun()` or `getSeries`) within the engine's existing tolerance — a sanity guard that both engines computed the same thing (mirrors `backend_bench.rs:336-363`). Throw on mismatch. +- `async function runBenchmark(opts): Promise`: for each selected model, load + `mainModel()`, run `crossCheck`, then `timeEngine` for `'vm'` and `'wasm'`; collect `{ model, vm: Stat, wasm: Stat, ratio: vm.medianMs / wasm.medianMs }`. Return the rows (so the test can assert) and `console.log` a markdown table `| model | VM eval (median ms) | wasm eval (median ms) | wasm/VM | iters |`, plus a one-line note that this is eval-only (compile excluded) and that absolute numbers include the async public-API overhead (the VM/wasm ratio is the meaningful figure). + +`backend-bench.test.ts`: +- Always-on: nothing heavy (the pure harness is covered by Task 1). +- Gated heavy run: `const RUN = process.env.RUN_BENCH === '1'; (RUN ? it : it.skip)('benchmarks VM vs wasm eval (fishbanks/WORLD3/C-LEARN)', async () => { const rows = await runBenchmark({ warmup: 3, minIters: 3, maxIters: 100, budgetMs: 2500 }); for (const r of rows) { expect(Number.isFinite(r.vm.medianMs)).toBe(true); expect(r.vm.medianMs).toBeGreaterThan(0); expect(Number.isFinite(r.wasm.medianMs)).toBe(true); expect(r.wasm.medianMs).toBeGreaterThan(0); } }, 300_000);` — the cross-check inside `runBenchmark` asserts correctness; this asserts every model ran on both engines with a positive finite median. The 5-minute timeout covers C-LEARN's compile-heavy setup. + +> **Why gated:** C-LEARN's compile (~3.85s) × 2 engines, plus iterated evals, exceeds the few-seconds-per-test budget. `it.skip` keeps it out of the default `pnpm test`; `RUN_BENCH=1` opts in. **Do not** commit a results file — report the printed numbers in the PR/chat per the "no stale benchmark data" rule. + +**Verification:** +Run (the gated benchmark, all three models, both engines): `RUN_BENCH=1 pnpm -C src/engine exec jest backend-bench 2>&1 | tail -40` +Expected: it loads fishbanks/WORLD3/C-LEARN, runs each on `vm` and `wasm`, the cross-check passes for each, the assertions pass, and a markdown table of warm-median eval times + wasm/VM ratios is printed. +Run (confirm it stays out of the default suite): `pnpm -C src/engine test 2>&1 | tail -15` +Expected: green, with the benchmark `it` skipped (and the fast `bench-stats` unit tests passing). + +**Commit:** `engine: node VM-vs-wasm eval benchmark for fishbanks/WORLD3/C-LEARN` + + + + +Subcomponent B: keep the benchmarks doc current. + + +### Task 3: Document the node benchmark in `docs/dev/benchmarks.md` + +**Verifies:** (documentation; no AC — supports AC9.1 discoverability) + +**Files:** +- Modify: `docs/dev/benchmarks.md` + +**Implementation:** +Add a short "Node VM-vs-wasm eval benchmark" section: how to run it (`RUN_BENCH=1 pnpm -C src/engine exec jest backend-bench`, optional `BENCH_MODELS=fishbanks,wrld3`), what it measures (eval-only, compile excluded), the median+warmup policy, and the reminder that results are reported in-chat/PR and **not** checked in (per the "no stale benchmark data" rule). Cross-reference `src/simlin-engine/examples/backend_bench.rs` as the Rust counterpart. Do not add a "Last updated" line (per repo CLAUDE.md). + +**Verification:** +Run: `pnpm format 2>&1 | tail -5` (or the repo's markdown/format check) +Expected: the doc passes formatting. No `docs/README.md` index change is needed: `docs/CLAUDE.md` requires updating the index only when **adding/moving/renaming** a docs file, and this task **modifies** an already-indexed file (`docs/dev/benchmarks.md` is already listed). Do not edit the index. + +**Commit:** `doc: document the node VM-vs-wasm eval benchmark` + + + +--- + +## Phase 4 Done When + +- A gated node benchmark runs fishbanks, WORLD3, and C-LEARN on both engines via `Model.simulate({ engine })`, with an explicit warmup phase, and reports a warm **median** eval (simulation) time per engine plus the wasm/VM ratio in a markdown table (AC9.1). +- The eval timing excludes blob compile/instantiate and result-extraction (`getRun`/`getSeries`); a cross-check confirms both engines produce matching series before the numbers are trusted. +- The pure harness (`median`, adaptive warmup/measure) is unit-tested and always-on; the heavy benchmark is `RUN_BENCH`-gated so `pnpm -C src/engine test` stays within the time budget. Only the harness is committed — no results file. +- `docs/dev/benchmarks.md` documents how to run it. diff --git a/docs/implementation-plans/2026-05-22-engine-wasm-sim/test-requirements.md b/docs/implementation-plans/2026-05-22-engine-wasm-sim/test-requirements.md new file mode 100644 index 000000000..56e4679af --- /dev/null +++ b/docs/implementation-plans/2026-05-22-engine-wasm-sim/test-requirements.md @@ -0,0 +1,126 @@ +# @simlin/engine WebAssembly Simulation Backend — Test Requirements + +This document maps every acceptance criterion of the `@simlin/engine` WebAssembly +simulation backend (WasmSim) feature to its verification. The authoritative AC list is the +"Acceptance Criteria" section of the design plan +([2026-05-22-engine-wasm-sim.md](/docs/design-plans/2026-05-22-engine-wasm-sim.md)); the +verifying tests and their exact file paths come from the four implementation phase plans in +this directory (`phase_01.md` .. `phase_04.md`). + +There are **25 acceptance criteria** across AC1..AC9, identified with their fully-scoped +names `engine-wasm-sim.AC1.1` .. `engine-wasm-sim.AC9.1`. **All 25 are covered by automated +tests; none require human verification.** The phase plans were written so that every +criterion is automatable, and the mapping below confirms this end to end. + +## Coverage model (read this first) + +A few cross-cutting decisions in the phase plans shape how the rows below read; they are +called out here so each AC row stays terse. + +- **Two-level coverage for AC2 / AC3 / AC5.** These are proven *twice*. Phase 1 (Rust) + proves the emitted wasm blob matches the bytecode VM at the **blob/VM-parity level** + (driving the blob's `run` / `run_to` / `run_initials` / `reset` / `set_value` exports via + the DLR-FT `wasm-interpreter` test oracle, with the VM as the correctness oracle). Phase 2 + (TypeScript) proves the same behaviors again at the **`@simlin/engine` facade level** + through `DirectBackend` (`Model`/`Sim` driving the blob as an in-process + `WebAssembly.Instance`). Both covering tests are listed where they apply. +- **Parity tolerance.** Every "matches the VM" / "matches node" assertion uses the **engine's + existing comparators** (`ensure_results` / `ensure_results_excluding` on the Rust side; the + engine's existing tolerance on the TS side), **not a separate threshold**. The wasm backend + is intentionally not bit-identical to the VM's libm; agreement is judged within the same + tolerance that gates the wasm backend's own corpus parity. The Phase 4 benchmark instead + uses an exact-or-within-tolerance series cross-check (see AC9.1). +- **The Phase 4 benchmark (AC9.1) is gated.** Its heavy run is behind `RUN_BENCH=1` and is + **not part of the default `pnpm test` suite** (it `it.skip`s otherwise). Its *correctness* + is guarded by an in-benchmark `crossCheck` (both engines must produce matching series + before any number is trusted); the deliverable is the **warm-median eval-time reporting**, + which is reported in-chat/PR and **not committed** (per the "no stale benchmark data" + rule). The always-on pure stats/harness unit tests keep coverage in the default suite. + +Test-type legend: **rust-unit** = `#[cfg(test)]` module inside a crate source file; +**rust-integration** = a file under `tests/`; **jest-unit** = a pure-function jest test; +**jest-integration** = a jest test driving `DirectBackend` / `WorkerBackend` / the public +`Model`/`Sim` API end to end. + +--- + +## AC1: Engine selection via `Model.simulate`/`run` + +| AC | Automated test(s) | Asserts | Owner | +|----|-------------------|---------|-------| +| **engine-wasm-sim.AC1.1** Success: `Model.simulate({engine:'wasm'})` returns a blob-driven `Sim`; `simulate()` / `{engine:'vm'}` returns the VM-backed `Sim`. | jest-integration: `src/engine/tests/wasm-model.test.ts` (primary, public API); jest-integration: `src/engine/tests/wasm-backend.test.ts` (backend-level: the `'sim'` entry records `engine:'wasm'` and holds an instance). | Through `Model.simulate`, `{engine:'wasm'}` yields a `Sim` whose `runToEnd()`+`getSeries()` match the VM; `simulate()` and `{engine:'vm'}` yield VM-backed sims. At the backend, `simNew(modelHandle, false, 'wasm')` creates a sim handle whose entry records the wasm engine + instance, while `simNew(...)` / `simNew(..., 'vm')` still create VM sims. | Phase 2 Task 5 (facade) + Phase 2 Task 3 (backend creation). | +| **engine-wasm-sim.AC1.2** Success: `Model.run({engine:'wasm'})` returns a `Run` whose series match `Model.run({engine:'vm'})` within tolerance. | jest-integration: `src/engine/tests/wasm-model.test.ts`. | `model.run({engine:'wasm'})` series equal `model.run({engine:'vm'})` series within the engine's existing tolerance. | Phase 2 Task 5. | +| **engine-wasm-sim.AC1.3** Success: existing callers passing no `engine` get today's VM behavior (default unchanged). | jest-integration: `src/engine/tests/wasm-model.test.ts` (explicit default-path assertion); plus the **existing** engine suite run green (`pnpm -C src/engine test`) as a regression guard. | `model.simulate(overrides)` / `model.run(overrides)` with no `engine` behave exactly as before (VM); a representative existing test path is unaffected. | Phase 2 Task 5. | + +## AC2: `runToEnd`/`runTo` parity (resumable) — two-level + +| AC | Automated test(s) | Asserts | Owner | +|----|-------------------|---------|-------| +| **engine-wasm-sim.AC2.1** Success: `runToEnd()` (wasm) series equal the VM within tolerance. | rust-unit (blob): `src/simlin-engine/src/wasmgen/module.rs` `#[cfg(test)]` — `compile_simulation_run_to_matches_run_and_vm`; rust-integration (real models, `#[ignore]`d, `--release`): `src/simlin-engine/tests/simulate.rs` — `simulates_wrld3_03_wasm`, `simulates_clearn_wasm`; jest-integration (facade): `src/engine/tests/wasm-backend.test.ts` (`simRunToEnd`+`simGetSeries` vs VM). | Blob level: the full series from `run` and from `run_initials`+`run_to(stop)` both equal `Vm::run_to_end` within `ensure_results` tolerance (triple agreement), incl. on WORLD3 and C-LEARN. Facade level: `simRunToEnd` then `simGetSeries(name)` (wasm) equals the VM for the model's variables. | Phase 1 Tasks 1 & 6 (blob); Phase 2 Task 4 (facade). | +| **engine-wasm-sim.AC2.2** Success: `runTo(t)` then `getValue(name)` (wasm) equals the VM's value at `t`. | rust-unit (blob): `src/simlin-engine/src/wasmgen/module.rs` `#[cfg(test)]` — `compile_simulation_run_to_matches_run_and_vm` (foundation) + `run_to_at_save_and_between_save_points`; jest-integration (facade): `src/engine/tests/wasm-backend.test.ts` (`simRunTo(t)`+`simGetValue(name)` vs VM after the same `simRunTo(t)`). | Blob level: after `run_to(t)`, the strided value at the chunk for time `t` equals the VM's value at `t`; saved-row count after `run_to(t)` matches the VM for `t` on and between save points. Facade level: `simGetValue` reads the live `curr` chunk (linear-memory base 0), mirroring `vm.get_value_now`, and equals the VM. | Phase 1 Tasks 1 & 2 (blob); Phase 2 Task 4 (facade). | +| **engine-wasm-sim.AC2.3** Success: segmented `runTo(t1)` then `runTo(t2)` (`t1': not a simple constant"), matching the VM's constants-only rejection. | Phase 1 Task 4 (blob); Phase 2 Task 4 (facade). | +| **engine-wasm-sim.AC5.3** Success: `runTo(t1)`, `setValue(const, v)`, `runTo(t2)` affects only steps after `t1` (incremental, matches VM). | rust-unit (blob): `src/simlin-engine/src/wasmgen/module.rs` — `mid_run_set_value_matches_vm`; rust-integration (FFI): `src/libsimlin/tests/wasm.rs` — `compile_to_wasm_blob_supports_resumable_run`; jest-integration (facade): `src/engine/tests/wasm-backend.test.ts` (mid-run `simSetValue` affects only post-`t1` steps vs VM). | Blob level: `run_initials; run_to(t1); set_value(off, v2)` (rc 0); `run_to(stop)`: rows at times ≤ t1 unchanged from baseline, rows after reflect `v2`, matching the VM driven identically; works because overridable constants are re-read from the override region each step (no new mechanism needed). Holds across the FFI compile path. Facade level: same via `DirectBackend`. | Phase 1 Tasks 4 & 5 (blob/FFI); Phase 2 Task 4 (facade). | +| **engine-wasm-sim.AC5.4** Success: `setValue`/`reset`/re-run on an existing wasm `Sim` reuses the same blob instance (no recompile). | rust-unit (blob-level reuse): `src/simlin-engine/src/wasmgen/module.rs` — `reset_then_run_reproduces_defaults`, `reset_preserves_overrides` (all reuse one instantiated module/`Store` across calls); jest-integration (facade): `src/engine/tests/wasm-backend.test.ts` (instance created exactly once and stored on the entry; later `simReset`/`simSetValue`/re-run reuse it). | Blob level: the same instantiated module is driven through `set_value`/`reset`/re-run with no re-instantiation. Facade level: `DirectBackend.simNew` creates the `WebAssembly.Instance` once and stores it on the `'sim'` entry; subsequent ops reuse it (no recompile). | Phase 1 Task 3 (blob reuse); Phase 2 Task 3 (creation-once) + Task 4 (reuse across ops). | + +## AC6: `getLinks`/LTM explicit errors + +| AC | Automated test(s) | Asserts | Owner | +|----|-------------------|---------|-------| +| **engine-wasm-sim.AC6.1** Failure: `getLinks()` on a wasm sim throws an explicit "not supported on the wasm engine" error. | jest-integration: `src/engine/tests/wasm-backend.test.ts` (`simGetLinks` on a wasm sim throws); reinforced cross-worker by jest-integration: `src/engine/tests/worker-wasm.test.ts`. | `simGetLinks` for a wasm entry throws a clear `Error` ("getLinks is not supported on the wasm engine; use engine:'vm'") — rejected by the `DirectBackend` demux, with no silent fallback. | Phase 2 Task 4; Phase 3 Task 2 (worker reinforcement). | +| **engine-wasm-sim.AC6.2** Failure: `Model.simulate({engine:'wasm', enableLtm:true})` is rejected up front with a clear error. | jest-integration: `src/engine/tests/wasm-backend.test.ts` (`simNew(modelHandle, true, 'wasm')` throws and creates no sim); cross-worker by jest-integration: `src/engine/tests/worker-wasm.test.ts` (rejection serializes across the worker boundary). | `DirectBackend.simNew` rejects `enableLtm` for the wasm engine *before any compile* with a clear `Error` ("LTM is not supported on the wasm engine; use engine:'vm'") and creates no sim; the same rejection propagates through the worker. | Phase 2 Task 3 (authoritative); Phase 3 Task 2 (worker). | +| **engine-wasm-sim.AC6.3** Success: `Model.run({engine:'wasm'})` succeeds and returns a `Run` with empty `links`. | jest-integration: `src/engine/tests/wasm-model.test.ts` (`model.run({engine:'wasm'})` resolves to a `Run` with `links === []`, no throw). | Because `getRun` gates link-fetching on `ltmEnabled` (a wasm sim never enables LTM), `getLinks` is never called on the wasm sim; `Model.run({engine:'wasm'})` resolves with empty `links`. | Phase 2 Task 5. | + +## AC7: Unsupported model → explicit error (no fallback) + +| AC | Automated test(s) | Asserts | Owner | +|----|-------------------|---------|-------| +| **engine-wasm-sim.AC7.1** Failure: `Model.simulate({engine:'wasm'})` on a wasm-unsupported model throws the explicit `WasmGenError`, never silently using the VM. | jest-integration: `src/engine/tests/wasm-backend.test.ts` (a wasm-unsupported model — a runtime view range `[start:end]` / `ViewRangeDynamic` — makes `simNew(modelHandle, false, 'wasm')` throw a `SimlinError`/`Error`, no VM fallback); reinforced cross-worker by jest-integration: `src/engine/tests/worker-wasm.test.ts`. | `simlin_model_compile_to_wasm` reports the unsupported construct via the error out-ptr; the wrapper throws `SimlinError` and `simNew` surfaces it with no silent VM fallback. The libsimlin FFI surfacing is itself guarded by `src/libsimlin/tests/wasm.rs` `compile_to_wasm_unsupported_model_surfaces_error` (kept passing). | Phase 2 Task 3. | +| **engine-wasm-sim.AC7.2** Success: that same model runs fine via `engine:'vm'`. | jest-integration: `src/engine/tests/wasm-backend.test.ts` (`simNew(modelHandle, false, 'vm')` on the same unsupported-for-wasm model succeeds). | The exact model that fails for wasm still creates and runs a VM sim — confirming the error is wasm-specific, not a broken model. | Phase 2 Task 3. | + +## AC8: Browser/worker parity + minimal protocol + +| AC | Automated test(s) | Asserts | Owner | +|----|-------------------|---------|-------| +| **engine-wasm-sim.AC8.1** Success: through `WorkerBackend`, `engine:'wasm'` produces series matching node `DirectBackend` (and the VM). | jest-integration: `src/engine/tests/worker-wasm.test.ts` (real postMessage loopback via `createTestPair`). | A `WorkerBackend`-driven wasm sim (`simNew(modelHandle, false, 'wasm')` → `simRunToEnd` → `simGetSeries`) produces a series **exactly** equal to a node `DirectBackend` wasm series, and equal to the VM series within the engine's tolerance — through the real request/response serialization, FIFO queue, and `handleResponse`/`deserializeError`. | Phase 3 Task 2. | +| **engine-wasm-sim.AC8.2** Success: the protocol delta is exactly one optional `engine` field on the existing `simNew` message — no new message types/response shapes; `getSeries` still transfers zero-copy. | jest-integration: `src/engine/tests/worker-wasm.test.ts` (transfer + protocol-shape assertions); regression: existing `src/engine/tests/worker-backend.test.ts` / `worker-server.test.ts` pass unchanged; static: `tsc --noEmit` (the widened `simNew` literal typechecks, no new types). | A wasm-sim `simGetSeries` round-trips a `Float64Array` and adds exactly one one-element transfer entry (`[ArrayBuffer]`) — zero-copy preserved; the served `simNew` request carries `engine:'wasm'` with no new message `type` or response shape. `VALID_REQUEST_TYPES`/`isValidRequest` are untouched (field-agnostic). | Phase 3 Tasks 1 & 2. | + +## AC9: Node benchmark + +| AC | Automated test(s) | Asserts | Owner | +|----|-------------------|---------|-------| +| **engine-wasm-sim.AC9.1** Success: a node benchmark reports warm-median simulation (eval) time for fishbanks, WORLD3, and C-LEARN on both engines, via `Model.simulate({engine})`, with explicit warmup. | jest-unit (always-on, functional core): `src/engine/tests/bench-stats.test.ts` (`median` + `runTimed`/`runTimedAsync` warmup-discard and adaptive-median policy); jest-integration (**`RUN_BENCH`-gated**, not in the default suite): `src/engine/tests/backend-bench.test.ts` (runs all three models on both engines, asserts every model produced a positive finite median on each engine; the in-`runBenchmark` `crossCheck` asserts series agreement before any number is trusted). | Functional core: explicit `warmup` iterations are discarded, then a median over an adaptive measure loop (min/max iters + wall-clock budget) is returned — single-sourced and deterministically unit-tested with injected `now`/`body`. Gated runner: through `Model.simulate({ engine })`, with the blob compile/instantiate and `getRun`/`getSeries` excluded from the clock and `reset()` outside the measured region, each `(model, engine)` yields a warm **median** eval time; the `crossCheck` guards correctness. **The warm-median numbers are the deliverable and are reported in-chat/PR, not committed** (per "no stale benchmark data"); the harness is the only thing checked in. | Phase 4 Task 1 (functional core, always-on) + Task 2 (gated runner). | + +--- + +## Human verification + +**None required — all 25 criteria are covered by automated tests.** Every behavioral and +failure-mode AC is asserted by a deterministic Rust or jest test, with VM-vs-wasm parity +judged by the engine's existing comparators and the lone benchmark AC (AC9.1) split into an +always-on unit-tested functional core plus a gated runner whose correctness is guarded by an +in-benchmark cross-check (the only un-asserted-in-CI output is the warm-median *reporting*, +which is an intentionally non-committed, in-chat/PR deliverable rather than a pass/fail gate). diff --git a/docs/test-plans/2026-05-22-engine-wasm-sim.md b/docs/test-plans/2026-05-22-engine-wasm-sim.md new file mode 100644 index 000000000..1f28ed90a --- /dev/null +++ b/docs/test-plans/2026-05-22-engine-wasm-sim.md @@ -0,0 +1,167 @@ +# @simlin/engine WebAssembly Simulation Backend (WasmSim) — Human Test Plan + +This plan covers manual verification of the selectable wasm simulation engine +(`Model.simulate({ engine: 'wasm' })` / `Model.run(..., { engine: 'wasm' })`). +All 25 acceptance criteria already have automated coverage (see +`docs/implementation-plans/2026-05-22-engine-wasm-sim/test-requirements.md`); this +plan exists to (a) re-run the automated gates as a release checklist, (b) drive +the gated/ignored heavy tests that are out of the default suite, and (c) +exercise end-to-end behavior a human can judge — interactive parameter scrubbing +feel and the published VM-vs-wasm benchmark numbers — that the automated suite +intentionally does not gate. + +The bytecode VM is the correctness oracle throughout; the wasm path is held to +VM parity within the engine's existing tolerances (it is intentionally not +bit-identical to the VM's libm). + +## Prerequisites + +- Run `./scripts/dev-init.sh` from the repo root (idempotent). +- A current WASM build of libsimlin so the engine tests can load + `src/engine/core/libsimlin.wasm`: `pnpm build` (or the WASM build step). +- These default suites pass (the regression baseline): + - Rust blob parity (default, fast): `cargo test -p simlin-engine` + - libsimlin FFI: `cargo test -p simlin --test wasm` (the crate is named + `simlin`, not `libsimlin`) + - TS engine suite: `pnpm -C src/engine test` +- Model files present (used below): + - `src/pysimlin/tests/fixtures/teacup.stmx` (scalar Euler, wasm-supported) + - `default_projects/fishbanks/model.xmile` + - `test/metasd/WRLD3-03/wrld3-03.mdl` + - `test/xmutil_test_models/C-LEARN v77 for Vensim.mdl` and the sibling `Ref.vdf` + +## Phase 1: Run the automated gates (release checklist) + +| Step | Action | Expected | +|------|--------|----------| +| 1 | `cargo test -p simlin-engine` | Green. Includes the blob/VM-parity unit tests in `wasmgen/module.rs` (resumable ABI, reset, set_value, mid-run override) and the always-on corpus `wasm_parity_hook` (every VM-simulated model also runs through the wasm backend and matches). | +| 2 | `cargo test -p simlin --test wasm` | Green. `compile_to_wasm_returns_blob_and_layout`, `compile_to_wasm_blob_supports_resumable_run`, `compile_to_wasm_unsupported_model_surfaces_error`, `compile_to_wasm_null_outputs_error` pass — the FFI surfaces a `SimlinError` (never a panic) and the resumable exports survive the FFI compile path. | +| 3 | `pnpm -C src/engine test` | Green. Includes `wasm-backend.test.ts`, `wasm-model.test.ts`, `worker-wasm.test.ts`, `wasmgen.test.ts`, `canonicalize.test.ts`, `bench-stats.test.ts`. The gated `backend-bench` suite `it.skip`s (correct — heavy run is opt-in). | +| 4 | `pnpm -C src/engine exec tsc --noEmit` (or the package's typecheck script) | No type errors — confirms the widened `simNew(modelHandle, enableLtm, engine?)` literal typechecks with no new worker message types. | + +## Phase 2: Drive the heavy gated/ignored automated tests + +These are real automated tests excluded from the default suites only for runtime +class. Run them on demand to exercise the wasm path on production-scale models. + +| Step | Action | Expected | +|------|--------|----------| +| 5 | `cargo test -p simlin-engine --features file_io --release -- --ignored simulates_wrld3_03_wasm` | Pass. WORLD3 compiles to wasm (a `WasmGenError::Unsupported` here is a hard failure), single-`run` matches the VM element-for-element, and a two-segment `run_to(mid); run_to(stop)` matches the single-`run` series. | +| 6 | `cargo test -p simlin-engine --features file_io --release -- --ignored simulates_clearn_wasm` | Pass. C-LEARN compiles to wasm and clears the same hard 1% `Ref.vdf` gate (with the documented `EXPECTED_VDF_RESIDUAL` carve-out) that the VM clears; the two-segment resumable run matches single-`run`. Takes seconds (non-JIT interpreter on a ~53k-line model) — this is expected. | +| 7 | `RUN_BENCH=1 pnpm -C src/engine exec jest backend-bench` | Pass. Runs fishbanks, WORLD3, C-LEARN on both engines through `Model.simulate({ engine })`. The in-runner `crossCheck` asserts VM-vs-wasm series agreement before any timing is trusted; the test asserts a positive finite warm median per `(model, engine)`. Inspect the printed markdown table for the warm-median eval times and the `wasm/VM` ratio. Per the "no stale benchmark data" rule, these numbers are reported, not committed — record them in the PR/chat, not in a file. | + +## End-to-End: Interactive parameter scrubbing (the design's motivating use case) + +Purpose: validate that the wasm engine delivers the design's intended workflow — +instantiate a blob once, then re-simulate repeatedly under changing constants and +partial `runTo` advances — and that results stay VM-faithful. This spans engine +selection (AC1), resumable runTo (AC2), reset (AC3), by-name reads (AC4), +constant `setValue` + reuse (AC5). + +Setup: a tiny Node script (or a Node REPL) against the built `@simlin/engine`, +configured with `src/engine/core/libsimlin.wasm`, opening +`src/pysimlin/tests/fixtures/teacup.stmx`. + +Steps and expected results: +1. Open the project, get the main model. Create two sims for the same model: + `vm = model.simulate({}, { engine: 'vm' })` and + `wasm = model.simulate({}, { engine: 'wasm' })`. + - Expected: both are `Sim` instances; no throw. +2. `await wasm.runToEnd()` and `await vm.runToEnd()`. For each name in + `await wasm.getVarNames()`, compare `await wasm.getSeries(name)` to the VM's. + - Expected: arrays equal length; every element within ~1e-9. Confirms AC2.1 + + AC4.1 on the supported scalar model. +3. Reuse the SAME wasm sim: `await wasm.setValue('room temperature', 40)` then + `await wasm.runToEnd()`. Read `getSeries('teacup_temperature')`. + - Expected: the cooling curve changes vs step 2 (warmer asymptote). No + recompile occurs (the blob instance is reused) — the call returns promptly. +4. Scrub the constant several times in a row on the same sim: for v in + [50, 60, 70], `await wasm.reset(); await wasm.setValue('room temperature', v); + await wasm.runToEnd()`. After each, also run a fresh VM sim with the same + override and compare every variable's series. + - Expected: each wasm run matches its VM twin within ~1e-9. Confirms reuse + (AC5.4) + reset-preserves-then-overrides (AC3.2/AC5.1) feel instantaneous + and stay correct across repeated scrubs. +5. Incremental advance: on a fresh wasm sim, `await wasm.runTo(10)`, read + `getValue('teacup_temperature')`; then `await wasm.setValue('room temperature', + 30)`; `await wasm.runToEnd()`; read the full `getSeries`. + - Expected: the value at t=10 equals a VM `runTo(10)` getValue for the stock; + the post-t=10 trajectory bends toward the new room temperature, and the whole + series matches a VM driven through the identical sequence within ~1e-9. + Confirms AC2.2 + AC5.3. + +## End-to-End: Default-path safety (no behavior change for existing callers) + +Purpose: confirm AC1.3 — code that never passes `engine` is byte-for-byte +unchanged. + +Steps: +1. In the same script, `const a = await model.run();` and + `const b = await model.run({}, { engine: 'vm' });`. + - Expected: `a.varNames` deep-equals `b.varNames`; every `a.getSeries(name)` + equals `b.getSeries(name)`. +2. `const c = await model.run({}, { engine: 'wasm' });` + - Expected: `c` is a `Run`; `c.links` is `[]` (LTM is wasm-unsupported, links + are empty by design — #626); `c.varNames` equals `a.varNames`; series match + within ~1e-9. No throw despite getLinks being unsupported on wasm. + +## End-to-End: Explicit-error behavior (no silent fallback) + +Purpose: confirm AC6/AC7 surface clear errors rather than quietly using the VM. + +Steps (script): +1. `await model.simulate({}, { enableLtm: true, engine: 'wasm' })`. + - Expected: rejects with a message matching /not supported on the wasm engine/i. +2. On a wasm sim, `await wasmSim.getLinks()`. + - Expected: rejects with /not supported on the wasm engine/i. (The VM sim's + `getLinks()` resolves to an array.) +3. Build a wasm-UNSUPPORTED model: an XMILE with a dynamic view range + `summed = SUM(source[lo:hi])` where `lo`/`hi` are scalar auxes (the + `ViewRangeDynamic` case, GH #612). Open it and call + `model.simulate({}, { engine: 'wasm' })`. + - Expected: rejects (the compile error surfaces; no VM fallback). +4. Open the SAME unsupported model and `model.simulate({}, { engine: 'vm' })`, + then `runToEnd` and `getSeries('summed')`. + - Expected: succeeds; `summed[0] ≈ 6` (1+2+3). Proves the error in step 3 was + wasm-specific, not a broken model. + +## Human Verification Required + +None of the 25 acceptance criteria require human judgment — all are asserted by +deterministic Rust/jest tests. The two items below are human-value checks layered +on top of the automated gates, not uncovered ACs. + +| Item | Why a human looks at it | Steps | +|------|-------------------------|-------| +| Scrubbing responsiveness | The design's purpose is fast repeated re-simulation for interactive scrubbing; "fast enough to feel live" is a human judgment the suite does not gate. | After Phase 1, run the scrubbing E2E above and confirm each `reset; setValue; runToEnd` cycle returns without a perceptible stall on teacup (and, optionally, on a larger model). | +| Benchmark numbers | The warm-median VM-vs-wasm eval times are an intentionally non-committed, in-chat/PR deliverable; the automated test only asserts they are positive and finite and that the engines agree. | Run step 7, read the printed table, and record the `wasm/VM` ratios per model in the PR. Sanity-check the direction matches expectations (wasm competitive-to-faster under V8 for the eval region). | + +## Traceability + +| Acceptance Criterion | Automated Test | Manual Step | +|----------------------|----------------|-------------| +| AC1.1 | `wasm-model.test.ts`, `wasm-backend.test.ts` | Phase 1 step 3; Scrubbing E2E step 1–2 | +| AC1.2 | `wasm-model.test.ts` | Phase 1 step 3; Default-path E2E step 2 | +| AC1.3 | `wasm-model.test.ts` + green engine suite | Phase 1 step 3; Default-path E2E step 1 | +| AC2.1 | `module.rs::compile_simulation_run_to_matches_run_and_vm`; `simulate.rs::{simulates_wrld3_03_wasm,simulates_clearn_wasm}` + `wasm_parity_hook`; `wasm-backend.test.ts` | Phase 1 step 1; Phase 2 steps 5–6; Scrubbing E2E step 2 | +| AC2.2 | `module.rs::{compile_simulation_run_to_matches_run_and_vm, run_to_at_save_and_between_save_points}`; `wasm-backend.test.ts` | Phase 1 steps 1,3; Scrubbing E2E step 5 | +| AC2.3 | `module.rs::run_to_segmented_matches_single_and_vm`; `wasm.rs::compile_to_wasm_blob_supports_resumable_run`; real-model twins; `wasm-backend.test.ts` | Phase 1 steps 1–3; Phase 2 steps 5–6 | +| AC2.4 | `module.rs::run_to_past_final_time_clamps`; `wasm-backend.test.ts` | Phase 1 steps 1,3 | +| AC3.1 | `module.rs::reset_then_run_reproduces_defaults`; `wasm-backend.test.ts` | Phase 1 steps 1,3; Scrubbing E2E step 4 | +| AC3.2 | `module.rs::reset_preserves_overrides`; `wasm.rs`; `wasm-backend.test.ts` | Phase 1 steps 1–3; Scrubbing E2E step 4 | +| AC4.1 | `wasm-backend.test.ts` (arrayed E2E suite); `canonicalize.test.ts`; `wasmgen.test.ts` | Phase 1 steps 1,3; Scrubbing E2E step 2 | +| AC4.2 | `wasm-backend.test.ts` | Phase 1 step 3 | +| AC4.3 | `wasmgen.test.ts`; `wasm-backend.test.ts` | Phase 1 step 3 | +| AC4.4 | `wasm-backend.test.ts`; `canonicalize.test.ts` | Phase 1 step 3 | +| AC5.1 | `module.rs::compile_simulation_set_value_override_matches_vm`; `wasm-backend.test.ts` | Phase 1 steps 1,3; Scrubbing E2E step 4 | +| AC5.2 | `module.rs::set_value_nonconstant_returns_error`; `wasm-backend.test.ts` | Phase 1 steps 1,3 | +| AC5.3 | `module.rs::mid_run_set_value_matches_vm`; `wasm.rs`; `wasm-backend.test.ts` | Phase 1 steps 1–3; Scrubbing E2E step 5 | +| AC5.4 | `module.rs` (reuse one Store); `wasm-backend.test.ts` (instance-once white-box) | Phase 1 steps 1,3; Scrubbing E2E steps 3–4 | +| AC6.1 | `wasm-backend.test.ts`; `worker-wasm.test.ts` | Phase 1 step 3; Error E2E step 2 | +| AC6.2 | `wasm-backend.test.ts`, `wasm-model.test.ts`, `worker-wasm.test.ts` | Phase 1 step 3; Error E2E step 1 | +| AC6.3 | `wasm-model.test.ts` | Phase 1 step 3; Default-path E2E step 2 | +| AC7.1 | `wasm-backend.test.ts`, `worker-wasm.test.ts`; `wasm.rs::compile_to_wasm_unsupported_model_surfaces_error` | Phase 1 steps 2–3; Error E2E step 3 | +| AC7.2 | `wasm-backend.test.ts`, `worker-wasm.test.ts` | Phase 1 step 3; Error E2E step 4 | +| AC8.1 | `worker-wasm.test.ts` | Phase 1 step 3 | +| AC8.2 | `worker-wasm.test.ts`; existing `worker-backend.test.ts`/`worker-server.test.ts`; `tsc --noEmit` | Phase 1 steps 3–4 | +| AC9.1 | `bench-stats.test.ts` (always-on); `backend-bench.test.ts` (gated) + `backend-bench.ts` | Phase 2 step 7; Benchmark-numbers human check | diff --git a/src/engine/CLAUDE.md b/src/engine/CLAUDE.md index 73e2f81a9..6ef737a0d 100644 --- a/src/engine/CLAUDE.md +++ b/src/engine/CLAUDE.md @@ -1,7 +1,5 @@ # @simlin/engine -Last verified: 2026-04-08 - TypeScript API for interacting with the WASM-compiled simulation engine. Promise-based; in the browser, WASM runs in a Web Worker to avoid jank. For global development standards, see the root [CLAUDE.md](/CLAUDE.md). @@ -23,12 +21,22 @@ For build/test/lint commands, see [docs/dev/commands.md](/docs/dev/commands.md). - `src/worker-protocol.ts` -- Worker message protocol - `src/backend-factory.ts` / `.browser.ts` / `.node.ts` -- Platform-specific backend factories - `src/internal/` -- Internal modules (project, model, memory, error, import-export) +- `src/internal/wasmgen.ts` -- `simlin_model_compile_to_wasm` FFI wrapper + the pure `parseWasmLayout` / `readStridedSeries` decoders for the per-model wasm blob (re-exported via `@simlin/engine/internal`) +- `src/internal/canonicalize.ts` -- pure `canonicalizeIdent`, a faithful port of the Rust canonicalizer (used to resolve caller names to wasm-layout slots); not re-exported from the `internal` barrel ## Contracts - `JsonProjectOperation` is a union type: `SetSimSpecsOp | AddModelOp`. The `AddModelOp` (`type: 'addModel'`) creates a new empty model in the project. Type guards `isSetSimSpecs` and `isAddModel` are provided. - The engine processes `projectOps` before model-level `ops` in a patch, so `AddModel` can be combined with `upsertModule` in a single patch to atomically create a model and reference it. +### Simulation engine selection (vm vs wasm) + +- `SimEngine = 'vm' | 'wasm'` (exported from `backend.ts`). `Model.simulate(overrides, { engine })` and `Model.run(overrides, { engine })` accept it; `'vm'` (the bytecode VM, via libsimlin) is the default. `'wasm'` runs the model as a self-contained per-model WebAssembly blob, intended for fast repeated re-runs (interactive scrubbing). +- The wasm path is currently exercised under Node (`DirectBackend`) and through the Web Worker (`WorkerBackend`). The VM remains the correctness oracle; the wasm twin is held to VM parity by tests. +- `EngineBackend.simNew(modelHandle, enableLtm, engine?)` takes the optional engine. `DirectBackend` demuxes every sim op on the entry's engine: a `'wasm'` handle has no native sim pointer (`ptr === 0`); it owns a `WebAssembly.Instance` plus decoded `WasmLayout`, drives the blob's exports directly (`run_to`/`reset`/`set_value`/`memory`), reads series strided from linear memory, and resolves caller names via `canonicalizeIdent`. `'vm'` (the default/absent case) calls libsimlin. +- Worker path: an optional `engine` field on the `simNew` worker message (`worker-protocol.ts` / `-server.ts` / `-backend.ts`) threads selection through; it is purely additive and defaults to vm when absent. +- Wasm restrictions (enforced authoritatively in the backend, covering the worker path): LTM + wasm is rejected at `simNew`; `simGetLinks` throws on a wasm sim; an unsupported model surfaces the compile error with **no VM fallback**. `Sim.getRun` only fetches link scores when LTM is enabled AND the engine is not wasm, so a wasm `Run` carries empty links. + ## Tests - `tests/api.test.ts` -- Public API tests @@ -36,3 +44,9 @@ For build/test/lint commands, see [docs/dev/commands.md](/docs/dev/commands.md). - `tests/worker-backend.test.ts`, `tests/worker-server.test.ts`, `tests/direct-backend.test.ts` -- Backend tests - `tests/race.test.ts` -- Concurrency tests - `tests/cleanup.test.ts` -- Resource cleanup tests +- `tests/wasmgen.test.ts`, `tests/canonicalize.test.ts` -- Unit tests for the pure layout decoders and `canonicalizeIdent` +- `tests/wasm-backend.test.ts`, `tests/wasm-model.test.ts`, `tests/worker-wasm.test.ts` -- wasm-vs-VM parity through `DirectBackend`, the `Model`/`Sim` facade, and the Web Worker + +## Benchmarks + +`tests/backend-bench.ts` (runner) + `tests/bench-stats.ts` (pure median/warmup harness, always unit-tested) measure node VM-vs-wasm eval time via `Model.simulate({ engine })`. The runner is gated behind `RUN_BENCH` so it stays out of the default `pnpm test`. See [docs/dev/benchmarks.md](/docs/dev/benchmarks.md#node-vm-vs-wasm-eval-benchmark). diff --git a/src/engine/src/backend.ts b/src/engine/src/backend.ts index e3319e040..d29dc1367 100644 --- a/src/engine/src/backend.ts +++ b/src/engine/src/backend.ts @@ -23,6 +23,13 @@ export type ProjectHandle = Handle & { __brand: 'project' }; export type ModelHandle = Handle & { __brand: 'model' }; export type SimHandle = Handle & { __brand: 'sim' }; +/** + * Which execution backend a `Sim` runs on: the bytecode VM (the default) or the + * per-model WebAssembly blob. The wasm engine is for fast repeated re-runs + * (interactive scrubbing); it does not support LTM or `getLinks`. + */ +export type SimEngine = 'vm' | 'wasm'; + export interface SimRunResult { varNames: string[]; results: Map; @@ -58,7 +65,11 @@ export interface EngineBackend { projectGetModel(handle: ProjectHandle, name: string | null): MaybePromise; projectIsSimulatable(handle: ProjectHandle, modelName: string | null): MaybePromise; projectSerializeProtobuf(handle: ProjectHandle): MaybePromise; - projectSerializeJson(handle: ProjectHandle, format: SimlinJsonFormat, includeStdlib?: boolean): MaybePromise; + projectSerializeJson( + handle: ProjectHandle, + format: SimlinJsonFormat, + includeStdlib?: boolean, + ): MaybePromise; projectSerializeXmile(handle: ProjectHandle): MaybePromise; projectRenderSvg(handle: ProjectHandle, modelName: string): MaybePromise; projectRenderPng(handle: ProjectHandle, modelName: string, width: number, height: number): MaybePromise; @@ -82,7 +93,7 @@ export interface EngineBackend { modelGetSimSpecsJson(handle: ModelHandle): MaybePromise; // Sim operations - simNew(modelHandle: ModelHandle, enableLtm: boolean): MaybePromise; + simNew(modelHandle: ModelHandle, enableLtm: boolean, engine?: SimEngine): MaybePromise; simDispose(handle: SimHandle): MaybePromise; simRunTo(handle: SimHandle, time: number): MaybePromise; simRunToEnd(handle: SimHandle): MaybePromise; diff --git a/src/engine/src/direct-backend.ts b/src/engine/src/direct-backend.ts index f7095e121..666f69430 100644 --- a/src/engine/src/direct-backend.ts +++ b/src/engine/src/direct-backend.ts @@ -9,7 +9,7 @@ * Maps opaque integer handles to WASM pointers. */ -import { EngineBackend, ProjectHandle, ModelHandle, SimHandle } from './backend'; +import { EngineBackend, ProjectHandle, ModelHandle, SimHandle, SimEngine } from './backend'; import { simlin_project_open_protobuf, simlin_project_open_json, @@ -61,10 +61,17 @@ import { simlin_free_links, } from './internal/analysis'; import { readAllErrorDetails, simlin_error_free } from './internal/error'; +import { + simlin_model_compile_to_wasm, + parseWasmLayout, + readStridedSeries, + WasmLayout, + WasmBlobExports, +} from './internal/wasmgen'; +import { canonicalizeIdent } from './internal/canonicalize'; import { SimlinProjectPtr, SimlinModelPtr, - SimlinSimPtr, SimlinJsonFormat, SimlinLinkPolarity, ErrorDetail, @@ -81,6 +88,33 @@ import { WasmSourceProvider, } from '@simlin/engine/internal/wasm'; +/** + * Compare two strings by Unicode code point, matching Rust's `str` `sort()` + * (which orders by UTF-8 bytes -- equivalent to code-point order for valid + * Unicode). The default JS `Array.prototype.sort` compares by UTF-16 code unit, + * which mis-orders characters outside the BMP (a surrogate-pair lead unit + * 0xD800-0xDBFF sorts below a BMP char like U+E000 even though its code point is + * higher), so the wasm `getVarNames` must use this comparator to stay byte-for- + * byte identical to the VM's sorted output. + */ +function compareByCodePoint(a: string, b: string): number { + const ai = a[Symbol.iterator](); + const bi = b[Symbol.iterator](); + for (;;) { + const an = ai.next(); + const bn = bi.next(); + if (an.done || bn.done) { + // The shorter string sorts first; if both ended, they are equal. + return an.done ? (bn.done ? 0 : -1) : 1; + } + const ac = an.value.codePointAt(0)!; + const bc = bn.value.codePointAt(0)!; + if (ac !== bc) { + return ac - bc; + } + } +} + function convertLinkPolarity(raw: SimlinLinkPolarity): LinkPolarity { switch (raw) { case SimlinLinkPolarity.Positive: @@ -121,6 +155,28 @@ interface HandleEntry { disposed: boolean; // For model/sim handles, track which project they belong to projectHandle?: number; + // For sim handles: which execution backend this sim runs on. A 'wasm' entry + // has no native sim pointer (ptr is 0); it owns a WebAssembly.Instance and + // drives the blob's exports directly. Absent/'vm' means the bytecode VM. + engine?: SimEngine; + // Wasm-engine state (set only when engine === 'wasm'). The instance is owned + // here so it is created exactly once and GC'd when the entry is dropped. + wasmInstance?: WebAssembly.Instance; + wasmLayout?: WasmLayout; + wasmExports?: WasmBlobExports; + // The model's stop time, captured at creation so simRunToEnd can drive the + // blob's resumable run_to(stop) (mirroring the VM's run_to(specs.stop)). + wasmStopTime?: number; +} + +/** Optional fields carried onto a freshly-allocated handle entry. */ +interface HandleExtra { + projectHandle?: number; + engine?: SimEngine; + wasmInstance?: WebAssembly.Instance; + wasmLayout?: WasmLayout; + wasmExports?: WasmBlobExports; + wasmStopTime?: number; } export class DirectBackend implements EngineBackend { @@ -128,13 +184,18 @@ export class DirectBackend implements EngineBackend { private _handles = new Map(); private _projectChildren = new Map>(); - private allocHandle(kind: HandleKind, ptr: number, extra?: { projectHandle?: number }): number { + private allocHandle(kind: HandleKind, ptr: number, extra?: HandleExtra): number { const handle = this._nextHandle++; this._handles.set(handle, { kind, ptr, disposed: false, projectHandle: extra?.projectHandle, + engine: extra?.engine, + wasmInstance: extra?.wasmInstance, + wasmLayout: extra?.wasmLayout, + wasmExports: extra?.wasmExports, + wasmStopTime: extra?.wasmStopTime, }); if (kind === 'project') { this._projectChildren.set(handle, new Set()); @@ -166,10 +227,6 @@ export class DirectBackend implements EngineBackend { return this.getEntry(handle as number, 'model').ptr; } - private getSimPtr(handle: SimHandle): SimlinSimPtr { - return this.getEntry(handle as number, 'sim').ptr; - } - // Lifecycle async init(wasmSource?: WasmSourceProvider): Promise { @@ -232,7 +289,14 @@ export class DirectBackend implements EngineBackend { if (childEntry && !childEntry.disposed) { childEntry.disposed = true; if (childEntry.kind === 'sim') { - simlin_sim_unref(childEntry.ptr); + // A wasm sim has no native sim pointer; release its heavy wasm state + // (instance + layout) instead of unref'ing, so disposing the project + // does not leave child WebAssembly.Instances pinned via the map. + if (childEntry.engine === 'wasm') { + this.releaseWasmSimState(childEntry); + } else { + simlin_sim_unref(childEntry.ptr); + } } else if (childEntry.kind === 'model') { simlin_model_unref(childEntry.ptr); } @@ -375,14 +439,78 @@ export class DirectBackend implements EngineBackend { // Sim operations - simNew(modelHandle: ModelHandle, enableLtm: boolean): SimHandle { + simNew(modelHandle: ModelHandle, enableLtm: boolean, engine: SimEngine = 'vm'): SimHandle { const modelEntry = this.getEntry(modelHandle as number, 'model'); + if (engine === 'wasm') { + return this.simNewWasm(modelHandle, modelEntry, enableLtm); + } const ptr = simlin_sim_new(modelEntry.ptr, enableLtm); return this.allocHandle('sim', ptr, { projectHandle: modelEntry.projectHandle, + engine: 'vm', }) as SimHandle; } + /** + * Create a wasm-engine sim: compile the model to a self-contained wasm blob, + * instantiate it import-free, and store the instance + decoded layout + stop + * time on the handle entry. There is intentionally no VM fallback -- an + * unsupported model surfaces the compile error to the caller. + */ + private simNewWasm(modelHandle: ModelHandle, modelEntry: HandleEntry, enableLtm: boolean): SimHandle { + // Reject LTM up front, before any compile work: the wasm backend does not + // emit LTM instrumentation, so a wasm sim can never satisfy enableLtm. + if (enableLtm) { + throw new Error("LTM is not supported on the wasm engine; use engine:'vm'"); + } + + // Throws SimlinError on an unsupported model (e.g. a runtime view range); + // we deliberately do not catch-and-fall-back to the VM. + const { wasm, layout } = simlin_model_compile_to_wasm(modelEntry.ptr); + const parsed = parseWasmLayout(layout); + + // Capture the model's stop time so simRunToEnd can drive run_to(stop), + // mirroring Model.timeSpec()'s defensive endTime parse (model.ts:297). + const specs = JSON.parse(new TextDecoder().decode(this.modelGetSimSpecsJson(modelHandle))) as { + endTime?: number; + }; + const wasmStopTime = specs.endTime ?? 10; + + // The blob is import-free and DirectBackend never runs on the browser main + // thread, so synchronous compile + instantiate is allowed here. The blob has + // its own (non-growing) linear memory, independent of the libsimlin singleton. + // `copyFromWasm` returns a fresh, non-shared Uint8Array (byteOffset 0), so its + // backing buffer is a plain ArrayBuffer -- the cast only drops the lib's + // ArrayBufferLike widening (which admits SharedArrayBuffer) that does not apply here. + const wasmBytes = wasm.buffer as ArrayBuffer; + const instance = new WebAssembly.Instance(new WebAssembly.Module(wasmBytes), {}); + const wasmExports = instance.exports as unknown as WasmBlobExports; + + return this.allocHandle('sim', 0, { + projectHandle: modelEntry.projectHandle, + engine: 'wasm', + wasmInstance: instance, + wasmLayout: parsed, + wasmExports, + wasmStopTime, + }) as SimHandle; + } + + /** + * Release a wasm sim entry's heavy state (the WebAssembly.Instance, its + * exports, and the decoded layout). The disposed entry is intentionally kept + * in `_handles` as a tombstone so a use-after-dispose still throws the clear + * "has been disposed" diagnostic; but those heavy refs must be cleared, or the + * map would pin a whole WebAssembly.Instance + layout per disposed sim and + * memory would grow unbounded across create/dispose cycles. `wasmStopTime` is + * a plain number, so it costs nothing to leave -- only the heavy refs matter. + */ + private releaseWasmSimState(entry: HandleEntry): void { + entry.wasmInstance = undefined; + entry.wasmExports = undefined; + entry.wasmLayout = undefined; + } + simDispose(handle: SimHandle): void { const entry = this._handles.get(handle as number); if (!entry || entry.disposed) { @@ -392,48 +520,164 @@ export class DirectBackend implements EngineBackend { if (entry.projectHandle !== undefined) { this._projectChildren.get(entry.projectHandle)?.delete(handle as number); } - simlin_sim_unref(entry.ptr); + // A wasm sim has no native sim pointer; instead it owns a WebAssembly.Instance, + // so explicitly release that heavy state (the tombstone stays for diagnostics). + // Only the VM path holds a native sim to unref. + if (entry.engine === 'wasm') { + this.releaseWasmSimState(entry); + } else { + simlin_sim_unref(entry.ptr); + } + } + + /** + * Resolve a caller variable name to its f64 slot in the wasm layout. + * Canonicalizes the name (Rust-faithful) and looks it up in `varOffsets`; + * throws an "unknown variable" error when absent (parity with the VM's + * not-found error on by-name reads/writes). + */ + private wasmSlot(layout: WasmLayout, name: string): number { + const slot = layout.varOffsets.get(canonicalizeIdent(name)); + if (slot === undefined) { + throw new Error(`unknown variable: ${name}`); + } + return slot; } simRunTo(handle: SimHandle, time: number): void { - simlin_sim_run_to(this.getSimPtr(handle), time); + const entry = this.getEntry(handle as number, 'sim'); + if (entry.engine === 'wasm') { + // The blob's run_to is resumable (calls run_initials internally and + // resumes from the prior cursor); a time past the stop is clamped by the blob. + entry.wasmExports!.run_to(time); + return; + } + simlin_sim_run_to(entry.ptr, time); } simRunToEnd(handle: SimHandle): void { - simlin_sim_run_to_end(this.getSimPtr(handle)); + const entry = this.getEntry(handle as number, 'sim'); + if (entry.engine === 'wasm') { + // Drive run_to(stop), mirroring the VM's run_to(specs.stop). + entry.wasmExports!.run_to(entry.wasmStopTime!); + return; + } + simlin_sim_run_to_end(entry.ptr); } simReset(handle: SimHandle): void { - simlin_sim_reset(this.getSimPtr(handle)); + const entry = this.getEntry(handle as number, 'sim'); + if (entry.engine === 'wasm') { + // The blob's reset clears the run cursor and preserves constant overrides, + // faithfully mirroring Vm::reset() -- which also leaves the previous run's + // values in the live curr chunk and defers re-init to the next run. The VM + // stack still presents a fresh pre-run state after reset (libsimlin recreates + // a zeroed VM), so the host must do the same: zero the live curr chunk + // (memory base 0, the nSlots f64 that simGetTime/simGetValue read). Without + // this, getTime()/getValue() return stale end-of-run values until the next + // run_to repopulates curr -- diverging from the VM, which reads 0. + entry.wasmExports!.reset(); + new Float64Array(entry.wasmExports!.memory.buffer, 0, entry.wasmLayout!.nSlots).fill(0); + return; + } + simlin_sim_reset(entry.ptr); } simGetTime(handle: SimHandle): number { - return simlin_sim_get_value(this.getSimPtr(handle), 'time'); + const entry = this.getEntry(handle as number, 'sim'); + if (entry.engine === 'wasm') { + // `time` is slot 0 of the live curr chunk at linear-memory base 0. + return new DataView(entry.wasmExports!.memory.buffer).getFloat64(0, true); + } + return simlin_sim_get_value(entry.ptr, 'time'); } simGetStepCount(handle: SimHandle): number { - return simlin_sim_get_stepcount(this.getSimPtr(handle)); + const entry = this.getEntry(handle as number, 'sim'); + if (entry.engine === 'wasm') { + // Return COMPLETED steps, not the slab capacity. The blob's live G_SAVED + // counter (the `saved_steps` global) equals nChunks after a full run but + // is 0 before any run and after reset -- so reading nChunks here would + // falsely report a complete run on a fresh/just-reset sim. The exported + // i32 global's `.value` is typed `any`, so coerce through Number(). + return Number(entry.wasmExports!.saved_steps.value); + } + return simlin_sim_get_stepcount(entry.ptr); } simGetValue(handle: SimHandle, name: string): number { - return simlin_sim_get_value(this.getSimPtr(handle), name); + const entry = this.getEntry(handle as number, 'sim'); + if (entry.engine === 'wasm') { + // Read the variable's current value from the blob's live curr chunk at + // linear-memory base 0, mirroring the VM's get_value_now (vm.rs:880-887). + const slot = this.wasmSlot(entry.wasmLayout!, name); + return new DataView(entry.wasmExports!.memory.buffer).getFloat64(slot * 8, true); + } + return simlin_sim_get_value(entry.ptr, name); } simSetValue(handle: SimHandle, name: string, value: number): void { - simlin_sim_set_value(this.getSimPtr(handle), name, value); + const entry = this.getEntry(handle as number, 'sim'); + if (entry.engine === 'wasm') { + const slot = this.wasmSlot(entry.wasmLayout!, name); + const rc = entry.wasmExports!.set_value(slot, value); + if (rc !== 0) { + // The blob returns nonzero when the slot is not a settable constant, + // mirroring the VM's BadOverride rejection (constants only). + throw new Error(`cannot set value of '${name}': not a simple constant`); + } + // The blob's set_value writes only the constants-override region read by the + // NEXT evaluation; the VM's apply_override also writes the value into the + // live curr chunk immediately (set_value_now, vm.rs:869-873), so getValue() + // reflects an override before any run. Mirror that live write here (memory + // base 0, slot*8 -- the same cell simGetValue/simGetTime read) so an + // interactive read agrees with the VM rather than returning the prior value. + new DataView(entry.wasmExports!.memory.buffer).setFloat64(slot * 8, value, true); + return; + } + simlin_sim_set_value(entry.ptr, name, value); } simGetSeries(handle: SimHandle, name: string): Float64Array { + const entry = this.getEntry(handle as number, 'sim'); + // Both engines truncate the series to the completed-step count so a partially + // run -- or just-reset -- sim never exposes uncommitted/stale tail rows. On + // wasm the results slab keeps its full nChunks capacity even when saved_steps + // is smaller (reset clears the run cursor but not the slab), so slicing the + // strided read to the saved count is what keeps it at VM parity (the VM + // returns only saved rows mid-run and bounds the read by the passed count). const stepCount = this.simGetStepCount(handle); - return simlin_sim_get_series(this.getSimPtr(handle), name, stepCount); + if (entry.engine === 'wasm') { + // Read memory.buffer fresh per call (uniform with the singleton helpers). + const slot = this.wasmSlot(entry.wasmLayout!, name); + return readStridedSeries(entry.wasmExports!.memory.buffer, entry.wasmLayout!, slot, stepCount); + } + return simlin_sim_get_series(entry.ptr, name, stepCount); } simGetVarNames(handle: SimHandle): string[] { - return simlin_sim_get_var_names_fn(this.getSimPtr(handle)); + const entry = this.getEntry(handle as number, 'sim'); + if (entry.engine === 'wasm') { + // Mirror the VM's simlin_sim_get_var_names: filter ONLY `$`-prefixed + // internal vars (is_internal_var) -- the reserved time/dt/initial_time/ + // final_time names are kept -- and sort by Rust byte order. Rust's + // str `sort()` compares by UTF-8 bytes, which for valid Unicode orders + // identically to code-point order; the default JS UTF-16 Array.sort + // would mis-order non-ASCII (surrogate-pair) names, so compare by code point. + const names = Array.from(entry.wasmLayout!.varOffsets.keys()).filter((n) => !n.startsWith('$')); + names.sort(compareByCodePoint); + return names; + } + return simlin_sim_get_var_names_fn(entry.ptr); } simGetLinks(handle: SimHandle): Link[] { - const linksPtr = simlin_analyze_get_links(this.getSimPtr(handle)); + const entry = this.getEntry(handle as number, 'sim'); + if (entry.engine === 'wasm') { + // LTM link scores are a VM-only analysis; a wasm sim never enables LTM. + throw new Error("getLinks is not supported on the wasm engine; use engine:'vm'"); + } + const linksPtr = simlin_analyze_get_links(entry.ptr); return convertLinks(linksPtr); } } diff --git a/src/engine/src/internal/canonicalize.ts b/src/engine/src/internal/canonicalize.ts new file mode 100644 index 000000000..ffb896e52 --- /dev/null +++ b/src/engine/src/internal/canonicalize.ts @@ -0,0 +1,163 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// pattern: Functional Core +// Pure name canonicalization: same input always yields the same output, no I/O. + +// The literal-period sentinel: a `.` inside a quoted identifier maps to U+2024 +// (ONE DOT LEADER) rather than a raw `.`. A raw `.` is not canonical, so a +// re-canonicalization pass would treat the now-unquoted period as the U+00B7 +// module separator and corrupt the identity (simlin-engine issue #559). Mapping +// it to a dedicated sentinel keeps canonicalize idempotent while preserving the +// literal-period-vs-module-separator distinction. Mirrors +// simlin-engine/src/common.rs `LITERAL_PERIOD_SENTINEL`. +const LITERAL_PERIOD_SENTINEL = '\u{2024}'; + +// The module-hierarchy separator: an unquoted `.` (e.g. `model.variable`) maps +// to U+00B7 (MIDDLE DOT). Mirrors the `·` substitution in `canonicalize`. +const MODULE_SEPARATOR = '\u{00B7}'; + +/** + * Split a trimmed identifier into its quoted and unquoted parts, quote-aware. + * + * A `.` is NOT a split boundary; the parts retain their dots, and the caller + * substitutes them per-part (a `.` inside a quoted part is a literal period; a + * `.` in an unquoted part is the module separator). A quoted part keeps its + * surrounding quotes so the caller can detect it. Escaped quotes (`\"`) inside + * a quoted section do not close it. + * + * Faithful port of Rust's `IdentifierPartIterator` (simlin-engine/src/common.rs): + * matches the regex `[^"]+|"((\\")|[^"])*"`. + */ +function splitIdentifierParts(s: string): Array { + const parts: Array = []; + let remaining = s; + + while (remaining.length > 0) { + if (remaining.charCodeAt(0) === 0x22 /* '"' */) { + // Quoted section: find the closing quote, skipping escaped quotes. + let i = 1; + let closed = false; + while (i < remaining.length) { + if ( + remaining.charCodeAt(i) === 0x5c /* '\' */ && + i + 1 < remaining.length && + remaining.charCodeAt(i + 1) === 0x22 + ) { + i += 2; // skip the escaped quote + } else if (remaining.charCodeAt(i) === 0x22) { + parts.push(remaining.slice(0, i + 1)); + remaining = remaining.slice(i + 1); + closed = true; + break; + } else { + i += 1; + } + } + if (!closed) { + // Unclosed quote: emit the rest as-is. + parts.push(remaining); + remaining = ''; + } + } else { + // Unquoted section: run up to the next quote (or the end). + const next = remaining.indexOf('"'); + const end = next === -1 ? remaining.length : next; + // `end` is always > 0 here (index 0 is not a quote), so the part is non-empty. + parts.push(remaining.slice(0, end)); + remaining = remaining.slice(end); + } + } + + return parts; +} + +/** + * Collapse whitespace runs into a single underscore. + * + * Handles the two-character escape sequences `\n` and `\r` (a backslash + * followed by `n`/`r`) as well as actual whitespace characters (space, `\t`, + * `\r`, `\n`, U+00A0); consecutive matches collapse to one underscore. A + * backslash NOT starting an `\n`/`\r` escape passes through unchanged (and + * resets the run). Faithful port of Rust's `replace_whitespace_with_underscore`. + */ +function replaceWhitespaceWithUnderscore(s: string): string { + let result = ''; + let inWhitespace = false; + + for (let i = 0; i < s.length; i++) { + const c = s[i]; + if (c === '\\' && i + 1 < s.length && (s[i + 1] === 'n' || s[i + 1] === 'r')) { + i += 1; // consume the 'n' or 'r' + if (!inWhitespace) { + result += '_'; + inWhitespace = true; + } + } else if (c === '\\') { + // Not an escape sequence we handle; pass through. + inWhitespace = false; + result += c; + } else if (c === '\n' || c === '\r' || c === '\t' || c === ' ' || c === '\u{00A0}') { + if (!inWhitespace) { + result += '_'; + inWhitespace = true; + } + } else { + inWhitespace = false; + result += c; + } + } + + return result; +} + +/** + * Canonicalize a variable/model name into the engine's normalized form. + * + * Reproduces Rust `simlin-engine/src/common.rs` `canonicalize` exactly so that + * a raw caller name resolves to the same canonical key the wasm `WasmLayout` + * (and the VM's `get_var_names`) uses. The steps, per quote-aware part: + * 1. trim the whole input; + * 2. split into quote-aware parts (a `.` does not split a quoted segment); + * 3. per part: a quoted part's inner `.` -> U+2024 (literal-period sentinel), + * an unquoted part's `.` -> U+00B7 (module separator); + * 4. per part: `\\` -> `\`, collapse whitespace runs (and the literal `\n`/`\r` + * escapes) to a single `_`, then lowercase; + * 5. concatenate the parts (the sentinel/separator substitutions carry the join). + * + * This is intentionally a separate, fully-correct copy rather than reusing the + * incomplete `@simlin/core` `canonicalize` (which has no dot/quote handling and + * is shared by consumers whose behavior must not shift). The two should later + * be unified into one Rust-faithful implementation. + * + * @param name The raw identifier to canonicalize. + * @returns The canonical form. + */ +export function canonicalizeIdent(name: string): string { + const trimmed = name.trim(); + + let canonical = ''; + for (const part of splitIdentifierParts(trimmed)) { + const isQuoted = part.length >= 2 && part.charCodeAt(0) === 0x22 && part.charCodeAt(part.length - 1) === 0x22; + + let mapped: string; + if (isQuoted) { + const inner = part.slice(1, part.length - 1); + // A literal period inside quotes becomes the canonical-stable sentinel, + // not a raw `.` (which would re-canonicalize into the module separator). + mapped = inner.includes('.') ? inner.split('.').join(LITERAL_PERIOD_SENTINEL) : inner; + } else { + // An unquoted `.` is a module-hierarchy separator. + mapped = part.split('.').join(MODULE_SEPARATOR); + } + + mapped = mapped.split('\\\\').join('\\'); + mapped = replaceWhitespaceWithUnderscore(mapped); + mapped = mapped.toLowerCase(); + + canonical += mapped; + } + + return canonical; +} diff --git a/src/engine/src/internal/index.ts b/src/engine/src/internal/index.ts index 60f2bb5ea..cca16e07e 100644 --- a/src/engine/src/internal/index.ts +++ b/src/engine/src/internal/index.ts @@ -19,3 +19,4 @@ export * from './model'; export * from './sim'; export * from './analysis'; export * from './import-export'; +export * from './wasmgen'; diff --git a/src/engine/src/internal/wasmgen.ts b/src/engine/src/internal/wasmgen.ts new file mode 100644 index 000000000..5bc6fb777 --- /dev/null +++ b/src/engine/src/internal/wasmgen.ts @@ -0,0 +1,212 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// pattern: Mixed (unavoidable) +// Reason: This module is the complete "compile a model to a wasm blob and read +// it back" contract. The two pure functions (parseWasmLayout, readStridedSeries) +// are the Functional Core; the FFI wrapper (simlin_model_compile_to_wasm) is the +// Imperative Shell. They live together because the shell produces the very layout +// bytes the core decodes and they share the WasmLayout/WasmBlobExports types -- +// the same arrangement the sibling FFI wrappers in model.ts use. The pure +// functions take plain buffers (not a live instance) so they remain unit-testable +// in isolation, which their tests exercise without any WASM instance. + +import { getExports } from '@simlin/engine/internal/wasm'; +import { free, copyFromWasm, allocOutPtr, readOutPtr, allocOutUsize, readOutUsize } from './memory'; +import { SimlinModelPtr } from './types'; +import { + simlin_error_free, + simlin_error_get_code, + simlin_error_get_message, + readAllErrorDetails, + SimlinError, +} from './error'; + +const textDecoder = new TextDecoder(); + +/** + * Geometry and the canonical-name -> slot-offset map decoded from a compiled + * model's serialized WasmLayout. + * + * `varOffsets` keys are canonical idents (the same keys the VM's + * `simlin_sim_get_var_names` returns); a caller must canonicalize a raw name + * before looking it up. Results in the blob's linear memory are stored + * step-major (one contiguous chunk of `nSlots` f64 per saved step), so a + * variable's series is read by striding the results region by `nSlots`. + */ +export interface WasmLayout { + /** Number of f64 slots per saved step (the step-major row width). */ + nSlots: number; + /** Number of saved steps (== the VM's saved-row count / series length). */ + nChunks: number; + /** Byte offset of the results region within the blob's linear memory. */ + resultsOffset: number; + /** Canonical variable name -> its f64 slot offset within a step. */ + varOffsets: Map; +} + +/** + * The exports of a compiled-model wasm blob. The blob is import-free; the host + * instantiates it and drives `run`/`run_to`/`reset` directly (libsimlin is not + * on this hot path). `run_to` is resumable: it calls the idempotent + * `run_initials` internally and resumes from where a prior call stopped; + * `reset` clears the run cursor while preserving constant overrides. + */ +export interface WasmBlobExports { + memory: WebAssembly.Memory; + run(): void; + run_to(time: number): void; + run_initials(): void; + reset(): void; + /** Override a constant by slot offset; returns 0 on success, nonzero if the slot is not a settable constant. */ + set_value(offset: number, value: number): number; + clear_values(): void; + n_slots: WebAssembly.Global; + n_chunks: WebAssembly.Global; + results_offset: WebAssembly.Global; + /** Live count of saved rows: 0 before any run / after `reset`, `n_chunks` after a full run. */ + saved_steps: WebAssembly.Global; +} + +/** + * Compile a model to a self-contained WebAssembly blob plus its serialized + * WasmLayout. + * + * Imperative shell: mirrors `model.ts`'s `callBufferReturningFn` error-ptr + + * out-buffer idiom, but returns two buffers (the wasm bytes and the layout + * bytes). On an unsupported model the FFI stores a `SimlinErrorCode::Generic` + * error, which surfaces here as a thrown `SimlinError` (no VM fallback). + * + * @param model Model pointer (owned by the caller). + * @returns The wasm blob bytes and the serialized WasmLayout bytes. + */ +export function simlin_model_compile_to_wasm(model: SimlinModelPtr): { wasm: Uint8Array; layout: Uint8Array } { + const fn = getExports().simlin_model_compile_to_wasm as ( + model: number, + outWasm: number, + outWasmLen: number, + outLayout: number, + outLayoutLen: number, + outErr: number, + ) => void; + + const outWasmPtr = allocOutPtr(); + const outWasmLenPtr = allocOutUsize(); + const outLayoutPtr = allocOutPtr(); + const outLayoutLenPtr = allocOutUsize(); + const outErrPtr = allocOutPtr(); + + try { + fn(model, outWasmPtr, outWasmLenPtr, outLayoutPtr, outLayoutLenPtr, outErrPtr); + const errPtr = readOutPtr(outErrPtr); + + if (errPtr !== 0) { + const code = simlin_error_get_code(errPtr); + const message = simlin_error_get_message(errPtr) ?? 'Unknown error'; + const details = readAllErrorDetails(errPtr); + simlin_error_free(errPtr); + throw new SimlinError(message, code, details); + } + + const wasmPtr = readOutPtr(outWasmPtr); + const wasmLen = readOutUsize(outWasmLenPtr); + const wasm = copyFromWasm(wasmPtr, wasmLen); + free(wasmPtr); + + const layoutPtr = readOutPtr(outLayoutPtr); + const layoutLen = readOutUsize(outLayoutLenPtr); + const layout = copyFromWasm(layoutPtr, layoutLen); + free(layoutPtr); + + return { wasm, layout }; + } finally { + free(outWasmPtr); + free(outWasmLenPtr); + free(outLayoutPtr); + free(outLayoutLenPtr); + free(outErrPtr); + } +} + +/** + * Decode a serialized WasmLayout from its little-endian wire format. + * + * Functional core: pure decode of the byte buffer + * `simlin_model_compile_to_wasm` returns. Wire format: u64 nSlots, u64 nChunks, + * u64 resultsOffset, u32 count, then `count` entries of + * { u32 nameLen, utf8 name, u64 offset }. The u64 fields are read via + * `getBigUint64` and narrowed to `number` (slot/offset/step counts are far + * below 2^53). + * + * @param bytes The serialized layout buffer. + * @returns The decoded geometry and name->offset map. + */ +export function parseWasmLayout(bytes: Uint8Array): WasmLayout { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let p = 0; + + const readU64 = (): number => { + const value = Number(view.getBigUint64(p, true)); + p += 8; + return value; + }; + const readU32 = (): number => { + const value = view.getUint32(p, true); + p += 4; + return value; + }; + + const nSlots = readU64(); + const nChunks = readU64(); + const resultsOffset = readU64(); + const count = readU32(); + + const varOffsets = new Map(); + for (let i = 0; i < count; i++) { + const nameLen = readU32(); + const name = textDecoder.decode(bytes.subarray(p, p + nameLen)); + p += nameLen; + const offset = readU64(); + varOffsets.set(name, offset); + } + + return { nSlots, nChunks, resultsOffset, varOffsets }; +} + +/** + * Read one variable's series out of a step-major results region. + * + * Functional core: takes an `ArrayBufferLike` (the blob's linear memory, or any + * buffer in a test) rather than a live `WebAssembly.Instance`, so it is + * unit-testable in isolation. Allocates exactly one `Float64Array(count)` and + * fills it via strided `DataView.getFloat64` reads -- no intermediate arrays. + * + * `count` is the number of COMPLETED steps to read (the blob's live + * `saved_steps`). It defaults to the full slab capacity (`nChunks`), but a + * partially run -- or just-reset -- sim has saved fewer rows than the slab + * holds, and the slab is not zeroed in between, so reading `nChunks` rows + * unconditionally would surface uncommitted/stale tail rows. `count` is clamped + * to `[0, nChunks]` so this pure reader never strides past the results region + * it was handed the geometry for. + * + * @param memory The linear-memory buffer holding the step-major results region. + * @param layout The decoded layout (provides resultsOffset, nSlots, nChunks). + * @param slot The variable's slot offset within a step (from `varOffsets`). + * @param count Completed steps to read; defaults to and is capped at `nChunks`. + * @returns A new `Float64Array` of length `min(count, nChunks)` -- the series. + */ +export function readStridedSeries( + memory: ArrayBufferLike, + layout: WasmLayout, + slot: number, + count: number = layout.nChunks, +): Float64Array { + const rows = Math.max(0, Math.min(count, layout.nChunks)); + const view = new DataView(memory); + const series = new Float64Array(rows); + for (let c = 0; c < rows; c++) { + series[c] = view.getFloat64(layout.resultsOffset + (c * layout.nSlots + slot) * 8, true); + } + return series; +} diff --git a/src/engine/src/model.ts b/src/engine/src/model.ts index d072722e3..70f3e469c 100644 --- a/src/engine/src/model.ts +++ b/src/engine/src/model.ts @@ -10,7 +10,7 @@ * Sim instances. */ -import { EngineBackend, ModelHandle } from './backend'; +import { EngineBackend, ModelHandle, SimEngine } from './backend'; import { Stock, Flow, Aux, Module, Variable, TimeSpec, Link, Loop, ModelIssue, GraphicalFunction } from './types'; import { JsonStock, @@ -102,10 +102,7 @@ function parseJsonGraphicalFunction(gf: JsonGraphicalFunction): GraphicalFunctio }; } -function extractEquation( - topLevel: string | undefined, - arrayed: { equation?: string } | undefined, -): string { +function extractEquation(topLevel: string | undefined, arrayed: { equation?: string } | undefined): string { if (topLevel) { return topLevel; } @@ -120,10 +117,7 @@ function extractEquation( * in the top-level `initialEquation` or in the arrayed `equation` field * (XMILE-sourced data where `` IS the initial value). */ -function extractStockInitialEquation( - topLevel: string | undefined, - arrayed: { equation?: string } | undefined, -): string { +function extractStockInitialEquation(topLevel: string | undefined, arrayed: { equation?: string } | undefined): string { if (topLevel) { return topLevel; } @@ -424,13 +418,19 @@ export class Model { /** * Create low-level simulation for step-by-step execution. * @param overrides Variable value overrides - * @param options Simulation options + * @param options Simulation options. `engine` selects the execution backend + * ('vm', the default, or 'wasm' for fast repeated re-runs). The + * enableLtm+wasm rejection is enforced authoritatively in the backend's + * simNew (covering the worker path too); this method only forwards. * @returns Sim instance for step-by-step execution */ - async simulate(overrides: Record = {}, options: { enableLtm?: boolean } = {}): Promise { + async simulate( + overrides: Record = {}, + options: { enableLtm?: boolean; engine?: SimEngine } = {}, + ): Promise { this.checkDisposed(); - const { enableLtm = false } = options; - return Sim.create(this, overrides, enableLtm); + const { enableLtm = false, engine = 'vm' } = options; + return Sim.create(this, overrides, enableLtm, engine); } /** @@ -442,14 +442,19 @@ export class Model { * scores must pass `{ analyzeLtm: true }` explicitly. * * @param overrides Override values for any model variables - * @param options Run options + * @param options Run options. `engine` selects the execution backend ('vm', + * the default, or 'wasm'); a wasm run never enables LTM, so its Run carries + * empty links. * @returns Run object with results and analysis */ - async run(overrides: Record = {}, options: { analyzeLtm?: boolean } = {}): Promise { + async run( + overrides: Record = {}, + options: { analyzeLtm?: boolean; engine?: SimEngine } = {}, + ): Promise { this.checkDisposed(); - const { analyzeLtm = false } = options; + const { analyzeLtm = false, engine = 'vm' } = options; - const sim = await this.simulate(overrides, { enableLtm: analyzeLtm }); + const sim = await this.simulate(overrides, { enableLtm: analyzeLtm, engine }); await sim.runToEnd(); return await sim.getRun(); diff --git a/src/engine/src/sim.ts b/src/engine/src/sim.ts index 08f087e46..e707a5cea 100644 --- a/src/engine/src/sim.ts +++ b/src/engine/src/sim.ts @@ -10,7 +10,7 @@ * For batch analysis, use Model.run() instead. */ -import { EngineBackend, SimHandle } from './backend'; +import { EngineBackend, SimHandle, SimEngine } from './backend'; import { Link } from './types'; import { Model } from './model'; import { Run } from './run'; @@ -28,32 +28,47 @@ export class Sim { private _overrides: Record; private _disposed: boolean = false; private _enableLtm: boolean; + // Which execution backend this sim runs on. Kept private so getRun and + // diagnostics can branch on it without widening the public surface. + private _engine: SimEngine; /** @internal Use Sim.create() instead. */ - private constructor(handle: SimHandle, model: Model, overrides: Record, enableLtm: boolean) { + private constructor( + handle: SimHandle, + model: Model, + overrides: Record, + enableLtm: boolean, + engine: SimEngine, + ) { this._handle = handle; this._model = model; this._overrides = { ...overrides }; this._enableLtm = enableLtm; + this._engine = engine; } /** * Create a Sim from a Model. * This is internal - use Model.simulate() instead. */ - static async create(model: Model, overrides: Record = {}, enableLtm: boolean = false): Promise { + static async create( + model: Model, + overrides: Record = {}, + enableLtm: boolean = false, + engine: SimEngine = 'vm', + ): Promise { if (model.project === null) { throw new Error('Model is not attached to a Project'); } const backend = model.project.backend; - const handle = await backend.simNew(model.handle, enableLtm); + const handle = await backend.simNew(model.handle, enableLtm, engine); // Apply any overrides for (const [name, value] of Object.entries(overrides)) { await backend.simSetValue(handle, name, value); } - return new Sim(handle, model, overrides, enableLtm); + return new Sim(handle, model, overrides, enableLtm, engine); } /** @internal */ @@ -209,7 +224,19 @@ export class Sim { results.set(allNames[i], seriesArrays[i]); } - const [loops, links, stepCount] = await Promise.all([this._model.loops(), this.getLinks(), this.getStepCount()]); + // Fetch LTM link scores only when this sim can actually produce them: LTM + // must be enabled, and the wasm engine never supports getLinks (it throws). + // The two are correlated -- LTM-on-wasm is rejected at sim creation -- but + // reading `_engine` here makes the guard self-evidently safe and keeps + // Model.run({engine:'wasm'}) working (empty links, no getLinks call). The + // VM path with LTM off also carries empty links. loops() is model-level + // (engine-agnostic) and stays unconditional. + const wantLinks = this.ltmEnabled && this._engine !== 'wasm'; + const [loops, links, stepCount] = await Promise.all([ + this._model.loops(), + wantLinks ? this.getLinks() : Promise.resolve([]), + this.getStepCount(), + ]); return new Run({ varNames, diff --git a/src/engine/src/worker-backend.ts b/src/engine/src/worker-backend.ts index 4e59c5a70..ffd61ddc9 100644 --- a/src/engine/src/worker-backend.ts +++ b/src/engine/src/worker-backend.ts @@ -15,7 +15,7 @@ * are transferred for zero-copy. */ -import type { EngineBackend, ProjectHandle, ModelHandle, SimHandle } from './backend'; +import type { EngineBackend, ProjectHandle, ModelHandle, SimHandle, SimEngine } from './backend'; import type { ErrorDetail, SimlinJsonFormat } from './internal/types'; import type { Loop, Link } from './types'; import type { JsonProjectPatch } from './json-types'; @@ -509,12 +509,13 @@ export class WorkerBackend implements EngineBackend { // ---- Sim operations ---- - simNew(modelHandle: ModelHandle, enableLtm: boolean): Promise { + simNew(modelHandle: ModelHandle, enableLtm: boolean, engine?: SimEngine): Promise { return this.sendRequest((requestId) => ({ type: 'simNew', requestId, modelHandle, enableLtm, + engine, })); } diff --git a/src/engine/src/worker-protocol.ts b/src/engine/src/worker-protocol.ts index 22911c9ee..25bfc0f4a 100644 --- a/src/engine/src/worker-protocol.ts +++ b/src/engine/src/worker-protocol.ts @@ -11,6 +11,7 @@ * Handles are opaque integers that reference WASM objects in the worker. */ +import type { SimEngine } from './backend'; import type { ErrorDetail } from './internal/types'; // Branded handle types for messages (mirrors backend.ts, but without class brands) @@ -80,7 +81,7 @@ export type WorkerRequest = | { type: 'modelGetVarNames'; requestId: number; handle: WorkerModelHandle; typeMask: number; filter: string | null } | { type: 'modelGetSimSpecsJson'; requestId: number; handle: WorkerModelHandle } // Sim operations - | { type: 'simNew'; requestId: number; modelHandle: WorkerModelHandle; enableLtm: boolean } + | { type: 'simNew'; requestId: number; modelHandle: WorkerModelHandle; enableLtm: boolean; engine?: SimEngine } | { type: 'simDispose'; requestId: number; handle: WorkerSimHandle } | { type: 'simRunTo'; requestId: number; handle: WorkerSimHandle; time: number } | { type: 'simRunToEnd'; requestId: number; handle: WorkerSimHandle } diff --git a/src/engine/src/worker-server.ts b/src/engine/src/worker-server.ts index cf4445b7d..78c038900 100644 --- a/src/engine/src/worker-server.ts +++ b/src/engine/src/worker-server.ts @@ -366,7 +366,7 @@ export class WorkerServer { // Sim operations case 'simNew': { const modelHandle = this.getModelHandle(request.modelHandle); - const backendSimHandle = this.backend.simNew(modelHandle, request.enableLtm); + const backendSimHandle = this.backend.simNew(modelHandle, request.enableLtm, request.engine); const parentProject = this.modelToProject.get(request.modelHandle); if (parentProject === undefined) { throw new Error(`Model handle ${request.modelHandle} not associated with a project`); diff --git a/src/engine/tests/backend-bench.test.ts b/src/engine/tests/backend-bench.test.ts new file mode 100644 index 000000000..e3cad5d2a --- /dev/null +++ b/src/engine/tests/backend-bench.test.ts @@ -0,0 +1,31 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// pattern: Imperative Shell +// Gated VM-vs-wasm eval benchmark over fishbanks / WORLD3 / C-LEARN, driven +// through the public Model.simulate({engine}) API. The heavy run is opt-in via +// RUN_BENCH=1 so it stays out of the default `pnpm test` (C-LEARN's compile +// alone is seconds, well past the few-seconds-per-test budget). The pure +// harness it relies on is always-on-tested in bench-stats.test.ts; nothing +// heavy runs by default here. The cross-check inside runBenchmark guards +// correctness; this asserts every model produced a positive finite median on +// both engines. + +import { runBenchmark } from './backend-bench'; + +const RUN = process.env.RUN_BENCH === '1'; + +(RUN ? it : it.skip)( + 'benchmarks VM vs wasm eval (fishbanks/WORLD3/C-LEARN)', + async () => { + const rows = await runBenchmark({ warmup: 3, minIters: 3, maxIters: 100, budgetMs: 2500 }); + for (const r of rows) { + expect(Number.isFinite(r.vm.medianMs)).toBe(true); + expect(r.vm.medianMs).toBeGreaterThan(0); + expect(Number.isFinite(r.wasm.medianMs)).toBe(true); + expect(r.wasm.medianMs).toBeGreaterThan(0); + } + }, + 300_000, +); diff --git a/src/engine/tests/backend-bench.ts b/src/engine/tests/backend-bench.ts new file mode 100644 index 000000000..1b299a6fd --- /dev/null +++ b/src/engine/tests/backend-bench.ts @@ -0,0 +1,220 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// pattern: Imperative Shell +// The node VM-vs-wasm eval benchmark runner. It loads fishbanks / WORLD3 / +// C-LEARN, drives them through the public @simlin/engine API +// (Project.open* -> Model.simulate({engine}) -> Sim.reset/runToEnd), and times +// only the simulation (eval) region -- the blob compile/instantiate happens +// once in untimed setup, mirroring backend_bench.rs's eval-vs-eval methodology. +// The pure statistic + warmup/measure policy lives in the always-on-tested +// bench-stats.ts (Functional Core); this shell only does I/O + orchestration. +// Per the "no stale benchmark data" rule it prints results to the console and +// never writes a results file. + +import * as fs from 'fs'; +import * as path from 'path'; +import { performance } from 'node:perf_hooks'; + +import { Project } from '../src/project'; +import { Model } from '../src/model'; +import { runTimedAsync, seriesClose, type BenchOpts, type Stat } from './bench-stats'; + +// Which execution backend a Sim runs on. Mirrors SimEngine in src/backend.ts; +// the public Model.simulate accepts this union directly, so the benchmark needs +// no engine import. +type Engine = 'vm' | 'wasm'; + +type ModelKey = 'fishbanks' | 'wrld3' | 'clearn'; + +type ModelSpec = { + readonly label: string; + readonly path: string; + readonly open: (data: Uint8Array) => Promise; +}; + +// Repo root is three levels up from src/engine/tests/. Paths and formats mirror +// backend_bench.rs's MODELS table: fishbanks is XMILE; WORLD3 and C-LEARN are +// Vensim .mdl (the C-LEARN .xmile is a header-only stub -- the .mdl is the real +// model). +const MODELS: Record = { + fishbanks: { + label: 'fishbanks', + path: path.join(__dirname, '..', '..', '..', 'default_projects', 'fishbanks', 'model.xmile'), + open: (data) => Project.open(data), + }, + wrld3: { + label: 'WORLD3-03', + path: path.join(__dirname, '..', '..', '..', 'test', 'metasd', 'WRLD3-03', 'wrld3-03.mdl'), + open: (data) => Project.openVensim(data), + }, + clearn: { + label: 'C-LEARN v77', + path: path.join(__dirname, '..', '..', '..', 'test', 'xmutil_test_models', 'C-LEARN v77 for Vensim.mdl'), + open: (data) => Project.openVensim(data), + }, +}; + +const MODEL_ORDER: ReadonlyArray = ['fishbanks', 'wrld3', 'clearn']; + +export type BenchRow = { + readonly model: string; + readonly vm: Stat; + readonly wasm: Stat; + readonly ratio: number; +}; + +export type BenchTable = ReadonlyArray; + +/** Which models to run, drawn from {fishbanks, wrld3, clearn}; default is all + * three. A BENCH_MODELS comma-list env narrows the set (mirroring + * backend_bench.rs). */ +function selectedModels(): ReadonlyArray { + const env = process.env.BENCH_MODELS; + if (!env) { + return MODEL_ORDER; + } + const requested = new Set( + env + .split(',') + .map((s) => s.trim()) + .filter((s) => s.length > 0), + ); + return MODEL_ORDER.filter((key) => requested.has(key)); +} + +/** + * Median eval (simulation) time for `model` on one engine. + * + * The Sim is created once in untimed setup -- for wasm this compiles and + * instantiates the blob, for vm it creates the libsimlin sim. Each measured + * iteration calls reset() (setup: re-arms the run cursor; for wasm the resumable + * reset re-runs without recompiling) BEFORE the clock is sampled, then times + * only runToEnd(). Result extraction (getRun/getSeries) is never timed. + */ +async function timeEngine(model: Model, engine: Engine, opts: Readonly): Promise { + const sim = await model.simulate({}, { engine }); + try { + return await runTimedAsync(opts, async () => { + await sim.reset(); + const t0 = performance.now(); + await sim.runToEnd(); + return performance.now() - t0; + }); + } finally { + await sim.dispose(); + } +} + +/** + * Confirm the VM and wasm engines compute the same simulation, so the timings + * describe a real, correct run (mirrors backend_bench.rs's cross_check). Runs + * both engines to completion outside any timing and compares every variable's + * full series within the engine's VM-vs-wasm parity tolerance (the pure + * `seriesClose` predicate). Throws on the first mismatch -- a divergence beyond + * that tolerance is a real parity bug, not something to benchmark over. + */ +async function crossCheck(model: Model, label: string): Promise { + const vmSim = await model.simulate({}, { engine: 'vm' }); + const wasmSim = await model.simulate({}, { engine: 'wasm' }); + try { + await vmSim.runToEnd(); + await wasmSim.runToEnd(); + + const names = await wasmSim.getVarNames(); + if (names.length === 0) { + throw new Error(`cross-check failed for ${label}: model produced no variables`); + } + + for (const name of names) { + const vmSeries = await vmSim.getSeries(name); + const wasmSeries = await wasmSim.getSeries(name); + const result = seriesClose(vmSeries, wasmSeries); + if (!result.match) { + if (result.index < 0) { + throw new Error( + `cross-check failed for ${label}: series length differs for '${name}' ` + + `(vm ${result.expected} vs wasm ${result.actual})`, + ); + } + throw new Error( + `cross-check failed for ${label}: '${name}' diverges at step ${result.index} ` + + `(vm ${result.expected} vs wasm ${result.actual})`, + ); + } + } + } finally { + await vmSim.dispose(); + await wasmSim.dispose(); + } +} + +function fmtMs(v: number): string { + if (!Number.isFinite(v)) { + return '-'; + } + if (v >= 100) { + return v.toFixed(1); + } + if (v >= 1) { + return v.toFixed(3); + } + return v.toFixed(4); +} + +function fmtRatio(ratio: number): string { + return Number.isFinite(ratio) ? `${ratio.toFixed(2)}x` : '-'; +} + +function printSummary(rows: BenchTable): void { + const lines: Array = []; + lines.push(''); + lines.push('### Node VM-vs-wasm eval benchmark (median ms)'); + lines.push(''); + lines.push('| model | VM eval (median ms) | wasm eval (median ms) | wasm/VM | iters |'); + lines.push('|---|--:|--:|--:|--:|'); + for (const r of rows) { + lines.push( + `| ${r.model} | ${fmtMs(r.vm.medianMs)} | ${fmtMs(r.wasm.medianMs)} | ${fmtRatio(r.ratio)} ` + + `| vm ${r.vm.iters} / wasm ${r.wasm.iters} |`, + ); + } + lines.push(''); + lines.push( + '_Eval-only: blob compile/instantiate and result extraction are excluded. ' + + 'Absolute numbers include the async public-API overhead (per-call await), ' + + 'so the wasm/VM ratio is the meaningful figure._', + ); + console.log(lines.join('\n')); +} + +/** + * Run the benchmark for the selected models. For each, load the project, take + * its main model, cross-check VM-vs-wasm parity, then measure eval time on both + * engines. Returns the rows (so a test can assert) and prints a markdown table. + */ +export async function runBenchmark(opts: Readonly): Promise { + const rows: Array = []; + + for (const key of selectedModels()) { + const spec = MODELS[key]; + const data = fs.readFileSync(spec.path); + const project = await spec.open(data); + try { + const model = await project.mainModel(); + + await crossCheck(model, spec.label); + + const vm = await timeEngine(model, 'vm', opts); + const wasm = await timeEngine(model, 'wasm', opts); + + rows.push({ model: spec.label, vm, wasm, ratio: vm.medianMs / wasm.medianMs }); + } finally { + await project.dispose(); + } + } + + printSummary(rows); + return rows; +} diff --git a/src/engine/tests/bench-stats.test.ts b/src/engine/tests/bench-stats.test.ts new file mode 100644 index 000000000..cdd6e7e93 --- /dev/null +++ b/src/engine/tests/bench-stats.test.ts @@ -0,0 +1,304 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// pattern: Functional Core +// Unit tests for the pure benchmark harness: the median statistic and the +// adaptive warmup/measure policy. Both `body` and `now` are injected, so these +// tests are deterministic and never touch the wall clock or any model/WASM. + +import { median, runTimed, runTimedAsync, seriesClose, type BenchOpts } from './bench-stats'; + +describe('median', () => { + it('returns the middle element of an odd-length input', () => { + // length 3, len>>1 == 1: the middle of the sorted copy. + expect(median([3, 1, 2])).toBe(2); + }); + + it('returns the upper-middle element of an even-length input (len>>1)', () => { + // length 4, len>>1 == 2: the third element of the sorted copy (mirrors + // backend_bench.rs `times[iters/2]`, NOT an averaged median). + expect(median([4, 1, 3, 2])).toBe(3); + }); + + it('sorts a copy ascending and does not mutate the input', () => { + const input = [5, 2, 9, 1]; + const result = median(input); + // sorted: [1, 2, 5, 9], len>>1 == 2 -> 5 + expect(result).toBe(5); + expect(input).toEqual([5, 2, 9, 1]); + }); + + it('returns the single element for a one-element input', () => { + expect(median([42])).toBe(42); + }); + + it('returns NaN for an empty input', () => { + expect(Number.isNaN(median([]))).toBe(true); + }); +}); + +// A body that returns a fixed sequence of "elapsed ms" values, advancing one +// element per call. Lets a test assert exactly which iterations were measured +// vs. discarded as warmup. +function sequenceBody(values: ReadonlyArray): () => number { + let i = 0; + return () => { + const v = values[i] ?? 0; + i += 1; + return v; + }; +} + +// A monotonic fake clock: each read advances by `step` ms. Independent of +// `body`, mirroring how the real loop samples wall-clock between iterations. +function fakeClock(step: number, startAt = 0): () => number { + let t = startAt; + return () => { + const cur = t; + t += step; + return cur; + }; +} + +describe('runTimed', () => { + it('discards exactly `warmup` iterations before measuring', () => { + // warmup=2 discards the first two (100, 200); measurement sees 3,4,5. + const opts: BenchOpts = { warmup: 2, minIters: 3, maxIters: 3, budgetMs: 1e9 }; + const body = sequenceBody([100, 200, 3, 4, 5]); + const stat = runTimed(opts, body, fakeClock(0)); + + expect(stat.iters).toBe(3); + // median of [3,4,5] (len>>1==1) is 4; min is 3. + expect(stat.medianMs).toBe(4); + expect(stat.minMs).toBe(3); + }); + + it('stops at maxIters even when the budget allows more', () => { + const opts: BenchOpts = { warmup: 0, minIters: 1, maxIters: 4, budgetMs: 1e9 }; + const body = sequenceBody([1, 2, 3, 4, 5, 6]); + const stat = runTimed(opts, body, fakeClock(0)); + + expect(stat.iters).toBe(4); + // median of [1,2,3,4] (len>>1==2) is 3. + expect(stat.medianMs).toBe(3); + expect(stat.minMs).toBe(1); + }); + + it('stops early once the budget is exceeded after minIters', () => { + // budget 50ms, clock advances 20ms per read. Deterministic trace with + // fakeClock(20): the first read is consumed as `start` (=0); the first two + // pushes short-circuit on `times.length < minIters` so the clock is NOT + // read. From iteration 3 the guard reads the clock: 20 (<50 push), 40 + // (<50 push), 60 (>=50 stop). So the loop runs EXACTLY 4 iterations -- the + // exact count is pinned so an off-by-one in the budget guard (`<` -> `<=`) + // is caught (a loose `>= 2 && < 100` would not notice it). + const opts: BenchOpts = { warmup: 0, minIters: 2, maxIters: 100, budgetMs: 50 }; + const body = sequenceBody([10, 11, 12, 13, 14, 15, 16, 17]); + const stat = runTimed(opts, body, fakeClock(20)); + + expect(stat.iters).toBe(4); + // measured = [10, 11, 12, 13]; sorted, len>>1==2 -> 12; min is 10. + expect(stat.medianMs).toBe(12); + expect(stat.minMs).toBe(10); + }); + + it('honors minIters even when the budget is already exceeded', () => { + // budget is 0ms, so the wall-clock predicate is false from the start; only + // minIters keeps the loop running. It must still collect exactly minIters. + const opts: BenchOpts = { warmup: 0, minIters: 3, maxIters: 100, budgetMs: 0 }; + const body = sequenceBody([7, 8, 9, 10, 11]); + const stat = runTimed(opts, body, fakeClock(1000)); + + expect(stat.iters).toBe(3); + // median of [7,8,9] is 8. + expect(stat.medianMs).toBe(8); + expect(stat.minMs).toBe(7); + }); + + it('reports correct medianMs, minMs, and iters together', () => { + const opts: BenchOpts = { warmup: 1, minIters: 5, maxIters: 5, budgetMs: 1e9 }; + // first value discarded as warmup; measured = [9, 3, 7, 1, 5]. + const body = sequenceBody([99, 9, 3, 7, 1, 5]); + const stat = runTimed(opts, body, fakeClock(0)); + + expect(stat.iters).toBe(5); + // sorted measured: [1,3,5,7,9], len>>1==2 -> 5 + expect(stat.medianMs).toBe(5); + expect(stat.minMs).toBe(1); + }); +}); + +// The async twin of sequenceBody: resolves the same fixed sequence. +function asyncSequenceBody(values: ReadonlyArray): () => Promise { + let i = 0; + return () => { + const v = values[i] ?? 0; + i += 1; + return Promise.resolve(v); + }; +} + +describe('runTimedAsync', () => { + it('discards exactly `warmup` iterations before measuring', async () => { + const opts: BenchOpts = { warmup: 2, minIters: 3, maxIters: 3, budgetMs: 1e9 }; + const body = asyncSequenceBody([100, 200, 3, 4, 5]); + const stat = await runTimedAsync(opts, body, fakeClock(0)); + + expect(stat.iters).toBe(3); + expect(stat.medianMs).toBe(4); + expect(stat.minMs).toBe(3); + }); + + it('stops at maxIters even when the budget allows more', async () => { + const opts: BenchOpts = { warmup: 0, minIters: 1, maxIters: 4, budgetMs: 1e9 }; + const body = asyncSequenceBody([1, 2, 3, 4, 5, 6]); + const stat = await runTimedAsync(opts, body, fakeClock(0)); + + expect(stat.iters).toBe(4); + expect(stat.medianMs).toBe(3); + expect(stat.minMs).toBe(1); + }); + + it('stops early once the budget is exceeded after minIters', async () => { + // Same deterministic trace as the sync twin: `start`=0, the first two + // pushes short-circuit (no clock read), then guards read 20, 40, 60 and + // stop at 60 >= 50. Exactly 4 iterations -- pinned to catch an off-by-one + // in the (byte-identical) async budget guard. + const opts: BenchOpts = { warmup: 0, minIters: 2, maxIters: 100, budgetMs: 50 }; + const body = asyncSequenceBody([10, 11, 12, 13, 14, 15, 16, 17]); + const stat = await runTimedAsync(opts, body, fakeClock(20)); + + expect(stat.iters).toBe(4); + // measured = [10, 11, 12, 13]; sorted, len>>1==2 -> 12; min is 10. + expect(stat.medianMs).toBe(12); + expect(stat.minMs).toBe(10); + }); + + it('honors minIters even when the budget is already exceeded', async () => { + const opts: BenchOpts = { warmup: 0, minIters: 3, maxIters: 100, budgetMs: 0 }; + const body = asyncSequenceBody([7, 8, 9, 10, 11]); + const stat = await runTimedAsync(opts, body, fakeClock(1000)); + + expect(stat.iters).toBe(3); + expect(stat.medianMs).toBe(8); + expect(stat.minMs).toBe(7); + }); + + it('reports correct medianMs, minMs, and iters together', async () => { + const opts: BenchOpts = { warmup: 1, minIters: 5, maxIters: 5, budgetMs: 1e9 }; + const body = asyncSequenceBody([99, 9, 3, 7, 1, 5]); + const stat = await runTimedAsync(opts, body, fakeClock(0)); + + expect(stat.iters).toBe(5); + expect(stat.medianMs).toBe(5); + expect(stat.minMs).toBe(1); + }); +}); + +describe('seriesClose', () => { + it('reports two identical series as matching', () => { + const a = new Float64Array([1, 2, 3]); + const b = new Float64Array([1, 2, 3]); + expect(seriesClose(a, b).match).toBe(true); + }); + + it('treats differences within the absolute tolerance as matching', () => { + // 1e-4 < 2e-3 absolute tolerance. + const a = new Float64Array([1.0, 100.0]); + const b = new Float64Array([1.0001, 100.0001]); + expect(seriesClose(a, b).match).toBe(true); + }); + + it('treats near-zero noise as matching', () => { + // both within the near-zero guard (expected <= 3e-6, actual <= 1e-6). + const a = new Float64Array([0.0, 1e-7]); + const b = new Float64Array([5e-7, 0.0]); + expect(seriesClose(a, b).match).toBe(true); + }); + + it('matches the real C-LEARN VM-vs-wasm reassociation noise', () => { + // The exact divergence observed: |diff| ~ 2.47e-9 on a value ~0.153 is + // floating-point reassociation noise, six orders of magnitude inside the + // engine's 2e-3 absolute VM-vs-wasm parity tolerance -- a match, not a bug. + const a = new Float64Array([0.15306828340588152]); + const b = new Float64Array([0.15306828094062933]); + const result = seriesClose(a, b); + expect(result.match).toBe(true); + }); + + it('reports a genuine divergence beyond tolerance, with the offending index and values', () => { + const a = new Float64Array([1.0, 2.0, 3.0]); + const b = new Float64Array([1.0, 2.5, 3.0]); + const result = seriesClose(a, b); + expect(result.match).toBe(false); + if (!result.match) { + expect(result.index).toBe(1); + expect(result.expected).toBe(2.0); + expect(result.actual).toBe(2.5); + } + }); + + it('reports a length mismatch (index -1)', () => { + const a = new Float64Array([1, 2, 3]); + const b = new Float64Array([1, 2]); + const result = seriesClose(a, b); + expect(result.match).toBe(false); + if (!result.match) { + expect(result.index).toBe(-1); + } + }); + + it('reports NaN-vs-finite as a mismatch at the offending index', () => { + // The Rust oracle (`ensure_results`) PANICS on this: a NaN is never + // around-zero and approx_eq!(NaN, finite) is false. A naive + // `Math.abs(NaN - finite) > tol` is NaN > tol === false, which would + // wrongly wave it through -- this pins the faithful rejection. + const a = new Float64Array([1.0, NaN, 3.0]); + const b = new Float64Array([1.0, 2.0, 3.0]); + const result = seriesClose(a, b); + expect(result.match).toBe(false); + if (!result.match) { + expect(result.index).toBe(1); + expect(Number.isNaN(result.expected)).toBe(true); + expect(result.actual).toBe(2.0); + } + }); + + it('reports finite-vs-NaN as a mismatch at the offending index', () => { + // Symmetric to the above: NaN on the `actual` side is equally a mismatch. + const a = new Float64Array([1.0, 2.0, 3.0]); + const b = new Float64Array([1.0, NaN, 3.0]); + const result = seriesClose(a, b); + expect(result.match).toBe(false); + if (!result.match) { + expect(result.index).toBe(1); + expect(result.expected).toBe(2.0); + expect(Number.isNaN(result.actual)).toBe(true); + } + }); + + it('reports NaN-vs-NaN as a mismatch (faithful to approx_eq! on NaN)', () => { + // approx_eq!(NaN, NaN) is false, so the Rust oracle would PANIC even + // here. A parity comparison must never silently accept NaN. + const a = new Float64Array([1.0, NaN]); + const b = new Float64Array([1.0, NaN]); + const result = seriesClose(a, b); + expect(result.match).toBe(false); + if (!result.match) { + expect(result.index).toBe(1); + } + }); + + it('reports +Infinity-vs-finite as a mismatch', () => { + // A non-finite value on either side (Infinity as well as NaN) is a + // broken run the cross-check exists to reject. + const a = new Float64Array([1.0, Infinity]); + const b = new Float64Array([1.0, 2.0]); + const result = seriesClose(a, b); + expect(result.match).toBe(false); + if (!result.match) { + expect(result.index).toBe(1); + } + }); +}); diff --git a/src/engine/tests/bench-stats.ts b/src/engine/tests/bench-stats.ts new file mode 100644 index 000000000..fe15f7a0c --- /dev/null +++ b/src/engine/tests/bench-stats.ts @@ -0,0 +1,180 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// pattern: Functional Core +// The pure statistic + warmup/measure policy shared by the node VM-vs-wasm +// benchmark (backend-bench.ts). The timed `body` and the wall clock `now` are +// both injected, so this module is deterministic and side-effect-free: it never +// reads the clock itself nor touches a model/WASM. It mirrors the methodology +// of `src/simlin-engine/examples/backend_bench.rs` (the `Stat`/`bench` pair), +// adding the explicit warmup phase AC9.1 requires. +// +// The single deliberate impurity is `defaultNow()` (a `performance.now()` read). +// It exists ONLY as the default value of the injected `now` parameter -- a +// default-injection seam, analogous to the FCIS logger exception. Every public +// function takes `now` as a parameter and the pure logic reads the clock solely +// through it, so all unit tests pass a fake clock and the core stays +// deterministically testable; the real wall clock is reached only when a caller +// omits the argument. + +/** One phase's timing summary. `iters` makes the sample size visible so a + * single-iteration heavy phase is never silently treated as an average. */ +export type Stat = { + readonly medianMs: number; + readonly minMs: number; + readonly iters: number; +}; + +/** + * The median of `times`, in milliseconds. + * + * Sorts a copy ascending (the input is left unmodified) and returns + * `times[times.length >> 1]` -- the upper-middle element for an even length, + * matching `backend_bench.rs:181`'s `times[iters/2]` (NOT an averaged median). + * An empty input returns `NaN`. + */ +export function median(times: ReadonlyArray): number { + if (times.length === 0) { + return NaN; + } + const sorted = [...times].sort((a, b) => a - b); + return sorted[sorted.length >> 1]; +} + +/** The warmup + adaptive-measure policy. `warmup` iterations are discarded, + * then timings are collected until `maxIters` or (after `minIters`) the + * wall-clock `budgetMs` elapses. */ +export type BenchOpts = { + readonly warmup: number; + readonly minIters: number; + readonly maxIters: number; + readonly budgetMs: number; +}; + +/** + * Run `body` under the warmup/adaptive-measure policy and summarize the timings. + * + * `body()` returns the elapsed milliseconds of one measured run; it does any + * per-iteration setup untimed and times only the precise region itself (exactly + * as `backend_bench.rs`'s closure returns `ms_since(t0)`). This function does + * NOT wrap `body` in a `now()` measurement -- `now` is consulted only for the + * adaptive wall-clock budget, and is injectable so callers/tests can supply a + * deterministic fake clock. + * + * First, `opts.warmup` iterations are run and discarded (the explicit warmup + * AC9.1 requires). Then, with `start = now()`, timings are collected while + * `times.length < maxIters && (times.length < minIters || now() - start < budgetMs)`. + * So `minIters` is always honored (even if the budget is already spent) and the + * loop never exceeds `maxIters`. + */ +export function runTimed(opts: Readonly, body: () => number, now: () => number = defaultNow): Stat { + for (let i = 0; i < opts.warmup; i++) { + body(); + } + + const times: Array = []; + const start = now(); + while (times.length < opts.maxIters && (times.length < opts.minIters || now() - start < opts.budgetMs)) { + times.push(body()); + } + + return summarize(times); +} + +/** + * The async twin of {@link runTimed}: identical warmup-discard + adaptive-median + * policy, but it `await`s `asyncBody()` each iteration. The benchmark's eval is + * async (it drives the Promise-based public API), so keeping the policy here + * single-sources it -- the imperative runner never hand-rolls an inline loop. + */ +export async function runTimedAsync( + opts: Readonly, + asyncBody: () => Promise, + now: () => number = defaultNow, +): Promise { + for (let i = 0; i < opts.warmup; i++) { + await asyncBody(); + } + + const times: Array = []; + const start = now(); + while (times.length < opts.maxIters && (times.length < opts.minIters || now() - start < opts.budgetMs)) { + times.push(await asyncBody()); + } + + return summarize(times); +} + +/** The result of comparing two series element-wise. On a mismatch it carries + * the offending step (or -1 for a length mismatch) and the two values, so the + * caller can build a precise diagnostic. */ +export type SeriesCloseResult = + | { readonly match: true } + | { readonly match: false; readonly index: number; readonly expected: number; readonly actual: number }; + +// VM-vs-wasm parity tolerance for the same salsa-compiled simulation. These are +// the engine's corpus-wide VM-vs-wasm bounds (src/simlin-engine/tests/test_helpers.rs +// `ensure_results`, the comparator the heavy C-LEARN/WORLD3 wasm tests clear), +// NOT the far tighter teacup-only 1e-9 in tests/wasm-model.test.ts: a large model +// run to its final time accumulates floating-point reassociation noise far above +// 1e-9 (e.g. C-LEARN's ~2.5e-9 on an O(0.1) value) that is benign, not a parity +// bug. The non-Vensim branch applies because this compares two engines' output of +// the SAME compiled model, not against Vensim-sourced reference data. +const PARITY_ABS_TOL = 2e-3; +const NEAR_ZERO_EXPECTED = 3e-6; +const NEAR_ZERO_ACTUAL = 1e-6; + +/** + * Element-wise comparison of two simulation series within the engine's VM-vs-wasm + * parity tolerance (a faithful port of `ensure_results`'s non-Vensim, near-zero- + * robust isclose). Returns a match, or the first divergence with its step index + * and values. A length mismatch is reported with `index === -1`. A non-finite + * value (NaN/Infinity) on either side is a mismatch -- matching `ensure_results`, + * which panics on any NaN (see the loop body for the rationale). + * + * Pure: no I/O, no clock. The benchmark's `crossCheck` is the imperative shell + * that fetches the series and turns a non-match into a thrown error. + */ +export function seriesClose(expected: Readonly, actual: Readonly): SeriesCloseResult { + if (expected.length !== actual.length) { + return { match: false, index: -1, expected: expected.length, actual: actual.length }; + } + for (let i = 0; i < expected.length; i++) { + const e = expected[i]; + const a = actual[i]; + // A non-finite value (NaN/Infinity) on EITHER side is a mismatch, checked + // before the tolerance comparison. `ensure_results` PANICS on any NaN -- + // approx_eq!(NaN, x) is false and NaN is never around-zero, so it would + // reject even NaN-vs-NaN. Relying on `Math.abs(e - a) > tol` alone would + // wave a NaN/finite divergence through, since NaN > tol is false. The wasm + // backend can legitimately emit genuine NaN (OOB vector reads, empty + // reducers), exactly the broken run this cross-check must reject. + if (!Number.isFinite(e) || !Number.isFinite(a)) { + return { match: false, index: i, expected: e, actual: a }; + } + const aroundZero = Math.abs(e) <= NEAR_ZERO_EXPECTED && Math.abs(a) <= NEAR_ZERO_ACTUAL; + if (aroundZero) { + continue; + } + if (Math.abs(e - a) > PARITY_ABS_TOL) { + return { match: false, index: i, expected: e, actual: a }; + } + } + return { match: true }; +} + +function summarize(times: ReadonlyArray): Stat { + const minMs = times.length === 0 ? NaN : Math.min(...times); + return { medianMs: median(times), minMs, iters: times.length }; +} + +// performance.now() returns fractional milliseconds. This is the module's single +// deliberate impurity (see the header note): a non-deterministic read that the +// FCIS Functional Core would otherwise forbid, permitted here ONLY as the +// default-injection seam for `now` -- the same exception class as a logger. +// Every public function takes `now` as a parameter, so the pure logic never +// reaches the clock except through it, and all tests inject a fake clock. +function defaultNow(): number { + return performance.now(); +} diff --git a/src/engine/tests/canonicalize.test.ts b/src/engine/tests/canonicalize.test.ts new file mode 100644 index 000000000..205db9600 --- /dev/null +++ b/src/engine/tests/canonicalize.test.ts @@ -0,0 +1,165 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +/** + * Unit tests for the engine-local, Rust-faithful canonicalizeIdent. + * + * The expected sentinel codepoints are written as explicit \uXXXX escapes, + * NEVER bare glyphs: U+2024 (ONE DOT LEADER, the literal-period sentinel), + * U+2025 (TWO DOT LEADER), and U+00B7 (MIDDLE DOT, the module separator) are + * visually indistinguishable in many fonts, so a copied glyph would silently + * assert the wrong codepoint and corrupt name resolution. + * + * Vectors are taken verbatim from the Rust test vectors in + * simlin-engine/src/common.rs (test_canonicalize and + * test_canonicalize_non_period_idents_byte_unchanged), the oracle this + * function must reproduce exactly. + */ + +import { canonicalizeIdent } from '../src/internal/canonicalize'; + +describe('canonicalizeIdent', () => { + it('lowercases and collapses whitespace to underscore', () => { + expect(canonicalizeIdent('Hello World')).toBe('hello_world'); + }); + + it('maps an unquoted dot to the U+00B7 module separator', () => { + expect(canonicalizeIdent('a.b')).toBe('a\u{00B7}b'); + }); + + it('maps a quoted-inner dot to the U+2024 literal-period sentinel', () => { + expect(canonicalizeIdent('"a.b"')).toBe('a\u{2024}b'); + }); + + it('handles an unquoted part followed by a quoted part', () => { + // a."b c" -> parts ["a.", "\"b c\""]: the unquoted "a." dot -> U+00B7, + // the quoted "b c" -> b_c. + expect(canonicalizeIdent('a."b c"')).toBe('a\u{00B7}b_c'); + }); + + it('maps a module path separator to U+00B7', () => { + expect(canonicalizeIdent('model.variable')).toBe('model\u{00B7}variable'); + }); + + it('treats the dot between two quoted parts as a module separator', () => { + expect(canonicalizeIdent('"a/d"."b c"')).toBe('a/d\u{00B7}b_c'); + }); + + it('treats the dot between a quoted and an unquoted part as a module separator', () => { + expect(canonicalizeIdent('"a/d".b')).toBe('a/d\u{00B7}b'); + }); + + it('strips surrounding quotes from a simple quoted ident', () => { + expect(canonicalizeIdent('"quoted"')).toBe('quoted'); + }); + + it('strips quotes and collapses inner whitespace', () => { + expect(canonicalizeIdent('"b c"')).toBe('b_c'); + }); + + it('passes non-ASCII through, lowercased', () => { + expect(canonicalizeIdent('café')).toBe('café'); + }); + + it('lowercases non-ASCII and turns a literal \\n escape into underscore', () => { + // 'Å\nb' is the three-char string Å, backslash-n's actual newline; Rust's + // expectation for this vector is 'å_b'. + expect(canonicalizeIdent('Å\nb')).toBe('å_b'); + }); + + it('trims leading whitespace and collapses interior whitespace', () => { + expect(canonicalizeIdent(' a b')).toBe('a_b'); + }); + + it('is a no-op on already-canonical input', () => { + expect(canonicalizeIdent('room_temperature')).toBe('room_temperature'); + }); + + // Additional vectors from common.rs that exercise the per-part order of + // operations (quote handling, backslash unescape, whitespace collapse). + it('collapses a run of mixed whitespace inside quotes to a single underscore', () => { + expect(canonicalizeIdent('a \n b')).toBe('a_b'); + }); + + it('lowercases a bare uppercase identifier', () => { + expect(canonicalizeIdent('Population')).toBe('population'); + }); + + it('collapses multiple unquoted whitespace tokens', () => { + expect(canonicalizeIdent('a b c')).toBe('a_b_c'); + }); + + it('treats a non-breaking space (U+00A0) as whitespace', () => { + expect(canonicalizeIdent('a\u{00A0}b')).toBe('a_b'); + }); + + it('collapses a literal \\r escape to a single underscore', () => { + // Two characters: backslash then r. + expect(canonicalizeIdent('a\\rb')).toBe('a_b'); + }); + + it('unescapes a doubled backslash to a single backslash', () => { + expect(canonicalizeIdent('a\\\\b')).toBe('a\\b'); + }); + + it('leaves the synthetic separator U+205A and the module dot untouched', () => { + expect(canonicalizeIdent('stdlib\u{205A}smth1')).toBe('stdlib\u{205A}smth1'); + expect(canonicalizeIdent('model\u{00B7}variable')).toBe('model\u{00B7}variable'); + }); + + it('canonicalizes a quoted ident with an escaped inner quote', () => { + // "a/d"."b \"c\"" -> a/d·b_\"c\" (Rust: "a/d·b_\\\"c\\\""). + expect(canonicalizeIdent('"a/d"."b \\"c\\""')).toBe('a/d\u{00B7}b_\\"c\\"'); + }); + + it('returns an empty string for an empty or all-whitespace input', () => { + expect(canonicalizeIdent('')).toBe(''); + expect(canonicalizeIdent(' ')).toBe(''); + }); + + describe('idempotency (canonicalizeIdent(canonicalizeIdent(x)) === canonicalizeIdent(x))', () => { + // Includes the #559 quoted-literal-period family that the Rust oracle pins + // (test_canonicalize_idempotent_quoted_period): a literal period inside a + // quoted name must canonicalize to a form that re-canonicalizes unchanged. + const inputs: ReadonlyArray = [ + 'Hello World', + 'a.b', + '"a.b"', + 'a."b c"', + 'model.variable', + '"a/d"."b c"', + '"a/d".b', + '"quoted"', + '"b c"', + 'café', + 'Å\nb', + ' a b', + 'room_temperature', + '"a.b c"', + '"Goal 1.5 for Temperature"', + '"goal_1.5_for_temperature"', + '"Fig. 3"', + '"v1.2 target"', + 'stdlib\u{205A}smth1', + 'model\u{00B7}variable', + 'a\\\\b', + ]; + + for (const input of inputs) { + it(`is idempotent for ${JSON.stringify(input)}`, () => { + const once = canonicalizeIdent(input); + const twice = canonicalizeIdent(once); + expect(twice).toBe(once); + }); + } + + it('never leaves a raw "." or maps a quoted period to U+00B7 (the #559 corruption)', () => { + for (const input of ['"a.b"', '"Goal 1.5 for Temperature"', '"Fig. 3"']) { + const once = canonicalizeIdent(input); + expect(once.includes('.')).toBe(false); + expect(once.includes('\u{00B7}')).toBe(false); + } + }); + }); +}); diff --git a/src/engine/tests/wasm-backend.test.ts b/src/engine/tests/wasm-backend.test.ts new file mode 100644 index 000000000..389346f3c --- /dev/null +++ b/src/engine/tests/wasm-backend.test.ts @@ -0,0 +1,921 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// pattern: Imperative Shell +// These are integration tests driving the imperative-shell DirectBackend +// directly (not yet through Model/Sim). The VM path is the correctness oracle: +// every wasm-engine operation is driven identically to the VM path and compared +// within the engine's existing simulation tolerance. + +import * as fs from 'fs'; +import * as path from 'path'; + +import { DirectBackend } from '../src/direct-backend'; +import { ModelHandle, ProjectHandle, SimHandle } from '../src/backend'; + +function loadWasmBuffer(): Buffer { + const wasmPath = path.join(__dirname, '..', 'core', 'libsimlin.wasm'); + return fs.readFileSync(wasmPath); +} + +// The teacup model: a scalar Euler model the wasm backend supports. It has the +// constant auxes `room temperature` and `characteristic time` (overridable) and +// the non-constant flow `heat loss to room` (rejected by setValue). +function loadTeacupXmile(): Uint8Array { + const xmilePath = path.join(__dirname, '..', '..', 'pysimlin', 'tests', 'fixtures', 'teacup.stmx'); + if (!fs.existsSync(xmilePath)) { + throw new Error('Required test XMILE model not found: ' + xmilePath); + } + return fs.readFileSync(xmilePath); +} + +// A model the wasm backend cannot compile: `summed = SUM(source[lo:hi])` uses a +// runtime view range `[lo:hi]` whose bounds reference scalar auxes (not +// constants and not dimension elements), so the range cannot be constant-folded +// and codegen emits the `ViewRangeDynamic` opcode, which wasmgen reports as +// Unsupported (GH #612). The VM runs the same model fine. This was verified +// against the engine: the VM ran it and `compile_datamodel_to_wasm` returned +// "wasmgen: ViewRangeDynamic (dim 0) needs a runtime view size; not supported". +const WASM_UNSUPPORTED_XMILE = ` + +
+ Test + Simlin +
+ + 0 + 2 +
1
+
+ + + + + + + + + + + 1 + 2 + 3 + + + 1 + 3 + SUM(source[lo:hi]) + + +
`; + +describe('DirectBackend wasm engine: sim creation and disposal (Task 3)', () => { + let backend: DirectBackend; + + beforeAll(async () => { + backend = new DirectBackend(); + backend.reset(); + backend.configureWasm({ source: loadWasmBuffer() }); + await backend.init(); + }); + + afterAll(() => { + backend.reset(); + }); + + describe('AC1.1: wasm sim creation', () => { + let projectHandle: ProjectHandle; + let modelHandle: ModelHandle; + + beforeEach(() => { + projectHandle = backend.projectOpenXmile(loadTeacupXmile()); + modelHandle = backend.projectGetModel(projectHandle, null); + }); + + afterEach(() => { + backend.modelDispose(modelHandle); + backend.projectDispose(projectHandle); + }); + + it('creates a wasm-backed sim handle', () => { + const sim = backend.simNew(modelHandle, false, 'wasm'); + expect(typeof sim).toBe('number'); + expect(sim).toBeGreaterThan(0); + backend.simDispose(sim); + }); + + it('defaults to a vm-backed sim when no engine is passed', () => { + const sim = backend.simNew(modelHandle, false); + expect(typeof sim).toBe('number'); + expect(sim).toBeGreaterThan(0); + backend.simDispose(sim); + }); + + it("creates a vm-backed sim when engine is 'vm'", () => { + const sim = backend.simNew(modelHandle, false, 'vm'); + expect(typeof sim).toBe('number'); + expect(sim).toBeGreaterThan(0); + backend.simDispose(sim); + }); + }); + + describe('AC6.2: enableLtm rejected on wasm engine', () => { + it('throws a clear error and creates no sim', () => { + const projectHandle = backend.projectOpenXmile(loadTeacupXmile()); + const modelHandle = backend.projectGetModel(projectHandle, null); + expect(() => backend.simNew(modelHandle, true, 'wasm')).toThrow(/LTM/i); + backend.modelDispose(modelHandle); + backend.projectDispose(projectHandle); + }); + + it('rejects enableLtm before attempting any compile (clear message)', () => { + const projectHandle = backend.projectOpenXmile(loadTeacupXmile()); + const modelHandle = backend.projectGetModel(projectHandle, null); + expect(() => backend.simNew(modelHandle, true, 'wasm')).toThrow(/not supported on the wasm engine/i); + backend.modelDispose(modelHandle); + backend.projectDispose(projectHandle); + }); + }); + + describe('AC7.1/AC7.2: unsupported model errors on wasm, runs on vm', () => { + it('throws on wasm with no VM fallback', () => { + const projectHandle = backend.projectOpenXmile(new TextEncoder().encode(WASM_UNSUPPORTED_XMILE)); + const modelHandle = backend.projectGetModel(projectHandle, null); + expect(() => backend.simNew(modelHandle, false, 'wasm')).toThrow(); + backend.modelDispose(modelHandle); + backend.projectDispose(projectHandle); + }); + + it('runs the same model fine via the vm engine', () => { + const projectHandle = backend.projectOpenXmile(new TextEncoder().encode(WASM_UNSUPPORTED_XMILE)); + const modelHandle = backend.projectGetModel(projectHandle, null); + const sim = backend.simNew(modelHandle, false, 'vm'); + expect(() => backend.simRunToEnd(sim)).not.toThrow(); + const series = backend.simGetSeries(sim, 'summed'); + expect(series).toBeInstanceOf(Float64Array); + // SUM(source[1:3]) = 1 + 2 + 3 = 6 at every step. + expect(series[0]).toBeCloseTo(6, 9); + backend.simDispose(sim); + backend.modelDispose(modelHandle); + backend.projectDispose(projectHandle); + }); + }); + + describe('AC5.4 (creation half): the wasm instance is owned once on the entry', () => { + // The handle store is private; reach into it to assert the entry's recorded + // wasm state. This is a white-box check of the imperative shell -- the blob + // is owned on the entry so it is created exactly once (Task 4 then reuses it + // across reset/setValue/re-run with no recompile). + type EntryView = { + engine?: string; + ptr: number; + wasmInstance?: WebAssembly.Instance; + wasmLayout?: { nChunks: number }; + wasmStopTime?: number; + }; + function entryOf(sim: SimHandle): EntryView { + const handles = (backend as unknown as { _handles: Map })._handles; + const entry = handles.get(sim as unknown as number); + if (!entry) { + throw new Error('sim entry not found'); + } + return entry; + } + + it('records engine, a live instance, layout, and stop time on the entry', () => { + const projectHandle = backend.projectOpenXmile(loadTeacupXmile()); + const modelHandle = backend.projectGetModel(projectHandle, null); + const sim = backend.simNew(modelHandle, false, 'wasm'); + + const entry = entryOf(sim); + expect(entry.engine).toBe('wasm'); + expect(entry.ptr).toBe(0); // no native sim pointer + expect(entry.wasmInstance).toBeInstanceOf(WebAssembly.Instance); + expect(entry.wasmLayout?.nChunks).toBeGreaterThan(0); + // teacup: start 0, stop 30. + expect(entry.wasmStopTime).toBe(30); + + backend.simDispose(sim); + backend.modelDispose(modelHandle); + backend.projectDispose(projectHandle); + }); + + it('owns a distinct instance per sim (each created once)', () => { + const projectHandle = backend.projectOpenXmile(loadTeacupXmile()); + const modelHandle = backend.projectGetModel(projectHandle, null); + const simA = backend.simNew(modelHandle, false, 'wasm'); + const simB = backend.simNew(modelHandle, false, 'wasm'); + + const instA = entryOf(simA).wasmInstance; + const instB = entryOf(simB).wasmInstance; + expect(instA).toBeInstanceOf(WebAssembly.Instance); + expect(instB).toBeInstanceOf(WebAssembly.Instance); + expect(instA).not.toBe(instB); + + backend.simDispose(simA); + backend.simDispose(simB); + backend.modelDispose(modelHandle); + backend.projectDispose(projectHandle); + }); + }); + + describe('disposal of wasm sims', () => { + it('disposes a wasm sim without throwing and is idempotent', () => { + const projectHandle = backend.projectOpenXmile(loadTeacupXmile()); + const modelHandle = backend.projectGetModel(projectHandle, null); + const sim = backend.simNew(modelHandle, false, 'wasm'); + expect(() => backend.simDispose(sim)).not.toThrow(); + expect(() => backend.simDispose(sim)).not.toThrow(); + backend.modelDispose(modelHandle); + backend.projectDispose(projectHandle); + }); + + it('does not call simlin_sim_unref for a wasm sim (no native sim ptr)', () => { + const projectHandle = backend.projectOpenXmile(loadTeacupXmile()); + const modelHandle = backend.projectGetModel(projectHandle, null); + const sim = backend.simNew(modelHandle, false, 'wasm'); + // A wasm entry carries ptr 0; the disposal path must not unref a 0 ptr. + // We verify behaviorally: disposing twice never throws and a subsequent + // operation reports the handle as disposed (the FFI was never touched). + backend.simDispose(sim); + expect(() => backend.simRunToEnd(sim)).toThrow(/disposed/); + backend.modelDispose(modelHandle); + backend.projectDispose(projectHandle); + }); + + it('cleans up wasm child sims when the project is disposed', () => { + const projectHandle = backend.projectOpenXmile(loadTeacupXmile()); + const modelHandle = backend.projectGetModel(projectHandle, null); + const sim = backend.simNew(modelHandle, false, 'wasm'); + expect(() => backend.projectDispose(projectHandle)).not.toThrow(); + expect(() => backend.simRunToEnd(sim)).toThrow(/disposed/); + }); + }); + + // A disposed wasm entry is kept in the handle map as a tombstone (so a + // use-after-dispose still throws the clear "has been disposed" diagnostic), + // but it must NOT keep pinning the heavy wasm state -- the WebAssembly.Instance + // and decoded layout -- or memory grows unbounded across create/dispose cycles + // even though simDispose was called. These white-box checks reach into the + // private handle map to confirm the heavy refs are released on dispose while + // the tombstone (and its disposed-error semantics) is preserved. + describe('disposal releases heavy wasm state but keeps the tombstone', () => { + type DisposedEntryView = { + disposed: boolean; + wasmInstance?: WebAssembly.Instance; + wasmLayout?: unknown; + wasmExports?: unknown; + }; + function entryOf(sim: SimHandle): DisposedEntryView { + const handles = (backend as unknown as { _handles: Map })._handles; + const entry = handles.get(sim as unknown as number); + if (!entry) { + throw new Error('sim entry not found'); + } + return entry; + } + + it('simDispose releases the wasm instance, exports, and layout', () => { + const projectHandle = backend.projectOpenXmile(loadTeacupXmile()); + const modelHandle = backend.projectGetModel(projectHandle, null); + const sim = backend.simNew(modelHandle, false, 'wasm'); + + // Precondition: a live wasm sim holds all three heavy refs. + const live = entryOf(sim); + expect(live.wasmInstance).toBeInstanceOf(WebAssembly.Instance); + expect(live.wasmExports).toBeDefined(); + expect(live.wasmLayout).toBeDefined(); + + backend.simDispose(sim); + + // The tombstone is preserved (entry still present, marked disposed) ... + const dead = entryOf(sim); + expect(dead.disposed).toBe(true); + // ... but the heavy wasm state is released so GC can reclaim it. + expect(dead.wasmInstance).toBeUndefined(); + expect(dead.wasmExports).toBeUndefined(); + expect(dead.wasmLayout).toBeUndefined(); + + // Nulling the heavy fields must not change the disposed-error semantics: + // getEntry checks `disposed` before touching any wasm field. + expect(() => backend.simRunToEnd(sim)).toThrow(/disposed/); + + backend.modelDispose(modelHandle); + backend.projectDispose(projectHandle); + }); + + it('projectDispose releases heavy wasm state of a live child sim', () => { + const projectHandle = backend.projectOpenXmile(loadTeacupXmile()); + const modelHandle = backend.projectGetModel(projectHandle, null); + const sim = backend.simNew(modelHandle, false, 'wasm'); + + // Disposing the project must cascade the same release to its wasm child. + backend.projectDispose(projectHandle); + + const dead = entryOf(sim); + expect(dead.disposed).toBe(true); + expect(dead.wasmInstance).toBeUndefined(); + expect(dead.wasmExports).toBeUndefined(); + expect(dead.wasmLayout).toBeUndefined(); + expect(() => backend.simRunToEnd(sim)).toThrow(/disposed/); + }); + }); +}); + +// VM-vs-wasm parity: the bytecode VM is the correctness oracle. Each wasm-engine +// operation is driven identically to the VM path and compared within a tight +// tolerance (the wasm blob mirrors the VM opcode-for-opcode, so identical f64 +// arithmetic is expected). Teacup is the supported scalar fixture; its constant +// `room temperature` is the override exercised by the setValue cases. +describe('DirectBackend wasm engine: per-op vm/wasm parity (Task 4)', () => { + let backend: DirectBackend; + + // Tolerance for VM-vs-wasm comparison. Both executors run the same compiled + // simulation, so the difference is at most floating-point reassociation noise. + const TOL = 1e-9; + + beforeAll(async () => { + backend = new DirectBackend(); + backend.reset(); + backend.configureWasm({ source: loadWasmBuffer() }); + await backend.init(); + }); + + afterAll(() => { + backend.reset(); + }); + + // Open teacup and return both a vm sim and a wasm sim for the same model, plus + // a disposer. Each test drives the two identically and compares. + function openPair(): { + vm: SimHandle; + wasm: SimHandle; + projectHandle: ProjectHandle; + modelHandle: ModelHandle; + dispose: () => void; + } { + const projectHandle = backend.projectOpenXmile(loadTeacupXmile()); + const modelHandle = backend.projectGetModel(projectHandle, null); + const vm = backend.simNew(modelHandle, false, 'vm'); + const wasm = backend.simNew(modelHandle, false, 'wasm'); + const dispose = () => { + backend.simDispose(vm); + backend.simDispose(wasm); + backend.modelDispose(modelHandle); + backend.projectDispose(projectHandle); + }; + return { vm, wasm, projectHandle, modelHandle, dispose }; + } + + function expectSeriesClose(actual: Float64Array, expected: Float64Array): void { + expect(actual.length).toBe(expected.length); + for (let i = 0; i < expected.length; i++) { + expect(Math.abs(actual[i] - expected[i])).toBeLessThanOrEqual(TOL); + } + } + + describe('AC2.1: runToEnd series parity', () => { + it('wasm runToEnd series equal the VM for every variable', () => { + const { vm, wasm, dispose } = openPair(); + backend.simRunToEnd(vm); + backend.simRunToEnd(wasm); + + const names = backend.simGetVarNames(wasm); + expect(names.length).toBeGreaterThan(0); + for (const name of names) { + expectSeriesClose(backend.simGetSeries(wasm, name), backend.simGetSeries(vm, name)); + } + dispose(); + }); + }); + + describe('AC2.2: runTo(t) then getValue parity', () => { + // The VM's get_value_now reads its live curr chunk; after a run_to(t) that + // stops mid-interval the curr chunk holds the integrated state (stocks + the + // reserved time vars) but NOT the dependent flows/auxes/constants, because + // the VM's Euler loop breaks before evaluating the chunk whose time exceeds + // t (vm.rs:711). The wasm read is the byte-identical base-0 curr-chunk read + // (the determined source of truth) and agrees with the VM exactly on the + // integrated state mid-run. Both agree on EVERY variable after a full run + // (covered by 'getValue after runToEnd equals the VM for every variable'). + it('wasm getValue after runTo(t) equals the VM on the integrated state', () => { + const { vm, wasm, dispose } = openPair(); + const t = 5; + backend.simRunTo(vm, t); + backend.simRunTo(wasm, t); + + // The stock and the reserved time vars are the well-defined "value at t". + for (const name of ['teacup_temperature', 'time', 'dt', 'initial_time', 'final_time']) { + expect(Math.abs(backend.simGetValue(wasm, name) - backend.simGetValue(vm, name))).toBeLessThanOrEqual(TOL); + } + // simGetTime must agree too (it reads slot 0 of the live curr chunk). + expect(Math.abs(backend.simGetTime(wasm) - backend.simGetTime(vm))).toBeLessThanOrEqual(TOL); + dispose(); + }); + + it('getValue after runToEnd equals the VM for every variable', () => { + const { vm, wasm, dispose } = openPair(); + backend.simRunToEnd(vm); + backend.simRunToEnd(wasm); + // After a full run the curr chunk is fully evaluated, so every variable -- + // stocks, flows, auxes, constants, and the reserved time vars -- matches. + for (const name of backend.simGetVarNames(wasm)) { + expect(Math.abs(backend.simGetValue(wasm, name) - backend.simGetValue(vm, name))).toBeLessThanOrEqual(TOL); + } + dispose(); + }); + }); + + describe('AC2.3: segmented runTo equals a single runTo and the VM', () => { + it('runTo(t1)+runTo(t2) equals runTo(t2) and the VM', () => { + const projectHandle = backend.projectOpenXmile(loadTeacupXmile()); + const modelHandle = backend.projectGetModel(projectHandle, null); + const vm = backend.simNew(modelHandle, false, 'vm'); + const wasmSeg = backend.simNew(modelHandle, false, 'wasm'); + const wasmOne = backend.simNew(modelHandle, false, 'wasm'); + + const t1 = 7; + const t2 = 19; + backend.simRunTo(vm, t2); + backend.simRunTo(wasmSeg, t1); + backend.simRunTo(wasmSeg, t2); + backend.simRunTo(wasmOne, t2); + + // Segmented vs single (wasm-vs-wasm): both fully evaluate their stopping + // chunk, so getValue agrees on EVERY variable -- this is the core "segments + // accumulate to the same place" check. + for (const name of backend.simGetVarNames(wasmSeg)) { + expect(Math.abs(backend.simGetValue(wasmSeg, name) - backend.simGetValue(wasmOne, name))).toBeLessThanOrEqual( + TOL, + ); + } + // Against the VM: the live integrated state (stock + time) matches mid-run. + // (Mid-run getSeries is unavailable on the VM -- it builds Results only at + // the end -- and non-stock getValue is a VM artifact mid-run; see AC2.2.) + for (const name of ['teacup_temperature', 'time']) { + expect(Math.abs(backend.simGetValue(wasmSeg, name) - backend.simGetValue(vm, name))).toBeLessThanOrEqual(TOL); + } + + backend.simDispose(vm); + backend.simDispose(wasmSeg); + backend.simDispose(wasmOne); + backend.modelDispose(modelHandle); + backend.projectDispose(projectHandle); + }); + }); + + describe('AC2.4: runTo past the stop time clamps to the end', () => { + it('runTo(stop*2) equals runToEnd and the VM', () => { + const projectHandle = backend.projectOpenXmile(loadTeacupXmile()); + const modelHandle = backend.projectGetModel(projectHandle, null); + const vm = backend.simNew(modelHandle, false, 'vm'); + const wasmPast = backend.simNew(modelHandle, false, 'wasm'); + const wasmEnd = backend.simNew(modelHandle, false, 'wasm'); + + // teacup stop is 30; run well past it. + backend.simRunToEnd(vm); + backend.simRunTo(wasmPast, 60); + backend.simRunToEnd(wasmEnd); + + for (const name of backend.simGetVarNames(wasmPast)) { + expectSeriesClose(backend.simGetSeries(wasmPast, name), backend.simGetSeries(wasmEnd, name)); + expectSeriesClose(backend.simGetSeries(wasmPast, name), backend.simGetSeries(vm, name)); + } + + backend.simDispose(vm); + backend.simDispose(wasmPast); + backend.simDispose(wasmEnd); + backend.modelDispose(modelHandle); + backend.projectDispose(projectHandle); + }); + }); + + describe('AC3.1/AC3.2: reset parity', () => { + it('reset then re-run reproduces the compiled defaults (matches VM)', () => { + const { vm, wasm, dispose } = openPair(); + backend.simRunToEnd(wasm); + const before = backend.simGetSeries(wasm, 'teacup_temperature'); + + backend.simReset(wasm); + backend.simRunToEnd(wasm); + const after = backend.simGetSeries(wasm, 'teacup_temperature'); + + // Reset+re-run with no override reproduces the same defaults. + expectSeriesClose(after, before); + + // And matches the VM run. + backend.simRunToEnd(vm); + expectSeriesClose(after, backend.simGetSeries(vm, 'teacup_temperature')); + dispose(); + }); + + it('reset preserves a constant override (matches VM reset semantics)', () => { + const { vm, wasm, dispose } = openPair(); + + // Override the same constant on both, run, reset, run again. The VM's + // reset preserves overrides; the wasm reset must do the same. + backend.simSetValue(vm, 'room temperature', 40); + backend.simSetValue(wasm, 'room temperature', 40); + backend.simRunToEnd(vm); + backend.simRunToEnd(wasm); + backend.simReset(vm); + backend.simReset(wasm); + backend.simRunToEnd(vm); + backend.simRunToEnd(wasm); + + for (const name of backend.simGetVarNames(wasm)) { + expectSeriesClose(backend.simGetSeries(wasm, name), backend.simGetSeries(vm, name)); + } + // Sanity: the override is still in effect after reset (room temperature 40, + // not the compiled default 70). + expect(backend.simGetSeries(wasm, 'room_temperature')[0]).toBeCloseTo(40, 9); + dispose(); + }); + + // reset must return the live curr chunk (what getTime/getValue read) to the + // fresh pre-run state, not leave the previous run's end-of-run values there. + // The blob's reset clears only the run cursor (mirroring Vm::reset), so the + // host must present the fresh state -- exactly as libsimlin's FFI does by + // recreating a zeroed VM. The VM reads 0 for every variable after reset (a + // freshly-created sim reads 0); the wasm twin must match, not leak stale tail. + it('reset returns the live curr state to the fresh pre-run state (matches the VM)', () => { + const { vm, wasm, dispose } = openPair(); + + // Run both to the end so the live curr chunk holds end-of-run values, then + // confirm they agree there before reset (precondition for a meaningful test). + backend.simRunToEnd(vm); + backend.simRunToEnd(wasm); + expect(backend.simGetValue(wasm, 'teacup_temperature')).toBeGreaterThan(0); + + backend.simReset(vm); + backend.simReset(wasm); + + // After reset (no re-run) every by-name read must equal the VM's fresh-state + // value, not the previous run's stale tail. Both are exactly 0 here. + for (const name of backend.simGetVarNames(wasm)) { + expect(backend.simGetValue(wasm, name)).toBe(backend.simGetValue(vm, name)); + } + expect(backend.simGetTime(wasm)).toBe(backend.simGetTime(vm)); + // Spot-check the well-known reads: the stock and the reserved time var. + expect(backend.simGetValue(wasm, 'teacup_temperature')).toBe(0); + expect(backend.simGetTime(wasm)).toBe(0); + dispose(); + }); + }); + + describe('AC4.1/AC4.2/AC4.4: by-name reads parity', () => { + it('getSeries for every variable equals the VM', () => { + const { vm, wasm, dispose } = openPair(); + backend.simRunToEnd(vm); + backend.simRunToEnd(wasm); + for (const name of backend.simGetVarNames(wasm)) { + expectSeriesClose(backend.simGetSeries(wasm, name), backend.simGetSeries(vm, name)); + } + dispose(); + }); + + it('getVarNames and getStepCount equal the VM (exact array equality)', () => { + const { vm, wasm, dispose } = openPair(); + backend.simRunToEnd(vm); + backend.simRunToEnd(wasm); + + // The VM's getVarNames includes the reserved time vars (it filters only + // $-prefixed names); the wasm path must produce the identical array. + expect(backend.simGetVarNames(wasm)).toEqual(backend.simGetVarNames(vm)); + expect(backend.simGetStepCount(wasm)).toBe(backend.simGetStepCount(vm)); + + // The reserved names are present (not filtered out). + const names = backend.simGetVarNames(wasm); + expect(names).toContain('time'); + expect(names).toContain('dt'); + expect(names).toContain('initial_time'); + expect(names).toContain('final_time'); + dispose(); + }); + + it('getSeries(unknownName) throws like the VM', () => { + const { vm, wasm, dispose } = openPair(); + backend.simRunToEnd(vm); + backend.simRunToEnd(wasm); + expect(() => backend.simGetSeries(vm, 'definitely_not_a_var')).toThrow(); + expect(() => backend.simGetSeries(wasm, 'definitely_not_a_var')).toThrow(); + dispose(); + }); + }); + + describe('AC4.3: getSeries returns a single Float64Array of length nChunks', () => { + it('returns one Float64Array whose length equals the step count', () => { + const { wasm, dispose } = openPair(); + backend.simRunToEnd(wasm); + const stepCount = backend.simGetStepCount(wasm); + const series = backend.simGetSeries(wasm, 'teacup_temperature'); + expect(series).toBeInstanceOf(Float64Array); + expect(series.length).toBe(stepCount); + dispose(); + }); + }); + + // getSeries must truncate to the COMPLETED-step count, never the slab capacity + // (nChunks). The wasm results slab keeps its full capacity across a partial run + // and across reset (reset clears the run cursor but does NOT zero the slab), so + // reading nChunks rows unconditionally would surface uncommitted/stale tail + // rows. The VM truncates by step count -- it returns only saved rows mid-run + // and libsimlin further bounds the read by the passed count -- so the wasm twin + // must do the same to keep getSeries().length == getStepCount() at parity. + describe('getSeries truncates to completed steps (not slab capacity)', () => { + it('returns the VM full-run prefix after a partial runTo(t), with no stale tail', () => { + const { vm, wasm, dispose } = openPair(); + backend.simRunToEnd(vm); + const vmFull = backend.simGetSeries(vm, 'teacup_temperature'); + + // teacup: start 0, stop 30; stop at a strictly-interior time so saved_steps + // is strictly less than nChunks -- the window where the bug surfaces. + backend.simRunTo(wasm, 15); + const partial = backend.simGetStepCount(wasm); + expect(partial).toBeGreaterThan(0); + expect(partial).toBeLessThan(vmFull.length); + + const wasmPartial = backend.simGetSeries(wasm, 'teacup_temperature'); + // Length tracks completed steps, not the slab capacity ... + expect(wasmPartial.length).toBe(partial); + // ... and the committed rows are exactly the VM full-run's prefix. + expectSeriesClose(wasmPartial, vmFull.slice(0, partial)); + dispose(); + }); + + it('returns an empty series after reset (the prior run is not surfaced as a stale tail)', () => { + const { wasm, dispose } = openPair(); + backend.simRunToEnd(wasm); + expect(backend.simGetSeries(wasm, 'teacup_temperature').length).toBeGreaterThan(0); + + backend.simReset(wasm); + // saved_steps is 0 after reset even though the slab still holds the prior + // run's rows; getSeries must agree with getStepCount and report 0 rows. + expect(backend.simGetStepCount(wasm)).toBe(0); + expect(backend.simGetSeries(wasm, 'teacup_temperature').length).toBe(0); + dispose(); + }); + }); + + // getStepCount reports COMPLETED steps, not the slab capacity (nChunks). A + // fresh or just-reset wasm sim has saved no rows yet, so it must report 0 -- + // matching the documented "number of simulation steps completed" contract and + // the VM (whose count only becomes nonzero once a run has produced Results). + // After a full run the count equals nChunks and the VM's count (parity). + describe('getStepCount reflects completed steps (not slab capacity)', () => { + it('is 0 on a fresh wasm sim, equals the VM after a full run, 0 again after reset', () => { + const { vm, wasm, dispose } = openPair(); + + // Fresh: no run has happened, so no rows are saved. + expect(backend.simGetStepCount(wasm)).toBe(0); + + // After a full run: equals nChunks (the slab capacity) and equals the VM. + backend.simRunToEnd(vm); + backend.simRunToEnd(wasm); + const fullCount = backend.simGetStepCount(vm); + expect(fullCount).toBeGreaterThan(0); + expect(backend.simGetStepCount(wasm)).toBe(fullCount); + + // After reset (no re-run): back to 0. + backend.simReset(wasm); + expect(backend.simGetStepCount(wasm)).toBe(0); + + // After re-running: the completed count returns to the full count. + backend.simRunToEnd(wasm); + expect(backend.simGetStepCount(wasm)).toBe(fullCount); + dispose(); + }); + + it('is strictly between 0 and the full count after a partial runTo(t)', () => { + const { vm, wasm, dispose } = openPair(); + backend.simRunToEnd(vm); + const fullCount = backend.simGetStepCount(vm); + + // teacup: start 0, stop 30; run to a strictly-interior time. + backend.simRunTo(wasm, 15); + const partial = backend.simGetStepCount(wasm); + expect(partial).toBeGreaterThan(0); + expect(partial).toBeLessThan(fullCount); + dispose(); + }); + }); + + describe('AC5.1/AC5.2/AC5.3: setValue (constants only) + mid-run', () => { + it('setValue(const) then run matches the VM under the same override', () => { + const { vm, wasm, dispose } = openPair(); + backend.simSetValue(vm, 'room temperature', 55); + backend.simSetValue(wasm, 'room temperature', 55); + backend.simRunToEnd(vm); + backend.simRunToEnd(wasm); + for (const name of backend.simGetVarNames(wasm)) { + expectSeriesClose(backend.simGetSeries(wasm, name), backend.simGetSeries(vm, name)); + } + dispose(); + }); + + // setValue must update the live curr state, not only the override region read + // by the next run. The VM's apply_override writes the new value into the live + // curr chunk immediately (set_value_now, vm.rs:869-873), so getValue() reflects + // the override before any run; the blob's set_value only writes the constants + // region, so the wasm host must mirror the live write to keep an interactive + // read at parity (the divergence the reviewer flagged). + it('setValue(const) is reflected by getValue immediately, before any run (matches the VM)', () => { + const { vm, wasm, dispose } = openPair(); + // No run has happened: a fresh sim's live curr chunk is the pre-run zero + // state, so this read exercises the override write, not a run's output. + backend.simSetValue(vm, 'room temperature', 55); + backend.simSetValue(wasm, 'room temperature', 55); + + expect(backend.simGetValue(vm, 'room_temperature')).toBe(55); + expect(backend.simGetValue(wasm, 'room_temperature')).toBe(backend.simGetValue(vm, 'room_temperature')); + dispose(); + }); + + it('setValue(nonConstant) throws, matching the VM constants-only rejection', () => { + const { vm, wasm, dispose } = openPair(); + // heat_loss_to_room is a flow (computed), not a settable constant. + expect(() => backend.simSetValue(vm, 'heat loss to room', 1)).toThrow(); + expect(() => backend.simSetValue(wasm, 'heat loss to room', 1)).toThrow(); + dispose(); + }); + + it('setValue(unknownVariable) throws', () => { + const { wasm, dispose } = openPair(); + expect(() => backend.simSetValue(wasm, 'definitely_not_a_var', 1)).toThrow(); + dispose(); + }); + + it('mid-run setValue affects only post-t1 steps (matches VM driven identically)', () => { + const { vm, wasm, dispose } = openPair(); + const t1 = 10; + backend.simRunTo(vm, t1); + backend.simRunTo(wasm, t1); + backend.simSetValue(vm, 'room temperature', 30); + backend.simSetValue(wasm, 'room temperature', 30); + backend.simRunToEnd(vm); + backend.simRunToEnd(wasm); + + // Full-series parity against the VM driven the same way. + for (const name of backend.simGetVarNames(wasm)) { + expectSeriesClose(backend.simGetSeries(wasm, name), backend.simGetSeries(vm, name)); + } + dispose(); + }); + }); + + describe('AC6.1: getLinks rejected on the wasm engine', () => { + it('getLinks on a wasm sim throws a clear error', () => { + const { vm, wasm, dispose } = openPair(); + // The VM path returns links (empty with LTM off); the wasm path rejects. + expect(() => backend.simGetLinks(vm)).not.toThrow(); + expect(() => backend.simGetLinks(wasm)).toThrow(/not supported on the wasm engine/i); + dispose(); + }); + }); +}); + +// A statically-arrayed model the wasm backend supports. `source` is dimensioned +// over `Dim` (a STATIC dimension -- NOT a dynamic `[lo:hi]` view range, which is +// the unsupported case), and `scaled` is an arrayed aux derived from it. Its +// layout keys are per-element with the canonical base + bracketed canonical +// element name (verified empirically: `source[boston]`, `scaled[la]`, ...). This +// exercises the part of the name pipeline that scalar teacup cannot: a raw, +// mixed-case array-element name (`source[Boston]`) flowing through +// canonicalizeIdent -> wasmLayout.varOffsets lookup -> readStridedSeries. +const WASM_ARRAYED_XMILE = ` + +
+ Test + Simlin +
+ + 0 + 2 +
1
+
+ + + + + + + + + + 10 + 20 + + + + source*2 + + + + +
`; + +// End-to-end name resolution for NON-SCALAR variables (the design's "correctness +// crux"). canonicalizeIdent is proven correct in isolation, but the TS-side +// canonicalize -> varOffsets lookup -> strided read had no test for an array +// element name (a key containing `[`/`]`); scalar teacup never exercises it. The +// VM is the oracle here, driven identically to wasm and compared within TOL. +describe('DirectBackend wasm engine: end-to-end name resolution for arrayed vars', () => { + let backend: DirectBackend; + const TOL = 1e-9; + + beforeAll(async () => { + backend = new DirectBackend(); + backend.reset(); + backend.configureWasm({ source: loadWasmBuffer() }); + await backend.init(); + }); + + afterAll(() => { + backend.reset(); + }); + + function expectSeriesClose(actual: Float64Array, expected: Float64Array): void { + expect(actual.length).toBe(expected.length); + for (let i = 0; i < expected.length; i++) { + expect(Math.abs(actual[i] - expected[i])).toBeLessThanOrEqual(TOL); + } + } + + function openPair(): { + vm: SimHandle; + wasm: SimHandle; + dispose: () => void; + } { + const projectHandle = backend.projectOpenXmile(new TextEncoder().encode(WASM_ARRAYED_XMILE)); + const modelHandle = backend.projectGetModel(projectHandle, null); + const vm = backend.simNew(modelHandle, false, 'vm'); + const wasm = backend.simNew(modelHandle, false, 'wasm'); + const dispose = () => { + backend.simDispose(vm); + backend.simDispose(wasm); + backend.modelDispose(modelHandle); + backend.projectDispose(projectHandle); + }; + return { vm, wasm, dispose }; + } + + // Guard the precondition the rest of the suite relies on: the fixture must be + // a wasm-SUPPORTED model. If a future engine change made a static array + // unsupported, this fails loudly here rather than masquerading as a parity bug. + it('the static-arrayed fixture compiles to wasm without throwing', () => { + const projectHandle = backend.projectOpenXmile(new TextEncoder().encode(WASM_ARRAYED_XMILE)); + const modelHandle = backend.projectGetModel(projectHandle, null); + let wasm: SimHandle | undefined; + expect(() => { + wasm = backend.simNew(modelHandle, false, 'wasm'); + }).not.toThrow(); + if (wasm !== undefined) { + backend.simDispose(wasm); + } + backend.modelDispose(modelHandle); + backend.projectDispose(projectHandle); + }); + + it('getVarNames (wasm) exposes the per-element bracketed keys and equals the VM', () => { + const { vm, wasm, dispose } = openPair(); + backend.simRunToEnd(vm); + backend.simRunToEnd(wasm); + + const names = backend.simGetVarNames(wasm); + expect(names).toEqual(backend.simGetVarNames(vm)); + // The arrayed vars appear as canonical per-element keys (base + bracketed, + // lowercased element name), not as a bare scalar base name. + expect(names).toContain('source[boston]'); + expect(names).toContain('source[la]'); + expect(names).toContain('scaled[boston]'); + expect(names).toContain('scaled[la]'); + dispose(); + }); + + it('getSeries resolves a raw mixed-case array-element name to the VM series', () => { + const { vm, wasm, dispose } = openPair(); + backend.simRunToEnd(vm); + backend.simRunToEnd(wasm); + + // Each name is passed RAW (mixed case, original element casing) so the read + // path must canonicalize it (`source[Boston]` -> `source[boston]`) before the + // varOffsets lookup -- the exact integration scalar teacup cannot cover. + for (const rawName of ['source[Boston]', 'source[LA]', 'scaled[Boston]', 'scaled[LA]']) { + expectSeriesClose(backend.simGetSeries(wasm, rawName), backend.simGetSeries(vm, rawName)); + } + dispose(); + }); + + it('getSeries (wasm) equals the VM for every variable in the arrayed layout', () => { + const { vm, wasm, dispose } = openPair(); + backend.simRunToEnd(vm); + backend.simRunToEnd(wasm); + const names = backend.simGetVarNames(wasm); + expect(names.length).toBeGreaterThan(0); + for (const name of names) { + expectSeriesClose(backend.simGetSeries(wasm, name), backend.simGetSeries(vm, name)); + } + dispose(); + }); +}); diff --git a/src/engine/tests/wasm-model.test.ts b/src/engine/tests/wasm-model.test.ts new file mode 100644 index 000000000..628ada79c --- /dev/null +++ b/src/engine/tests/wasm-model.test.ts @@ -0,0 +1,277 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// pattern: Imperative Shell +// Public-API parity tests for engine selection. These drive the full facade +// (Project -> Model -> Sim -> Run) rather than DirectBackend directly. The +// bytecode VM is the correctness oracle: a wasm-engine Sim/Run is compared +// against the VM-engine result for the same model within a tight tolerance +// (the wasm blob mirrors the VM opcode-for-opcode). The default (no-engine) +// path must keep behaving exactly as the VM does today. + +import * as fs from 'fs'; +import * as path from 'path'; + +import { Project, Model, Sim, Run, configureWasm, ready, resetWasm } from '../src'; + +// Configure the node DirectBackend with the libsimlin wasm singleton. Mirrors +// api.test.ts; the per-model wasm blob compiled for engine:'wasm' is a separate +// WebAssembly.Instance, independent of this singleton. +async function loadWasm(): Promise { + const wasmPath = path.join(__dirname, '..', 'core', 'libsimlin.wasm'); + const wasmBuffer = fs.readFileSync(wasmPath); + await resetWasm(); + configureWasm({ source: wasmBuffer }); + await ready(); +} + +// Teacup: a scalar Euler model the wasm backend fully supports. +function loadTeacupXmile(): Uint8Array { + const xmilePath = path.join(__dirname, '..', '..', 'pysimlin', 'tests', 'fixtures', 'teacup.stmx'); + if (!fs.existsSync(xmilePath)) { + throw new Error('Required test XMILE model not found: ' + xmilePath); + } + return fs.readFileSync(xmilePath); +} + +// Tolerance for VM-vs-wasm comparison: both executors run the same compiled +// simulation, so any difference is at most floating-point reassociation noise. +const TOL = 1e-9; + +function expectSeriesClose(actual: Float64Array, expected: Float64Array): void { + expect(actual.length).toBe(expected.length); + for (let i = 0; i < expected.length; i++) { + expect(Math.abs(actual[i] - expected[i])).toBeLessThanOrEqual(TOL); + } +} + +describe('Model/Sim engine selection (public API)', () => { + let project: Project; + + beforeAll(async () => { + await loadWasm(); + project = await Project.open(loadTeacupXmile()); + }); + + afterAll(async () => { + await project.dispose(); + }); + + describe('AC1.1: simulate({engine}) drives the selected backend', () => { + it("simulate({engine:'wasm'}) runToEnd+getSeries match the VM", async () => { + const model = await project.mainModel(); + const vmSim = await model.simulate({}, { engine: 'vm' }); + const wasmSim = await model.simulate({}, { engine: 'wasm' }); + + await vmSim.runToEnd(); + await wasmSim.runToEnd(); + + const names = await wasmSim.getVarNames(); + expect(names.length).toBeGreaterThan(0); + for (const name of names) { + expectSeriesClose(await wasmSim.getSeries(name), await vmSim.getSeries(name)); + } + + await vmSim.dispose(); + await wasmSim.dispose(); + }); + + it('returns a Sim regardless of engine selection', async () => { + const model = await project.mainModel(); + const defaultSim = await model.simulate(); + const vmSim = await model.simulate({}, { engine: 'vm' }); + const wasmSim = await model.simulate({}, { engine: 'wasm' }); + + expect(defaultSim).toBeInstanceOf(Sim); + expect(vmSim).toBeInstanceOf(Sim); + expect(wasmSim).toBeInstanceOf(Sim); + + await defaultSim.dispose(); + await vmSim.dispose(); + await wasmSim.dispose(); + }); + + it('actually drives the wasm backend, not the VM, for engine:wasm', async () => { + // The behavioral discriminator at the facade level: a wasm-backed Sim + // rejects getLinks ("not supported on the wasm engine"), whereas the VM + // and default sims return a links array. This fails if engine selection + // is silently dropped and a VM sim is created instead. + const model = await project.mainModel(); + const wasmSim = await model.simulate({}, { engine: 'wasm' }); + const vmSim = await model.simulate({}, { engine: 'vm' }); + const defaultSim = await model.simulate(); + + await expect(wasmSim.getLinks()).rejects.toThrow(/not supported on the wasm engine/i); + await expect(vmSim.getLinks()).resolves.toEqual(expect.any(Array)); + await expect(defaultSim.getLinks()).resolves.toEqual(expect.any(Array)); + + await wasmSim.dispose(); + await vmSim.dispose(); + await defaultSim.dispose(); + }); + + it("simulate() and simulate({engine:'vm'}) agree (both VM-backed)", async () => { + const model = await project.mainModel(); + const defaultSim = await model.simulate(); + const vmSim = await model.simulate({}, { engine: 'vm' }); + + await defaultSim.runToEnd(); + await vmSim.runToEnd(); + + for (const name of await defaultSim.getVarNames()) { + expectSeriesClose(await defaultSim.getSeries(name), await vmSim.getSeries(name)); + } + + await defaultSim.dispose(); + await vmSim.dispose(); + }); + }); + + describe('AC1.2: run({engine}) series parity', () => { + it("run({engine:'wasm'}) series equal run({engine:'vm'}) within tolerance", async () => { + const model = await project.mainModel(); + const vmRun = await model.run({}, { engine: 'vm' }); + const wasmRun = await model.run({}, { engine: 'wasm' }); + + expect(wasmRun).toBeInstanceOf(Run); + expect(wasmRun.varNames).toEqual(vmRun.varNames); + expect(wasmRun.varNames.length).toBeGreaterThan(0); + for (const name of wasmRun.varNames) { + expectSeriesClose(wasmRun.getSeries(name), vmRun.getSeries(name)); + } + // The time axis is collected even though it is not in varNames. + expectSeriesClose(wasmRun.time, vmRun.time); + }); + + it("run({engine:'wasm'}) respects a constant override, matching the VM", async () => { + const model = await project.mainModel(); + const overrides = { room_temperature: 40 }; + const vmRun = await model.run(overrides, { engine: 'vm' }); + const wasmRun = await model.run(overrides, { engine: 'wasm' }); + + expect(wasmRun.overrides).toEqual(overrides); + for (const name of wasmRun.varNames) { + expectSeriesClose(wasmRun.getSeries(name), vmRun.getSeries(name)); + } + }); + }); + + describe('AC1.3: no-engine calls behave exactly as before (VM)', () => { + it('run() with no engine equals run({engine:vm})', async () => { + const model = await project.mainModel(); + const defaultRun = await model.run(); + const vmRun = await model.run({}, { engine: 'vm' }); + + expect(defaultRun.varNames).toEqual(vmRun.varNames); + for (const name of defaultRun.varNames) { + expectSeriesClose(defaultRun.getSeries(name), vmRun.getSeries(name)); + } + }); + + it('default run() reproduces the documented teacup cooling result', async () => { + const model = await project.mainModel(); + const run = await model.run(); + // teacup_temperature cools monotonically from its initial value. + const temp = run.getSeries('teacup_temperature'); + expect(temp.length).toBeGreaterThan(0); + expect(temp[0]).toBeGreaterThan(temp[temp.length - 1]); + }); + + it('default simulate() with an override tracks the override (VM behavior)', async () => { + const model = await project.mainModel(); + const sim = await model.simulate({ room_temperature: 30 }); + expect(sim.overrides).toEqual({ room_temperature: 30 }); + expect(await sim.getValue('room_temperature')).toBe(30); + await sim.dispose(); + }); + }); + + describe('AC6.2: enableLtm rejected on the wasm engine through the public facade', () => { + it("simulate({enableLtm:true, engine:'wasm'}) rejects with the LTM-not-supported error", async () => { + const model = await project.mainModel(); + // The rejection is enforced authoritatively in DirectBackend.simNew; this + // closes the loop that the facade forwards the option (it does not swallow + // or default it away) and that the rejection surfaces as a rejected promise. + await expect(model.simulate({}, { enableLtm: true, engine: 'wasm' })).rejects.toThrow( + /not supported on the wasm engine/i, + ); + }); + + it("run({analyzeLtm:true, engine:'wasm'}) rejects the same way (run forwards analyzeLtm)", async () => { + const model = await project.mainModel(); + await expect(model.run({}, { analyzeLtm: true, engine: 'wasm' })).rejects.toThrow(/LTM/i); + }); + }); + + describe('Sim.reset() reset+reapply-overrides path on a wasm sim (public API)', () => { + it('setValue(const) -> reset() -> runToEnd reproduces the override result (matches the VM)', async () => { + const model = await project.mainModel(); + const wasmSim = await model.simulate({}, { engine: 'wasm' }); + const vmSim = await model.simulate({}, { engine: 'vm' }); + + // Drive both identically through the public Sim API: override a constant, + // run, reset, run again. Sim.reset() calls backend.simReset then re-applies + // its recorded overrides; the wasm blob's reset preserves the override too. + await wasmSim.setValue('room temperature', 55); + await vmSim.setValue('room temperature', 55); + await wasmSim.runToEnd(); + await vmSim.runToEnd(); + await wasmSim.reset(); + await vmSim.reset(); + await wasmSim.runToEnd(); + await vmSim.runToEnd(); + + for (const name of await wasmSim.getVarNames()) { + expectSeriesClose(await wasmSim.getSeries(name), await vmSim.getSeries(name)); + } + // The override is still in effect after reset (room temperature 55, not the + // compiled default), confirming reset did not silently drop it. + expect((await wasmSim.getSeries('room_temperature'))[0]).toBeCloseTo(55, 9); + + await wasmSim.dispose(); + await vmSim.dispose(); + }); + + it('reset() re-applies a creation-time override and stays at VM parity', async () => { + const model = await project.mainModel(); + // A creation-time override populates Sim._overrides, so Sim.reset()'s + // reapply loop actually runs (the half a bare setValue does not touch). + const wasmSim = await model.simulate({ room_temperature: 40 }, { engine: 'wasm' }); + const vmSim = await model.simulate({ room_temperature: 40 }, { engine: 'vm' }); + + await wasmSim.runToEnd(); + await vmSim.runToEnd(); + await wasmSim.reset(); + await vmSim.reset(); + await wasmSim.runToEnd(); + await vmSim.runToEnd(); + + expect(wasmSim.overrides).toEqual({ room_temperature: 40 }); + for (const name of await wasmSim.getVarNames()) { + expectSeriesClose(await wasmSim.getSeries(name), await vmSim.getSeries(name)); + } + expect((await wasmSim.getSeries('room_temperature'))[0]).toBeCloseTo(40, 9); + + await wasmSim.dispose(); + await vmSim.dispose(); + }); + }); + + describe('AC6.3: run({engine:wasm}) yields a Run with empty links and never calls getLinks', () => { + it('resolves to a Run whose links array is empty', async () => { + const model = await project.mainModel(); + const run = await model.run({}, { engine: 'wasm' }); + + expect(run).toBeInstanceOf(Run); + expect(run.links).toEqual([]); + }); + + it('does not throw despite getLinks being unsupported on the wasm engine', async () => { + const model = await project.mainModel(); + // getRun must not call getLinks() on the wasm sim (which would throw the + // "not supported on the wasm engine" error); LTM gating skips it instead. + await expect(model.run({}, { engine: 'wasm' })).resolves.toBeInstanceOf(Run); + }); + }); +}); diff --git a/src/engine/tests/wasmgen.test.ts b/src/engine/tests/wasmgen.test.ts new file mode 100644 index 000000000..969d1cab8 --- /dev/null +++ b/src/engine/tests/wasmgen.test.ts @@ -0,0 +1,306 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +/** + * Unit tests for the pure functions in internal/wasmgen: parseWasmLayout + * (decode the little-endian WasmLayout wire format) and readStridedSeries + * (strided f64 read of one variable's series out of a linear-memory buffer). + * + * These are functional-core tests: hand-built byte buffers, no WASM instance + * and no libsimlin. The imperative-shell FFI wrapper (simlin_model_compile_to_wasm) + * needs a live instance and is covered by the DirectBackend integration tests. + */ + +import { parseWasmLayout, readStridedSeries, WasmLayout } from '../src/internal/wasmgen'; + +const textEncoder = new TextEncoder(); + +/** + * Build a serialized WasmLayout buffer in the documented little-endian wire + * format: u64 nSlots, u64 nChunks, u64 resultsOffset, u32 count, then `count` + * entries of { u32 nameLen, utf8 name, u64 offset }. + */ +function buildLayoutBytes(options: { + readonly nSlots: number; + readonly nChunks: number; + readonly resultsOffset: number; + readonly entries: ReadonlyArray; +}): Uint8Array { + const { nSlots, nChunks, resultsOffset, entries } = options; + + const encodedNames = entries.map(([name]) => textEncoder.encode(name)); + let total = 8 + 8 + 8 + 4; + for (const name of encodedNames) { + total += 4 + name.length + 8; + } + + const bytes = new Uint8Array(total); + const view = new DataView(bytes.buffer); + let p = 0; + view.setBigUint64(p, BigInt(nSlots), true); + p += 8; + view.setBigUint64(p, BigInt(nChunks), true); + p += 8; + view.setBigUint64(p, BigInt(resultsOffset), true); + p += 8; + view.setUint32(p, entries.length, true); + p += 4; + for (let i = 0; i < entries.length; i++) { + const name = encodedNames[i]; + view.setUint32(p, name.length, true); + p += 4; + bytes.set(name, p); + p += name.length; + view.setBigUint64(p, BigInt(entries[i][1]), true); + p += 8; + } + return bytes; +} + +describe('parseWasmLayout', () => { + it('decodes geometry and the name->offset map from the wire format', () => { + const bytes = buildLayoutBytes({ + nSlots: 4, + nChunks: 11, + resultsOffset: 64, + entries: [ + ['time', 0], + ['population', 2], + ], + }); + + const layout = parseWasmLayout(bytes); + + expect(layout.nSlots).toBe(4); + expect(layout.nChunks).toBe(11); + expect(layout.resultsOffset).toBe(64); + expect(layout.varOffsets).toBeInstanceOf(Map); + expect(layout.varOffsets.size).toBe(2); + expect(layout.varOffsets.get('time')).toBe(0); + expect(layout.varOffsets.get('population')).toBe(2); + }); + + it('handles an empty variable map (count == 0)', () => { + const bytes = buildLayoutBytes({ nSlots: 0, nChunks: 0, resultsOffset: 0, entries: [] }); + + const layout = parseWasmLayout(bytes); + + expect(layout.nSlots).toBe(0); + expect(layout.nChunks).toBe(0); + expect(layout.resultsOffset).toBe(0); + expect(layout.varOffsets.size).toBe(0); + }); + + it('decodes multi-byte UTF-8 names by byte length, not code-unit length', () => { + // The middle dot (U+00B7) encodes to two UTF-8 bytes; the wire format's + // nameLen is a byte count, so an off-by-byte parser would corrupt the map. + const bytes = buildLayoutBytes({ + nSlots: 2, + nChunks: 3, + resultsOffset: 8, + entries: [['model\u{00B7}variable', 1]], + }); + + const layout = parseWasmLayout(bytes); + + expect(layout.varOffsets.get('model\u{00B7}variable')).toBe(1); + }); + + it('parses offsets that exceed 32 bits via the u64 fields', () => { + const big = 0x1_0000_0001; // 4294967297, beyond u32 range + const bytes = buildLayoutBytes({ + nSlots: 1, + nChunks: 1, + resultsOffset: big, + entries: [['x', big]], + }); + + const layout = parseWasmLayout(bytes); + + expect(layout.resultsOffset).toBe(big); + expect(layout.varOffsets.get('x')).toBe(big); + }); + + it('round-trips the documented wire format against a hand-built buffer', () => { + const entries: ReadonlyArray = [ + ['time', 0], + ['dt', 1], + ['births', 2], + ['deaths', 3], + ]; + const bytes = buildLayoutBytes({ nSlots: 4, nChunks: 7, resultsOffset: 32, entries }); + + const layout = parseWasmLayout(bytes); + + expect(layout.nSlots).toBe(4); + expect(layout.nChunks).toBe(7); + expect(layout.resultsOffset).toBe(32); + expect([...layout.varOffsets.entries()]).toEqual(entries.map(([n, o]) => [n, o])); + }); +}); + +describe('readStridedSeries', () => { + /** + * Build a step-major (nChunks x nSlots) f64 results region at a known + * resultsOffset inside a larger ArrayBuffer, filling cell (chunk, slot) + * with a deterministic value so a wrong stride is detectable. + */ + function buildResultsBuffer(options: { + readonly nSlots: number; + readonly nChunks: number; + readonly resultsOffset: number; + readonly cell: (chunk: number, slot: number) => number; + }): ArrayBuffer { + const { nSlots, nChunks, resultsOffset, cell } = options; + const totalBytes = resultsOffset + nChunks * nSlots * 8; + const buffer = new ArrayBuffer(totalBytes); + const view = new DataView(buffer); + for (let c = 0; c < nChunks; c++) { + for (let s = 0; s < nSlots; s++) { + view.setFloat64(resultsOffset + (c * nSlots + s) * 8, cell(c, s), true); + } + } + return buffer; + } + + function makeLayout(nSlots: number, nChunks: number, resultsOffset: number): WasmLayout { + return { nSlots, nChunks, resultsOffset, varOffsets: new Map() }; + } + + it('extracts one variable column exactly, striding by nSlots', () => { + const nSlots = 3; + const nChunks = 5; + const resultsOffset = 16; + // cell value encodes both chunk and slot so a mis-stride is visible. + const buffer = buildResultsBuffer({ + nSlots, + nChunks, + resultsOffset, + cell: (c, s) => c * 10 + s, + }); + const layout = makeLayout(nSlots, nChunks, resultsOffset); + + const slot1 = readStridedSeries(buffer, layout, 1); + + expect(Array.from(slot1)).toEqual([1, 11, 21, 31, 41]); + }); + + it('returns a Float64Array of length nChunks', () => { + const nSlots = 4; + const nChunks = 9; + const resultsOffset = 24; + const buffer = buildResultsBuffer({ + nSlots, + nChunks, + resultsOffset, + cell: (c, s) => c + s, + }); + const layout = makeLayout(nSlots, nChunks, resultsOffset); + + const series = readStridedSeries(buffer, layout, 0); + + expect(series).toBeInstanceOf(Float64Array); + expect(series.length).toBe(nChunks); + }); + + it('reads the first and last slots correctly (column boundaries)', () => { + const nSlots = 3; + const nChunks = 4; + const resultsOffset = 0; + const buffer = buildResultsBuffer({ + nSlots, + nChunks, + resultsOffset, + cell: (c, s) => c * 100 + s, + }); + const layout = makeLayout(nSlots, nChunks, resultsOffset); + + expect(Array.from(readStridedSeries(buffer, layout, 0))).toEqual([0, 100, 200, 300]); + expect(Array.from(readStridedSeries(buffer, layout, 2))).toEqual([2, 102, 202, 302]); + }); + + it('honors a nonzero resultsOffset (does not assume base 0)', () => { + const nSlots = 2; + const nChunks = 3; + const resultsOffset = 40; + const buffer = buildResultsBuffer({ + nSlots, + nChunks, + resultsOffset, + cell: (c, s) => c + s * 1000, + }); + const layout = makeLayout(nSlots, nChunks, resultsOffset); + + expect(Array.from(readStridedSeries(buffer, layout, 1))).toEqual([1000, 1001, 1002]); + }); + + it('reads only `count` rows when given a completed-step count below nChunks', () => { + const nSlots = 3; + const nChunks = 6; + const resultsOffset = 8; + const buffer = buildResultsBuffer({ + nSlots, + nChunks, + resultsOffset, + cell: (c, s) => c * 10 + s, + }); + const layout = makeLayout(nSlots, nChunks, resultsOffset); + + // Only 3 of the 6 slab rows are completed; the read must stop there so the + // stale tail (rows 3..5) never reaches the caller. + expect(Array.from(readStridedSeries(buffer, layout, 1, 3))).toEqual([1, 11, 21]); + }); + + it('returns an empty series when count is 0 (a fresh or just-reset sim)', () => { + const layout = makeLayout(2, 5, 0); + const buffer = buildResultsBuffer({ nSlots: 2, nChunks: 5, resultsOffset: 0, cell: (c, s) => c + s }); + + expect(readStridedSeries(buffer, layout, 0, 0)).toEqual(new Float64Array(0)); + }); + + it('defaults to nChunks rows when count is omitted', () => { + const layout = makeLayout(2, 4, 0); + const buffer = buildResultsBuffer({ nSlots: 2, nChunks: 4, resultsOffset: 0, cell: (c) => c }); + + expect(readStridedSeries(buffer, layout, 0).length).toBe(4); + }); + + it('clamps a count above nChunks so it never reads past the results region', () => { + const layout = makeLayout(2, 3, 0); + const buffer = buildResultsBuffer({ nSlots: 2, nChunks: 3, resultsOffset: 0, cell: (c) => c }); + + // A count larger than the slab capacity must be clamped, not read out of bounds. + expect(readStridedSeries(buffer, layout, 0, 99).length).toBe(3); + }); + + it('allocates exactly one Float64Array of length nChunks and nothing else', () => { + const nSlots = 2; + const nChunks = 6; + const resultsOffset = 8; + const buffer = buildResultsBuffer({ + nSlots, + nChunks, + resultsOffset, + cell: (c) => c, + }); + const layout = makeLayout(nSlots, nChunks, resultsOffset); + + // Spy on the Float64Array constructor to assert a single typed-array + // allocation of exactly nChunks elements (no intermediate arrays). + const RealFloat64Array = Float64Array; + const allocations: Array = []; + const spy = jest.spyOn(global, 'Float64Array').mockImplementation(function (this: unknown, arg: number) { + allocations.push(arg); + return new RealFloat64Array(arg); + } as unknown as typeof Float64Array); + + try { + const series = readStridedSeries(buffer, layout, 0); + expect(allocations).toEqual([nChunks]); + expect(Array.from(series)).toEqual([0, 1, 2, 3, 4, 5]); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/src/engine/tests/worker-wasm.test.ts b/src/engine/tests/worker-wasm.test.ts new file mode 100644 index 000000000..d8382263c --- /dev/null +++ b/src/engine/tests/worker-wasm.test.ts @@ -0,0 +1,361 @@ +// Copyright 2026 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +// pattern: Imperative Shell +// Phase 3 of the @simlin/engine wasm backend: drive engine:'wasm' end-to-end +// through the real postMessage protocol (request/response serialization, the +// FIFO queue, handleResponse, deserializeError) using the in-memory loopback +// that wires a real WorkerBackend to a real WorkerServer (which wraps a +// DirectBackend). A node DirectBackend is the oracle: the worker-driven wasm +// series must equal the DirectBackend wasm series exactly, and the VM series +// within the engine's parity tolerance. There is no real Worker/jsdom here; +// testEnvironment is node. + +import { readFileSync } from 'fs'; +import { join } from 'path'; + +import { WorkerBackend } from '../src/worker-backend'; +import { WorkerServer } from '../src/worker-server'; +import { DirectBackend } from '../src/direct-backend'; +import type { WorkerRequest, WorkerResponse } from '../src/worker-protocol'; +import type { ModelHandle } from '../src/backend'; + +const wasmPath = join(__dirname, '..', 'core', 'libsimlin.wasm'); + +function loadTeacupXmile(): Uint8Array { + const xmilePath = join(__dirname, '..', '..', 'pysimlin', 'tests', 'fixtures', 'teacup.stmx'); + return readFileSync(xmilePath); +} + +function loadWasmSource(): Uint8Array { + return readFileSync(wasmPath); +} + +// A model the wasm backend cannot compile: `summed = SUM(source[lo:hi])` uses a +// runtime view range `[lo:hi]` whose bounds reference scalar auxes (not +// constants and not dimension elements), so the range cannot be constant-folded +// and codegen emits the ViewRangeDynamic opcode, which wasmgen reports as +// Unsupported (GH #612). The VM runs the same model fine. This is the same +// fixture the Phase 2 DirectBackend tests used to prove there is no silent VM +// fallback; here it must reject across the worker boundary instead. +const WASM_UNSUPPORTED_XMILE = ` + +
+ Test + Simlin +
+ + 0 + 2 +
1
+
+ + + + + + + + + + + 1 + 2 + 3 + + + 1 + 3 + SUM(source[lo:hi]) + + +
`; + +// Tolerance for the worker-wasm-vs-VM comparison, matching the Phase 2 +// DirectBackend parity tests (wasm-backend.test.ts). The wasm blob mirrors the +// VM opcode-for-opcode, but wasm is not bit-identical to the VM's native libm +// by design, so transcendental-heavy variables can differ by reassociation +// noise within this bound. Worker-vs-DirectBackend on the same engine ('wasm') +// is the same compiled simulation, so that comparison is exact. +const TOL = 1e-9; + +interface WorkerWasmPair { + backend: WorkerBackend; + server: WorkerServer; + /** Requests delivered to the server, in order (the backend -> server leg). */ + requests: WorkerRequest[]; + /** + * Transfer lists the server attached to its responses (the server -> backend + * leg). The zero-copy Float64Array from getSeries travels on this leg, so + * this is where the transfer assertion must look (the request leg carries no + * transfer for getSeries). + */ + responseTransfers: (Transferable[] | undefined)[]; +} + +// Wire a real WorkerBackend to a real WorkerServer via fake transport closures, +// mirroring createTestPair in worker-backend.test.ts but additionally recording +// the served requests and the server's response-side transfer lists (the leg +// the zero-copy getSeries buffer rides on, mirroring worker-server.test.ts's +// safe-buffer-transfer harness). +function createWorkerWasmPair(): WorkerWasmPair { + let backendOnMessage: ((msg: WorkerResponse) => void) | null = null; + const requests: WorkerRequest[] = []; + const responseTransfers: (Transferable[] | undefined)[] = []; + + const server = new WorkerServer((msg: WorkerResponse, transfer?: Transferable[]) => { + responseTransfers.push(transfer); + if (backendOnMessage) { + setTimeout(() => backendOnMessage!(msg), 0); + } + }); + + const backend = new WorkerBackend( + (msg: WorkerRequest) => { + requests.push(msg); + setTimeout(() => server.handleMessage(msg), 0); + }, + (callback: (msg: WorkerResponse) => void) => { + backendOnMessage = callback; + }, + ); + + return { backend, server, requests, responseTransfers }; +} + +describe('WorkerBackend wasm engine parity (Phase 3)', () => { + // A fresh DirectBackend oracle per suite. The worker pair owns its own + // DirectBackend inside the WorkerServer; both load the same wasm blob. + let oracle: DirectBackend; + + beforeAll(async () => { + oracle = new DirectBackend(); + oracle.reset(); + oracle.configureWasm({ source: loadWasmSource() }); + await oracle.init(); + }); + + afterAll(() => { + oracle.reset(); + }); + + function expectSeriesClose(actual: Float64Array, expected: Float64Array): void { + expect(actual.length).toBe(expected.length); + for (let i = 0; i < expected.length; i++) { + expect(Math.abs(actual[i] - expected[i])).toBeLessThanOrEqual(TOL); + } + } + + describe('AC8.1: worker wasm series match the node DirectBackend (and the VM)', () => { + it('teacup_temperature via the worker wasm path equals DirectBackend wasm exactly and the VM within tolerance', async () => { + const { backend } = createWorkerWasmPair(); + await backend.init(loadWasmSource()); + const projHandle = await backend.projectOpenXmile(loadTeacupXmile()); + const modelHandle = await backend.projectGetModel(projHandle, null); + const simHandle = await backend.simNew(modelHandle, false, 'wasm'); + await backend.simRunToEnd(simHandle); + const workerSeries = await backend.simGetSeries(simHandle, 'teacup_temperature'); + + // Oracle: the same model, on the node DirectBackend, on both engines. + const oracleProject = oracle.projectOpenXmile(loadTeacupXmile()); + const oracleModel = oracle.projectGetModel(oracleProject, null); + const wasmSim = oracle.simNew(oracleModel, false, 'wasm'); + const vmSim = oracle.simNew(oracleModel, false, 'vm'); + oracle.simRunToEnd(wasmSim); + oracle.simRunToEnd(vmSim); + const directWasmSeries = oracle.simGetSeries(wasmSim, 'teacup_temperature'); + const directVmSeries = oracle.simGetSeries(vmSim, 'teacup_temperature'); + + // Worker wasm vs DirectBackend wasm: same compiled simulation -> exact. + expect(workerSeries).toBeInstanceOf(Float64Array); + expect(Array.from(workerSeries)).toEqual(Array.from(directWasmSeries)); + // Worker wasm vs the VM oracle: within the engine's parity tolerance. + expectSeriesClose(workerSeries, directVmSeries); + + oracle.simDispose(wasmSim); + oracle.simDispose(vmSim); + oracle.modelDispose(oracleModel); + oracle.projectDispose(oracleProject); + }); + + it('every variable via the worker wasm path matches DirectBackend wasm exactly and the VM within tolerance', async () => { + const { backend } = createWorkerWasmPair(); + await backend.init(loadWasmSource()); + const projHandle = await backend.projectOpenXmile(loadTeacupXmile()); + const modelHandle = await backend.projectGetModel(projHandle, null); + const simHandle = await backend.simNew(modelHandle, false, 'wasm'); + await backend.simRunToEnd(simHandle); + + const oracleProject = oracle.projectOpenXmile(loadTeacupXmile()); + const oracleModel = oracle.projectGetModel(oracleProject, null); + const wasmSim = oracle.simNew(oracleModel, false, 'wasm'); + const vmSim = oracle.simNew(oracleModel, false, 'vm'); + oracle.simRunToEnd(wasmSim); + oracle.simRunToEnd(vmSim); + + const names = await backend.simGetVarNames(simHandle); + expect(names.length).toBeGreaterThan(0); + for (const name of names) { + const workerSeries = await backend.simGetSeries(simHandle, name); + // Exact against the DirectBackend wasm engine. + expect(Array.from(workerSeries)).toEqual(Array.from(oracle.simGetSeries(wasmSim, name))); + // Within tolerance against the VM oracle. + expectSeriesClose(workerSeries, oracle.simGetSeries(vmSim, name)); + } + + oracle.simDispose(wasmSim); + oracle.simDispose(vmSim); + oracle.modelDispose(oracleModel); + oracle.projectDispose(oracleProject); + }); + }); + + describe('AC8.2: minimal additive protocol + zero-copy getSeries for the wasm engine', () => { + it('getSeries round-trips a Float64Array and adds exactly one one-element transfer on the response leg', async () => { + const { backend, responseTransfers } = createWorkerWasmPair(); + await backend.init(loadWasmSource()); + const projHandle = await backend.projectOpenXmile(loadTeacupXmile()); + const modelHandle = await backend.projectGetModel(projHandle, null); + const simHandle = await backend.simNew(modelHandle, false, 'wasm'); + await backend.simRunToEnd(simHandle); + + // Measure the transfer delta around the single getSeries call: the + // cumulative responseTransfers list accumulates across every prior op, so + // assert on what this one call appends, not on the absolute length. + const before = responseTransfers.length; + const series = await backend.simGetSeries(simHandle, 'teacup_temperature'); + const appended = responseTransfers.slice(before); + + expect(series).toBeInstanceOf(Float64Array); + expect(series.length).toBeGreaterThan(0); + // Exactly one response carried a transfer for this call, and it was the + // single Float64Array buffer (zero-copy), and the view owns its buffer. + const withTransfer = appended.filter((t): t is Transferable[] => t !== undefined && t.length > 0); + expect(withTransfer.length).toBe(1); + expect(withTransfer[0].length).toBe(1); + expect(series.byteOffset).toBe(0); + expect(series.buffer.byteLength).toBe(series.byteLength); + }); + + it('the served simNew request carries engine:wasm and introduces no new message type', async () => { + const { backend, requests } = createWorkerWasmPair(); + await backend.init(loadWasmSource()); + const projHandle = await backend.projectOpenXmile(loadTeacupXmile()); + const modelHandle = await backend.projectGetModel(projHandle, null); + await backend.simNew(modelHandle, false, 'wasm'); + + const simNewRequests = requests.filter((r) => r.type === 'simNew'); + expect(simNewRequests.length).toBe(1); + const simNew = simNewRequests[0] as Extract; + expect(simNew.engine).toBe('wasm'); + expect(simNew.enableLtm).toBe(false); + + // The protocol delta is purely additive: only the pre-existing request + // type strings appear on the wire (no new discriminant was introduced). + const KNOWN_TYPES = new Set([ + 'init', + 'isInitialized', + 'reset', + 'configureWasm', + 'projectOpenXmile', + 'projectOpenProtobuf', + 'projectOpenJson', + 'projectOpenVensim', + 'projectDispose', + 'projectGetModelCount', + 'projectGetModelNames', + 'projectGetModel', + 'projectIsSimulatable', + 'projectSerializeProtobuf', + 'projectSerializeJson', + 'projectSerializeXmile', + 'projectRenderSvg', + 'projectRenderPng', + 'projectGetErrors', + 'projectApplyPatch', + 'modelGetName', + 'modelDispose', + 'modelGetIncomingLinks', + 'modelGetLinks', + 'modelGetLoops', + 'modelGetLatexEquation', + 'modelGetVarJson', + 'modelGetVarNames', + 'modelGetSimSpecsJson', + 'simNew', + 'simDispose', + 'simRunTo', + 'simRunToEnd', + 'simReset', + 'simGetTime', + 'simGetStepCount', + 'simGetValue', + 'simSetValue', + 'simGetSeries', + 'simGetVarNames', + 'simGetLinks', + ]); + for (const req of requests) { + expect(KNOWN_TYPES.has(req.type)).toBe(true); + } + }); + + it('the VM path still omits the engine field (additive: undefined engine -> absent field)', async () => { + const { backend, requests } = createWorkerWasmPair(); + await backend.init(loadWasmSource()); + const projHandle = await backend.projectOpenXmile(loadTeacupXmile()); + const modelHandle = await backend.projectGetModel(projHandle, null); + // No engine argument: the message must serialize engine as undefined, + // i.e. structurally identical to the pre-Phase-3 simNew message. + await backend.simNew(modelHandle, false); + + const simNew = requests.find((r) => r.type === 'simNew') as Extract; + expect(simNew.engine).toBeUndefined(); + }); + }); + + describe('worker-boundary error propagation (no silent VM fallback)', () => { + it('rejects enableLtm on the wasm engine across the worker boundary', async () => { + const { backend } = createWorkerWasmPair(); + await backend.init(loadWasmSource()); + const projHandle = await backend.projectOpenXmile(loadTeacupXmile()); + const modelHandle = await backend.projectGetModel(projHandle, null); + + // The DirectBackend inside the worker throws before any compile; the + // serialized error must round-trip and reject the main-thread promise. + await expect(backend.simNew(modelHandle, true, 'wasm')).rejects.toThrow(/not supported on the wasm engine/i); + }); + + it('rejects a wasm-unsupported model rather than silently falling back to the VM', async () => { + const { backend } = createWorkerWasmPair(); + await backend.init(loadWasmSource()); + const projHandle = await backend.projectOpenXmile(new TextEncoder().encode(WASM_UNSUPPORTED_XMILE)); + const modelHandle = await backend.projectGetModel(projHandle, null); + + // wasm codegen reports the dynamic view range as Unsupported; that error + // must surface across the worker boundary, NOT be swallowed by a VM run. + await expect(backend.simNew(modelHandle, false, 'wasm')).rejects.toThrow(); + + // Prove no silent fallback happened: the same model runs fine on the VM + // engine through the same worker, so the wasm rejection was specific to + // the wasm path (not a malformed model that would also break the VM). + const vmSim = await backend.simNew(modelHandle, false, 'vm'); + await backend.simRunToEnd(vmSim); + const series = await backend.simGetSeries(vmSim, 'summed'); + expect(series).toBeInstanceOf(Float64Array); + // SUM(source[1:3]) = 1 + 2 + 3 = 6 at every step. + expect(series[0]).toBeCloseTo(6, 9); + }); + + it('the wasm-unsupported model also rejects on the node DirectBackend (oracle agreement)', () => { + // Pin the no-fallback contract at the oracle layer too: the worker + // rejection above mirrors the DirectBackend, not worker-only behavior. + const oracleProject = oracle.projectOpenXmile(new TextEncoder().encode(WASM_UNSUPPORTED_XMILE)); + const oracleModel: ModelHandle = oracle.projectGetModel(oracleProject, null); + expect(() => oracle.simNew(oracleModel, false, 'wasm')).toThrow(); + oracle.modelDispose(oracleModel); + oracle.projectDispose(oracleProject); + }); + }); +}); diff --git a/src/libsimlin/CLAUDE.md b/src/libsimlin/CLAUDE.md index b29edbd4b..c53d79404 100644 --- a/src/libsimlin/CLAUDE.md +++ b/src/libsimlin/CLAUDE.md @@ -82,7 +82,7 @@ Integration tests live in `tests/` (standard Rust layout), organized by FFI modu - **`tests/patch.rs`** - JSON patch application, error collection, unit warnings, XMILE patches - **`tests/incremental.rs`** - Incremental compilation path (patch-then-sim, snapshot isolation) - **`tests/analysis.rs`** - Causal analysis: incoming links, loop detection, loop scores -- **`tests/wasm.rs`** - `simlin_model_compile_to_wasm`: validates and executes the returned blob under the DLR-FT interpreter (a libsimlin dev-dependency), parses the returned layout per its documented wire format, and checks the strided series against the VM via `simlin_sim_get_series`; also asserts a graceful `SimlinError` (no panic) for an unsupported model +- **`tests/wasm.rs`** - `simlin_model_compile_to_wasm`: validates and executes the returned blob under the DLR-FT interpreter (a libsimlin dev-dependency), parses the returned layout per its documented wire format, and checks the strided series against the VM via `simlin_sim_get_series`; also asserts a graceful `SimlinError` (no panic) for an unsupported model. `compile_to_wasm_blob_supports_resumable_run` further drives the FFI-compiled blob's resumable ABI (`run_initials`/`run_to`/`set_value`/`reset`, the additive exports) against a VM oracle driven identically through `simlin_sim_run_to`/`simlin_sim_set_value`/`simlin_sim_run_to_end`, confirming the resumable surface survives the FFI compile path and the export set grew purely additively - **`tests/rendering.rs`** - SVG and PNG diagram rendering - **`tests/diagram.rs`** - Diagram layout sync - **`tests/errors.rs`** - Error formatting, error kind mapping, diagnostics diff --git a/src/libsimlin/tests/wasm.rs b/src/libsimlin/tests/wasm.rs index 21c1c3381..076127412 100644 --- a/src/libsimlin/tests/wasm.rs +++ b/src/libsimlin/tests/wasm.rs @@ -17,12 +17,16 @@ mod common; use std::ptr; -use checked::Store; +use checked::{Store, Stored}; use common::open_project_from_datamodel; use simlin::*; use simlin_engine::test_common::TestProject; +use wasm::addrs::ModuleAddr; use wasm::validate; +/// A DLR-FT module instance handle, as returned by `module_instantiate`. +type Inst = Stored; + /// A small scalar stock-and-flow model: a constant inflow fills a stock. Used as /// the supported-model fixture (it runs through the wasm backend cleanly). fn simple_model() -> simlin_engine::datamodel::Project { @@ -183,6 +187,162 @@ fn compile_to_wasm_returns_blob_and_layout() { } } +/// engine-wasm-sim.AC2.3 + AC5.3 across the `simlin_model_compile_to_wasm` path: +/// the blob compiled via the FFI carries and honors the resumable ABI +/// (`run_initials`/`run_to`/`reset`) added in Subcomponent A. The FFI signature +/// itself is unchanged -- the resumable surface is reached purely through the +/// blob's own exports. +/// +/// Both the blob and the bytecode-VM oracle are driven through the *same* +/// segmented sequence: advance to `t1`, override the constant `inflow_rate` +/// mid-run, then advance to the end. Because a mid-run constant override is +/// re-read each step (it affects only steps after `t1`), and because we compare +/// the complete end-of-run `level` series (not a partial-run intermediate slab, +/// which can differ by the VM's one leaked working chunk), the two must agree +/// exactly here. +#[test] +fn compile_to_wasm_blob_supports_resumable_run() { + let datamodel = simple_model(); + // t1 lands on a save point; the override raises inflow_rate partway through. + let t1 = 5.0; + let stop = 10.0; + let override_val = 5.0; + unsafe { + let project = open_project_from_datamodel(&datamodel); + let model_name = std::ffi::CString::new("main").unwrap(); + let mut err: *mut SimlinError = ptr::null_mut(); + let model = simlin_project_get_model(project, model_name.as_ptr(), &mut err); + assert!(err.is_null(), "get_model should not error"); + assert!(!model.is_null(), "model handle must be non-null"); + + let mut out_wasm: *mut u8 = ptr::null_mut(); + let mut out_wasm_len: usize = 0; + let mut out_layout: *mut u8 = ptr::null_mut(); + let mut out_layout_len: usize = 0; + let mut err: *mut SimlinError = ptr::null_mut(); + simlin_model_compile_to_wasm( + model, + &mut out_wasm, + &mut out_wasm_len, + &mut out_layout, + &mut out_layout_len, + &mut err, + ); + assert!(err.is_null(), "compile_to_wasm should not error"); + assert!( + !out_wasm.is_null() && out_wasm_len > 0, + "blob must be non-empty" + ); + + let wasm = std::slice::from_raw_parts(out_wasm, out_wasm_len).to_vec(); + validate(&wasm).expect("returned blob must validate"); + let layout_bytes = std::slice::from_raw_parts(out_layout, out_layout_len).to_vec(); + let layout = parse_layout(&layout_bytes); + + // The export-set growth is purely additive: the blob still carries every + // original export at its original kind, plus the two new resumable funcs. + assert_blob_exports(&wasm); + + let level_off = layout + .var_offsets + .iter() + .find(|(n, _)| n == "level") + .map(|(_, o)| *o) + .expect("level must be in the layout"); + let inflow_rate_off = layout + .var_offsets + .iter() + .find(|(n, _)| n == "inflow_rate") + .map(|(_, o)| *o) + .expect("inflow_rate must be in the layout"); + + // Drive the blob's resumable ABI on ONE instance: run_initials -> + // run_to(t1) -> set_value(inflow_rate) -> run_to(stop), reading level's + // strided series at the end. The same instance is then reset and re-run. + let info = validate(&wasm).expect("validate"); + let mut store = Store::new(()); + let inst = store + .module_instantiate(&info, Vec::new(), None) + .expect("instantiate") + .module_addr; + + invoke_unit(&mut store, inst, "run_initials"); + invoke_run_to(&mut store, inst, t1); + let rc = invoke_set_value(&mut store, inst, inflow_rate_off as i32, override_val); + assert_eq!(rc, 0, "set_value on the overridable constant must return 0"); + invoke_run_to(&mut store, inst, stop); + let blob_segmented = stride_var(&store, inst, &layout, level_off); + + // VM oracle driven identically through the FFI: new -> run_to(t1) -> + // set_value -> run_to_end -> get_series. + let vm_segmented = vm_series_segmented_override( + project, + &model_name, + "level", + "inflow_rate", + t1, + override_val, + layout.n_chunks, + ); + assert_eq!( + blob_segmented.len(), + vm_segmented.len(), + "blob and VM series length must match" + ); + for (c, (&b, &v)) in blob_segmented.iter().zip(vm_segmented.iter()).enumerate() { + assert!( + (b - v).abs() < 1e-9, + "segmented level chunk {c}: blob {b} != vm {v}" + ); + } + + // reset across the FFI compile path: the override survives reset (the + // const-override region is untouched), so a fresh full `run` on the SAME + // instance reproduces the override-applied defaults -- a from-t0 run with + // inflow_rate = override_val throughout. Peer of `simlin_sim_reset`. + invoke_unit(&mut store, inst, "reset"); + invoke_unit(&mut store, inst, "run"); + let blob_after_reset = stride_var(&store, inst, &layout, level_off); + + let vm_override_full = vm_series_with_override( + project, + &model_name, + "level", + "inflow_rate", + override_val, + layout.n_chunks, + ); + assert_eq!( + blob_after_reset.len(), + vm_override_full.len(), + "post-reset blob and VM series length must match" + ); + for (c, (&b, &v)) in blob_after_reset + .iter() + .zip(vm_override_full.iter()) + .enumerate() + { + assert!( + (b - v).abs() < 1e-9, + "post-reset level chunk {c}: blob {b} != vm {v}" + ); + } + // The override raised every step relative to the unmodified defaults, so + // the post-reset run is genuinely the override-applied series, not the + // compiled default (a guard against reset silently clearing overrides). + assert!( + (blob_after_reset[blob_after_reset.len() - 1] - 50.0).abs() < 1e-9, + "with inflow_rate={override_val} throughout, level reaches 50, got {}", + blob_after_reset[blob_after_reset.len() - 1] + ); + + simlin_free(out_wasm); + simlin_free(out_layout); + simlin_model_unref(model); + simlin_project_unref(project); + } +} + /// AC6.2: a model the wasm backend cannot compile surfaces a `SimlinError` /// (out_error is set, both buffers stay NULL), never a panic across the FFI /// boundary. `SUM(source[lo:hi])` with variable bounds lowers to a runtime view @@ -308,6 +468,202 @@ fn run_and_stride(wasm: &[u8], layout: &ParsedLayout, off: usize) -> Vec { }) } +/// Assert the FFI-compiled blob carries every original export (at its original +/// kind) plus the two resumable functions added in Subcomponent A. The original +/// set is `run`/`set_value`/`reset`/`clear_values` (funcs), `memory`, and the +/// geometry globals `n_slots`/`n_chunks`/`results_offset`; the additions are +/// `run_to`/`run_initials` (funcs) and `saved_steps` (the live saved-row counter +/// global). This pins the export-set growth as purely additive. +fn assert_blob_exports(wasm: &[u8]) { + let info = validate(wasm).expect("validate"); + let mut store = Store::new(()); + let inst = store + .module_instantiate(&info, Vec::new(), None) + .expect("instantiate") + .module_addr; + for name in [ + "run", + "set_value", + "reset", + "clear_values", + "run_to", + "run_initials", + ] { + let exp = store + .instance_export(inst, name) + .unwrap_or_else(|_| panic!("blob must export `{name}`")); + assert!( + exp.as_func().is_some(), + "export `{name}` must be a function" + ); + } + assert!( + store + .instance_export(inst, "memory") + .expect("blob must export `memory`") + .as_mem() + .is_some(), + "export `memory` must be a memory" + ); + for name in ["n_slots", "n_chunks", "results_offset", "saved_steps"] { + let exp = store + .instance_export(inst, name) + .unwrap_or_else(|_| panic!("blob must export `{name}`")); + assert!( + exp.as_global().is_some(), + "export `{name}` must be a global" + ); + } +} + +/// Invoke a `() -> ()` blob export (`run_initials`/`run`/`reset`) on `inst`. +fn invoke_unit(store: &mut Store<()>, inst: Inst, name: &str) { + let f = store + .instance_export(inst, name) + .unwrap_or_else(|_| panic!("`{name}` export must exist")) + .as_func() + .unwrap_or_else(|| panic!("`{name}` export must be a function")); + store + .invoke_simple_typed::<(), ()>(f, ()) + .unwrap_or_else(|_| panic!("invoke `{name}`")); +} + +/// Invoke `run_to(target)` (a `(f64) -> ()` export) on `inst`. +fn invoke_run_to(store: &mut Store<()>, inst: Inst, target: f64) { + let f = store + .instance_export(inst, "run_to") + .expect("`run_to` export must exist") + .as_func() + .expect("`run_to` export must be a function"); + store + .invoke_simple_typed::<(f64,), ()>(f, (target,)) + .expect("invoke `run_to`"); +} + +/// Invoke `set_value(offset, val)` (a `(i32, f64) -> i32` export) on `inst`, +/// returning the blob's status code (0 = applied, nonzero = rejected). +fn invoke_set_value(store: &mut Store<()>, inst: Inst, offset: i32, val: f64) -> i32 { + let f = store + .instance_export(inst, "set_value") + .expect("`set_value` export must exist") + .as_func() + .expect("`set_value` export must be a function"); + store + .invoke_simple_typed::<(i32, f64), i32>(f, (offset, val)) + .expect("invoke `set_value`") +} + +/// Stride the `n_chunks`-long series for the variable at `off` out of the live +/// instance's results region (using only the layout geometry), without +/// re-invoking `run` -- the cursor/run state already lives in the instance. +fn stride_var(store: &Store<()>, inst: Inst, layout: &ParsedLayout, off: usize) -> Vec { + let mem = store + .instance_export(inst, "memory") + .expect("`memory` export must exist") + .as_mem() + .expect("`memory` export must be a memory"); + let base = layout.results_offset; + let n_slots = layout.n_slots; + store.mem_access_mut_slice(mem, |bytes| { + (0..layout.n_chunks) + .map(|c| { + let a = base + (c * n_slots + off) * 8; + f64::from_le_bytes(bytes[a..a + 8].try_into().unwrap()) + }) + .collect() + }) +} + +/// VM oracle for a segmented, mid-run-override drive through the FFI: +/// `simlin_sim_new` -> `run_to(t1)` -> `set_value(const_name, v)` -> +/// `run_to_end` -> `get_series(name)`. Mirrors the blob's resumable sequence. +unsafe fn vm_series_segmented_override( + project: *mut SimlinProject, + model_name: &std::ffi::CStr, + name: &str, + const_name: &str, + t1: f64, + override_val: f64, + n_chunks: usize, +) -> Vec { + let mut err: *mut SimlinError = ptr::null_mut(); + let model = simlin_project_get_model(project, model_name.as_ptr(), &mut err); + assert!(err.is_null()); + let sim = simlin_sim_new(model, false, &mut err); + assert!(err.is_null(), "sim_new should succeed"); + + let mut err: *mut SimlinError = ptr::null_mut(); + simlin_sim_run_to(sim, t1, &mut err); + assert!(err.is_null(), "run_to(t1) should succeed"); + + let const_c = std::ffi::CString::new(const_name).unwrap(); + let mut err: *mut SimlinError = ptr::null_mut(); + simlin_sim_set_value(sim, const_c.as_ptr(), override_val, &mut err); + assert!(err.is_null(), "set_value on a constant should succeed"); + + let mut err: *mut SimlinError = ptr::null_mut(); + simlin_sim_run_to_end(sim, &mut err); + assert!(err.is_null(), "run_to_end should succeed"); + + let series = read_series(sim, name, n_chunks); + simlin_sim_unref(sim); + simlin_model_unref(model); + series +} + +/// VM oracle for a full from-t0 run with a constant override applied before the +/// run: `simlin_sim_new` -> `set_value(const_name, v)` -> `run_to_end` -> +/// `get_series(name)`. This is the "override-applied defaults" the blob must +/// reproduce after a `reset` that preserves overrides. +unsafe fn vm_series_with_override( + project: *mut SimlinProject, + model_name: &std::ffi::CStr, + name: &str, + const_name: &str, + override_val: f64, + n_chunks: usize, +) -> Vec { + let mut err: *mut SimlinError = ptr::null_mut(); + let model = simlin_project_get_model(project, model_name.as_ptr(), &mut err); + assert!(err.is_null()); + let sim = simlin_sim_new(model, false, &mut err); + assert!(err.is_null(), "sim_new should succeed"); + + let const_c = std::ffi::CString::new(const_name).unwrap(); + let mut err: *mut SimlinError = ptr::null_mut(); + simlin_sim_set_value(sim, const_c.as_ptr(), override_val, &mut err); + assert!(err.is_null(), "set_value on a constant should succeed"); + + let mut err: *mut SimlinError = ptr::null_mut(); + simlin_sim_run_to_end(sim, &mut err); + assert!(err.is_null(), "run_to_end should succeed"); + + let series = read_series(sim, name, n_chunks); + simlin_sim_unref(sim); + simlin_model_unref(model); + series +} + +/// Read `name`'s series from a run sim via `simlin_sim_get_series`, truncated to +/// the number actually written. +unsafe fn read_series(sim: *mut SimlinSim, name: &str, n_chunks: usize) -> Vec { + let name_c = std::ffi::CString::new(name).unwrap(); + let mut results = vec![0.0f64; n_chunks]; + let mut written: usize = 0; + let mut err: *mut SimlinError = ptr::null_mut(); + simlin_sim_get_series( + sim, + name_c.as_ptr(), + results.as_mut_ptr(), + n_chunks, + &mut written, + &mut err, + ); + assert!(err.is_null(), "get_series should succeed"); + results.truncate(written); + results +} + /// The VM's series for `name` via `simlin_sim_new` + `simlin_sim_get_series`. unsafe fn vm_series( project: *mut SimlinProject, diff --git a/src/simlin-engine/CLAUDE.md b/src/simlin-engine/CLAUDE.md index 2a9a305a5..613bfeab1 100644 --- a/src/simlin-engine/CLAUDE.md +++ b/src/simlin-engine/CLAUDE.md @@ -26,9 +26,9 @@ Equation text flows through these stages in order: - **`src/vm_vector_sort_order.rs`** - Genuine-Vensim VECTOR SORT ORDER. Ranks WITHIN each currently-iterated source slice (the innermost/last-declared dim is the sorted axis; outer dims select independent rows), 0-based: result position `j` of a row holds the 0-based source index *within that row* of its `j`-th element in sorted order (`direction == 1` ascending, else descending; stable ties). A 1-D view is the degenerate single-row case (in-row ranks == whole-view ranks). The prior whole-flattened-view absolute-index behavior (GH #585) made a multi-row source feed out-of-range flat indices into a downstream single-column ELM MAP; ground truth is real Vensim DSS `/test/test-models/tests/vector_order/output.tab` (ranks include `0`, impossible for a 1-based permutation). RANK is a distinct, correctly 1-based opcode. - **`src/vm_vector_elm_map.rs`** - Genuine-Vensim VECTOR ELM MAP: result element `i` = `source[base_i + round(offset[i])]` over the source variable's FULL row-major contiguous storage, where `base_i` is the flat position arg-1's element reference establishes and the offset steps the source's innermost dim (stride 1). An offset+base outside `[0, full_source_len)`, or a NaN offset, yields genuine IEEE NaN (the out-of-range result Vensim documents as `:NA:`; this is the absorbing NaN, NOT the finite `crate::float::NA` sentinel). NO modulo / NO wraparound (the bug the prior sliced-view-no-base implementation had). 8. **`src/alloc.rs`** - Allocation helpers for VM priority allocation: `allocate_available()` (bisection-based priority allocation), `alloc_curve()` (per-requester allocation curves for 6 profile types), `normal_cdf()`/`erfc_approx()`. -9. **`src/wasmgen/`** - WebAssembly code-generation backend: an alternative execution path to the bytecode VM (item 7) that lowers the salsa-compiled `CompiledSimulation` to one self-contained wasm module (no host imports), mirroring the VM opcode-for-opcode. Intended for fast repeated re-simulation (e.g. interactive parameter scrubbing): a host instantiates the blob once and calls its exported `run` on every change. **The bytecode VM remains the correctness oracle** -- every emitted module is executed under the pure-Rust DLR-FT `wasm-interpreter` in tests and compared against `Vm::run_to_end`. Entry point `compile_simulation(&CompiledSimulation) -> WasmArtifact { wasm: Vec, layout: WasmLayout }`; the blob exports `memory`, `run`, the geometry globals `n_slots`/`n_chunks`/`results_offset` (step-major results), and `set_value`/`reset`/`clear_values` (constant-override semantics matching the VM, sourced from a mutable const-override region indexed by absolute slot). `WasmLayout` (canonical-name -> slot offset) lets a host read one variable's series by striding the results region. Coverage is the full core-simulation surface: every scalar opcode + builtin (transcendentals open-coded as wasm helpers, so the blob needs no math imports), arrays (subscripts, iteration, reducers, dynamic subscripts with OOB->NaN), graphical-function lookups (scalar + per-element `LookupArray`), the vector ops (`VectorSelect`/`VectorElmMap`/`VectorSortOrder`/`Rank`) and market-clearing allocation (`AllocateAvailable`/`AllocateByPriority`), Euler/RK2/RK4 integration, `PREVIOUS`/`INIT`, and nested modules (one set of initials/flows/stocks functions per `(model, input_set)` instance, addressed by a runtime `module_off`). Out of scope: LTM (VM-only); a true-runtime-range subscript (`ViewRangeDynamic`, GH #612) returns `WasmGenError::Unsupported`; array unrolling is bounded by `MAX_UNROLL_UNITS` (65,536 elements/function), above which a model cleanly returns `Unsupported` and the caller falls back to the VM. Files: +9. **`src/wasmgen/`** - WebAssembly code-generation backend: an alternative execution path to the bytecode VM (item 7) that lowers the salsa-compiled `CompiledSimulation` to one self-contained wasm module (no host imports), mirroring the VM opcode-for-opcode. Intended for fast repeated re-simulation (e.g. interactive parameter scrubbing): a host instantiates the blob once and calls its exported `run` on every change. **The bytecode VM remains the correctness oracle** -- every emitted module is executed under the pure-Rust DLR-FT `wasm-interpreter` in tests and compared against `Vm::run_to_end`. Entry point `compile_simulation(&CompiledSimulation) -> WasmArtifact { wasm: Vec, layout: WasmLayout }`; the blob exports `memory`, `run`, the resumable run pair `run_to(target)`/`run_initials` (mirroring `vm.rs::run_to`/`run_initials`; `run` re-expresses as `reset` then `run_to(stop)`), the geometry globals `n_slots`/`n_chunks`/`results_offset` (step-major results), and `set_value`/`reset`/`clear_values` (constant-override semantics matching the VM, sourced from a mutable const-override region indexed by absolute slot). The per-step run cursor (`saved`/`step_accum`/`did_initials`) lives in *internal* mutable wasm globals (not exported) so a run survives across separate exported calls -- `run_initials` runs once and each `run_to` resumes from where the prior call stopped, and `reset` clears the cursor while preserving constant overrides. `WasmLayout` (canonical-name -> slot offset) lets a host read one variable's series by striding the results region. Coverage is the full core-simulation surface: every scalar opcode + builtin (transcendentals open-coded as wasm helpers, so the blob needs no math imports), arrays (subscripts, iteration, reducers, dynamic subscripts with OOB->NaN), graphical-function lookups (scalar + per-element `LookupArray`), the vector ops (`VectorSelect`/`VectorElmMap`/`VectorSortOrder`/`Rank`) and market-clearing allocation (`AllocateAvailable`/`AllocateByPriority`), Euler/RK2/RK4 integration, `PREVIOUS`/`INIT`, and nested modules (one set of initials/flows/stocks functions per `(model, input_set)` instance, addressed by a runtime `module_off`). Out of scope: LTM (VM-only); a true-runtime-range subscript (`ViewRangeDynamic`, GH #612) returns `WasmGenError::Unsupported`; array unrolling is bounded by `MAX_UNROLL_UNITS` (65,536 elements/function), above which a model cleanly returns `Unsupported` and the caller falls back to the VM. Files: - **`mod.rs`** - the `WasmGenError` error type + module re-exports. - - **`module.rs`** - `compile_simulation`/`compile_datamodel_to_*`: whole-module assembly -- memory layout, the per-instance initials/flows/stocks functions + the `run` driver (Euler/RK2/RK4 loops), the GF/temp/snapshot/const-override regions, the `set_value`/`reset` exports, and `WasmLayout` (de)serialization. + - **`module.rs`** - `compile_simulation`/`compile_datamodel_to_*`: whole-module assembly -- memory layout, the per-instance initials/flows/stocks functions + the resumable run ABI (`emit_run_to` is the single Euler/RK2/RK4 stepping loop; `run` delegates as `reset` then `run_to(stop)` and `run_to` first calls the idempotent `run_initials`, so the cursor in the mutable globals `G_SAVED`/`G_STEP_ACCUM`/`G_DID_INITIALS` makes a run resumable across calls), the GF/temp/snapshot/const-override regions, the `set_value`/`reset` exports (`reset` clears the cursor but keeps overrides), and `WasmLayout` (de)serialization. - **`lower.rs`** - the per-opcode emitter (`emit_bytecode` over the un-fused + peephole opcode set), the `HelperFns` registry, and the `EmitState` unroll budget. Its `#[cfg(test)]` tests live in the sibling **`lower_tests.rs`** (split out for the per-file line cap). - **`views.rs`** - the compile-time `ViewDesc` view-descriptor stack + element-address arithmetic mirroring `RuntimeView::flat_offset`/`offset_for_iter_index`. - **`math.rs`** - open-coded transcendental wasm helpers (`exp`/`ln`/`sin`/`cos`/`tan`/`atan`/`asin`/`acos`/`log10`/`pow`), each validated against Rust `f64`. diff --git a/src/simlin-engine/src/wasmgen/module.rs b/src/simlin-engine/src/wasmgen/module.rs index 8839646f4..6aafdd7b1 100644 --- a/src/simlin-engine/src/wasmgen/module.rs +++ b/src/simlin-engine/src/wasmgen/module.rs @@ -72,17 +72,34 @@ const CURR_BASE: u32 = 0; const TIME_ADDR: u64 = TIME_OFF as u64 * SLOT_SIZE as u64; // Global indices. The three self-describing geometry globals come first (so the -// exported indices 0/1/2 stay stable for hosts); `use_prev_fallback` -- the only -// mutable global -- follows at index 3. It gates `LoadPrev`: init 1 (return the -// fallback) until the first `prev_values` snapshot clears it (`vm.rs:668`). +// exported indices 0/1/2 stay stable for hosts), all immutable. The mutable +// globals follow: `use_prev_fallback` at index 3, then the persistent step +// cursor (`saved`/`step_accum`/`did_initials`) at 4/5/6. The cursor globals make +// a run resumable: they survive across separate exported calls so `run_initials` +// can run once and each `run_to(target)` resumes from where the prior one +// stopped (the blob analogue of the VM's `curr_chunk`/`step_accum`/`did_initials` +// fields). They are internal -- not exported -- since a host drives the run only +// through `run`/`run_to`/`run_initials`/`reset`. +// +// `use_prev_fallback` gates `LoadPrev`: init 1 (return the fallback) until the +// first `prev_values` snapshot clears it (`vm.rs:668`); it is the inverse of the +// VM's `prev_values_valid`. const G_N_SLOTS: u32 = 0; const G_N_CHUNKS: u32 = 1; const G_RESULTS_OFFSET: u32 = 2; const G_USE_PREV_FALLBACK: u32 = 3; - -// `run`'s i32 locals. -const L_SAVED: u32 = 0; -const L_STEP_ACCUM: u32 = 1; +// The persistent step cursor (mutable, internal): +const G_SAVED: u32 = 4; // saved-row counter (was the run-local `L_SAVED`) +const G_STEP_ACCUM: u32 = 5; // save-cadence accumulator (was `L_STEP_ACCUM`) +const G_DID_INITIALS: u32 = 6; // 0 until initials have run (cf. `Vm::did_initials`) + +// `run_to`'s i32 locals. Its sole f64 *param* (the run target) occupies local 0, +// so the i32 working locals start at index 1 and `L_DST` is index 2 -- the same +// index the per-step emitters (`emit_save_advance`/`emit_rk*_step`) use, which +// lets those helpers stay shared between the (now removed) function-local cursor +// and the global cursor. Index 1 is an unused i32 filler that keeps `L_DST` at 2. +// The saved-row/step-accum cursor lives in `G_SAVED`/`G_STEP_ACCUM` (globals), +// not locals, so it survives across `run_to` calls. const L_DST: u32 = 2; /// Compile the named model of a datamodel `Project` to a full [`WasmArtifact`] @@ -338,6 +355,16 @@ const F_FLOWS: u32 = 1; const F_STOCKS: u32 = 2; const FUNCS_PER_INSTANCE: u32 = 3; +/// The function index of `run` (the first driver function, after the helpers and +/// the per-instance triples). The driver functions follow in this fixed order: +/// `run`, `set_value`, `reset`, `clear_values`, `run_to`, `run_initials` (the two +/// resumable exports append last, keeping the original four at stable indices). +/// Used both at emit time (`compile_simulation`, to resolve the delegation +/// targets) and at assembly time (`assemble_simulation`), so the two never drift. +fn run_fn_index_of(n_helpers: u32, n_instances: u32) -> u32 { + n_helpers + n_instances * FUNCS_PER_INSTANCE +} + // Type-section indices. The `run` type comes first; one opcode-program type per // distinct module-input count follows (`(i32, f64*k) -> ()`), and helper types // are appended after those. `run` is `() -> ()`. @@ -683,35 +710,61 @@ pub fn compile_simulation(sim: &CompiledSimulation) -> Result `reset` + + // `run_to`; `run_to` -> `run_initials`), so their indices must be known before + // their bodies are emitted -- the function section declares all indices up + // front, so this is sound. Keeping run/set_value/reset/clear_values at their + // original indices (the two new exports append after) keeps the change additive. + let run_fn_index = run_fn_index_of(n_helpers, instances.len() as u32); + let reset_fn_index = run_fn_index + 2; + let run_to_fn_index = run_fn_index + 4; + let run_initials_fn_index = run_fn_index + 5; + + // The resumable run ABI: `run_initials` (idempotent), `run_to(target)` (the + // single shared stepping loop), and `run` (re-expressed as `reset; + // run_to(stop)`). The cursor lives in mutable globals so a run is resumable. + let run_initials_fn = emit_run_initials(specs, regions, root_fn_base); + let run_to_fn = emit_run_to( specs, - RunRegions { - n_slots, - results_base, - stride, - n_chunks, - initial_values_base, - prev_values_base, - rk_saved_base, - rk_accum_base, - }, + regions, save_every, &stock_offsets, root_fn_base, + run_initials_fn_index, + ); + let run_fn = emit_run( + specs, + RunFnIndices { + run_to: run_to_fn_index, + reset: reset_fn_index, + }, ); // The constants-override exports (Phase 7 Task 2): `set_value` writes an // override into the constants region (validated against the validity bytes), - // `reset` resets the run state (`use_prev_fallback`) without clearing the - // region, and `clear_values` restores the compiled defaults. + // `reset` resets the run state (the cursor globals + `use_prev_fallback`) + // without clearing the region, and `clear_values` restores the compiled + // defaults. let set_value_fn = emit_set_value(n_slots, const_region_base, const_valid_base); let reset_fn = emit_reset(); let clear_values_fn = emit_clear_values(const_region_base, &overridable_defaults); @@ -734,6 +787,8 @@ pub fn compile_simulation(sim: &CompiledSimulation) -> Result Function { - // Three i32 locals (saved/step_accum/dst) + two f64 locals (saved_time, s). - let mut f = Function::new([(3, ValType::I32), (2, ValType::F64)]); +/// `run_to`'s f64 param: the run target (the strict upper bound on `curr[TIME]`), +/// at local 0. The loop steps until `curr[TIME] > target`. +const RT_TARGET: u32 = 0; + +/// The function indices `run`'s delegating body calls: `run` is re-expressed as +/// `reset(); run_to(stop)` (one shared stepping loop). The indices are resolved +/// in `compile_simulation` before the bodies are emitted (the function section +/// declares all indices up front). (`run_to` calls `run_initials` directly via +/// its own index argument, so that index is not threaded here.) +#[derive(Clone, Copy)] +struct RunFnIndices { + run_to: u32, + reset: u32, +} + +/// Emit `run_initials() -> ()`: seed the reserved time slots, run the root +/// initials, capture `initial_values`, and arm the step cursor -- but only the +/// first time per `reset`. Idempotent via the `G_DID_INITIALS` guard, mirroring +/// `vm.rs:1080-1082` (`if self.did_initials { return Ok(()); }`), so a `run_to` +/// after another `run_to` re-runs initials zero times and resumes the existing +/// cursor instead. +fn emit_run_initials(specs: &Specs, regions: RunRegions, root_fn_base: u32) -> Function { + let mut f = Function::new([]); + + // if G_DID_INITIALS != 0: return (idempotency -- already initialized). + f.instruction(&I::GlobalGet(G_DID_INITIALS)); + f.instruction(&I::If(BlockType::Empty)); + f.instruction(&I::Return); + f.instruction(&I::End); - // Absolute function indices of the ROOT instance's three program functions: - // its function-triple base + the per-phase offset. `run` drives the root with - // `module_off = 0`; nested instances are reached via `EvalModule` from there. let f_initials = root_fn_base + F_INITIALS; - let f_flows = root_fn_base + F_FLOWS; - let f_stocks = root_fn_base + F_STOCKS; - // Seed the reserved global slots into curr (chunk base 0), then run the - // initials. The seeds mirror the VM, which writes start/dt/start/stop into - // TIME/DT/INITIAL_TIME/FINAL_TIME before run_initials. + // Seed the reserved global slots into curr (chunk base 0), mirroring the VM, + // which writes start/dt/start/stop into TIME/DT/INITIAL_TIME/FINAL_TIME before + // run_initials. store_curr_const_abs(&mut f, TIME_OFF, specs.start); store_curr_const_abs(&mut f, DT_OFF, specs.dt); store_curr_const_abs(&mut f, INITIAL_TIME_OFF, specs.start); store_curr_const_abs(&mut f, FINAL_TIME_OFF, specs.stop); - // Re-arm the PREVIOUS fallback for this run, mirroring the VM's - // `run_initials` (which sets `use_prev_fallback = true` at the start of - // every run). `run` reseeds the time globals + reruns initials and is the - // documented per-change entry point for repeated re-simulation, so it must - // reset this flag itself: the loop below clears it to 0 after the first - // `prev_values` snapshot, and without re-arming it here a second `run` on - // the same instance would read the prior run's `prev_values` on step 0 (and - // during initials) instead of the fallback. The module-init value is also 1, - // so this is a no-op only on the very first run. + + // Arm the PREVIOUS fallback for this run, mirroring the VM's `run_initials` + // (which sets `use_prev_fallback = true`). `reset` also re-arms it, but a bare + // `run_initials` (no `reset` first, e.g. the resumable test driver) must arm + // it here too so a `PREVIOUS(x)` evaluated during initials returns its + // fallback. The first `run_to` step clears it after the first `prev_values` + // snapshot. f.instruction(&I::I32Const(1)); f.instruction(&I::GlobalSet(G_USE_PREV_FALLBACK)); f.instruction(&I::I32Const(0)); f.instruction(&I::Call(f_initials)); - // Capture `initial_values := curr` exactly once, after initials, for - // `INIT(x)` reads in the flows/stocks programs (`vm.rs:1124-1128`). - // `use_prev_fallback` is 1 (re-armed just above) through initials, so any - // `PREVIOUS(x)` evaluated during initials returns its fallback. + // Capture `initial_values := curr` exactly once, after initials, for `INIT(x)` + // reads in the flows/stocks programs (`vm.rs:1124-1128`). emit_copy_chunk( &mut f, CURR_BASE, @@ -1060,13 +1122,59 @@ fn emit_run_simulation( regions.n_slots, ); + // Arm the cursor: nothing saved yet, accumulator cleared, initials done. The + // first save happens in `run_to`'s loop (the forced t=start row), matching the + // VM (`run_initials` does not save chunk 0). + f.instruction(&I::I32Const(0)); + f.instruction(&I::GlobalSet(G_SAVED)); + f.instruction(&I::I32Const(0)); + f.instruction(&I::GlobalSet(G_STEP_ACCUM)); + f.instruction(&I::I32Const(1)); + f.instruction(&I::GlobalSet(G_DID_INITIALS)); + + f.instruction(&I::End); // end function + f +} + +/// Emit `run_to(target: f64) -> ()`: advance the simulation until `curr[TIME] > +/// target` (strict `>`, matching `vm.rs:644`), starting from wherever the +/// persistent cursor left off. Calls `run_initials` first (idempotent), then runs +/// the per-method stepping loop -- the single shared stepping-loop implementation +/// both `run` and `run_to` use. The loop reads/writes the saved-row cursor from +/// `G_SAVED`/`G_STEP_ACCUM` (globals), so it resumes correctly across calls; the +/// saved-row exhaustion break (`if saved >= n_chunks`) clamps a target past +/// FINAL_TIME to the slab end, exactly like the VM's chunk-ring exhaustion. +fn emit_run_to( + specs: &Specs, + regions: RunRegions, + save_every: i32, + stock_offsets: &[usize], + root_fn_base: u32, + run_initials_idx: u32, +) -> Function { + // One f64 param (`target`, local 0) + two i32 locals (index 1 filler, `L_DST` + // at 2) + two f64 locals (`saved_time`, `s` at 3/4). The cursor lives in + // globals, not locals; the i32 at index 1 is unused filler that keeps `L_DST` + // at the index the per-step emitters expect. + let mut f = Function::new([(2, ValType::I32), (2, ValType::F64)]); + + // Absolute function indices of the ROOT instance's three program functions: + // its function-triple base + the per-phase offset. The root is driven with + // `module_off = 0`; nested instances are reached via `EvalModule` from there. + let f_flows = root_fn_base + F_FLOWS; + let f_stocks = root_fn_base + F_STOCKS; + + // Idempotent initials (seeds time slots, runs initials, arms the cursor on the + // first call after a reset; a no-op otherwise). + f.instruction(&I::Call(run_initials_idx)); + f.instruction(&I::Block(BlockType::Empty)); // $break f.instruction(&I::Loop(BlockType::Empty)); // $continue - // if curr[TIME] > stop: break + // if curr[TIME] > target: break f.instruction(&I::I32Const(0)); f.instruction(&I::F64Load(memarg(TIME_ADDR))); - f.instruction(&f64_const(specs.stop)); + f.instruction(&I::LocalGet(RT_TARGET)); f.instruction(&I::F64Gt); f.instruction(&I::BrIf(1)); @@ -1086,7 +1194,8 @@ fn emit_run_simulation( // The save + advance tail is method-agnostic: every method leaves `next[off]` // holding the new stock values and `curr` holding the time-`t` state, so the // save row records `curr`, the advance copies the new stocks `next -> curr`, - // and `curr[TIME] += dt`. + // and `curr[TIME] += dt`. The saved-row counter is the `G_SAVED` global, so + // the cursor survives across `run_to` calls. emit_save_advance(&mut f, specs, save_every, stock_offsets, ®ions); f.instruction(&I::Br(0)); // continue @@ -1096,6 +1205,25 @@ fn emit_run_simulation( f } +/// Emit `run() -> ()` for the `CompiledSimulation` path by *delegating* to the +/// resumable ABI: `reset(); run_to(stop)`. This keeps exactly one stepping-loop +/// implementation (in `run_to`), so `run` and `run_to` can never drift apart. +/// +/// Invariant (the linchpin): `run()` must produce a full from-t0 simulation on +/// every call to a reused instance. The delegation satisfies this for free -- +/// `reset` clears `G_DID_INITIALS`/`G_SAVED`/`G_STEP_ACCUM` and re-arms +/// `G_USE_PREV_FALLBACK = 1`, so the subsequent `run_to` -> `run_initials` (no +/// longer short-circuited, since `reset` cleared `G_DID_INITIALS`) re-seeds the +/// reserved time slots and re-runs initials from scratch. +fn emit_run(specs: &Specs, indices: RunFnIndices) -> Function { + let mut f = Function::new([]); + f.instruction(&I::Call(indices.reset)); + f.instruction(&f64_const(specs.stop)); + f.instruction(&I::Call(indices.run_to)); + f.instruction(&I::End); + f +} + /// The Euler step: `flows`+`stocks` (the stocks program writes `next[off]`), /// then the `prev_values` snapshot. Mirrors `vm.rs:698-708`. fn emit_euler_step(f: &mut Function, f_flows: u32, f_stocks: u32, regions: &RunRegions) { @@ -1135,17 +1263,22 @@ fn emit_save_advance( ) { let n_slots = regions.n_slots; + // The saved-row counter (`G_SAVED`) and the save-cadence accumulator + // (`G_STEP_ACCUM`) are mutable globals, not function locals, so the cursor + // persists across the separate `run_to` calls a resumable run makes. `L_DST` + // is a per-step transient and stays a function local. + // step_accum += 1 - f.instruction(&I::LocalGet(L_STEP_ACCUM)); + f.instruction(&I::GlobalGet(G_STEP_ACCUM)); f.instruction(&I::I32Const(1)); f.instruction(&I::I32Add); - f.instruction(&I::LocalSet(L_STEP_ACCUM)); + f.instruction(&I::GlobalSet(G_STEP_ACCUM)); // save_cond = (step_accum == save_every) | (saved == 0 & time == start) - f.instruction(&I::LocalGet(L_STEP_ACCUM)); + f.instruction(&I::GlobalGet(G_STEP_ACCUM)); f.instruction(&I::I32Const(save_every)); f.instruction(&I::I32Eq); - f.instruction(&I::LocalGet(L_SAVED)); + f.instruction(&I::GlobalGet(G_SAVED)); f.instruction(&I::I32Eqz); f.instruction(&I::I32Const(0)); f.instruction(&I::F64Load(memarg(TIME_ADDR))); @@ -1157,7 +1290,7 @@ fn emit_save_advance( // dst = results_base + saved * stride f.instruction(&I::I32Const(regions.results_base as i32)); - f.instruction(&I::LocalGet(L_SAVED)); + f.instruction(&I::GlobalGet(G_SAVED)); f.instruction(&I::I32Const(regions.stride as i32)); f.instruction(&I::I32Mul); f.instruction(&I::I32Add); @@ -1172,15 +1305,15 @@ fn emit_save_advance( } // saved += 1; step_accum = 0 - f.instruction(&I::LocalGet(L_SAVED)); + f.instruction(&I::GlobalGet(G_SAVED)); f.instruction(&I::I32Const(1)); f.instruction(&I::I32Add); - f.instruction(&I::LocalSet(L_SAVED)); + f.instruction(&I::GlobalSet(G_SAVED)); f.instruction(&I::I32Const(0)); - f.instruction(&I::LocalSet(L_STEP_ACCUM)); + f.instruction(&I::GlobalSet(G_STEP_ACCUM)); // if saved >= n_chunks: break (depth 2: if -> loop -> block) - f.instruction(&I::LocalGet(L_SAVED)); + f.instruction(&I::GlobalGet(G_SAVED)); f.instruction(&I::I32Const(regions.n_chunks as i32)); f.instruction(&I::I32GeS); f.instruction(&I::BrIf(2)); @@ -1287,16 +1420,24 @@ fn emit_set_value(n_slots: u32, const_region_base: u32, const_valid_base: u32) - f } -/// Emit `reset() -> ()`: reset the run state so the next `run` re-runs initials -/// and the loop from t=start. The wasm `run` already re-seeds the time slots and -/// re-runs initials on every call and uses fresh i32 locals for the chunk/step -/// counters, so the only cross-run state is the `use_prev_fallback` global, which -/// `run` clears after the first `prev_values` snapshot. Setting it back to 1 here -/// is the analogue of the VM's `reset` clearing `prev_values_valid` (`vm.rs:976-989`), -/// and -- like the VM -- it deliberately does NOT touch the constants region, so -/// overrides persist across reset. +/// Emit `reset() -> ()`: clear the persistent run state so the next `run_to` +/// (and therefore `run`, which delegates `reset; run_to(stop)`) re-runs initials +/// and steps the loop from t=start. The run cursor now lives in mutable globals +/// (since the run is resumable), so `reset` must clear all of it: +/// `G_SAVED`/`G_STEP_ACCUM` to 0 (no rows saved, accumulator empty), +/// `G_DID_INITIALS` to 0 (so `run_initials` no longer short-circuits and re-seeds +/// the time slots + re-runs initials), and `G_USE_PREV_FALLBACK` back to 1 (the +/// analogue of the VM's `reset` clearing `prev_values_valid`). This mirrors +/// `vm.rs:989-1002` exactly. Like the VM, it deliberately does NOT touch the +/// constants-override region, so a `set_value` override persists across `reset`. fn emit_reset() -> Function { let mut f = Function::new([]); + f.instruction(&I::I32Const(0)); + f.instruction(&I::GlobalSet(G_SAVED)); + f.instruction(&I::I32Const(0)); + f.instruction(&I::GlobalSet(G_STEP_ACCUM)); + f.instruction(&I::I32Const(0)); + f.instruction(&I::GlobalSet(G_DID_INITIALS)); f.instruction(&I::I32Const(1)); f.instruction(&I::GlobalSet(G_USE_PREV_FALLBACK)); f.instruction(&I::End); @@ -1672,13 +1813,18 @@ struct AssembleParts<'a> { /// `[initials_0, flows_0, stocks_0, initials_1, ...]`. `instance_input_counts` /// (same instance order) gives each triple's f64 input-param count. program_fns: Vec, + /// `run() -> ()`, re-expressed as `reset; run_to(stop)`. run_fn: Function, /// `set_value(offset: i32, val: f64) -> i32` (Phase 7 Task 2). set_value_fn: Function, - /// `reset() -> ()` (Phase 7 Task 2). + /// `reset() -> ()` (Phase 7 Task 2; now also clears the run cursor globals). reset_fn: Function, /// `clear_values() -> ()` (Phase 7 Task 2). clear_values_fn: Function, + /// `run_to(target: f64) -> ()`: advance the resumable run to `target`. + run_to_fn: Function, + /// `run_initials() -> ()`: idempotent initials for the resumable run. + run_initials_fn: Function, /// Module-input parameter count per instance, in the same order the triples /// appear in `program_fns`. Drives the per-triple wasm type /// (`(i32, f64*k) -> ()`). @@ -1712,6 +1858,8 @@ fn assemble_simulation(parts: AssembleParts) -> Vec { set_value_fn, reset_fn, clear_values_fn, + run_to_fn, + run_initials_fn, instance_input_counts, pages, n_slots, @@ -1724,19 +1872,25 @@ fn assemble_simulation(parts: AssembleParts) -> Vec { let mut wasm = WasmModule::new(); let n_helpers = helpers.functions.len() as u32; let n_instances = instance_input_counts.len() as u32; - // Function layout: helpers, the per-instance triples, then `run`, then the - // three constants-override exports (`set_value`/`reset`/`clear_values`). - let run_fn_index = n_helpers + n_instances * FUNCS_PER_INSTANCE; + // Function layout: helpers, the per-instance triples, then the driver + // functions in this fixed order: `run`, `set_value`, `reset`, `clear_values`, + // `run_to`, `run_initials`. The two resumable exports append last so the + // original four keep stable indices (the growth is purely additive). The + // emit-time index math in `compile_simulation` uses the same `run_fn_index_of`. + let run_fn_index = run_fn_index_of(n_helpers, n_instances); let set_value_fn_index = run_fn_index + 1; let reset_fn_index = run_fn_index + 2; let clear_values_fn_index = run_fn_index + 3; + let run_to_fn_index = run_fn_index + 4; + let run_initials_fn_index = run_fn_index + 5; // Type section: `run`'s `() -> ()` first, then one opcode-program type per // *distinct* module-input count (`(i32, f64*k) -> ()`, sorted), then the - // helper types, then the `set_value` type (`(i32, f64) -> i32`). - // `reset`/`clear_values` reuse `TYPE_RUN_FN`. `opcode_type_for` maps an - // instance's `n_inputs` to its type index; a helper at function index `i` - // uses the type appended after those. + // helper types, then the `set_value` type (`(i32, f64) -> i32`), then + // `run_to`'s `(f64) -> ()` type. `reset`/`clear_values`/`run_initials` reuse + // `TYPE_RUN_FN` (`() -> ()`). `opcode_type_for` maps an instance's `n_inputs` + // to its type index; a helper at function index `i` uses the type appended + // after those. let mut distinct_inputs: Vec = instance_input_counts.to_vec(); distinct_inputs.sort_unstable(); distinct_inputs.dedup(); @@ -1747,6 +1901,7 @@ fn assemble_simulation(parts: AssembleParts) -> Vec { .collect(); let first_helper_type = TYPE_RUN_FN + 1 + distinct_inputs.len() as u32; let set_value_type = first_helper_type + helpers.functions.len() as u32; + let run_to_type = set_value_type + 1; let mut types = TypeSection::new(); types.ty().function([], []); // TYPE_RUN_FN: () -> () @@ -1764,11 +1919,14 @@ fn assemble_simulation(parts: AssembleParts) -> Vec { types .ty() .function([ValType::I32, ValType::F64], [ValType::I32]); + // `run_to(target: f64) -> ()`. + types.ty().function([ValType::F64], []); wasm.section(&types); // Function section: helpers first (indices `0..n_helpers`), then each // instance's three program functions (typed by that instance's `n_inputs`), - // then `run`, then `set_value`/`reset`/`clear_values`. + // then the driver functions in index order: `run`, `set_value`, `reset`, + // `clear_values`, `run_to`, `run_initials`. let mut functions = FunctionSection::new(); for (i, _) in helpers.functions.iter().enumerate() { functions.function(first_helper_type + i as u32); @@ -1783,6 +1941,8 @@ fn assemble_simulation(parts: AssembleParts) -> Vec { functions.function(set_value_type); // set_value functions.function(TYPE_RUN_FN); // reset functions.function(TYPE_RUN_FN); // clear_values + functions.function(run_to_type); // run_to + functions.function(TYPE_RUN_FN); // run_initials wasm.section(&functions); let mut memories = MemorySection::new(); @@ -1800,20 +1960,25 @@ fn assemble_simulation(parts: AssembleParts) -> Vec { mutable: false, shared: false, }; + let mutable_i32_global = || GlobalType { + val_type: ValType::I32, + mutable: true, + shared: false, + }; let mut globals = GlobalSection::new(); globals.global(i32_global(), &ConstExpr::i32_const(n_slots as i32)); globals.global(i32_global(), &ConstExpr::i32_const(n_chunks as i32)); globals.global(i32_global(), &ConstExpr::i32_const(results_base as i32)); - // `use_prev_fallback`: the only mutable global. Init 1 so `LoadPrev` returns - // its fallback until the first `prev_values` snapshot clears it (`vm.rs:668`). - globals.global( - GlobalType { - val_type: ValType::I32, - mutable: true, - shared: false, - }, - &ConstExpr::i32_const(1), - ); + // The mutable globals (index 3..=6), all internal. `use_prev_fallback` (index + // 3) inits 1 so `LoadPrev` returns its fallback until the first `prev_values` + // snapshot clears it (`vm.rs:668`). The persistent step cursor follows: + // `G_SAVED`/`G_STEP_ACCUM`/`G_DID_INITIALS` (4/5/6), all init 0 -- the + // module-init state is "no rows saved, accumulator empty, initials not yet + // run", which `run_initials` arms and `reset` restores. + globals.global(mutable_i32_global(), &ConstExpr::i32_const(1)); // G_USE_PREV_FALLBACK + globals.global(mutable_i32_global(), &ConstExpr::i32_const(0)); // G_SAVED + globals.global(mutable_i32_global(), &ConstExpr::i32_const(0)); // G_STEP_ACCUM + globals.global(mutable_i32_global(), &ConstExpr::i32_const(0)); // G_DID_INITIALS wasm.section(&globals); let mut exports = ExportSection::new(); @@ -1821,15 +1986,24 @@ fn assemble_simulation(parts: AssembleParts) -> Vec { exports.export("set_value", ExportKind::Func, set_value_fn_index); exports.export("reset", ExportKind::Func, reset_fn_index); exports.export("clear_values", ExportKind::Func, clear_values_fn_index); + // The resumable run ABI (purely additive to the export set above). + exports.export("run_to", ExportKind::Func, run_to_fn_index); + exports.export("run_initials", ExportKind::Func, run_initials_fn_index); exports.export("memory", ExportKind::Memory, 0); exports.export("n_slots", ExportKind::Global, G_N_SLOTS); exports.export("n_chunks", ExportKind::Global, G_N_CHUNKS); exports.export("results_offset", ExportKind::Global, G_RESULTS_OFFSET); + // The live saved-row counter (a mutable global): it is 0 before any run and + // after `reset`, and equals `n_chunks` after a full run. A host reads it as + // the number of completed steps (the VM's `results.step_count`), which the + // static `n_chunks` capacity cannot express mid-run / pre-run. Additive. + exports.export("saved_steps", ExportKind::Global, G_SAVED); wasm.section(&exports); // Code section order must match the function section: helper bodies, then the - // per-instance program functions (in `program_fns` order), then `run`, then - // `set_value`/`reset`/`clear_values`. + // per-instance program functions (in `program_fns` order), then the driver + // functions in index order: `run`, `set_value`, `reset`, `clear_values`, + // `run_to`, `run_initials`. let mut code = CodeSection::new(); for hf in &helpers.functions { code.function(&hf.body); @@ -1841,6 +2015,8 @@ fn assemble_simulation(parts: AssembleParts) -> Vec { code.function(&set_value_fn); code.function(&reset_fn); code.function(&clear_values_fn); + code.function(&run_to_fn); + code.function(&run_initials_fn); wasm.section(&code); // The GF directory + data regions and the constants-override init values @@ -2099,6 +2275,12 @@ mod tests { }, reset_fn: empty(), clear_values_fn: empty(), + // Empty (no-op) resumable-run functions: this test only checks the GF + // data segments. `run_to` is `(f64) -> ()` and `run_initials` is + // `() -> ()`; an empty body type-checks against either (the type comes + // from the function section, and a no-op leaves the stack empty). + run_to_fn: empty(), + run_initials_fn: empty(), instance_input_counts: &[0], pages, n_slots: 0, @@ -4554,4 +4736,757 @@ mod tests { wasm_slab[level_off] ); } + + // ── Resumable run ABI (run_initials/run_to) vs the VM oracle ────────── + // + // The blob's persistent step cursor lives in mutable globals + // (`G_SAVED`/`G_STEP_ACCUM`/`G_DID_INITIALS`), so a run can be advanced + // incrementally: `run_initials()` once, then `run_to(t)` per target. The VM + // (`Vm::run_initials`/`run_to`/`reset`/`set_value`) is the correctness oracle + // for every behavior below; the comparator tolerance matches the + // single-shot-`run` tests above (1e-9 cell-for-cell on the in-memory + // fixtures, which run identically on both backends). + + /// A small stock + constant-flow fixture with `n_chunks` save points spanning + /// `[0, stop]` at `dt`/`save_step` = 1. `level` integrates `inflow_rate` per + /// step, so a wrong cursor or guard diverges immediately and visibly. + fn resumable_fixture(stop: f64) -> crate::datamodel::Project { + crate::test_common::TestProject::new("resumable") + .with_sim_time(0.0, stop, 1.0) + .aux("inflow_rate", "2", None) + .stock("level", "0", &["inflow"], &[], None) + .flow("inflow", "inflow_rate", None) + .build_datamodel() + } + + /// Drive the blob's resumable exports on a *fresh* instance: `run_initials` + /// once, then `run_to(t)` for each `t` in `targets`, then copy the whole + /// step-major slab out. The in-module peer of the integration-test helper + /// `run_wasm_results_segmented`; kept here because the lib `#[cfg(test)]` + /// module cannot reach the integration crate's private helpers. + fn run_artifact_segmented(artifact: &WasmArtifact, targets: &[f64]) -> Vec { + let info = validate(&artifact.wasm).expect("generated module must validate"); + let mut store = Store::new(()); + let inst = store + .module_instantiate(&info, Vec::new(), None) + .expect("instantiate") + .module_addr; + let run_initials = store + .instance_export(inst, "run_initials") + .expect("run_initials export") + .as_func() + .expect("run_initials is a function"); + store + .invoke_simple_typed::<(), ()>(run_initials, ()) + .expect("run_initials wasm"); + for &t in targets { + let run_to = store + .instance_export(inst, "run_to") + .expect("run_to export") + .as_func() + .expect("run_to is a function"); + store + .invoke_simple_typed::<(f64,), ()>(run_to, (t,)) + .expect("run_to wasm"); + } + read_slab(&mut store, inst, &artifact.layout) + } + + /// Copy the whole step-major results slab (`n_chunks * n_slots` f64 at + /// `layout.results_offset`) out of an already-driven instance's `memory`. + fn read_slab( + store: &mut Store<()>, + inst: checked::Stored, + layout: &WasmLayout, + ) -> Vec { + let mem = store + .instance_export(inst, "memory") + .unwrap() + .as_mem() + .unwrap(); + let n = layout.n_chunks * layout.n_slots; + let base = layout.results_offset; + store.mem_access_mut_slice(mem, |bytes| { + (0..n) + .map(|i| { + let a = base + i * 8; + f64::from_le_bytes(bytes[a..a + 8].try_into().unwrap()) + }) + .collect() + }) + } + + /// Task 1 (AC2.1, AC2.2 foundation): the re-expressed `run`, the resumable + /// `run_initials`+`run_to(stop)`, and the VM must all agree on the full series. + /// `run` is now `reset; run_to(stop)`, so this proves the delegation is + /// faithful (the `run` export matches the segmented drive) and that the + /// resumable path matches the VM (`Vm::run_to_end`) cell-for-cell. + #[test] + fn compile_simulation_run_to_matches_run_and_vm() { + let datamodel = resumable_fixture(10.0); + let sim = compile_sim(&datamodel, "main"); + let artifact = compile_simulation(&sim).expect("wasm codegen"); + let n_slots = artifact.layout.n_slots; + let n_chunks = artifact.layout.n_chunks; + let stop = sim.specs.stop; + + // (a) the single-shot `run` export. + let via_run = run_artifact_results(&artifact); + // (b) run_initials + run_to(stop). + let via_run_to = run_artifact_segmented(&artifact, &[stop]); + // (c) the VM oracle. + let mut vm = Vm::new(compile_sim(&datamodel, "main")).expect("vm"); + vm.run_to_end().expect("vm run"); + let vm_results = vm.into_results(); + assert_eq!(vm_results.step_count, n_chunks, "VM saved-chunk count"); + + // The two wasm paths must be byte-identical (the run re-expression is a + // pure delegation to run_to, so there is no numeric slack between them). + assert_eq!( + via_run, via_run_to, + "run export diverged from run_initials+run_to(stop) -- the run re-expression is unfaithful" + ); + + // Both wasm paths equal the VM cell-for-cell over every layout variable. + for (name, wasm_off) in &artifact.layout.var_offsets { + let wasm_off = *wasm_off; + let ident = Ident::::from_str_unchecked(name); + let Some(&vm_off) = vm_results.offsets.get(&ident) else { + continue; + }; + for c in 0..n_chunks { + let vm_val = vm_results.data[c * vm_results.step_size + vm_off]; + let run_val = via_run[c * n_slots + wasm_off]; + assert!( + (vm_val - run_val).abs() < 1e-9, + "{name} mismatch at chunk {c}: vm={vm_val} wasm={run_val}" + ); + } + } + + // AC2.2 foundation: after run_to(t), the saved row for time t holds the + // VM's value at t. level integrates inflow_rate=2/step from 0, so at t its + // saved value is 2*t. Drive a fresh instance to t=4 and read level's row 4. + let level_off = layout_offset(&artifact, "level"); + let to_4 = run_artifact_segmented(&artifact, &[4.0]); + let mut vm4 = Vm::new(compile_sim(&datamodel, "main")).expect("vm"); + vm4.run_to(4.0).expect("vm run_to(4)"); + let vm4_results = vm4.into_results(); + let vm4_level_off = vm4_results.offsets[&Ident::::from_str_unchecked("level")]; + let wasm_at_4 = to_4[4 * n_slots + level_off]; + let vm_at_4 = vm4_results.data[4 * vm4_results.step_size + vm4_level_off]; + assert!( + (wasm_at_4 - vm_at_4).abs() < 1e-9 && (wasm_at_4 - 8.0).abs() < 1e-9, + "level at t=4 after run_to(4): wasm={wasm_at_4} vm={vm_at_4} (expected 8)" + ); + } + + /// Read a single f64 slot from the live `curr` chunk (base 0) of an + /// already-driven instance -- the value `run_to` left "current". This is the + /// blob-side analogue of the VM's `get_value_now(off)` (which reads the VM's + /// current chunk): the phase reads AC2.2's "getValue after run_to(t)" at the + /// blob level as the live curr chunk, since the blob has no `getValue` export. + fn read_curr_slot( + store: &mut Store<()>, + inst: checked::Stored, + off: usize, + ) -> f64 { + let mem = store + .instance_export(inst, "memory") + .unwrap() + .as_mem() + .unwrap(); + let addr = off * 8; // curr chunk starts at byte 0 + store.mem_access_mut_slice(mem, |bytes| { + f64::from_le_bytes(bytes[addr..addr + 8].try_into().unwrap()) + }) + } + + /// The VM's saved slab after driving it through `run_to(t)` for each `t` in + /// `targets` (mirrors `run_artifact_segmented`). `run_to` calls `run_initials` + /// internally, so the VM advances exactly as the blob does. + fn vm_slab_segmented(sim: CompiledSimulation, targets: &[f64]) -> (Vec, usize, usize) { + let mut vm = Vm::new(sim).expect("vm creation"); + for &t in targets { + vm.run_to(t).expect("vm run_to"); + } + let results = vm.into_results(); + (results.data.to_vec(), results.step_size, results.step_count) + } + + /// Count the committed (written) rows in a *blob* results slab after a partial + /// run, via the TIME column. This is sound for the blob specifically because + /// the blob keeps its working `curr`/`next` chunks SEPARATE from the results + /// region (see the "Cursor mapping" caveat): an unwritten results row stays at + /// its zero-initialized state, so its TIME slot reads 0.0 and won't match the + /// expected save-point time `start + c*save_step` for `c > 0`. Row 0 is always + /// written (the forced t=start save). The same heuristic is NOT sound for the + /// VM slab, whose chunk-ring leaks the working chunk into the exported range + /// (its TIME slot holds a genuine overshoot time) -- hence callers derive the + /// VM's committed count analytically instead. Used only with the resumable + /// fixture, where save_step == dt. + fn live_saved_rows( + slab: &[f64], + n_slots: usize, + n_chunks: usize, + start: f64, + dt: f64, + ) -> usize { + let mut count = 0usize; + for c in 0..n_chunks { + let t = slab[c * n_slots + TIME_OFF]; + let expected = start + c as f64 * dt; + if c == 0 || (t - expected).abs() < 1e-9 { + count += 1; + } else { + break; + } + } + count + } + + /// Task 2 (AC2.3): a segmented `run_initials; run_to(t1); run_to(t2)` produces + /// the same rows (up to t2) as a single `run_initials; run_to(t2)` and as the + /// VM driven through the same `run_to(t1); run_to(t2)` segments. The cursor in + /// the globals must survive across the two `run_to` calls and resume exactly. + #[test] + fn run_to_segmented_matches_single_and_vm() { + let datamodel = resumable_fixture(10.0); + let sim = compile_sim(&datamodel, "main"); + let artifact = compile_simulation(&sim).expect("wasm codegen"); + let n_slots = artifact.layout.n_slots; + let n_chunks = artifact.layout.n_chunks; + + let (t1, t2) = (3.0, 7.0); + let segmented = run_artifact_segmented(&artifact, &[t1, t2]); + let single = run_artifact_segmented(&artifact, &[t2]); + let (vm_data, vm_step_size, _) = + vm_slab_segmented(compile_sim(&datamodel, "main"), &[t1, t2]); + + // Rows for times <= t2 (chunks 0..=7) must agree across all three. + let last_row = t2 as usize; // save_step == dt == 1, so row index == time. + for (name, wasm_off) in &artifact.layout.var_offsets { + let wasm_off = *wasm_off; + let ident = Ident::::from_str_unchecked(name); + let Some(vm_off) = sim.get_offset(&ident) else { + continue; + }; + for c in 0..=last_row { + let seg = segmented[c * n_slots + wasm_off]; + let sng = single[c * n_slots + wasm_off]; + let vm_val = vm_data[c * vm_step_size + vm_off]; + assert!( + (seg - sng).abs() < 1e-9, + "{name} segmented vs single mismatch at chunk {c}: {seg} vs {sng}" + ); + assert!( + (seg - vm_val).abs() < 1e-9, + "{name} segmented vs VM mismatch at chunk {c}: {seg} vs {vm_val}" + ); + } + } + assert!( + n_chunks >= 8, + "fixture must have at least 8 chunks for t2=7" + ); + } + + /// Task 2 (AC2.2): the count of saved rows after `run_to(t)` matches the VM's, + /// for `t` exactly on a save point and `t` between save points. + /// + /// Layout note (the phase's "Cursor mapping" caveat): the blob keeps its + /// working `curr`/`next` chunks SEPARATE from the results region, so a partial + /// `run_to(t)` writes exactly the committed save-cadence rows (t=0..floor(t), + /// 5 rows for both t=4 and t=4.5). The VM stores results in a chunk-ring and + /// advances `curr_chunk` THROUGH it, so its working chunk (the t=floor(t)+dt + /// overshoot the guard `curr[TIME] > end` leaves behind) leaks into the + /// exported slab as one extra populated row -- a known chunk-ring artifact, not + /// a committed save point. We therefore compare the committed-save-point count + /// (which both backends agree on) and assert the blob's committed rows equal + /// the VM's on exactly those rows; separately we assert AC2.2 directly: the + /// blob's live `curr` chunk after `run_to(t)` equals the VM's `get_value_now` + /// (which reads the VM's current chunk, i.e. the same t=floor(t)+dt overshoot). + #[test] + fn run_to_at_save_and_between_save_points() { + let datamodel = resumable_fixture(10.0); + let sim = compile_sim(&datamodel, "main"); + let artifact = compile_simulation(&sim).expect("wasm codegen"); + let n_slots = artifact.layout.n_slots; + let n_chunks = artifact.layout.n_chunks; + let start = sim.specs.start; + let dt = sim.specs.dt; + let level_off = layout_offset(&artifact, "level"); + + for &t in &[4.0_f64, 4.5_f64] { + // The committed save-cadence count is analytic from the spec: save + // points at start + c*save_step (save_step == dt here) for every c with + // start + c*dt <= t, capped at n_chunks. + let committed = ((t - start) / dt).floor() as usize + 1; + let committed = committed.min(n_chunks); + + let slab = run_artifact_segmented(&artifact, &[t]); + let wasm_rows = live_saved_rows(&slab, n_slots, n_chunks, start, dt); + assert_eq!( + wasm_rows, committed, + "run_to({t}) committed-row count: wasm={wasm_rows}, expected {committed}" + ); + assert_eq!(committed, 5, "run_to({t}) should commit 5 rows (t=0..4)"); + + // The blob's committed rows equal the VM's corresponding rows. + let (vm_data, vm_step_size, _) = + vm_slab_segmented(compile_sim(&datamodel, "main"), &[t]); + for (name, wasm_off) in &artifact.layout.var_offsets { + let wasm_off = *wasm_off; + let ident = Ident::::from_str_unchecked(name); + let Some(vm_off) = sim.get_offset(&ident) else { + continue; + }; + for c in 0..committed { + let w = slab[c * n_slots + wasm_off]; + let v = vm_data[c * vm_step_size + vm_off]; + assert!( + (w - v).abs() < 1e-9, + "{name} committed-row mismatch at chunk {c} (run_to({t})): wasm={w} vm={v}" + ); + } + } + + // AC2.2: the blob's live curr chunk (base 0) after run_to(t) equals the + // VM's get_value_now. Both advance one step past the on-grid target: + // run_to(4) and run_to(4.5) both leave the cursor at t=5, level=10. + let info = validate(&artifact.wasm).expect("module must validate"); + let mut store = Store::new(()); + let inst = store + .module_instantiate(&info, Vec::new(), None) + .expect("instantiate") + .module_addr; + let run_initials = store + .instance_export(inst, "run_initials") + .unwrap() + .as_func() + .unwrap(); + store + .invoke_simple_typed::<(), ()>(run_initials, ()) + .expect("run_initials"); + let run_to = store + .instance_export(inst, "run_to") + .unwrap() + .as_func() + .unwrap(); + store + .invoke_simple_typed::<(f64,), ()>(run_to, (t,)) + .expect("run_to"); + let curr_level = read_curr_slot(&mut store, inst, level_off); + + let mut vm = Vm::new(compile_sim(&datamodel, "main")).expect("vm"); + vm.run_to(t).expect("vm run_to"); + let vm_now = vm.get_value_now( + vm.get_offset(&Ident::::from_str_unchecked("level")) + .unwrap(), + ); + assert!( + (curr_level - vm_now).abs() < 1e-9 && (curr_level - 10.0).abs() < 1e-9, + "live curr level after run_to({t}): wasm={curr_level} vm={vm_now} (expected 10)" + ); + } + } + + /// Task 2 (AC2.4): `run_to(stop * 2)` clamps to the end -- it equals both a + /// `run_to(stop)` and `Vm::run_to_end`, and saves exactly `n_chunks` rows. The + /// blob clamps via the saved-row exhaustion break (`if saved >= n_chunks`), + /// exactly like the VM's chunk-ring exhaustion: it can never overrun the slab. + #[test] + fn run_to_past_final_time_clamps() { + let datamodel = resumable_fixture(10.0); + let sim = compile_sim(&datamodel, "main"); + let artifact = compile_simulation(&sim).expect("wasm codegen"); + let n_slots = artifact.layout.n_slots; + let n_chunks = artifact.layout.n_chunks; + let stop = sim.specs.stop; + let start = sim.specs.start; + let dt = sim.specs.dt; + + let clamped = run_artifact_segmented(&artifact, &[stop * 2.0]); + let to_stop = run_artifact_segmented(&artifact, &[stop]); + + let mut vm = Vm::new(compile_sim(&datamodel, "main")).expect("vm"); + vm.run_to_end().expect("vm run"); + let vm_results = vm.into_results(); + + assert_eq!( + clamped, to_stop, + "run_to(stop*2) must equal run_to(stop) -- past-FINAL_TIME must clamp" + ); + // Exactly n_chunks rows are live (the full slab), none beyond. + assert_eq!( + live_saved_rows(&clamped, n_slots, n_chunks, start, dt), + n_chunks, + "run_to(stop*2) must save exactly n_chunks rows" + ); + + for (name, wasm_off) in &artifact.layout.var_offsets { + let wasm_off = *wasm_off; + let ident = Ident::::from_str_unchecked(name); + let Some(&vm_off) = vm_results.offsets.get(&ident) else { + continue; + }; + for c in 0..n_chunks { + let vm_val = vm_results.data[c * vm_results.step_size + vm_off]; + let wasm_val = clamped[c * n_slots + wasm_off]; + assert!( + (vm_val - wasm_val).abs() < 1e-9, + "{name} clamp mismatch at chunk {c}: vm={vm_val} wasm={wasm_val}" + ); + } + } + } + + /// Task 3 (AC3.1, AC5.4): on a single reused instance, `run` then + /// `reset; run` reproduce the same compiled-default series, and both equal the + /// VM (with a `reset` between two VM runs). `reset` clears the cursor globals + /// so the second `run` is a full from-t0 simulation, not a stale resume. + #[test] + fn reset_then_run_reproduces_defaults() { + let datamodel = resumable_fixture(5.0); + let sim = compile_sim(&datamodel, "main"); + let artifact = compile_simulation(&sim).expect("wasm codegen"); + let n_slots = artifact.layout.n_slots; + let n_chunks = artifact.layout.n_chunks; + + let info = validate(&artifact.wasm).expect("module must validate"); + let mut store = Store::new(()); + let inst = store + .module_instantiate(&info, Vec::new(), None) + .expect("instantiate") + .module_addr; + let invoke_run = |store: &mut Store<()>| { + let run = store + .instance_export(inst, "run") + .unwrap() + .as_func() + .unwrap(); + store.invoke_simple_typed::<(), ()>(run, ()).expect("run"); + }; + let invoke_reset = |store: &mut Store<()>| { + let reset = store + .instance_export(inst, "reset") + .unwrap() + .as_func() + .unwrap(); + store + .invoke_simple_typed::<(), ()>(reset, ()) + .expect("reset"); + }; + + invoke_run(&mut store); + let series_a = read_slab(&mut store, inst, &artifact.layout); + invoke_reset(&mut store); + invoke_run(&mut store); + let series_b = read_slab(&mut store, inst, &artifact.layout); + + assert_eq!( + series_a, series_b, + "reset; run must reproduce the first run's default series exactly" + ); + + // The VM oracle: a fresh run, then reset, then a second run -- both equal + // the wasm series. + let mut vm = Vm::new(compile_sim(&datamodel, "main")).expect("vm"); + vm.run_to_end().expect("vm run"); + vm.reset(); + vm.run_to_end().expect("vm run after reset"); + let vm_results = vm.into_results(); + for (name, wasm_off) in &artifact.layout.var_offsets { + let wasm_off = *wasm_off; + let ident = Ident::::from_str_unchecked(name); + let Some(&vm_off) = vm_results.offsets.get(&ident) else { + continue; + }; + for c in 0..n_chunks { + let vm_val = vm_results.data[c * vm_results.step_size + vm_off]; + let wasm_val = series_b[c * n_slots + wasm_off]; + assert!( + (vm_val - wasm_val).abs() < 1e-9, + "{name} reset-default mismatch at chunk {c}: vm={vm_val} wasm={wasm_val}" + ); + } + } + } + + /// Task 3 (AC3.2, AC5.4): `reset` preserves a constant override. On one reused + /// instance: `set_value(inflow_rate, 5)`, `run` -> series A; `reset`, `run` -> + /// series B. A == B (the override survived the reset, since `reset` does not + /// touch the constants region), and both equal the VM run with the same + /// override and a `reset` between runs. + #[test] + fn reset_preserves_overrides() { + let datamodel = resumable_fixture(5.0); + let sim = compile_sim(&datamodel, "main"); + let artifact = compile_simulation(&sim).expect("wasm codegen"); + let n_slots = artifact.layout.n_slots; + let n_chunks = artifact.layout.n_chunks; + let rate_off = layout_offset(&artifact, "inflow_rate"); + assert!( + sim.is_constant_offset(rate_off), + "inflow_rate must be an overridable constant" + ); + + let info = validate(&artifact.wasm).expect("module must validate"); + let mut store = Store::new(()); + let inst = store + .module_instantiate(&info, Vec::new(), None) + .expect("instantiate") + .module_addr; + // set_value(inflow_rate, 5) on this instance. + let set_value = store + .instance_export(inst, "set_value") + .unwrap() + .as_func() + .unwrap(); + let rc: i32 = store + .invoke_simple_typed::<(i32, f64), i32>(set_value, (rate_off as i32, 5.0)) + .expect("set_value"); + assert_eq!(rc, 0, "set_value on inflow_rate must succeed"); + + let invoke_run = |store: &mut Store<()>| { + let run = store + .instance_export(inst, "run") + .unwrap() + .as_func() + .unwrap(); + store.invoke_simple_typed::<(), ()>(run, ()).expect("run"); + }; + + invoke_run(&mut store); + let series_a = read_slab(&mut store, inst, &artifact.layout); + let reset = store + .instance_export(inst, "reset") + .unwrap() + .as_func() + .unwrap(); + store + .invoke_simple_typed::<(), ()>(reset, ()) + .expect("reset"); + invoke_run(&mut store); + let series_b = read_slab(&mut store, inst, &artifact.layout); + + assert_eq!( + series_a, series_b, + "reset must preserve the override -- both runs use inflow_rate=5" + ); + + // The VM oracle: override, run, reset, run -- the override persists across + // the VM's reset too (it does not call clear_values). + let mut vm = Vm::new(compile_sim(&datamodel, "main")).expect("vm"); + vm.set_value_by_offset(rate_off, 5.0) + .expect("vm override on a constant"); + vm.run_to_end().expect("vm run"); + vm.reset(); + vm.run_to_end().expect("vm run after reset"); + let vm_results = vm.into_results(); + for (name, wasm_off) in &artifact.layout.var_offsets { + let wasm_off = *wasm_off; + let ident = Ident::::from_str_unchecked(name); + let Some(&vm_off) = vm_results.offsets.get(&ident) else { + continue; + }; + for c in 0..n_chunks { + let vm_val = vm_results.data[c * vm_results.step_size + vm_off]; + let wasm_val = series_b[c * n_slots + wasm_off]; + assert!( + (vm_val - wasm_val).abs() < 1e-9, + "{name} reset-override mismatch at chunk {c}: vm={vm_val} wasm={wasm_val}" + ); + } + } + // The override actually took: level reaches 5*5 = 25 (not the default 10). + let level_off = layout_offset(&artifact, "level"); + let last = (n_chunks - 1) * n_slots + level_off; + assert!( + (series_b[last] - 25.0).abs() < 1e-9, + "level under inflow_rate=5 should reach 25 after reset, got {}", + series_b[last] + ); + } + + /// Task 4 (AC5.3): a mid-run `set_value` affects only steps after the cursor. + /// On one instance: `run_initials; run_to(t1)`, `set_value(inflow_rate, v2)`, + /// `run_to(stop)`. Rows at times <= t1 match a no-override baseline; rows after + /// reflect v2. The whole slab matches the VM driven identically. The override + /// re-reads from the region every step (`lower.rs`'s `AssignConstCurr` + /// redirect), so no new mechanism is needed beyond the resumable run. + /// + /// AC5.1 (full-run override) is already covered by + /// `compile_simulation_set_value_override_matches_vm` above; this is the + /// incremental peer. + #[test] + fn mid_run_set_value_matches_vm() { + let datamodel = resumable_fixture(10.0); + let sim = compile_sim(&datamodel, "main"); + let artifact = compile_simulation(&sim).expect("wasm codegen"); + let n_slots = artifact.layout.n_slots; + let n_chunks = artifact.layout.n_chunks; + let stop = sim.specs.stop; + let rate_off = layout_offset(&artifact, "inflow_rate"); + let level_off = layout_offset(&artifact, "level"); + assert!(sim.is_constant_offset(rate_off)); + + let t1 = 5.0_f64; + let v2 = 5.0_f64; + + // Drive the blob: run_initials; run_to(t1); set_value(rate, v2); run_to(stop). + let info = validate(&artifact.wasm).expect("module must validate"); + let mut store = Store::new(()); + let inst = store + .module_instantiate(&info, Vec::new(), None) + .expect("instantiate") + .module_addr; + let run_initials = store + .instance_export(inst, "run_initials") + .unwrap() + .as_func() + .unwrap(); + store + .invoke_simple_typed::<(), ()>(run_initials, ()) + .expect("run_initials"); + let run_to_t1 = store + .instance_export(inst, "run_to") + .unwrap() + .as_func() + .unwrap(); + store + .invoke_simple_typed::<(f64,), ()>(run_to_t1, (t1,)) + .expect("run_to(t1)"); + let set_value = store + .instance_export(inst, "set_value") + .unwrap() + .as_func() + .unwrap(); + let rc: i32 = store + .invoke_simple_typed::<(i32, f64), i32>(set_value, (rate_off as i32, v2)) + .expect("set_value"); + assert_eq!(rc, 0, "mid-run set_value on a constant must succeed"); + let run_to_stop = store + .instance_export(inst, "run_to") + .unwrap() + .as_func() + .unwrap(); + store + .invoke_simple_typed::<(f64,), ()>(run_to_stop, (stop,)) + .expect("run_to(stop)"); + let wasm_slab = read_slab(&mut store, inst, &artifact.layout); + + // The VM oracle, driven identically. + let mut vm = Vm::new(compile_sim(&datamodel, "main")).expect("vm"); + vm.run_to(t1).expect("vm run_to(t1)"); + vm.set_value(&Ident::::from_str_unchecked("inflow_rate"), v2) + .expect("vm set_value"); + vm.run_to(stop).expect("vm run_to(stop)"); + let vm_results = vm.into_results(); + + for (name, wasm_off) in &artifact.layout.var_offsets { + let wasm_off = *wasm_off; + let ident = Ident::::from_str_unchecked(name); + let Some(&vm_off) = vm_results.offsets.get(&ident) else { + continue; + }; + for c in 0..n_chunks { + let vm_val = vm_results.data[c * vm_results.step_size + vm_off]; + let wasm_val = wasm_slab[c * n_slots + wasm_off]; + assert!( + (vm_val - wasm_val).abs() < 1e-9, + "{name} mid-run mismatch at chunk {c}: vm={vm_val} wasm={wasm_val}" + ); + } + } + + // Rows up to and including the override-application point match the + // no-override baseline (rate=2). Because `set_value` runs AFTER + // `run_to(t1)` -- which (like the VM) advances the committed cursor one + // step PAST t1, i.e. to t1+dt, running the t1->t1+dt step with the OLD + // rate -- the first overridden step is t1+dt -> t1+2dt. So rows for + // t <= t1+dt are unchanged; rows after reflect v2. This is exactly AC5.3's + // "affects only steps after t1" (the override re-reads from the const + // region every step, so it cannot retroactively change committed rows). + let baseline = run_artifact_segmented(&artifact, &[stop]); + let unchanged_through = (t1 + sim.specs.dt) as usize; + for c in 0..=unchanged_through { + let mid = wasm_slab[c * n_slots + level_off]; + let base = baseline[c * n_slots + level_off]; + assert!( + (mid - base).abs() < 1e-9, + "level at chunk {c} (<= t1+dt) must match the no-override baseline: mid={mid} base={base}" + ); + } + // The override took effect for the later steps: the final committed value + // exceeds the no-override baseline (rate 5 > 2 after the application point). + let last = (n_chunks - 1) * n_slots + level_off; + assert!( + wasm_slab[last] > baseline[last] + 1.0, + "level at stop after a mid-run rate bump must exceed the rate=2 baseline: mid={} base={}", + wasm_slab[last], + baseline[last] + ); + // And a row strictly after the application point differs from baseline. + let after = (unchanged_through + 1) * n_slots + level_off; + assert!( + wasm_slab[after] > baseline[after], + "the first overridden row must exceed the baseline: mid={} base={}", + wasm_slab[after], + baseline[after] + ); + } + + /// Task 4 (AC5.2): the blob's `set_value` returns nonzero for a non-constant + /// offset (a stock or a computed flow) and zero for an overridable constant. + /// This is the blob-level peer of the VM's `BadOverride` rejection + /// (`vm.rs:1036-1044`); the TS facade turns the nonzero code into a thrown + /// error in Phase 2. + #[test] + fn set_value_nonconstant_returns_error() { + let datamodel = resumable_fixture(5.0); + let sim = compile_sim(&datamodel, "main"); + let artifact = compile_simulation(&sim).expect("wasm codegen"); + + let level_off = layout_offset(&artifact, "level"); // a stock + let inflow_off = layout_offset(&artifact, "inflow"); // a computed flow + let rate_off = layout_offset(&artifact, "inflow_rate"); // a constant + assert!(!sim.is_constant_offset(level_off), "level is a stock"); + assert!(!sim.is_constant_offset(inflow_off), "inflow is computed"); + assert!( + sim.is_constant_offset(rate_off), + "inflow_rate is a constant" + ); + + assert_ne!( + set_value_rc(&artifact, level_off as i32, 1.0), + 0, + "set_value on a stock offset must return nonzero" + ); + assert_ne!( + set_value_rc(&artifact, inflow_off as i32, 1.0), + 0, + "set_value on a computed-flow offset must return nonzero" + ); + assert_eq!( + set_value_rc(&artifact, rate_off as i32, 1.0), + 0, + "set_value on the overridable constant must return zero" + ); + + // Cross-check the VM rejects the same non-constants and accepts the constant. + let mut vm = Vm::new(compile_sim(&datamodel, "main")).expect("vm"); + assert!( + vm.set_value_by_offset(level_off, 1.0).is_err(), + "VM must reject a stock offset" + ); + assert!( + vm.set_value_by_offset(inflow_off, 1.0).is_err(), + "VM must reject a computed-flow offset" + ); + assert!( + vm.set_value_by_offset(rate_off, 1.0).is_ok(), + "VM must accept the overridable constant" + ); + } } diff --git a/src/simlin-engine/tests/simulate.rs b/src/simlin-engine/tests/simulate.rs index 935e751d5..c89a4a169 100644 --- a/src/simlin-engine/tests/simulate.rs +++ b/src/simlin-engine/tests/simulate.rs @@ -16,7 +16,8 @@ use simlin_engine::{Method, Results, SimSpecs as Specs, Vm, project_io}; use simlin_engine::{load_csv, load_dat, open_vensim, open_vensim_with_data, xmile}; use test_helpers::{ - WasmRunOutcome, ensure_results, ensure_results_excluding, ensure_wasm_matches, wasm_results_for, + WasmRunOutcome, ensure_results, ensure_results_excluding, ensure_wasm_matches, + wasm_results_for, wasm_results_for_segmented, }; /// Generate one `#[test] fn` per corpus model so libtest (and nextest) schedule @@ -1850,6 +1851,24 @@ fn simulates_wrld3_03() { assert_eq!(vdf_results.step_count, results.step_count); } +/// Time of the saved chunk nearest the middle of `[start, stop]`, derived from a +/// model's compiled [`Specs`]. The blob/VM save cadence is +/// `effective_save_step = max(save_step, dt)` (mirroring `Specs::from`), and +/// chunk `k` is saved at `start + k * effective_save_step`, so the middle chunk +/// `n_chunks / 2` lands on a genuine save point strictly inside the horizon +/// (`n_chunks >= 3` for any real model). Reading the boundary from the specs -- +/// rather than hardcoding -- keeps the two-segment split meaningful for whatever +/// time axis the model declares. +fn midpoint_save_time(specs: &Specs) -> f64 { + let effective_save_step = if specs.save_step > specs.dt { + specs.save_step + } else { + specs.dt + }; + let mid_chunk = specs.n_chunks / 2; + specs.start + (mid_chunk as f64) * effective_save_step +} + /// WORLD3 wasm parity twin (wasm-backend.AC1.1, heavy-model scale check): WORLD3 /// is a large model, so its wasm blob exercises the backend well beyond the /// small/medium default corpus. The VM test above only smoke-checks the VDF @@ -1861,6 +1880,13 @@ fn simulates_wrld3_03() { /// core-simulation model the VM handles. `#[ignore]`d for runtime class, like /// the other heavy models. /// +/// In addition to the single-`run` vs VM parity check, this twin re-runs the +/// same blob through a TWO-SEGMENT `run_to` (split at the midpoint save time) and +/// asserts the segmented final series equals the single-`run` series -- and +/// therefore the VM (wasm-backend.AC2.3 segmented-run parity on a real model). +/// Comparing the full final series (both segments run through to `stop`) is exact +/// regardless of where the cursor paused mid-run. +/// /// Run with: cargo test --release -- --ignored simulates_wrld3_03_wasm #[test] #[ignore] @@ -1890,6 +1916,17 @@ fn simulates_wrld3_03_wasm() { }); ensure_results(&vm_results, &wasm_results); + + // Segmented resumable run: split at the midpoint save time and drive the same + // blob through `run_to(mid)` then `run_to(stop)`. The full final series must + // equal the single-`run` series (already matched to the VM above), proving the + // resumable cursor resumes correctly on a real model at scale. + let mid = midpoint_save_time(&wasm_results.specs); + let stop = wasm_results.specs.stop; + let segmented_results = wasm_results_for_segmented(&datamodel_project, "main", &[mid, stop]) + .unwrap_or_else(|msg| panic!("WORLD3 segmented wasm run failed: {msg}")); + + ensure_results(&wasm_results, &segmented_results); } /// Known-residual C-LEARN base-variable names excluded from the @@ -2079,6 +2116,12 @@ fn run_clearn_vs_vdf() -> (Results, Results) { /// A `WasmGenError::Unsupported` here would be a hard failure: C-LEARN is a /// core-simulation model the VM handles, so the wasm backend must too. /// +/// In addition to the single-`run` vs VDF gate, this twin re-runs the same blob +/// through a TWO-SEGMENT `run_to` (split at the midpoint save time) and asserts +/// the segmented final series equals the single-`run` series -- which is already +/// checked against the VDF oracle -- so the resumable cursor is proven on a real +/// model at C-LEARN scale (wasm-backend.AC2.3 segmented-run parity). +/// /// Run with: cargo test --release -- --ignored simulates_clearn_wasm #[test] #[ignore] @@ -2092,6 +2135,17 @@ fn simulates_clearn_wasm() { let vdf_results = clearn_vdf_results(); ensure_vdf_results_excluding(&vdf_results, &wasm_results, EXPECTED_VDF_RESIDUAL); + + // Segmented resumable run: split at the midpoint save time and drive the same + // blob through `run_to(mid)` then `run_to(stop)`. The full final series must + // equal the single-`run` series (already gated against `Ref.vdf` above), + // proving the resumable cursor resumes correctly at C-LEARN scale. + let mid = midpoint_save_time(&wasm_results.specs); + let stop = wasm_results.specs.stop; + let segmented_results = wasm_results_for_segmented(&datamodel_project, "main", &[mid, stop]) + .unwrap_or_else(|msg| panic!("C-LEARN segmented wasm run failed: {msg}")); + + ensure_results(&wasm_results, &segmented_results); } /// Committed regression guard that `EXPECTED_VDF_RESIDUAL` stays EXACT: it is diff --git a/src/simlin-engine/tests/test_helpers.rs b/src/simlin-engine/tests/test_helpers.rs index e4537bc8d..12145eedc 100644 --- a/src/simlin-engine/tests/test_helpers.rs +++ b/src/simlin-engine/tests/test_helpers.rs @@ -244,6 +244,45 @@ pub fn wasm_results_for( Ok(wasm_results_from_slab(&artifact.layout, slab, specs)) } +/// Resumable-ABI peer of [`wasm_results_for`]: compile `model_name` of +/// `datamodel` to wasm, then drive the blob through the segmented +/// `run_initials`-then-per-target-`run_to` path (rather than the single-shot +/// `run`) and reshape the final slab into a [`Results`]. +/// +/// `targets` is the ordered list of `run_to(t)` boundaries; the final target must +/// be the simulation's `stop` so the slab is fully populated and the result is +/// directly comparable (via [`ensure_results`]) to the single-`run` +/// [`wasm_results_for`] series. The whole-model `#[ignore]`d twins use this to +/// prove a mid-run-split run on a real model lands on the byte-identical final +/// series as a single uninterrupted run. +/// +/// Imperative Shell: drives the salsa compile pipeline and the wasm interpreter, +/// delegating the reshape to the pure [`wasm_results_from_slab`]. +#[allow(dead_code)] +pub fn wasm_results_for_segmented( + datamodel: &simlin_engine::datamodel::Project, + model_name: &str, + targets: &[f64], +) -> Result { + use simlin_engine::db::{ + SimlinDb, compile_project_incremental, sync_from_datamodel_incremental, + }; + + let mut db = SimlinDb::default(); + let sync = sync_from_datamodel_incremental(&mut db, datamodel, None); + let sim = compile_project_incremental(&db, sync.project, model_name) + .map_err(|e| format!("incremental compile failed: {e:?}"))?; + + let artifact = match compile_simulation(&sim) { + Ok(artifact) => artifact, + Err(WasmGenError::Unsupported(msg)) => return Err(msg), + }; + + let slab = run_wasm_results_segmented(&artifact.wasm, &artifact.layout, targets); + let specs = SimSpecs::from(&datamodel.sim_specs); + Ok(wasm_results_from_slab(&artifact.layout, slab, specs)) +} + /// Compile `model_name` of `datamodel` to wasm, run it under the DLR-FT /// interpreter, and assert its results clear the SAME `ensure_results_excluding` /// comparator the VM clears against `expected`. @@ -314,3 +353,56 @@ fn run_wasm_results(wasm: &[u8], layout: &WasmLayout) -> Vec { .collect() }) } + +/// Drive the blob's *resumable* run ABI: instantiate `wasm`, call `run_initials` +/// once, then `run_to(t)` for each `t` in `targets` (advancing the persistent +/// step cursor held in the blob's mutable globals), and copy the whole step-major +/// results slab out (`n_chunks * n_slots` f64 at `layout.results_offset`). +/// +/// This is the resumable peer of [`run_wasm_results`] (which calls the +/// single-shot `run`). A segmented drive `&[t1, t2]` must produce a slab whose +/// rows up to `t2` equal a single `run_to(t2)` and the VM driven through the same +/// `run_to` segments -- the parity the wasm-side tests assert. +#[allow(dead_code)] +pub fn run_wasm_results_segmented(wasm: &[u8], layout: &WasmLayout, targets: &[f64]) -> Vec { + let info = validate(wasm).expect("generated wasm module must validate"); + let mut store = Store::new(()); + let inst = store + .module_instantiate(&info, Vec::new(), None) + .expect("instantiate wasm module") + .module_addr; + let run_initials = store + .instance_export(inst, "run_initials") + .expect("run_initials export must exist") + .as_func() + .expect("run_initials export must be a function"); + store + .invoke_simple_typed::<(), ()>(run_initials, ()) + .expect("run_initials wasm"); + for &t in targets { + let run_to = store + .instance_export(inst, "run_to") + .expect("run_to export must exist") + .as_func() + .expect("run_to export must be a function"); + store + .invoke_simple_typed::<(f64,), ()>(run_to, (t,)) + .expect("run_to wasm"); + } + let mem = store + .instance_export(inst, "memory") + .expect("memory export must exist") + .as_mem() + .expect("memory export must be a memory"); + + let n = layout.n_chunks * layout.n_slots; + let base = layout.results_offset; + store.mem_access_mut_slice(mem, |bytes| { + (0..n) + .map(|i| { + let a = base + i * 8; + f64::from_le_bytes(bytes[a..a + 8].try_into().unwrap()) + }) + .collect() + }) +}