diff --git a/src/engine/src/direct-backend.ts b/src/engine/src/direct-backend.ts index 666f69430..b9f20abbb 100644 --- a/src/engine/src/direct-backend.ts +++ b/src/engine/src/direct-backend.ts @@ -568,16 +568,13 @@ export class DirectBackend implements EngineBackend { simReset(handle: SimHandle): void { const entry = this.getEntry(handle as number, 'sim'); if (entry.engine === 'wasm') { - // The blob's reset clears the run cursor and preserves constant overrides, - // faithfully mirroring Vm::reset() -- which also leaves the previous run's - // values in the live curr chunk and defers re-init to the next run. The VM - // stack still presents a fresh pre-run state after reset (libsimlin recreates - // a zeroed VM), so the host must do the same: zero the live curr chunk - // (memory base 0, the nSlots f64 that simGetTime/simGetValue read). Without - // this, getTime()/getValue() return stale end-of-run values until the next - // run_to repopulates curr -- diverging from the VM, which reads 0. + // The blob's reset owns the entire fresh-state contract: it clears the run + // cursor AND re-establishes the live curr chunk (zeroed, with constant + // overrides reapplied), matching libsimlin's recreate-and-reapply reset + // (simulation.rs:314-330). The host therefore does NO shadow write into + // curr -- the previous host-side zero-fill clobbered the very overrides + // that set_value had mirrored into curr. entry.wasmExports!.reset(); - new Float64Array(entry.wasmExports!.memory.buffer, 0, entry.wasmLayout!.nSlots).fill(0); return; } simlin_sim_reset(entry.ptr); @@ -626,13 +623,10 @@ 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); + // The blob's set_value writes both the constants-override region (read by the + // next evaluation) AND the live curr chunk (so getValue reflects the override + // before any run, matching the VM's set_value_now, vm.rs:869-873). The host + // therefore does NO shadow write into curr. return; } simlin_sim_set_value(entry.ptr, name, value); diff --git a/src/engine/src/internal/wasmgen.ts b/src/engine/src/internal/wasmgen.ts index 5bc6fb777..33bc34d2b 100644 --- a/src/engine/src/internal/wasmgen.ts +++ b/src/engine/src/internal/wasmgen.ts @@ -50,8 +50,12 @@ export interface WasmLayout { * 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. + * `run_initials` internally and resumes from where a prior call stopped (a + * resume on an already-complete slab is a no-op). The blob owns the live `curr` + * chunk's presentation, so the host needs no shadow writes: `set_value` mirrors + * the override into curr, and `reset` clears the run cursor AND re-establishes + * the fresh pre-run curr (zeroed, with constant overrides reapplied), preserving + * the overrides themselves across reset. */ export interface WasmBlobExports { memory: WebAssembly.Memory; @@ -59,7 +63,11 @@ export interface WasmBlobExports { 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. */ + /** + * Override a constant by slot offset: writes the constants region AND mirrors + * the value into the live curr chunk. 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; diff --git a/src/engine/tests/wasm-backend.test.ts b/src/engine/tests/wasm-backend.test.ts index 389346f3c..0d9952674 100644 --- a/src/engine/tests/wasm-backend.test.ts +++ b/src/engine/tests/wasm-backend.test.ts @@ -562,6 +562,66 @@ describe('DirectBackend wasm engine: per-op vm/wasm parity (Task 4)', () => { expect(backend.simGetTime(wasm)).toBe(0); dispose(); }); + + // Regression (PR #628 follow-up): a constant override must survive reset in + // the live curr state too -- getValue of the overridden constant after reset + // must return the override, matching the VM. The wasm reset used to zero-fill + // the curr chunk in the host, clobbering the override it had just mirrored; + // the blob now reapplies overrides into curr on reset, so the host does no + // shadow write into curr at all. + it('reset preserves an override in the live curr state (getValue matches VM)', () => { + const { vm, wasm, dispose } = openPair(); + + backend.simSetValue(vm, 'room temperature', 40); + backend.simSetValue(wasm, 'room temperature', 40); + backend.simRunToEnd(vm); + backend.simRunToEnd(wasm); + backend.simReset(vm); + backend.simReset(wasm); + + // The overridden constant reads the override (not 0) on both engines. + expect(backend.simGetValue(wasm, 'room_temperature')).toBe(40); + expect(backend.simGetValue(wasm, 'room_temperature')).toBe(backend.simGetValue(vm, 'room_temperature')); + // A non-overridden variable still reads the fresh-zero state on both. + expect(backend.simGetValue(wasm, 'teacup_temperature')).toBe(backend.simGetValue(vm, 'teacup_temperature')); + expect(backend.simGetValue(wasm, 'teacup_temperature')).toBe(0); + dispose(); + }); + }); + + // Regression (PR #628 follow-up): re-running on an already-complete slab (a + // second runToEnd, or interactive scrubbing that stays at the end) must be a + // no-op. The blob's run_to used to re-enter its stepping loop and write one + // results row past the slab -- corrupting adjacent memory and pushing + // saved_steps to nChunks + 1. The loop now breaks at the top when the slab is + // full, so the completed-step count and every series stay put. + describe('re-running on a complete slab is a no-op (no out-of-bounds write)', () => { + it('runToEnd twice leaves the step count and series unchanged (matches VM)', () => { + const { vm, wasm, dispose } = openPair(); + backend.simRunToEnd(vm); + backend.simRunToEnd(wasm); + + const names = backend.simGetVarNames(wasm); + const before = names.map((n) => backend.simGetSeries(wasm, n)); + const fullCount = backend.simGetStepCount(wasm); + + // Re-trigger the run on the full slab: a second runToEnd and a runTo well + // past the stop time. Both must do nothing. + backend.simRunToEnd(wasm); + backend.simRunTo(wasm, 1e9); + + // The completed-step count must not advance past the slab capacity (the OOB + // save used to bump saved_steps to nChunks + 1). + expect(backend.simGetStepCount(wasm)).toBe(fullCount); + expect(backend.simGetStepCount(wasm)).toBe(backend.simGetStepCount(vm)); + + // Every series is unchanged and still matches the VM. + for (let i = 0; i < names.length; i++) { + expectSeriesClose(backend.simGetSeries(wasm, names[i]), before[i]); + expectSeriesClose(backend.simGetSeries(wasm, names[i]), backend.simGetSeries(vm, names[i])); + } + dispose(); + }); }); describe('AC4.1/AC4.2/AC4.4: by-name reads parity', () => { diff --git a/src/simlin-engine/CLAUDE.md b/src/simlin-engine/CLAUDE.md index 613bfeab1..bf9149be8 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 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: +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 (a `run_to` that resumes on an already-complete slab is a no-op: the loop breaks at the top when `saved >= n_chunks`, so it can never write past the `n_chunks`-row results region). The blob owns the live `curr` chunk's presentation end-to-end, so a host needs no shadow writes: `set_value` mirrors the override into curr (like the VM's `set_value_now`), and `reset` clears the cursor AND re-establishes the fresh pre-run curr (zeroed, with explicitly-overridden constants reapplied -- tracked by a `const_override_set` marker region distinct from the validity region), matching libsimlin's recreate-and-reapply reset; constant overrides persist across `reset` and are dropped by `clear_values`. `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 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. + - **`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 (incl. the `const_override_set` marker region), the `set_value`/`reset`/`clear_values` exports (`set_value` mirrors the override into curr; `reset` clears the cursor, zeroes curr, and reapplies overrides into curr while keeping the override region; `clear_values` restores defaults and drops the override marks), and `WasmLayout` (de)serialization. - **`lower.rs`** - the per-opcode emitter (`emit_bytecode` over the un-fused + peephole opcode set), the `HelperFns` registry, and the `EmitState` unroll budget. Its `#[cfg(test)]` tests live in the sibling **`lower_tests.rs`** (split out for the per-file line cap). - **`views.rs`** - the compile-time `ViewDesc` view-descriptor stack + element-address arithmetic mirroring `RuntimeView::flat_offset`/`offset_for_iter_index`. - **`math.rs`** - open-coded transcendental wasm helpers (`exp`/`ln`/`sin`/`cos`/`tan`/`atan`/`asin`/`acos`/`log10`/`pow`), each validated against Rust `f64`. diff --git a/src/simlin-engine/src/wasmgen/module.rs b/src/simlin-engine/src/wasmgen/module.rs index 6aafdd7b1..40edd10a1 100644 --- a/src/simlin-engine/src/wasmgen/module.rs +++ b/src/simlin-engine/src/wasmgen/module.rs @@ -592,6 +592,19 @@ pub fn compile_simulation(sim: &CompiledSimulation) -> Result Result= n_chunks: break. A resumed `run_to` on an already-complete slab + // (`saved == n_chunks`, reachable via a second `run_to_end` or interactive + // scrubbing that stays at the end) must be a no-op: the results region is + // exactly `n_chunks` rows, so saving one more would write past it and corrupt + // the snapshot/GF regions that sit immediately after. This is the resumable + // analogue of the post-save exhaustion break below, moved to the loop *entry* + // so re-entry on a full slab steps and saves nothing. (A fresh run never trips + // it -- `saved` only reaches `n_chunks` via that post-save break, which exits + // before this guard is re-checked.) + f.instruction(&I::GlobalGet(G_SAVED)); + f.instruction(&I::I32Const(regions.n_chunks as i32)); + f.instruction(&I::I32GeS); + f.instruction(&I::BrIf(1)); + // if curr[TIME] > target: break f.instruction(&I::I32Const(0)); f.instruction(&I::F64Load(memarg(TIME_ADDR))); @@ -1382,7 +1419,12 @@ const SV_VALUE: u32 = 1; /// mirrors the VM's `set_value_by_offset` (`vm.rs:1037-1052`): an out-of-range or /// non-constant offset is rejected (the VM returns `Err`), a valid one applies /// the override (which persists across `reset`). -fn emit_set_value(n_slots: u32, const_region_base: u32, const_valid_base: u32) -> Function { +fn emit_set_value( + n_slots: u32, + const_region_base: u32, + const_valid_base: u32, + const_override_set_base: u32, +) -> Function { let mut f = Function::new([]); // if (offset < 0) | (offset >= n_slots): return 1 @@ -1414,24 +1456,81 @@ fn emit_set_value(n_slots: u32, const_region_base: u32, const_valid_base: u32) - f.instruction(&I::LocalGet(SV_VALUE)); f.instruction(&I::F64Store(memarg(u64::from(const_region_base)))); + // curr[offset] = val: mirror the override into the live curr chunk (base 0) + // so a by-name read reflects it immediately, before any run -- exactly what + // the VM's `apply_override` does via `set_value_now` (`vm.rs:1020`). The blob + // owns this so the host needs no shadow write into curr. + f.instruction(&I::LocalGet(SV_OFFSET)); + f.instruction(&I::I32Const(SLOT_SIZE as i32)); + f.instruction(&I::I32Mul); + f.instruction(&I::LocalGet(SV_VALUE)); + f.instruction(&I::F64Store(memarg(u64::from(CURR_BASE)))); + + // override_set[offset] = 1: mark this slot as explicitly overridden, so `reset` + // reapplies it into curr (and `clear_values` can later drop the mark). + f.instruction(&I::LocalGet(SV_OFFSET)); + f.instruction(&I::I32Const(1)); + f.instruction(&I::I32Store8(byte_memarg(u64::from( + const_override_set_base, + )))); + // return 0 f.instruction(&I::I32Const(0)); f.instruction(&I::End); f } -/// 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 { +/// Emit `reset() -> ()`: re-establish the fresh pre-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, and so a by-name read between the +/// reset and the next run sees the same fresh state libsimlin presents. +/// +/// Two parts, mirroring libsimlin's `simlin_sim_reset` recreate-and-reapply path +/// (`simulation.rs:314-330`): +/// +/// 1. **Live curr chunk** (base 0): each slot becomes its explicit override (if +/// `override_set[slot] != 0`) or 0 otherwise -- the state a freshly-created VM +/// presents after reapplying its tracked overrides. A non-overridden constant +/// reads 0 here (its compiled default is not materialized until `run_initials`), +/// so this reapplies *overrides only*, never defaults; that is why the +/// override-set marker is needed and the validity region alone would not do. +/// The host therefore needs no shadow write into curr (the zero-fill it used to +/// do clobbered the very override it had mirrored). Unrolled per slot, matching +/// `emit_copy_chunk`; `run_initials` overwrites curr wholesale on the next run, +/// so this matters only for a read taken between `reset` and the next run. +/// +/// 2. **Run cursor + PREVIOUS fallback** globals: `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`). Mirrors `vm.rs:989-1002`. +/// +/// Like the VM, `reset` deliberately does NOT touch the constants-override region +/// or its markers, so a `set_value` override persists across `reset`. +fn emit_reset(n_slots: u32, const_region_base: u32, const_override_set_base: u32) -> Function { let mut f = Function::new([]); + + // Part 1: curr[slot] = override_set[slot] ? const_region[slot] : 0.0 + for slot in 0..n_slots { + let slot_addr = u64::from(slot) * u64::from(SLOT_SIZE); + f.instruction(&I::I32Const(0)); // F64Store address operand (curr base 0) + // the overridden value ... + f.instruction(&I::I32Const(0)); + f.instruction(&I::F64Load(memarg( + u64::from(const_region_base) + slot_addr, + ))); + // ... vs 0.0 ... + f.instruction(&f64_const(0.0)); + // ... selected by the override-set marker byte. + f.instruction(&I::I32Const(0)); + f.instruction(&I::I32Load8U(byte_memarg( + u64::from(const_override_set_base) + u64::from(slot), + ))); + f.instruction(&I::Select); + f.instruction(&I::F64Store(memarg(slot_addr))); + } + + // Part 2: clear the persistent run state (cursor + PREVIOUS fallback). f.instruction(&I::I32Const(0)); f.instruction(&I::GlobalSet(G_SAVED)); f.instruction(&I::I32Const(0)); @@ -1446,19 +1545,34 @@ fn emit_reset() -> Function { /// Emit `clear_values() -> ()`: restore each overridable constant to its /// compiled-default literal by writing the defaults back into the constants -/// region (the VM's `clear_values`, `vm.rs:1055-1062`). The defaults are -/// compile-time constants, so this is a straight-line sequence of `f64.store`s -- -/// one per overridable absolute offset. The data segment also writes these at +/// region (the VM's `clear_values`, `vm.rs:1055-1062`), and drop each slot's +/// override-set marker so a subsequent `reset` no longer reapplies the cleared +/// override into curr (it reverts to the fresh-zero state). Like the VM, this +/// does NOT touch the live curr chunk -- the next run re-materializes the +/// defaults. The defaults and offsets are compile-time constants, so this is a +/// straight-line sequence of stores -- one f64 default + one zero marker byte per +/// overridable absolute offset. The data segment also writes the defaults at /// instantiation; `clear_values` lets a host undo a `set_value` without /// re-instantiating the module. -fn emit_clear_values(const_region_base: u32, overridable_defaults: &[(usize, f64)]) -> Function { +fn emit_clear_values( + const_region_base: u32, + const_override_set_base: u32, + overridable_defaults: &[(usize, f64)], +) -> Function { let mut f = Function::new([]); for &(abs_off, default) in overridable_defaults { + // const_region[abs_off] = default f.instruction(&I::I32Const(0)); f.instruction(&f64_const(default)); f.instruction(&I::F64Store(memarg( u64::from(const_region_base) + abs_off as u64 * u64::from(SLOT_SIZE), ))); + // override_set[abs_off] = 0 + f.instruction(&I::I32Const(0)); + f.instruction(&I::I32Const(0)); + f.instruction(&I::I32Store8(byte_memarg( + u64::from(const_override_set_base) + abs_off as u64, + ))); } f.instruction(&I::End); f @@ -5309,6 +5423,160 @@ mod tests { ); } + /// Regression (PR #628 follow-up, P2): the blob owns the live `curr` chunk's + /// override semantics end-to-end, so a host needs no shadow writes into curr. + /// `set_value(off, v)` writes the override into the live curr chunk immediately + /// (mirroring the VM's `set_value_now`, `vm.rs:869-873`), and `reset()` + /// re-establishes the fresh pre-run curr state -- zeroed everywhere except the + /// explicitly-overridden constants, which keep their override (mirroring + /// libsimlin's recreate-and-reapply `simlin_sim_reset`). Previously the blob's + /// set_value/reset left curr untouched and the TS host poked curr directly: a + /// zero-fill on reset that then clobbered the very override it had mirrored. + #[test] + fn set_value_writes_curr_and_reset_reapplies_override() { + let datamodel = resumable_fixture(5.0); + let sim = compile_sim(&datamodel, "main"); + let artifact = compile_simulation(&sim).expect("wasm codegen"); + let rate_off = layout_offset(&artifact, "inflow_rate"); + let level_off = layout_offset(&artifact, "level"); + + 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 set_value = store + .instance_export(inst, "set_value") + .unwrap() + .as_func() + .unwrap(); + let reset = store + .instance_export(inst, "reset") + .unwrap() + .as_func() + .unwrap(); + let clear_values = store + .instance_export(inst, "clear_values") + .unwrap() + .as_func() + .unwrap(); + + // set_value(inflow_rate, 5) on a fresh instance (no run): the override must + // land in the live curr chunk immediately, not only in the constants region. + 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"); + assert_eq!( + read_curr_slot(&mut store, inst, rate_off), + 5.0, + "set_value must write the override into the live curr chunk" + ); + + // reset(): curr returns to the fresh pre-run state -- the override persists + // in curr, while every non-overridden slot (the level stock and the reserved + // TIME slot) reads 0, exactly as a freshly-created VM does after reapply. + store + .invoke_simple_typed::<(), ()>(reset, ()) + .expect("reset"); + assert_eq!( + read_curr_slot(&mut store, inst, rate_off), + 5.0, + "reset must reapply the explicitly-overridden constant into curr" + ); + assert_eq!( + read_curr_slot(&mut store, inst, level_off), + 0.0, + "reset must zero non-overridden slots in curr" + ); + assert_eq!( + read_curr_slot(&mut store, inst, TIME_OFF), + 0.0, + "reset must zero the reserved time slot in curr" + ); + + // clear_values() drops the override, so a subsequent reset zeroes the slot: + // a cleared override is no longer reapplied (matching the VM's clear_values). + store + .invoke_simple_typed::<(), ()>(clear_values, ()) + .expect("clear_values"); + store + .invoke_simple_typed::<(), ()>(reset, ()) + .expect("reset after clear_values"); + assert_eq!( + read_curr_slot(&mut store, inst, rate_off), + 0.0, + "after clear_values, reset must no longer reapply the dropped override" + ); + } + + /// Regression (PR #628 follow-up, P1): a `run_to` that resumes on an + /// already-complete slab (`saved == n_chunks`, reachable via a second + /// `run_to_end` or interactive scrubbing that stays at the end) must be a + /// complete no-op. Previously the stepping loop re-entered -- its + /// `curr[TIME] > target` guard is false when `target >= stop` -- and + /// `emit_save_advance` wrote one results row at `results_base + n_chunks*stride`, + /// one full row past the `n_chunks`-row results region, silently corrupting the + /// snapshot/GF regions that sit immediately after it. The loop now breaks at the + /// top when `saved >= n_chunks`, so a resumed-on-full `run_to` cannot touch + /// linear memory at all. + #[test] + fn run_to_on_full_slab_is_a_noop() { + // save_step == dt == 1 => save_every == 1, so every step saves and the + // overshoot row is written immediately on re-entry (the worst case). + let datamodel = resumable_fixture(10.0); + let sim = compile_sim(&datamodel, "main"); + let artifact = compile_simulation(&sim).expect("wasm codegen"); + let stop = sim.specs.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(); + let run_to = store + .instance_export(inst, "run_to") + .unwrap() + .as_func() + .unwrap(); + let mem = store + .instance_export(inst, "memory") + .unwrap() + .as_mem() + .unwrap(); + + // Fill the slab: run_initials; run_to(stop). saved == n_chunks now. + store + .invoke_simple_typed::<(), ()>(run_initials, ()) + .expect("run_initials"); + store + .invoke_simple_typed::<(f64,), ()>(run_to, (stop,)) + .expect("run_to(stop)"); + let before: Vec = store.mem_access_mut_slice(mem, |bytes| bytes.to_vec()); + + // Resume on the full slab: a re-run to stop, and a run far past it, must each + // change nothing -- not the results region, not the regions following it. + store + .invoke_simple_typed::<(f64,), ()>(run_to, (stop,)) + .expect("run_to(stop) again"); + store + .invoke_simple_typed::<(f64,), ()>(run_to, (stop * 100.0,)) + .expect("run_to(stop*100)"); + let after: Vec = store.mem_access_mut_slice(mem, |bytes| bytes.to_vec()); + + assert!( + before == after, + "run_to on a full slab must be a no-op; linear memory changed (out-of-bounds results write)" + ); + } + /// 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