From 5f9580ada297a1681938567113af8fde30174632 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 22 May 2026 14:17:09 -0700 Subject: [PATCH 01/28] engine: add resumable run_to/run_initials ABI to wasmgen blob Promote the wasmgen blob's per-step cursor from function locals to mutable wasm globals (G_SAVED/G_STEP_ACCUM/G_DID_INITIALS) so a run can be advanced incrementally across separate exported calls. This adds two exports to the blob -- run_to(f64)->() and run_initials()->() -- mirroring the VM's run_to/run_initials, and re-expresses run()->() as `reset(); run_to(stop)` so there is exactly one stepping-loop implementation shared by run and run_to. The chosen path is the mandated delegation (run -> reset + run_to), not the fallback: it satisfies the linchpin invariant (run() produces a full from-t0 simulation on every call to a reused instance) for free, because reset clears the cursor globals and re-arms use_prev_fallback, so the now-idempotent run_initials (guarded by G_DID_INITIALS) re-seeds the reserved time slots and re-runs initials. emit_save_advance reads/writes the saved-row cursor from the globals; emit_reset clears the cursor while deliberately leaving the constants override region untouched (overrides survive reset, like the VM). The new exports append after the original four (run/set_value/reset/clear_values), so the export-set growth is purely additive and the FFI is unchanged. A triple-agreement parity test (run vs run_initials+run_to(stop) vs Vm::run_to_end) plus the existing wasm_parity_hook corpus catch any faithless re-expression. 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 (the VM's chunk-ring leaks its working chunk into the exported slab as one overshoot row -- a known layout difference the test accounts for by reading the live curr chunk for AC2.2). --- src/simlin-engine/src/wasmgen/module.rs | 535 +++++++++++++++++++----- src/simlin-engine/tests/test_helpers.rs | 53 +++ 2 files changed, 481 insertions(+), 107 deletions(-) diff --git a/src/simlin-engine/src/wasmgen/module.rs b/src/simlin-engine/src/wasmgen/module.rs index 8839646f..306cf998 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,6 +1986,9 @@ 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); @@ -1828,8 +1996,9 @@ fn assemble_simulation(parts: AssembleParts) -> Vec { 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 +2010,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 +2270,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 +4731,148 @@ 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)" + ); + } } diff --git a/src/simlin-engine/tests/test_helpers.rs b/src/simlin-engine/tests/test_helpers.rs index e4537bc8..7d3078a5 100644 --- a/src/simlin-engine/tests/test_helpers.rs +++ b/src/simlin-engine/tests/test_helpers.rs @@ -314,3 +314,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)] +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() + }) +} From 3c7476e3303852bd85612740e2f22a895f9e4db7 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 22 May 2026 14:21:07 -0700 Subject: [PATCH 02/28] engine: parity-test segmented and clamped wasm run_to vs VM Add engine-level VM-parity tests for the resumable run_to driver: a segmented run_to(t1); run_to(t2) reproduces a single run_to(t2) and the VM driven through the same segments (the cursor globals survive across calls and resume exactly); the committed save-cadence row count after run_to(t) matches the VM's for t on and between save points; and run_to(stop*2) clamps to the slab end (equals run_to(stop) and Vm::run_to_end, exactly n_chunks rows). These exercise Task 1's driver only -- no production change. The count test derives the VM's committed-row count analytically rather than by inspecting its slab, because the VM's chunk-ring exports its working (overshoot) chunk as one extra populated row; the blob keeps curr/next separate, so its results region holds exactly the committed rows. AC2.2's "value after run_to(t)" is verified by reading the blob's live curr chunk and matching the VM's get_value_now (both land one step past an on-grid target). Three helpers (read_curr_slot, vm_slab_segmented, live_saved_rows) support these tests. --- src/simlin-engine/src/wasmgen/module.rs | 260 ++++++++++++++++++++++++ 1 file changed, 260 insertions(+) diff --git a/src/simlin-engine/src/wasmgen/module.rs b/src/simlin-engine/src/wasmgen/module.rs index 306cf998..875ee0c2 100644 --- a/src/simlin-engine/src/wasmgen/module.rs +++ b/src/simlin-engine/src/wasmgen/module.rs @@ -4875,4 +4875,264 @@ mod tests { "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}" + ); + } + } + } } From 53f2eec3d901f4edf357c0cd24771fc585f51e77 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 22 May 2026 14:22:28 -0700 Subject: [PATCH 03/28] engine: parity-test wasm reset (defaults + override-preserving) vs VM Add two engine-level VM-parity tests, both driving a SINGLE instantiated module across calls (the blob-level demonstration of instance reuse, AC5.4): reset then re-run reproduces the compiled-default series and equals the VM with a reset between two runs (AC3.1); and reset preserves a constant override set via set_value -- both the override-applied series and the level-reaches-25 pin survive the reset, matching the VM (which likewise does not clear overrides on reset) (AC3.2). These lock in Task 1's emit_reset extension (clear the cursor globals, keep the constants region) -- no production change. --- src/simlin-engine/src/wasmgen/module.rs | 168 ++++++++++++++++++++++++ 1 file changed, 168 insertions(+) diff --git a/src/simlin-engine/src/wasmgen/module.rs b/src/simlin-engine/src/wasmgen/module.rs index 875ee0c2..7e98d47d 100644 --- a/src/simlin-engine/src/wasmgen/module.rs +++ b/src/simlin-engine/src/wasmgen/module.rs @@ -5135,4 +5135,172 @@ mod tests { } } } + + /// 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] + ); + } } From eda38833390336bfa1f8e12134249832e73b041f Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 22 May 2026 14:23:57 -0700 Subject: [PATCH 04/28] engine: parity-test mid-run wasm set_value and non-constant rejection Add two engine-level VM-parity tests for set_value semantics. The mid-run test drives one instance through run_initials; run_to(t1); set_value(const, v2); run_to(stop) and matches the VM driven identically; it pins AC5.3's "affects only steps after the cursor" by showing rows up to the override-application point (one step past t1, since set_value runs after run_to(t1) advanced the cursor) match a no-override baseline while later rows diverge. The rejection test asserts the blob's set_value returns nonzero for a stock and a computed flow and zero for an overridable constant -- the blob-level peer of the VM's BadOverride rejection (AC5.2), cross-checked against the VM. No production change: emit_set_value already rejects non-overridable offsets and overridable constants are re-read from the const region each step, so a mid-run override already affects only later steps. AC5.1 (full-run override) stays covered by compile_simulation_set_value_override_matches_vm. --- src/simlin-engine/src/wasmgen/module.rs | 181 ++++++++++++++++++++++++ 1 file changed, 181 insertions(+) diff --git a/src/simlin-engine/src/wasmgen/module.rs b/src/simlin-engine/src/wasmgen/module.rs index 7e98d47d..839e21cb 100644 --- a/src/simlin-engine/src/wasmgen/module.rs +++ b/src/simlin-engine/src/wasmgen/module.rs @@ -5303,4 +5303,185 @@ mod tests { 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" + ); + } } From 4c6302691f50b6d3b19ee791ce64e2745e0cd226 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 22 May 2026 14:36:48 -0700 Subject: [PATCH 05/28] libsimlin: exercise resumable wasm exports across the FFI compile path Subcomponent A added run_initials/run_to to the wasmgen blob and made reset cursor-clearing while preserving constant overrides. This adds an FFI-level parity test confirming those resumable exports survive the simlin_model_compile_to_wasm path: the compiled-via-FFI blob is driven run_initials -> run_to(t1) -> set_value(inflow_rate) -> run_to(stop) and its level series is checked against a bytecode-VM oracle driven identically through simlin_sim_run_to / simlin_sim_set_value / simlin_sim_run_to_end. A reset-then-run on the same instance confirms the override survives reset (the override-applied defaults reproduce), the FFI peer of simlin_sim_reset. The blob is also asserted to still carry every original export at its original kind, so the export-set growth is purely additive; the FFI signature is unchanged (test-only). --- src/libsimlin/CLAUDE.md | 2 +- src/libsimlin/tests/wasm.rs | 357 +++++++++++++++++++++++++++++++++++- 2 files changed, 357 insertions(+), 2 deletions(-) diff --git a/src/libsimlin/CLAUDE.md b/src/libsimlin/CLAUDE.md index b29edbd4..c53d7940 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 21c1c338..fad4b291 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,201 @@ 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`. 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"] { + 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, From 6685568b7d87787ce8907fb1b2b40760b2a48710 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 22 May 2026 14:48:49 -0700 Subject: [PATCH 06/28] engine: segmented run_to parity for WORLD3 and C-LEARN wasm twins Extend the two #[ignore]d whole-model wasm twins so each, in addition to its existing single-run parity check, drives 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 gated against the model's oracle (the VM for WORLD3, Ref.vdf for C-LEARN). This exercises the resumable cursor (run_initials then per-target run_to) on real models at scale, where the small TestProject fixtures in the engine-internal tests cannot. Comparing the FULL final series (both segments driven through to stop) is exact regardless of where the cursor paused mid-run, so the boundary need only be a meaningful interior time. midpoint_save_time derives it from the compiled sim's specs (start + (n_chunks/2) * max(save_step, dt), mirroring Specs::from's effective cadence) rather than hardcoding, so the split lands on a genuine save point for whatever time axis the model declares. The segmented pass is deliberately NOT added to the per-corpus wasm_parity_hook: that would double the wasm work across the whole corpus and risk the 3-minute debug-test budget. Only these two #[ignore]d release-only twins get it. wasm_results_for_segmented mirrors wasm_results_for, reusing run_wasm_results_segmented (now pub). --- src/simlin-engine/tests/simulate.rs | 56 ++++++++++++++++++++++++- src/simlin-engine/tests/test_helpers.rs | 41 +++++++++++++++++- 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/src/simlin-engine/tests/simulate.rs b/src/simlin-engine/tests/simulate.rs index 935e751d..c89a4a16 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 7d3078a5..12145eed 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`. @@ -325,7 +364,7 @@ fn run_wasm_results(wasm: &[u8], layout: &WasmLayout) -> Vec { /// 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)] -fn run_wasm_results_segmented(wasm: &[u8], layout: &WasmLayout, targets: &[f64]) -> Vec { +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 From e6c332299d3241eb155def8f1814c271c5fa3631 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 22 May 2026 15:00:42 -0700 Subject: [PATCH 07/28] doc: note resumable wasm run ABI in simlin-engine module map Phase 1 of the @simlin/engine wasm simulation backend added two blob exports (run_to, run_initials) and promoted the per-step run cursor (saved/step_accum/did_initials) into internal mutable wasm globals so a run survives across separate exported calls. The authoritative module map still listed only the original run/set_value/reset/clear_values exports and described run as a one-shot driver, so it understated the resumable ABI the libsimlin map already documents for its FFI test. --- src/simlin-engine/CLAUDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/simlin-engine/CLAUDE.md b/src/simlin-engine/CLAUDE.md index 2a9a305a..613bfeab 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`. From c04395f97b93aebd6a4077164254dd518011ff78 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 22 May 2026 15:07:56 -0700 Subject: [PATCH 08/28] engine: add wasmgen FFI wrapper + pure layout parser and strided read Adds internal/wasmgen.ts: the WasmLayout/WasmBlobExports types, the simlin_model_compile_to_wasm FFI wrapper (the two-out-buffer + error-ptr shape mirroring model.ts callBufferReturningFn), and two pure functions parseWasmLayout (decode the little-endian layout wire format) and readStridedSeries (single-Float64Array strided f64 read of one variable's series out of the blob's linear memory). The pure functions take plain buffers rather than a live WebAssembly.Instance so they are unit-tested in isolation against hand-built byte buffers. Registers the module in the internal barrel. --- src/engine/src/internal/index.ts | 1 + src/engine/src/internal/wasmgen.ts | 202 ++++++++++++++++++++++ src/engine/tests/wasmgen.test.ts | 269 +++++++++++++++++++++++++++++ 3 files changed, 472 insertions(+) create mode 100644 src/engine/src/internal/wasmgen.ts create mode 100644 src/engine/tests/wasmgen.test.ts diff --git a/src/engine/src/internal/index.ts b/src/engine/src/internal/index.ts index 60f2bb5e..cca16e07 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 00000000..51dc804a --- /dev/null +++ b/src/engine/src/internal/wasmgen.ts @@ -0,0 +1,202 @@ +// 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; +} + +/** + * 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(nChunks)` and + * fills it via strided `DataView.getFloat64` reads -- no intermediate arrays. + * + * @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`). + * @returns A new `Float64Array` of length `nChunks` -- the variable's series. + */ +export function readStridedSeries(memory: ArrayBufferLike, layout: WasmLayout, slot: number): Float64Array { + const view = new DataView(memory); + const series = new Float64Array(layout.nChunks); + for (let c = 0; c < layout.nChunks; c++) { + series[c] = view.getFloat64(layout.resultsOffset + (c * layout.nSlots + slot) * 8, true); + } + return series; +} diff --git a/src/engine/tests/wasmgen.test.ts b/src/engine/tests/wasmgen.test.ts new file mode 100644 index 00000000..cb73a764 --- /dev/null +++ b/src/engine/tests/wasmgen.test.ts @@ -0,0 +1,269 @@ +// 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('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(); + } + }); +}); From f246456f635815472bc8d7596d4f0cf82d6408d8 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 22 May 2026 15:11:17 -0700 Subject: [PATCH 09/28] engine: add Rust-faithful canonicalize for wasm name resolution Adds internal/canonicalize.ts: a pure canonicalizeIdent reproducing Rust simlin-engine/src/common.rs canonicalize exactly, so a raw caller name resolves to the same canonical key the wasm WasmLayout (and the VM's get_var_names) uses. It ports the quote-aware IdentifierPartIterator splitting, the quoted-inner-period -> U+2024 sentinel vs unquoted-period -> U+00B7 module-separator distinction, the \\ -> \ unescape, the whitespace-run + literal \n/\r escape collapse to a single underscore, and lowercasing. This is a deliberate engine-local copy rather than reusing the incomplete @simlin/core canonicalize, which lacks dot/quote handling and is shared by consumers whose behavior must not shift mid-feature; the two should be unified later. The output was differential-checked byte-for-byte against the Rust oracle across a broad input corpus. --- src/engine/src/internal/canonicalize.ts | 159 +++++++++++++++++++++++ src/engine/tests/canonicalize.test.ts | 165 ++++++++++++++++++++++++ 2 files changed, 324 insertions(+) create mode 100644 src/engine/src/internal/canonicalize.ts create mode 100644 src/engine/tests/canonicalize.test.ts diff --git a/src/engine/src/internal/canonicalize.ts b/src/engine/src/internal/canonicalize.ts new file mode 100644 index 00000000..3cfa68de --- /dev/null +++ b/src/engine/src/internal/canonicalize.ts @@ -0,0 +1,159 @@ +// 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/tests/canonicalize.test.ts b/src/engine/tests/canonicalize.test.ts new file mode 100644 index 00000000..205db960 --- /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); + } + }); + }); +}); From 6cd2414d11f425ef8bb780451236025cc79795ed Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 22 May 2026 15:23:39 -0700 Subject: [PATCH 10/28] engine: wasm-engine sim creation and disposal in DirectBackend Add an optional engine selector to EngineBackend.simNew ('vm' default, 'wasm' new) plus a shared SimEngine type. The wasm path compiles the model to a self-contained WebAssembly blob via simlin_model_compile_to_wasm, parses its layout, captures the model stop time, and instantiates the blob import-free, storing the instance/layout/exports/stop-time on the handle entry so the blob is owned exactly once and GC'd with the entry. enableLtm is rejected up front for the wasm engine (it emits no LTM instrumentation) before any compile work, and an unsupported model surfaces the compile error with no VM fallback. Disposal (simDispose, projectDispose) skips the native simlin_sim_unref for wasm entries, which carry no native sim pointer. The engine param is optional so WorkerBackend (untouched until Phase 3) still satisfies the widened interface. Per-op vm/wasm demux of the sim operations themselves lands in the next commit. --- src/engine/src/backend.ts | 9 +- src/engine/src/direct-backend.ts | 94 +++++++++- src/engine/tests/wasm-backend.test.ts | 255 ++++++++++++++++++++++++++ 3 files changed, 352 insertions(+), 6 deletions(-) create mode 100644 src/engine/tests/wasm-backend.test.ts diff --git a/src/engine/src/backend.ts b/src/engine/src/backend.ts index e3319e04..ef56f94a 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; @@ -82,7 +89,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 f7095e12..110c4be0 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,6 +61,7 @@ import { simlin_free_links, } from './internal/analysis'; import { readAllErrorDetails, simlin_error_free } from './internal/error'; +import { simlin_model_compile_to_wasm, parseWasmLayout, WasmLayout, WasmBlobExports } from './internal/wasmgen'; import { SimlinProjectPtr, SimlinModelPtr, @@ -121,6 +122,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 +151,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()); @@ -232,7 +260,10 @@ export class DirectBackend implements EngineBackend { if (childEntry && !childEntry.disposed) { childEntry.disposed = true; if (childEntry.kind === 'sim') { - simlin_sim_unref(childEntry.ptr); + // Skip the native unref for a wasm sim (no native sim pointer). + if (childEntry.engine !== 'wasm') { + simlin_sim_unref(childEntry.ptr); + } } else if (childEntry.kind === 'model') { simlin_model_unref(childEntry.ptr); } @@ -375,11 +406,60 @@ 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; } @@ -392,7 +472,11 @@ 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; dropping the entry lets the + // WebAssembly.Instance be GC'd. Only the VM path holds a native sim to unref. + if (entry.engine !== 'wasm') { + simlin_sim_unref(entry.ptr); + } } simRunTo(handle: SimHandle, time: number): void { diff --git a/src/engine/tests/wasm-backend.test.ts b/src/engine/tests/wasm-backend.test.ts new file mode 100644 index 00000000..6dcfdbf2 --- /dev/null +++ b/src/engine/tests/wasm-backend.test.ts @@ -0,0 +1,255 @@ +// 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/); + }); + }); +}); From 45b31e755c7ca6c928aa9449fd225dbe1ad39f6d Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 22 May 2026 15:32:19 -0700 Subject: [PATCH 11/28] engine: per-op vm/wasm demux in DirectBackend with VM parity Each sim op now fetches the handle entry and branches on entry.engine. The 'vm' path is unchanged; the 'wasm' path drives the blob's exports: run_to for runTo/runToEnd (run_to(stopTime) for the latter), reset, set_value (throwing on a nonzero rc to mirror the VM's constants-only BadOverride), a strided single-Float64Array series read, nChunks for the step count, a base-0 curr-chunk DataView read for getValue/getTime, and a 569Xhlprefix-filtered, code-point-sorted getVarNames. getLinks throws on the wasm engine (LTM is VM-only). Names resolve through the Rust-faithful canonicalizeIdent. getVarNames sorts by Unicode code point, not the default JS UTF-16 order, so non-BMP names match Rust's byte-order sort. The reserved time/dt/initial_time/ final_time names are kept (the VM filters only $-prefixed internal vars). VM-vs-wasm parity is asserted through DirectBackend with the VM as oracle. getValue mid-run is the byte-identical base-0 curr-chunk read on both sides, but they agree only on the integrated state (stocks + the reserved time vars) mid-run: the VM's Euler loop breaks before evaluating the chunk whose time exceeds the target (vm.rs:711), leaving its curr chunk's flow/aux/constant slots stale, whereas the wasm blob fully evaluates its stopping step. After a full run both agree on every variable. Segmented/clamped runs and all by-name reads are compared via the fully-evaluated series and the integrated-state getValue accordingly. --- src/engine/src/direct-backend.ts | 146 ++++++++++-- src/engine/tests/wasm-backend.test.ts | 319 ++++++++++++++++++++++++++ 2 files changed, 449 insertions(+), 16 deletions(-) diff --git a/src/engine/src/direct-backend.ts b/src/engine/src/direct-backend.ts index 110c4be0..50a86502 100644 --- a/src/engine/src/direct-backend.ts +++ b/src/engine/src/direct-backend.ts @@ -61,11 +61,17 @@ import { simlin_free_links, } from './internal/analysis'; import { readAllErrorDetails, simlin_error_free } from './internal/error'; -import { simlin_model_compile_to_wasm, parseWasmLayout, WasmLayout, WasmBlobExports } from './internal/wasmgen'; +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, @@ -82,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: @@ -194,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 { @@ -479,45 +508,130 @@ export class DirectBackend implements EngineBackend { } } + /** + * 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') { + // Phase-1 reset: clears the run cursor, preserves constant overrides. + entry.wasmExports!.reset(); + 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') { + // nChunks is the saved-row count == the VM's results.step_count. + return entry.wasmLayout!.nChunks; + } + 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`); + } + return; + } + simlin_sim_set_value(entry.ptr, name, value); } simGetSeries(handle: SimHandle, name: string): Float64Array { + const entry = this.getEntry(handle as number, 'sim'); + if (entry.engine === 'wasm') { + // One Float64Array(nChunks) read strided from the blob's results region. + // 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); + } const stepCount = this.simGetStepCount(handle); - return simlin_sim_get_series(this.getSimPtr(handle), name, 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/tests/wasm-backend.test.ts b/src/engine/tests/wasm-backend.test.ts index 6dcfdbf2..8860803b 100644 --- a/src/engine/tests/wasm-backend.test.ts +++ b/src/engine/tests/wasm-backend.test.ts @@ -253,3 +253,322 @@ describe('DirectBackend wasm engine: sim creation and disposal (Task 3)', () => }); }); }); + +// 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(); + }); + }); + + 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(); + }); + }); + + 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(); + }); + + 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(); + }); + }); +}); From 7e0d9af070edd9841ab7e9fdd96f5a9c6765015f Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 22 May 2026 15:41:20 -0700 Subject: [PATCH 12/28] engine: thread engine selection through Model/Sim and gate getRun links Sim.create gains an optional engine parameter (default 'vm') stored as a private field and forwarded to backend.simNew; Model.simulate and Model.run gain an optional engine option that forwards it, preserving run's analyzeLtm->enableLtm naming. The enableLtm+wasm rejection stays authoritative in DirectBackend.simNew, so the facade only forwards. getRun now fetches LTM link scores only when LTM is enabled and the sim is not wasm-backed (a wasm sim's getLinks throws), instead of fetching them unconditionally. This makes Model.run({engine:'wasm'}) resolve to a Run with empty links, and is invisible to the VM path: no consumer reads Run.links for an LTM-off run, and the populated-but-scoreless links a VM teacup run produced were never used. --- src/engine/src/model.ts | 29 ++-- src/engine/src/sim.ts | 39 +++++- src/engine/tests/wasm-model.test.ts | 206 ++++++++++++++++++++++++++++ 3 files changed, 259 insertions(+), 15 deletions(-) create mode 100644 src/engine/tests/wasm-model.test.ts diff --git a/src/engine/src/model.ts b/src/engine/src/model.ts index d072722e..087b2443 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, @@ -424,13 +424,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 +448,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 08f087e4..e707a5ce 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/tests/wasm-model.test.ts b/src/engine/tests/wasm-model.test.ts new file mode 100644 index 00000000..a7b1ccbe --- /dev/null +++ b/src/engine/tests/wasm-model.test.ts @@ -0,0 +1,206 @@ +// 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.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); + }); + }); +}); From b35173f195725626d2e646938ba1ad46c5fd236f Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 22 May 2026 16:08:15 -0700 Subject: [PATCH 13/28] engine: add end-to-end wasm name-resolution and facade parity tests The wasm simulation backend's name pipeline (canonicalizeIdent -> wasmLayout.varOffsets lookup -> readStridedSeries) was only exercised end-to-end on scalar teacup, so the non-scalar case it was written for went untested. Add a statically-arrayed fixture (a static dimension, not a dynamic view range, so it is wasm-supported) and assert that a raw, mixed-case array-element name like `source[Boston]` resolves to the same series as the VM through getSeries/getVarNames -- the keys contain the bracketed element separators that scalar names never produce. Also close the facade loop for two paths previously covered only at the DirectBackend level: model.simulate({enableLtm:true, engine:'wasm'}) must reject up front (the facade forwards the option to the backend's authoritative rejection), and Sim.reset()'s reset+reapply-overrides path on a wasm sim must reproduce the override-applied result, matching a VM Sim driven identically. The VM is the oracle throughout, compared within 1e-9. --- src/engine/tests/wasm-backend.test.ts | 145 ++++++++++++++++++++++++++ src/engine/tests/wasm-model.test.ts | 71 +++++++++++++ 2 files changed, 216 insertions(+) diff --git a/src/engine/tests/wasm-backend.test.ts b/src/engine/tests/wasm-backend.test.ts index 8860803b..1d06f5b1 100644 --- a/src/engine/tests/wasm-backend.test.ts +++ b/src/engine/tests/wasm-backend.test.ts @@ -572,3 +572,148 @@ describe('DirectBackend wasm engine: per-op vm/wasm parity (Task 4)', () => { }); }); }); + +// 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 index a7b1ccbe..628ada79 100644 --- a/src/engine/tests/wasm-model.test.ts +++ b/src/engine/tests/wasm-model.test.ts @@ -187,6 +187,77 @@ describe('Model/Sim engine selection (public API)', () => { }); }); + 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(); From c2373a6739f3c924eb4a26b081724723e43b52a7 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 22 May 2026 16:08:51 -0700 Subject: [PATCH 14/28] engine: format Phase 2 wasm-backend source and test files Run prettier --write over the files this branch created or modified (wasmgen.ts, canonicalize.ts, backend.ts, model.ts, wasmgen.test.ts). The pre-commit hook does not run prettier --check, so these reflows slipped the gate. Pure formatting: line joining/wrapping to the 120-col printWidth, no semantic changes. --- src/engine/src/backend.ts | 6 +++++- src/engine/src/internal/canonicalize.ts | 6 +++++- src/engine/src/internal/wasmgen.ts | 9 +-------- src/engine/src/model.ts | 10 ++-------- src/engine/tests/wasmgen.test.ts | 10 ++++------ 5 files changed, 17 insertions(+), 24 deletions(-) diff --git a/src/engine/src/backend.ts b/src/engine/src/backend.ts index ef56f94a..d29dc136 100644 --- a/src/engine/src/backend.ts +++ b/src/engine/src/backend.ts @@ -65,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; diff --git a/src/engine/src/internal/canonicalize.ts b/src/engine/src/internal/canonicalize.ts index 3cfa68de..ffb896e5 100644 --- a/src/engine/src/internal/canonicalize.ts +++ b/src/engine/src/internal/canonicalize.ts @@ -40,7 +40,11 @@ function splitIdentifierParts(s: string): Array { let i = 1; let closed = false; while (i < remaining.length) { - if (remaining.charCodeAt(i) === 0x5c /* '\' */ && i + 1 < remaining.length && remaining.charCodeAt(i + 1) === 0x22) { + 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)); diff --git a/src/engine/src/internal/wasmgen.ts b/src/engine/src/internal/wasmgen.ts index 51dc804a..14d13fc6 100644 --- a/src/engine/src/internal/wasmgen.ts +++ b/src/engine/src/internal/wasmgen.ts @@ -13,14 +13,7 @@ // 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 { free, copyFromWasm, allocOutPtr, readOutPtr, allocOutUsize, readOutUsize } from './memory'; import { SimlinModelPtr } from './types'; import { simlin_error_free, diff --git a/src/engine/src/model.ts b/src/engine/src/model.ts index 087b2443..70f3e469 100644 --- a/src/engine/src/model.ts +++ b/src/engine/src/model.ts @@ -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; } diff --git a/src/engine/tests/wasmgen.test.ts b/src/engine/tests/wasmgen.test.ts index cb73a764..78465812 100644 --- a/src/engine/tests/wasmgen.test.ts +++ b/src/engine/tests/wasmgen.test.ts @@ -251,12 +251,10 @@ describe('readStridedSeries', () => { // 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); + 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); From 0388270a7a5779a3d86732f506da1928186bda9d Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 22 May 2026 16:21:46 -0700 Subject: [PATCH 15/28] engine: thread optional engine field through the worker simNew message The wasm simulation demux already lives inside the Worker because WorkerServer wraps a DirectBackend; the only missing piece for a browser caller to select engine:'wasm' was the postMessage protocol. Add an optional engine field to the simNew request variant (reusing the exported SimEngine type), forward request.engine in the server's simNew case, and accept the engine arg in WorkerBackend.simNew. Purely additive: an absent engine produces the byte-identical message shape as before, so the VM path and every existing caller are unchanged. VALID_REQUEST_TYPES/isValidRequest are field-agnostic and untouched, and no response shape changes (the handle still returns inside result). --- src/engine/src/worker-backend.ts | 5 +++-- src/engine/src/worker-protocol.ts | 3 ++- src/engine/src/worker-server.ts | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/engine/src/worker-backend.ts b/src/engine/src/worker-backend.ts index 4e59c5a7..ffd61ddc 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 22911c9e..25bfc0f4 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 cf4445b7..78c03890 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`); From 3effbdf22aa3ebcd92c41adf203b6484ab703c01 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 22 May 2026 16:25:42 -0700 Subject: [PATCH 16/28] engine: parity-test the wasm engine through the Web Worker path Drive engine:'wasm' end-to-end through the real postMessage protocol via the in-memory loopback (a real WorkerBackend wired to a real WorkerServer, which wraps a DirectBackend). A node DirectBackend is the oracle: the worker-driven wasm series equals the DirectBackend wasm series exactly and the VM series within the engine's parity tolerance (wasm is not bit-identical to the VM's libm by design). Beyond parity, the tests pin the Phase 3 contract that closes the deferred Minor #4 (a browser caller silently getting a VM sim): the enableLtm-on-wasm rejection and a wasm-unsupported model both reject across the worker boundary with no silent VM fallback, and the same unsupported model still runs on the VM through the same worker. They also assert the served simNew request carries engine:'wasm', that an absent engine reproduces the prior message shape, that no new message type appears on the wire, and that getSeries still ships its Float64Array zero-copy (exactly one one-element transfer on the response leg). --- src/engine/tests/worker-wasm.test.ts | 361 +++++++++++++++++++++++++++ 1 file changed, 361 insertions(+) create mode 100644 src/engine/tests/worker-wasm.test.ts diff --git a/src/engine/tests/worker-wasm.test.ts b/src/engine/tests/worker-wasm.test.ts new file mode 100644 index 00000000..d8382263 --- /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); + }); + }); +}); From b801c6f593d224300a29384c39b827e375c03d8c Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 22 May 2026 16:35:14 -0700 Subject: [PATCH 17/28] engine: pure benchmark harness (median + adaptive warmup/measure) A side-effect-free statistic + warmup/measure policy shared by the node VM-vs-wasm benchmark. Both the timed body and the wall clock are injected, so the median statistic and the discard-warmup / adaptive-budget iteration loop are deterministically unit-tested without touching the clock or any model. Mirrors the Stat/bench pair of src/simlin-engine/examples/backend_bench.rs, adding the explicit warmup phase AC9.1 requires; runTimedAsync is the async twin so the benchmark's timed region is single-sourced and tested. --- src/engine/tests/bench-stats.test.ts | 189 +++++++++++++++++++++++++++ src/engine/tests/bench-stats.ts | 110 ++++++++++++++++ 2 files changed, 299 insertions(+) create mode 100644 src/engine/tests/bench-stats.test.ts create mode 100644 src/engine/tests/bench-stats.ts diff --git a/src/engine/tests/bench-stats.test.ts b/src/engine/tests/bench-stats.test.ts new file mode 100644 index 00000000..9935b10b --- /dev/null +++ b/src/engine/tests/bench-stats.test.ts @@ -0,0 +1,189 @@ +// 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, 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. The loop checks the clock + // before each push beyond minIters. minIters=2 is honored; then it keeps + // going while elapsed < 50: clock reads at the guard are 0, 20, 40, 60... + 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)); + + // The clock starts after the first read (used as `start`). minIters=2 push + // unconditionally; subsequent iterations gate on elapsed < 50ms. + expect(stat.iters).toBeGreaterThanOrEqual(2); + expect(stat.iters).toBeLessThan(100); + expect(Number.isFinite(stat.medianMs)).toBe(true); + }); + + 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 () => { + 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).toBeGreaterThanOrEqual(2); + expect(stat.iters).toBeLessThan(100); + expect(Number.isFinite(stat.medianMs)).toBe(true); + }); + + 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); + }); +}); diff --git a/src/engine/tests/bench-stats.ts b/src/engine/tests/bench-stats.ts new file mode 100644 index 00000000..3c0aee58 --- /dev/null +++ b/src/engine/tests/bench-stats.ts @@ -0,0 +1,110 @@ +// 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. + +/** 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); +} + +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 only wall-clock +// read in the module, and it is the injectable default so tests stay deterministic. +function defaultNow(): number { + return performance.now(); +} From 12fd4e8abf2a835e4c5d5890db9a2bd975d6f5ef Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 22 May 2026 16:40:10 -0700 Subject: [PATCH 18/28] engine: node VM-vs-wasm eval benchmark for fishbanks/WORLD3/C-LEARN A gated jest benchmark that times simulation (eval) only -- the wasm blob compile/instantiate happens once in untimed setup and result extraction is excluded -- through the public Model.simulate({engine}) API, mirroring backend_bench.rs's eval-vs-eval methodology with the explicit warmup AC9.1 requires. reset() runs inside the measured body but before the clock so only runToEnd() is timed. A pre-timing cross-check runs both engines to completion and compares every variable's full series via the pure seriesClose predicate, using the engine's corpus-wide VM-vs-wasm parity tolerance (ensure_results: 2e-3 absolute / near-zero guard) rather than the teacup-only 1e-9 -- a large model run to its final time accumulates benign floating-point reassociation noise (C-LEARN ~2.5e-9 on an O(0.1) value) far inside that bound. The heavy run is RUN_BENCH-gated so the default jest stays within the few-seconds-per-test budget; only the harness is committed, with results reported in-chat per the no-stale-benchmark-data rule. --- src/engine/tests/backend-bench.test.ts | 31 ++++ src/engine/tests/backend-bench.ts | 220 +++++++++++++++++++++++++ src/engine/tests/bench-stats.test.ts | 56 ++++++- src/engine/tests/bench-stats.ts | 46 ++++++ 4 files changed, 352 insertions(+), 1 deletion(-) create mode 100644 src/engine/tests/backend-bench.test.ts create mode 100644 src/engine/tests/backend-bench.ts diff --git a/src/engine/tests/backend-bench.test.ts b/src/engine/tests/backend-bench.test.ts new file mode 100644 index 00000000..e3cad5d2 --- /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 00000000..1b299a6f --- /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 index 9935b10b..4a25e75d 100644 --- a/src/engine/tests/bench-stats.test.ts +++ b/src/engine/tests/bench-stats.test.ts @@ -7,7 +7,7 @@ // 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, type BenchOpts } from './bench-stats'; +import { median, runTimed, runTimedAsync, seriesClose, type BenchOpts } from './bench-stats'; describe('median', () => { it('returns the middle element of an odd-length input', () => { @@ -187,3 +187,57 @@ describe('runTimedAsync', () => { 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); + } + }); +}); diff --git a/src/engine/tests/bench-stats.ts b/src/engine/tests/bench-stats.ts index 3c0aee58..834016e0 100644 --- a/src/engine/tests/bench-stats.ts +++ b/src/engine/tests/bench-stats.ts @@ -98,6 +98,52 @@ export async function runTimedAsync( 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`. + * + * 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]; + 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 }; From 57f0cfb8468440ce2b5633ff66c628aa99534ebe Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 22 May 2026 16:43:13 -0700 Subject: [PATCH 19/28] doc: document the node VM-vs-wasm eval benchmark --- docs/dev/benchmarks.md | 37 +++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/docs/dev/benchmarks.md b/docs/dev/benchmarks.md index 98830df8..a20021f4 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 From 296d3ee79dc03191c968a3cd6d18e7ece164293c Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 22 May 2026 16:56:02 -0700 Subject: [PATCH 20/28] engine: harden benchmark seriesClose NaN handling and tighten harness tests The pure cross-check comparator seriesClose flagged a divergence only when Math.abs(e - a) > PARITY_ABS_TOL. A NaN on one side made that NaN > tol, which is false, so a NaN-vs-finite (or NaN-vs-NaN) disagreement was silently reported as a match. This is the opposite of the Rust oracle it ports (test_helpers.rs::ensure_results), where approx_eq!(NaN, anything) is false and NaN is never around-zero, so any NaN panics the comparison. Because the wasm backend can legitimately emit genuine NaN (out-of-bounds vector reads, empty array reducers), a NaN-on-one-engine divergence is exactly the broken run the cross-check exists to reject, and the gap was latent for any future model. Now a non-finite value on either side at an index is a mismatch, checked before the tolerance comparison. Also tightens the two budget-stop harness tests to pin the exact deterministic iteration count (4, derived from the fakeClock(20) read sequence) instead of a loose >= 2 && < 100, so an off-by-one in the budget guard would be caught; and documents defaultNow() as the module's single deliberate impurity -- a default-injection seam analogous to the FCIS logger exception, with all pure logic taking now as a parameter so the core stays deterministically testable. --- src/engine/tests/bench-stats.test.ts | 83 ++++++++++++++++++++++++---- src/engine/tests/bench-stats.ts | 30 +++++++++- 2 files changed, 99 insertions(+), 14 deletions(-) diff --git a/src/engine/tests/bench-stats.test.ts b/src/engine/tests/bench-stats.test.ts index 4a25e75d..cdd6e7e9 100644 --- a/src/engine/tests/bench-stats.test.ts +++ b/src/engine/tests/bench-stats.test.ts @@ -86,18 +86,21 @@ describe('runTimed', () => { }); it('stops early once the budget is exceeded after minIters', () => { - // budget 50ms, clock advances 20ms per read. The loop checks the clock - // before each push beyond minIters. minIters=2 is honored; then it keeps - // going while elapsed < 50: clock reads at the guard are 0, 20, 40, 60... + // 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)); - // The clock starts after the first read (used as `start`). minIters=2 push - // unconditionally; subsequent iterations gate on elapsed < 50ms. - expect(stat.iters).toBeGreaterThanOrEqual(2); - expect(stat.iters).toBeLessThan(100); - expect(Number.isFinite(stat.medianMs)).toBe(true); + 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', () => { @@ -158,13 +161,18 @@ describe('runTimedAsync', () => { }); 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).toBeGreaterThanOrEqual(2); - expect(stat.iters).toBeLessThan(100); - expect(Number.isFinite(stat.medianMs)).toBe(true); + 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 () => { @@ -240,4 +248,57 @@ describe('seriesClose', () => { 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 index 834016e0..fe15f7a0 100644 --- a/src/engine/tests/bench-stats.ts +++ b/src/engine/tests/bench-stats.ts @@ -9,6 +9,14 @@ // 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. */ @@ -121,7 +129,9 @@ 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`. + * 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. @@ -133,6 +143,16 @@ export function seriesClose(expected: Readonly, actual: Readonly 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; @@ -149,8 +169,12 @@ function summarize(times: ReadonlyArray): Stat { return { medianMs: median(times), minMs, iters: times.length }; } -// performance.now() returns fractional milliseconds; this is the only wall-clock -// read in the module, and it is the injectable default so tests stay deterministic. +// 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(); } From 378ec1db9b16106152dfe3d73bf41684636f5e88 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 22 May 2026 17:03:21 -0700 Subject: [PATCH 21/28] doc: document wasm engine selection in @simlin/engine The 4-phase @simlin/engine WebAssembly simulation backend added a 'wasm' alternative to the bytecode VM, selectable via Model.simulate/run({ engine }), with a per-op DirectBackend demux, two new internal modules (wasmgen.ts, canonicalize.ts), an additive worker simNew engine field, and a RUN_BENCH-gated node VM-vs-wasm eval benchmark. Capture that contract surface and its restrictions (LTM/wasm rejected, getLinks throws, no VM fallback, wasm Run carries empty links) in src/engine/CLAUDE.md, and drop the forbidden 'Last verified' line while editing the file. --- src/engine/CLAUDE.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/engine/CLAUDE.md b/src/engine/CLAUDE.md index 73e2f81a..6ef737a0 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). From e1de1c407fb56bd229700f96af4757a2ef9cd884 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 22 May 2026 17:19:44 -0700 Subject: [PATCH 22/28] doc: add test plan for @simlin/engine wasm simulation backend --- docs/README.md | 1 + docs/test-plans/2026-05-22-engine-wasm-sim.md | 167 ++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 docs/test-plans/2026-05-22-engine-wasm-sim.md diff --git a/docs/README.md b/docs/README.md index 2ca9602d..b0191162 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/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 00000000..1f28ed90 --- /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 | From adcd6cd07f6297962be66c8d049102c5284d956c Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 22 May 2026 17:20:21 -0700 Subject: [PATCH 23/28] doc: add @simlin/engine wasm sim implementation plan and test requirements --- .../2026-05-22-engine-wasm-sim/phase_01.md | 304 ++++++++++++++++++ .../2026-05-22-engine-wasm-sim/phase_02.md | 302 +++++++++++++++++ .../2026-05-22-engine-wasm-sim/phase_03.md | 164 ++++++++++ .../2026-05-22-engine-wasm-sim/phase_04.md | 153 +++++++++ .../test-requirements.md | 126 ++++++++ 5 files changed, 1049 insertions(+) create mode 100644 docs/implementation-plans/2026-05-22-engine-wasm-sim/phase_01.md create mode 100644 docs/implementation-plans/2026-05-22-engine-wasm-sim/phase_02.md create mode 100644 docs/implementation-plans/2026-05-22-engine-wasm-sim/phase_03.md create mode 100644 docs/implementation-plans/2026-05-22-engine-wasm-sim/phase_04.md create mode 100644 docs/implementation-plans/2026-05-22-engine-wasm-sim/test-requirements.md 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 00000000..8be961a0 --- /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 00000000..5ecd4827 --- /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 00000000..aee0d6ac --- /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 00000000..7b5b8cb2 --- /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 00000000..56e4679a --- /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). From 6ba5ab9978cd152f6cd2f351286e8306202b9bcc Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 22 May 2026 18:22:45 -0700 Subject: [PATCH 24/28] engine: report completed-step count for wasm sims via saved-steps export simGetStepCount on a wasm sim returned the layout's nChunks unconditionally, which is the results-slab capacity, not the number of completed steps. A fresh or just-reset wasm sim therefore reported a fully-complete run though no rows had been saved, violating the documented "steps completed" contract and diverging from the VM (whose count only becomes nonzero once Results exist). The blob already tracks the live saved-row count in the internal mutable global G_SAVED (0 before any run / after reset, n_chunks after a full run). Export it additively as `saved_steps` and read it in the wasm branch of simGetStepCount. The mutable-global export is standard wasm and still validates under the DLR-FT interpreter and survives wasm-opt (--enable-mutable-globals). Parity after a full run is preserved: G_SAVED == n_chunks == the VM's count. --- src/engine/src/direct-backend.ts | 8 +++-- src/engine/src/internal/wasmgen.ts | 2 ++ src/engine/tests/wasm-backend.test.ts | 43 +++++++++++++++++++++++++ src/libsimlin/tests/wasm.rs | 5 +-- src/simlin-engine/src/wasmgen/module.rs | 5 +++ 5 files changed, 59 insertions(+), 4 deletions(-) diff --git a/src/engine/src/direct-backend.ts b/src/engine/src/direct-backend.ts index 50a86502..61f56a0c 100644 --- a/src/engine/src/direct-backend.ts +++ b/src/engine/src/direct-backend.ts @@ -565,8 +565,12 @@ export class DirectBackend implements EngineBackend { simGetStepCount(handle: SimHandle): number { const entry = this.getEntry(handle as number, 'sim'); if (entry.engine === 'wasm') { - // nChunks is the saved-row count == the VM's results.step_count. - return entry.wasmLayout!.nChunks; + // 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); } diff --git a/src/engine/src/internal/wasmgen.ts b/src/engine/src/internal/wasmgen.ts index 14d13fc6..c15ca150 100644 --- a/src/engine/src/internal/wasmgen.ts +++ b/src/engine/src/internal/wasmgen.ts @@ -65,6 +65,8 @@ export interface WasmBlobExports { 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; } /** diff --git a/src/engine/tests/wasm-backend.test.ts b/src/engine/tests/wasm-backend.test.ts index 1d06f5b1..5429b44d 100644 --- a/src/engine/tests/wasm-backend.test.ts +++ b/src/engine/tests/wasm-backend.test.ts @@ -517,6 +517,49 @@ describe('DirectBackend wasm engine: per-op vm/wasm parity (Task 4)', () => { }); }); + // 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(); diff --git a/src/libsimlin/tests/wasm.rs b/src/libsimlin/tests/wasm.rs index fad4b291..07612741 100644 --- a/src/libsimlin/tests/wasm.rs +++ b/src/libsimlin/tests/wasm.rs @@ -472,7 +472,8 @@ fn run_and_stride(wasm: &[u8], layout: &ParsedLayout, off: usize) -> Vec { /// 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`. This pins the export-set growth as purely additive. +/// `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(()); @@ -504,7 +505,7 @@ fn assert_blob_exports(wasm: &[u8]) { .is_some(), "export `memory` must be a memory" ); - for name in ["n_slots", "n_chunks", "results_offset"] { + 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}`")); diff --git a/src/simlin-engine/src/wasmgen/module.rs b/src/simlin-engine/src/wasmgen/module.rs index 839e21cb..6aafdd7b 100644 --- a/src/simlin-engine/src/wasmgen/module.rs +++ b/src/simlin-engine/src/wasmgen/module.rs @@ -1993,6 +1993,11 @@ fn assemble_simulation(parts: AssembleParts) -> Vec { 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 From c69539bcbc91d8e7f14cbc0a0d8ccfb7944088bb Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 22 May 2026 18:25:01 -0700 Subject: [PATCH 25/28] engine: release wasm sim instance refs on dispose to bound memory A disposed wasm sim entry was marked `disposed` but left in the `_handles` map with its WebAssembly.Instance, exports, and decoded layout still attached. The old comment claimed dropping the entry let the instance be GC'd, but nothing dropped it -- so each create/dispose cycle pinned a whole instance + layout indefinitely and memory grew unbounded even though simDispose had been called. The entry must stay in the map: existing tests rely on the disposed tombstone to surface a clear "has been disposed" error on use-after-dispose, and getEntry checks `disposed` before touching any wasm field, so nulling the heavy refs is safe. Add a releaseWasmSimState helper that clears the three heavy refs and call it from both simDispose and the projectDispose child-sim cascade; wasmStopTime is a plain number and is left in place. --- src/engine/src/direct-backend.ts | 32 +++++++++++-- src/engine/tests/wasm-backend.test.ts | 69 +++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 5 deletions(-) diff --git a/src/engine/src/direct-backend.ts b/src/engine/src/direct-backend.ts index 61f56a0c..ed919b39 100644 --- a/src/engine/src/direct-backend.ts +++ b/src/engine/src/direct-backend.ts @@ -289,8 +289,12 @@ export class DirectBackend implements EngineBackend { if (childEntry && !childEntry.disposed) { childEntry.disposed = true; if (childEntry.kind === 'sim') { - // Skip the native unref for a wasm sim (no native sim pointer). - if (childEntry.engine !== 'wasm') { + // 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') { @@ -492,6 +496,21 @@ export class DirectBackend implements EngineBackend { }) 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) { @@ -501,9 +520,12 @@ export class DirectBackend implements EngineBackend { if (entry.projectHandle !== undefined) { this._projectChildren.get(entry.projectHandle)?.delete(handle as number); } - // A wasm sim has no native sim pointer; dropping the entry lets the - // WebAssembly.Instance be GC'd. Only the VM path holds a native sim to unref. - if (entry.engine !== 'wasm') { + // 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); } } diff --git a/src/engine/tests/wasm-backend.test.ts b/src/engine/tests/wasm-backend.test.ts index 5429b44d..72e740a0 100644 --- a/src/engine/tests/wasm-backend.test.ts +++ b/src/engine/tests/wasm-backend.test.ts @@ -252,6 +252,75 @@ describe('DirectBackend wasm engine: sim creation and disposal (Task 3)', () => 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 From e7cbd337fc9b671835c4ce03c86c91b1cefa0dfc Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 22 May 2026 19:42:39 -0700 Subject: [PATCH 26/28] engine: truncate wasm getSeries to completed steps The wasm sim's getSeries always read nChunks rows -- the full results-slab capacity -- regardless of how many steps had actually run. The slab keeps its capacity across a partial runTo(t) and across reset (reset clears the run cursor but does not zero the slab), so a partially run or just-reset wasm sim surfaced uncommitted/stale tail rows. The VM path truncates by the completed-step count (its get_series returns only saved rows mid-run and the FFI further bounds the read by the passed count), so the wasm twin violated VM parity and the getSeries().length == getStepCount() contract precisely in the interactive-scrubbing scenario the wasm backend exists for. readStridedSeries now takes a completed-step count (defaulting to and clamped at nChunks so the pure reader never strides past the results region), and simGetSeries passes the live saved_steps count on both engines. --- src/engine/src/direct-backend.ts | 11 +++++-- src/engine/src/internal/wasmgen.ts | 25 ++++++++++++---- src/engine/tests/wasm-backend.test.ts | 42 +++++++++++++++++++++++++++ src/engine/tests/wasmgen.test.ts | 39 +++++++++++++++++++++++++ 4 files changed, 109 insertions(+), 8 deletions(-) diff --git a/src/engine/src/direct-backend.ts b/src/engine/src/direct-backend.ts index ed919b39..b4df11cb 100644 --- a/src/engine/src/direct-backend.ts +++ b/src/engine/src/direct-backend.ts @@ -625,13 +625,18 @@ export class DirectBackend implements EngineBackend { 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); if (entry.engine === 'wasm') { - // One Float64Array(nChunks) read strided from the blob's results region. // 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); + return readStridedSeries(entry.wasmExports!.memory.buffer, entry.wasmLayout!, slot, stepCount); } - const stepCount = this.simGetStepCount(handle); return simlin_sim_get_series(entry.ptr, name, stepCount); } diff --git a/src/engine/src/internal/wasmgen.ts b/src/engine/src/internal/wasmgen.ts index c15ca150..5bc6fb77 100644 --- a/src/engine/src/internal/wasmgen.ts +++ b/src/engine/src/internal/wasmgen.ts @@ -179,18 +179,33 @@ export function parseWasmLayout(bytes: Uint8Array): WasmLayout { * * 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(nChunks)` and + * 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`). - * @returns A new `Float64Array` of length `nChunks` -- the variable's series. + * @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): Float64Array { +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(layout.nChunks); - for (let c = 0; c < layout.nChunks; c++) { + 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/tests/wasm-backend.test.ts b/src/engine/tests/wasm-backend.test.ts index 72e740a0..b08c26ce 100644 --- a/src/engine/tests/wasm-backend.test.ts +++ b/src/engine/tests/wasm-backend.test.ts @@ -586,6 +586,48 @@ describe('DirectBackend wasm engine: per-op vm/wasm parity (Task 4)', () => { }); }); + // 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 diff --git a/src/engine/tests/wasmgen.test.ts b/src/engine/tests/wasmgen.test.ts index 78465812..969d1cab 100644 --- a/src/engine/tests/wasmgen.test.ts +++ b/src/engine/tests/wasmgen.test.ts @@ -235,6 +235,45 @@ describe('readStridedSeries', () => { 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; From 2fb132d2ed61bc84b43e72795c4115ecbb3d8088 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 22 May 2026 20:26:10 -0700 Subject: [PATCH 27/28] engine: restore fresh curr state on wasm sim reset The wasm sim's reset only called the blob's reset(), which clears the run cursor and preserves constant overrides but leaves the previous run's values in the live curr chunk (linear-memory base 0) that simGetTime/simGetValue read. So a runToEnd followed by reset then getTime()/getValue() returned the stale end-of-run values instead of the fresh pre-run state, diverging from the VM until the next run_to repopulated curr. The blob's reset faithfully mirrors Vm::reset(), which likewise leaves stale values and defers re-init to the next run. The VM stack still presents a fresh state after reset because libsimlin recreates a zeroed VM; the host is the right place to present that contract. simReset on the wasm path now zeros the live curr chunk (the nSlots f64 at base 0) after reset(), so every by-name read and getTime() return 0 -- matching a freshly-created sim and the VM -- rather than leaking the prior run's tail. --- src/engine/src/direct-backend.ts | 10 ++++++++- src/engine/tests/wasm-backend.test.ts | 30 +++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/engine/src/direct-backend.ts b/src/engine/src/direct-backend.ts index b4df11cb..4d751bb4 100644 --- a/src/engine/src/direct-backend.ts +++ b/src/engine/src/direct-backend.ts @@ -568,8 +568,16 @@ export class DirectBackend implements EngineBackend { simReset(handle: SimHandle): void { const entry = this.getEntry(handle as number, 'sim'); if (entry.engine === 'wasm') { - // Phase-1 reset: clears the run cursor, preserves constant overrides. + // 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); diff --git a/src/engine/tests/wasm-backend.test.ts b/src/engine/tests/wasm-backend.test.ts index b08c26ce..8fa8a27d 100644 --- a/src/engine/tests/wasm-backend.test.ts +++ b/src/engine/tests/wasm-backend.test.ts @@ -532,6 +532,36 @@ describe('DirectBackend wasm engine: per-op vm/wasm parity (Task 4)', () => { 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', () => { From 90aea2dcee245c0a4267ecd589fc7c5074eb36c3 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Fri, 22 May 2026 20:49:11 -0700 Subject: [PATCH 28/28] engine: reflect wasm setValue override in live curr state simSetValue on the wasm path only called the blob's set_value, which writes the constants-override region read by the next evaluation but leaves the live curr chunk (linear-memory base 0) unchanged. simGetValue/simGetTime read that curr chunk, so getValue() right after setValue() -- before any run -- returned the prior/zero value instead of the override, while the VM returned the new value. This is observable API divergence for interactive reads (e.g. setting a constant and reading it back, or reading an overridden constant before advancing time). The VM's apply_override writes the value into the live curr chunk immediately via set_value_now (vm.rs:869-873) in addition to updating the literals used by future evaluation. The blob faithfully mirrors only the future-evaluation half, so -- as with reset -- the host presents the live-state half: after a successful set_value, simSetValue now writes the override into the curr chunk at base 0 (slot*8, the same cell the by-name reads use), matching the VM. The next run re-evaluates the constant and overwrites the same cell, so this is a no-op for series output and only fixes the pre-run interactive read. --- src/engine/src/direct-backend.ts | 7 +++++++ src/engine/tests/wasm-backend.test.ts | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/engine/src/direct-backend.ts b/src/engine/src/direct-backend.ts index 4d751bb4..666f6943 100644 --- a/src/engine/src/direct-backend.ts +++ b/src/engine/src/direct-backend.ts @@ -626,6 +626,13 @@ export class DirectBackend implements EngineBackend { // 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); diff --git a/src/engine/tests/wasm-backend.test.ts b/src/engine/tests/wasm-backend.test.ts index 8fa8a27d..389346f3 100644 --- a/src/engine/tests/wasm-backend.test.ts +++ b/src/engine/tests/wasm-backend.test.ts @@ -714,6 +714,24 @@ describe('DirectBackend wasm engine: per-op vm/wasm parity (Task 4)', () => { 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.