Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 10 additions & 16 deletions src/engine/src/direct-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
14 changes: 11 additions & 3 deletions src/engine/src/internal/wasmgen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,16 +50,24 @@ 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;
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. */
/**
* 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;
Expand Down
60 changes: 60 additions & 0 deletions src/engine/tests/wasm-backend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
4 changes: 2 additions & 2 deletions src/simlin-engine/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8>, 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<u8>, 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`.
Expand Down
Loading
Loading