diff --git a/src/engine/tests/wasm-backend.test.ts b/src/engine/tests/wasm-backend.test.ts index 0d9952674..88de41062 100644 --- a/src/engine/tests/wasm-backend.test.ts +++ b/src/engine/tests/wasm-backend.test.ts @@ -391,22 +391,23 @@ describe('DirectBackend wasm engine: per-op vm/wasm parity (Task 4)', () => { }); 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', () => { + // After a runTo(t) that stops mid-interval, the live curr chunk is fully + // self-consistent at the resting time on BOTH backends: stocks + reserved + // time vars AND every flow/aux/constant are evaluated for the same time and + // stocks. Both backends re-evaluate root flows at the resting curr after the + // overshoot break (#625), so a mid-run getValue of ANY variable agrees with + // the VM -- not just the integrated state. (Previously the VM left stale + // non-stock slots -- e.g. 0 for a constant -- and the wasm left them one step + // behind, so this parity was scoped to stocks + reserved time vars.) + it('wasm getValue after runTo(t) equals the VM for every variable', () => { const { vm, wasm, dispose } = openPair(); - const t = 5; + const t = 15.05; // mid-interval (teacup dt=0.125): both rest at t=15.125 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']) { + // Every variable -- stocks, flows, auxes, constants, and reserved time vars + // -- is the well-defined "value at the current time" on both backends. + for (const name of backend.simGetVarNames(wasm)) { 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). diff --git a/src/simlin-engine/src/vm.rs b/src/simlin-engine/src/vm.rs index 0df6afade..955f41671 100644 --- a/src/simlin-engine/src/vm.rs +++ b/src/simlin-engine/src/vm.rs @@ -851,6 +851,45 @@ impl Vm { } } + // The integration loop breaks on `curr[TIME] > end` *after* an advance, so + // the live curr chunk holds the resting-point stocks + reserved time vars + // but its flow/aux/constant slots were never recomputed for the advanced + // time -- Euler's `curr.copy_from_slice(next)` leaves a stale `next` row, + // and the chunk-ring advance lands on a chunk whose non-stock slots are + // stale (e.g. 0 for a constant). A mid-run `get_value_now` of a non-stock + // would otherwise read that garbage. Re-evaluate root flows once at the + // resting curr so the chunk is fully self-consistent ("the value at the + // current time") and identical to the wasm backend's resting curr (#625). + // + // This touches only the live curr chunk: every results row was already + // saved (and `get_series` reads chunks `[0, curr_chunk)`, excluding this + // one), a resumed `run_to` re-evaluates from scratch, and `run_to_end` + // reads the last *results* row -- so it is invisible to the saved series, + // to resume, and to a full run. It does NOT re-snapshot `prev_values`, so a + // resume's `PREVIOUS` still sees the last completed step. + // + // Guarded on `curr_chunk != next_chunk`: when `run_to(target)` is called + // with `target` past FINAL_TIME the loop exits via the chunk-ring + // exhaustion break in `save_advance!`, which sets `curr_chunk = next_chunk` + // before breaking. Calling `borrow_two` with two equal chunk indices would + // slice out of bounds and panic. That exhausted-slab case is exactly the + // one a mid-run read never reaches (a full slab means time has reached + // FINAL_TIME, not mid-interval), so skipping the re-eval there is correct + // and matches the pre-#625 graceful clamp. + if self.curr_chunk != self.next_chunk { + let (curr, next) = borrow_two(&mut data, n_slots, self.curr_chunk, self.next_chunk); + Self::eval( + &self.sliced_sim, + &mut state, + root_idx, + StepPart::Flows, + 0, + &[], + curr, + next, + ); + } + self.data = Some(data); Ok(()) } @@ -3680,542 +3719,8 @@ mod per_variable_initials_tests { } #[cfg(test)] -mod vm_reset_and_run_initials_tests { - use super::*; - use crate::canonicalize; - use crate::test_common::TestProject; - - fn pop_model() -> TestProject { - TestProject::new("pop_model") - .with_sim_time(0.0, 100.0, 1.0) - .aux("birth_rate", "0.1", None) - .flow("births", "population * birth_rate", None) - .flow("deaths", "population / 80", None) - .stock("population", "100", &["births"], &["deaths"], None) - } - - fn build_compiled(tp: &TestProject) -> CompiledSimulation { - tp.compile_incremental() - .expect("incremental compile should succeed") - } - - /// End-to-end guard for the 3-address fusion (R2), which is applied to the - /// Vm's flow/stock bytecode at construction. Uses subtraction and division - /// (non-commutative) so a swapped operand encoding in any fused form is a - /// loud failure rather than a silent miscompile. `a`, `b`, `c` are distinct - /// variables (not foldable into a literal). - /// - /// Post-peephole + post-fusion, a *top-level* leaf binop assignment `x = a - /// op b` collapses all the way to one register-style leaf-assign op (the R2 - /// extension), while the inner subexpression of a nested expression stays a - /// pushing `BinVarVar` whose result the outer op consumes from the stack. - #[test] - fn test_fused_binops_preserve_operand_order() { - let tp = TestProject::new("fusion_order") - .with_sim_time(0.0, 1.0, 1.0) - .aux("a", "20", None) - .aux("b", "5", None) - .aux("c", "2", None) - .aux("vv", "a - b", None) // AssignSubVarVarCurr (leaf assign) - .aux("dvv", "a / b", None) // AssignDivVarVarCurr (leaf assign, division) - .aux("vc", "a - 3", None) // AssignSubVarConstCurr (leaf assign) - .aux("cv", "10 - a", None) // AssignSubConstVarCurr (leaf assign) - .aux("sv", "(a - b) - c", None) // BinVarVar then AssignStackVarCurr - .aux("sc", "(a - b) - 4", None); // BinVarVar then AssignStackConstCurr - - let compiled = build_compiled(&tp); - let mut vm = Vm::new(compiled).unwrap(); - vm.run_to_end().unwrap(); - let results = vm.into_results(); - - let val = |name: &str| -> f64 { - let off = *results - .offsets - .get(&*canonicalize(name)) - .unwrap_or_else(|| panic!("missing {name}")); - results.data[off] // step 0 - }; - - assert_eq!(val("vv"), 15.0, "a - b"); - assert_eq!(val("dvv"), 4.0, "a / b"); - assert_eq!(val("vc"), 17.0, "a - 3"); - assert_eq!(val("cv"), -10.0, "10 - a"); - assert_eq!(val("sv"), 13.0, "(a - b) - c"); - assert_eq!(val("sc"), 11.0, "(a - b) - 4"); - } - - /// End-to-end guard for the global-operand and two-constant fused binops - /// (the R2 extension capturing the leaf-operand loads the original fusion - /// missed). All operators are Sub or Div (non-commutative) so a swapped - /// operand encoding in any new fused form is a loud failure, not a silent - /// miscompile. Each global appears as a leaf operand of a *pushing* - /// subexpression (the outer `- c` is the assigning op) so the fusion emits - /// the pushing `BinGlobal*` / `BinConstConst` forms rather than a leaf - /// assign. The opcode each equation produces is pinned by a sibling - /// `bytecode_profile().fused_histogram` assertion so the test cannot pass - /// vacuously through the un-fused path. - /// - /// At step 0: TIME=10, DT=2 (the sim start time and dt -- both globals, - /// loaded via LoadGlobalVar), b=5, c=2, e=1. - #[test] - fn test_fused_global_and_const_binops_preserve_operand_order() { - let tp = TestProject::new("global_fusion_order") - .with_sim_time(10.0, 20.0, 2.0) - .aux("b", "5", None) - .aux("c", "2", None) - .aux("e", "1", None) - .aux("gv", "(TIME - b) - c", None) // BinGlobalVar(Sub) then stack-leaf - .aux("vg", "(b / DT) - c", None) // BinVarGlobal(Div) - .aux("gc", "(TIME - 3) - c", None) // BinGlobalConst(Sub) - .aux("cg", "(3 / DT) - c", None) // BinConstGlobal(Div) - .aux("gg", "(TIME - DT) - c", None) // BinGlobalGlobal(Sub) - .aux("cc", "(7 - 3) - c", None) // BinConstConst(Sub) - .aux("sg", "((b - c) / DT) - e", None); // BinVarVar then BinStackGlobal(Div) - - let compiled = build_compiled(&tp); - - // Pin the fused shape: every new form must actually be emitted, so the - // numeric asserts below exercise the fused dispatch arms (not a fallback). - let fused = &compiled.bytecode_profile().fused_histogram; - for name in [ - "BinGlobalVar", - "BinVarGlobal", - "BinGlobalConst", - "BinConstGlobal", - "BinGlobalGlobal", - "BinConstConst", - "BinStackGlobal", - ] { - assert!( - fused.get(name).copied().unwrap_or(0) >= 1, - "expected at least one {name} in the fused stream; got {fused:?}" - ); - } - - let mut vm = Vm::new(compiled).unwrap(); - vm.run_to_end().unwrap(); - let results = vm.into_results(); - let val = |name: &str| -> f64 { - let off = *results - .offsets - .get(&*canonicalize(name)) - .unwrap_or_else(|| panic!("missing {name}")); - results.data[off] // step 0 - }; - - assert_eq!(val("gv"), 3.0, "(TIME - b) - c = (10 - 5) - 2"); - assert_eq!(val("vg"), 0.5, "(b / DT) - c = (5 / 2) - 2"); - assert_eq!(val("gc"), 5.0, "(TIME - 3) - c = (10 - 3) - 2"); - assert_eq!(val("cg"), -0.5, "(3 / DT) - c = (3 / 2) - 2"); - assert_eq!(val("gg"), 6.0, "(TIME - DT) - c = (10 - 2) - 2"); - assert_eq!(val("cc"), 2.0, "(7 - 3) - c = (7 - 3) - 2"); - assert_eq!(val("sg"), 0.5, "((b - c) / DT) - e = ((5 - 2) / 2) - 1"); - } - - /// The single most dangerous risk for the global-operand fused binops: a - /// `_global` operand MUST read `curr[g]` (an absolute global slot), NOT - /// `curr[module_off + g]`. They are the same slot only at the root - /// (`module_off == 0`); inside a submodule they differ, so a `module_off + g` - /// miscompile is invisible at the root but corrupts every submodule. - /// - /// This builds a real two-model project: `main` instantiates `sub`, whose - /// body computes `gv = (TIME - b) - c` (a `BinGlobalVar`) and - /// `vg = (b / DT) - c` (a `BinVarGlobal`) where `module_off > 0`. With - /// TIME=10, DT=2 (the sim start/dt globals at curr[0]/curr[1]) and the - /// submodule's own b=5, c=2, the correct values are gv=3, vg=0.5. The - /// submodule's slots 0/1 hold its own variables (curr[module_off+0/1]), whose - /// values differ from the globals, so a `module_off`-relative read would - /// produce different numbers. - #[test] - fn test_global_operand_binop_reads_global_not_module_relative() { - use crate::datamodel; - use crate::db::{SimlinDb, compile_project_incremental, sync_from_datamodel_incremental}; - use crate::testutils::{x_aux, x_model, x_module, x_project}; - - let sim_specs = datamodel::SimSpecs { - start: 10.0, - stop: 12.0, - dt: datamodel::Dt::Dt(2.0), - save_step: None, - sim_method: datamodel::SimMethod::Euler, - time_units: None, - }; - - // `sub` is instantiated by `main`, so its variables live at module_off>0. - // `b`/`c` are plain auxes (module_off-relative LoadVar operands); TIME/DT - // are globals (LoadGlobalVar operands at curr[0]/curr[1]). - let main = x_model("main", vec![x_module("sub", &[], None)]); - let sub = x_model( - "sub", - vec![ - x_aux("b", "5", None), - x_aux("c", "2", None), - x_aux("gv", "(TIME - b) - c", None), - x_aux("vg", "(b / DT) - c", None), - ], - ); - let datamodel = x_project(sim_specs, &[main, sub]); - - let mut db = SimlinDb::default(); - let sync = sync_from_datamodel_incremental(&mut db, &datamodel, None); - let compiled = compile_project_incremental(&db, sync.project, "main") - .expect("two-model project should compile"); - - // Confirm the fused global ops were actually emitted (inside the - // submodule), so the dispatch path under test is exercised. - let fused = &compiled.bytecode_profile().fused_histogram; - assert!( - fused.get("BinGlobalVar").copied().unwrap_or(0) >= 1, - "expected a BinGlobalVar in the submodule's fused stream; got {fused:?}" - ); - assert!( - fused.get("BinVarGlobal").copied().unwrap_or(0) >= 1, - "expected a BinVarGlobal in the submodule's fused stream; got {fused:?}" - ); - - let mut vm = Vm::new(compiled).unwrap(); - vm.run_to_end().unwrap(); - let results = vm.into_results(); - let series = crate::test_common::collect_results(&results); - - // Submodule variables are reported under their qualified (middot-joined) - // `subĀ·` names. A module_off-relative read of TIME/DT would yield - // other numbers. - let gv = series - .get("sub\u{b7}gv") - .unwrap_or_else(|| panic!("missing sub\u{b7}gv; have {:?}", series.keys())); - let vg = series.get("sub\u{b7}vg").expect("missing sub\u{b7}vg"); - assert_eq!( - gv[0], 3.0, - "(TIME - b) - c = (10 - 5) - 2 -- TIME must be the global, not curr[module_off]" - ); - assert_eq!( - vg[0], 0.5, - "(b / DT) - c = (5 / 2) - 2 -- DT must be the global, not curr[module_off+1]" - ); - } - - #[test] - fn test_vm_reset_produces_identical_results() { - let tp = pop_model(); - let compiled = build_compiled(&tp); - - // First run - let mut vm1 = Vm::new(compiled.clone()).unwrap(); - vm1.run_to_end().unwrap(); - let results1 = vm1.into_results(); - - // Second fresh VM from same compiled - let mut vm2 = Vm::new(compiled.clone()).unwrap(); - vm2.run_to_end().unwrap(); - let results2 = vm2.into_results(); - - // Third: run, reset, run again - let mut vm3 = Vm::new(compiled).unwrap(); - vm3.run_to_end().unwrap(); - vm3.reset(); - vm3.run_to_end().unwrap(); - let results3 = vm3.into_results(); - - let pop_off = *results1.offsets.get(&*canonicalize("population")).unwrap(); - for step in 0..results1.step_count { - let idx = step * results1.step_size + pop_off; - let v1 = results1.data[idx]; - let v2 = results2.data[idx]; - let v3 = results3.data[idx]; - assert!( - (v1 - v2).abs() < 1e-10, - "fresh VMs should match at step {step}: {v1} vs {v2}" - ); - assert!( - (v1 - v3).abs() < 1e-10, - "reset VM should match fresh at step {step}: {v1} vs {v3}" - ); - } - } - - #[test] - fn test_vm_reset_after_partial_run() { - let tp = pop_model(); - let compiled = build_compiled(&tp); - - // Full run for reference - let mut vm_ref = Vm::new(compiled.clone()).unwrap(); - vm_ref.run_to_end().unwrap(); - let ref_results = vm_ref.into_results(); - - // Partial run, then reset and full run - let mut vm = Vm::new(compiled).unwrap(); - vm.run_to(50.0).unwrap(); - vm.reset(); - vm.run_to_end().unwrap(); - let results = vm.into_results(); - - let pop_off = *ref_results - .offsets - .get(&*canonicalize("population")) - .unwrap(); - for step in 0..ref_results.step_count { - let idx = step * ref_results.step_size + pop_off; - let v_ref = ref_results.data[idx]; - let v = results.data[idx]; - assert!( - (v_ref - v).abs() < 1e-10, - "reset-after-partial should match fresh at step {step}: {v_ref} vs {v}" - ); - } - } - - #[test] - fn test_vm_reset_clears_previous_snapshot() { - let tp = TestProject::new("reset_prev_snapshot") - .with_sim_time(0.0, 3.0, 1.0) - .aux("x", "1", None) - .aux("prev_x", "PREVIOUS(x)", None); - let compiled = build_compiled(&tp); - - let mut vm = Vm::new(compiled).unwrap(); - vm.run_to_end().unwrap(); - vm.reset(); - vm.run_to_end().unwrap(); - let results = vm.into_results(); - - let prev_off = *results.offsets.get(&*canonicalize("prev_x")).unwrap(); - let t0_prev = results.data[prev_off]; - assert!( - (t0_prev - 0.0).abs() < 1e-10, - "PREVIOUS(x) at t=0 after reset should be 0, got {t0_prev}" - ); - } - - #[test] - fn test_previous_in_initials_vm() { - let tp = TestProject::new("previous_in_initials") - .with_sim_time(0.0, 2.0, 1.0) - .aux("x", "5", None) - .stock("s", "PREVIOUS(x)", &[], &[], None); - - let vm = tp.run_vm().expect("vm should run"); - let vm_s = vm.get("s").expect("vm missing s"); - - // PREVIOUS(x) returns 0 at the initial timestep (the default value) - assert!( - (vm_s[0] - 0.0).abs() < 1e-10, - "stock initial PREVIOUS(x) should be 0.0 at t=0, got {}", - vm_s[0] - ); - } - - #[test] - fn test_init_on_module_backed_var_freezes_initial_value() { - let tp = TestProject::new("init_module_backed") - .with_sim_time(0.0, 4.0, 1.0) - .aux("x", "TIME", None) - .aux("delayed", "PREVIOUS(x, 99)", None) - .aux("frozen", "INIT(delayed)", None); - - let vm = tp.run_vm().expect("VM should run"); - let frozen_vals = vm.get("frozen").expect("frozen not in results"); - for (step, val) in frozen_vals.iter().enumerate() { - assert!( - (val - 99.0).abs() < 1e-10, - "frozen should be 99.0 at every step, got {val} at step {step}" - ); - } - } - - #[test] - fn test_compiled_simulation_clone_produces_equivalent_vm() { - let tp = pop_model(); - let compiled = build_compiled(&tp); - let compiled_clone = compiled.clone(); - - let mut vm1 = Vm::new(compiled).unwrap(); - vm1.run_to_end().unwrap(); - let results1 = vm1.into_results(); - - let mut vm2 = Vm::new(compiled_clone).unwrap(); - vm2.run_to_end().unwrap(); - let results2 = vm2.into_results(); - - let pop_off = *results1.offsets.get(&*canonicalize("population")).unwrap(); - for step in 0..results1.step_count { - let idx = step * results1.step_size + pop_off; - assert!( - (results1.data[idx] - results2.data[idx]).abs() < 1e-10, - "cloned compiled should produce identical results at step {step}" - ); - } - } - - #[test] - fn test_run_initials_then_run_to_end_matches_single_call() { - let tp = pop_model(); - let compiled = build_compiled(&tp); - - // VM A: single run_to_end - let mut vm_a = Vm::new(compiled.clone()).unwrap(); - vm_a.run_to_end().unwrap(); - let results_a = vm_a.into_results(); - - // VM B: run_initials then run_to_end - let mut vm_b = Vm::new(compiled).unwrap(); - vm_b.run_initials().unwrap(); - vm_b.run_to_end().unwrap(); - let results_b = vm_b.into_results(); - - let pop_off = *results_a.offsets.get(&*canonicalize("population")).unwrap(); - for step in 0..results_a.step_count { - let idx = step * results_a.step_size + pop_off; - assert!( - (results_a.data[idx] - results_b.data[idx]).abs() < 1e-10, - "run_initials+run_to_end should match single run_to_end at step {step}" - ); - } - } - - #[test] - fn test_run_initials_is_idempotent() { - let tp = pop_model(); - let compiled = build_compiled(&tp); - - let mut vm = Vm::new(compiled).unwrap(); - vm.run_initials().unwrap(); - vm.run_initials().unwrap(); // second call should be no-op - vm.run_to_end().unwrap(); - let results = vm.into_results(); - - let pop_off = *results.offsets.get(&*canonicalize("population")).unwrap(); - let initial_pop = results.data[pop_off]; - assert_eq!(initial_pop, 100.0, "population initial should be 100"); - } - - #[test] - fn test_run_initials_sets_correct_values() { - // Use a model where the aux is a stock dependency so it's in initials - let tp = TestProject::new("initials_check") - .with_sim_time(0.0, 10.0, 1.0) - .aux("rate", "0.1", None) - .flow("inflow", "0", None) - .stock("s", "rate * 1000", &["inflow"], &[], None); - - let compiled = build_compiled(&tp); - let mut vm = Vm::new(compiled).unwrap(); - vm.run_initials().unwrap(); - - let s_off = vm.get_offset(&Ident::new("s")).unwrap(); - let rate_off = vm.get_offset(&Ident::new("rate")).unwrap(); - - assert_eq!( - vm.get_value_now(s_off), - 100.0, - "stock initial = rate*1000 = 100" - ); - assert_eq!( - vm.get_value_now(rate_off), - 0.1, - "rate is a stock dependency, so it's in initials" - ); - assert_eq!( - vm.get_value_now(TIME_OFF), - 0.0, - "time should be 0 after initials" - ); - } - - #[test] - fn test_get_series_after_run_to_end() { - let tp = pop_model(); - let compiled = build_compiled(&tp); - - let mut vm = Vm::new(compiled).unwrap(); - vm.run_to_end().unwrap(); - - let series = vm.get_series(&Ident::new("population")).unwrap(); - // With start=0, stop=100, save_step=1: 101 steps (0,1,...,100) - assert_eq!(series.len(), 101, "should have 101 data points"); - assert_eq!(series[0], 100.0, "initial population should be 100"); - // Population should grow (birth_rate > death_rate for pop=100) - assert!( - series[100] > series[0], - "population should grow: final={} > initial={}", - series[100], - series[0] - ); - } - - #[test] - fn test_get_series_after_partial_run() { - let tp = pop_model(); - let compiled = build_compiled(&tp); - - let mut vm = Vm::new(compiled).unwrap(); - vm.run_to(50.0).unwrap(); - - let series = vm.get_series(&Ident::new("population")).unwrap(); - // With start=0, stop=100 but run_to(50): should have 51 steps (0..=50) - assert_eq!( - series.len(), - 51, - "should have 51 data points for run_to(50)" - ); - assert_eq!(series[0], 100.0, "initial population should be 100"); - - // After reset, the VM should still work - vm.reset(); - vm.run_to_end().unwrap(); - let full_series = vm.get_series(&Ident::new("population")).unwrap(); - assert_eq!( - full_series.len(), - 101, - "full run after reset should have 101 points" - ); - } - - #[test] - fn test_get_series_after_run_initials_only() { - let tp = pop_model(); - let compiled = build_compiled(&tp); - - let mut vm = Vm::new(compiled).unwrap(); - vm.run_initials().unwrap(); - - let series = vm.get_series(&Ident::new("population")).unwrap(); - assert_eq!( - series.len(), - 1, - "after run_initials only, series should have 1 element" - ); - assert_eq!( - series[0], 100.0, - "the single element should be the initial value" - ); - } - - #[test] - fn test_get_series_unknown_variable() { - let tp = pop_model(); - let compiled = build_compiled(&tp); - - let mut vm = Vm::new(compiled).unwrap(); - vm.run_to_end().unwrap(); - - assert!( - vm.get_series(&Ident::new("nonexistent_var")).is_none(), - "unknown variable should return None" - ); - } - - #[test] - fn test_get_series_before_any_run() { - let tp = pop_model(); - let compiled = build_compiled(&tp); - - let vm = Vm::new(compiled).unwrap(); - let series = vm.get_series(&Ident::new("population")).unwrap(); - assert!(series.is_empty(), "before any run, series should be empty"); - } -} +#[path = "vm_reset_and_run_initials_tests.rs"] +mod vm_reset_and_run_initials_tests; #[cfg(test)] #[path = "vm_set_value_tests.rs"] diff --git a/src/simlin-engine/src/vm_reset_and_run_initials_tests.rs b/src/simlin-engine/src/vm_reset_and_run_initials_tests.rs new file mode 100644 index 000000000..0ce9e565c --- /dev/null +++ b/src/simlin-engine/src/vm_reset_and_run_initials_tests.rs @@ -0,0 +1,578 @@ +// 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. + +//! Tests for `Vm` reset / `run_initials` / `run_to` behavior and bytecode +//! fusion. Split out of `vm.rs` to keep that file under the project +//! line-count lint; this is the `#[cfg(test)] mod vm_reset_and_run_initials_tests` +//! body, included via `#[path]` so `use super::*` still resolves `vm`'s private +//! items. +use super::*; +use crate::canonicalize; +use crate::test_common::TestProject; + +fn pop_model() -> TestProject { + TestProject::new("pop_model") + .with_sim_time(0.0, 100.0, 1.0) + .aux("birth_rate", "0.1", None) + .flow("births", "population * birth_rate", None) + .flow("deaths", "population / 80", None) + .stock("population", "100", &["births"], &["deaths"], None) +} + +fn build_compiled(tp: &TestProject) -> CompiledSimulation { + tp.compile_incremental() + .expect("incremental compile should succeed") +} + +/// End-to-end guard for the 3-address fusion (R2), which is applied to the +/// Vm's flow/stock bytecode at construction. Uses subtraction and division +/// (non-commutative) so a swapped operand encoding in any fused form is a +/// loud failure rather than a silent miscompile. `a`, `b`, `c` are distinct +/// variables (not foldable into a literal). +/// +/// Post-peephole + post-fusion, a *top-level* leaf binop assignment `x = a +/// op b` collapses all the way to one register-style leaf-assign op (the R2 +/// extension), while the inner subexpression of a nested expression stays a +/// pushing `BinVarVar` whose result the outer op consumes from the stack. +#[test] +fn test_fused_binops_preserve_operand_order() { + let tp = TestProject::new("fusion_order") + .with_sim_time(0.0, 1.0, 1.0) + .aux("a", "20", None) + .aux("b", "5", None) + .aux("c", "2", None) + .aux("vv", "a - b", None) // AssignSubVarVarCurr (leaf assign) + .aux("dvv", "a / b", None) // AssignDivVarVarCurr (leaf assign, division) + .aux("vc", "a - 3", None) // AssignSubVarConstCurr (leaf assign) + .aux("cv", "10 - a", None) // AssignSubConstVarCurr (leaf assign) + .aux("sv", "(a - b) - c", None) // BinVarVar then AssignStackVarCurr + .aux("sc", "(a - b) - 4", None); // BinVarVar then AssignStackConstCurr + + let compiled = build_compiled(&tp); + let mut vm = Vm::new(compiled).unwrap(); + vm.run_to_end().unwrap(); + let results = vm.into_results(); + + let val = |name: &str| -> f64 { + let off = *results + .offsets + .get(&*canonicalize(name)) + .unwrap_or_else(|| panic!("missing {name}")); + results.data[off] // step 0 + }; + + assert_eq!(val("vv"), 15.0, "a - b"); + assert_eq!(val("dvv"), 4.0, "a / b"); + assert_eq!(val("vc"), 17.0, "a - 3"); + assert_eq!(val("cv"), -10.0, "10 - a"); + assert_eq!(val("sv"), 13.0, "(a - b) - c"); + assert_eq!(val("sc"), 11.0, "(a - b) - 4"); +} + +/// Regression (#632 review): `run_to(target)` with `target` past FINAL_TIME +/// exits the integration loop via the chunk-ring exhaustion break in +/// `save_advance!`, which sets `self.curr_chunk = self.next_chunk` *before* +/// breaking. The post-loop flow re-eval (added for #625) must not then call +/// `borrow_two` with two equal chunk indices -- that slices +/// `left[a*n_slots..(a+1)*n_slots]` out of a `left` of length `a*n_slots` and +/// panics. Such a target is a supported clamp case (the FFI `simlin_sim_run_to` +/// forwards `time` unclamped), so it must return `Ok` gracefully, not abort +/// across the C boundary. +#[test] +fn run_to_past_final_time_does_not_panic() { + let tp = TestProject::new("past_end") + .with_sim_time(0.0, 3.0, 1.0) + .aux("rate", "2", None) + .stock("level", "0", &["inflow"], &[], None) + .flow("inflow", "rate", None); + let compiled = build_compiled(&tp); + let mut vm = Vm::new(compiled).unwrap(); + + // 10x past FINAL_TIME: the loop fills the chunk ring and exits via the + // exhaustion break (curr_chunk == next_chunk), the aliasing case. + vm.run_to(30.0) + .expect("run_to past the end must clamp gracefully, not panic"); + + // The live curr chunk is still well-formed and readable (the integrated + // stock, finite -- no out-of-bounds slice). + let level_off = vm + .get_offset(&Ident::::from_str_unchecked("level")) + .expect("level offset must exist"); + assert!( + vm.get_value_now(level_off).is_finite(), + "level must be finite after clamping past the end" + ); +} + +/// End-to-end guard for the global-operand and two-constant fused binops +/// (the R2 extension capturing the leaf-operand loads the original fusion +/// missed). All operators are Sub or Div (non-commutative) so a swapped +/// operand encoding in any new fused form is a loud failure, not a silent +/// miscompile. Each global appears as a leaf operand of a *pushing* +/// subexpression (the outer `- c` is the assigning op) so the fusion emits +/// the pushing `BinGlobal*` / `BinConstConst` forms rather than a leaf +/// assign. The opcode each equation produces is pinned by a sibling +/// `bytecode_profile().fused_histogram` assertion so the test cannot pass +/// vacuously through the un-fused path. +/// +/// At step 0: TIME=10, DT=2 (the sim start time and dt -- both globals, +/// loaded via LoadGlobalVar), b=5, c=2, e=1. +#[test] +fn test_fused_global_and_const_binops_preserve_operand_order() { + let tp = TestProject::new("global_fusion_order") + .with_sim_time(10.0, 20.0, 2.0) + .aux("b", "5", None) + .aux("c", "2", None) + .aux("e", "1", None) + .aux("gv", "(TIME - b) - c", None) // BinGlobalVar(Sub) then stack-leaf + .aux("vg", "(b / DT) - c", None) // BinVarGlobal(Div) + .aux("gc", "(TIME - 3) - c", None) // BinGlobalConst(Sub) + .aux("cg", "(3 / DT) - c", None) // BinConstGlobal(Div) + .aux("gg", "(TIME - DT) - c", None) // BinGlobalGlobal(Sub) + .aux("cc", "(7 - 3) - c", None) // BinConstConst(Sub) + .aux("sg", "((b - c) / DT) - e", None); // BinVarVar then BinStackGlobal(Div) + + let compiled = build_compiled(&tp); + + // Pin the fused shape: every new form must actually be emitted, so the + // numeric asserts below exercise the fused dispatch arms (not a fallback). + let fused = &compiled.bytecode_profile().fused_histogram; + for name in [ + "BinGlobalVar", + "BinVarGlobal", + "BinGlobalConst", + "BinConstGlobal", + "BinGlobalGlobal", + "BinConstConst", + "BinStackGlobal", + ] { + assert!( + fused.get(name).copied().unwrap_or(0) >= 1, + "expected at least one {name} in the fused stream; got {fused:?}" + ); + } + + let mut vm = Vm::new(compiled).unwrap(); + vm.run_to_end().unwrap(); + let results = vm.into_results(); + let val = |name: &str| -> f64 { + let off = *results + .offsets + .get(&*canonicalize(name)) + .unwrap_or_else(|| panic!("missing {name}")); + results.data[off] // step 0 + }; + + assert_eq!(val("gv"), 3.0, "(TIME - b) - c = (10 - 5) - 2"); + assert_eq!(val("vg"), 0.5, "(b / DT) - c = (5 / 2) - 2"); + assert_eq!(val("gc"), 5.0, "(TIME - 3) - c = (10 - 3) - 2"); + assert_eq!(val("cg"), -0.5, "(3 / DT) - c = (3 / 2) - 2"); + assert_eq!(val("gg"), 6.0, "(TIME - DT) - c = (10 - 2) - 2"); + assert_eq!(val("cc"), 2.0, "(7 - 3) - c = (7 - 3) - 2"); + assert_eq!(val("sg"), 0.5, "((b - c) / DT) - e = ((5 - 2) / 2) - 1"); +} + +/// The single most dangerous risk for the global-operand fused binops: a +/// `_global` operand MUST read `curr[g]` (an absolute global slot), NOT +/// `curr[module_off + g]`. They are the same slot only at the root +/// (`module_off == 0`); inside a submodule they differ, so a `module_off + g` +/// miscompile is invisible at the root but corrupts every submodule. +/// +/// This builds a real two-model project: `main` instantiates `sub`, whose +/// body computes `gv = (TIME - b) - c` (a `BinGlobalVar`) and +/// `vg = (b / DT) - c` (a `BinVarGlobal`) where `module_off > 0`. With +/// TIME=10, DT=2 (the sim start/dt globals at curr[0]/curr[1]) and the +/// submodule's own b=5, c=2, the correct values are gv=3, vg=0.5. The +/// submodule's slots 0/1 hold its own variables (curr[module_off+0/1]), whose +/// values differ from the globals, so a `module_off`-relative read would +/// produce different numbers. +#[test] +fn test_global_operand_binop_reads_global_not_module_relative() { + use crate::datamodel; + use crate::db::{SimlinDb, compile_project_incremental, sync_from_datamodel_incremental}; + use crate::testutils::{x_aux, x_model, x_module, x_project}; + + let sim_specs = datamodel::SimSpecs { + start: 10.0, + stop: 12.0, + dt: datamodel::Dt::Dt(2.0), + save_step: None, + sim_method: datamodel::SimMethod::Euler, + time_units: None, + }; + + // `sub` is instantiated by `main`, so its variables live at module_off>0. + // `b`/`c` are plain auxes (module_off-relative LoadVar operands); TIME/DT + // are globals (LoadGlobalVar operands at curr[0]/curr[1]). + let main = x_model("main", vec![x_module("sub", &[], None)]); + let sub = x_model( + "sub", + vec![ + x_aux("b", "5", None), + x_aux("c", "2", None), + x_aux("gv", "(TIME - b) - c", None), + x_aux("vg", "(b / DT) - c", None), + ], + ); + let datamodel = x_project(sim_specs, &[main, sub]); + + let mut db = SimlinDb::default(); + let sync = sync_from_datamodel_incremental(&mut db, &datamodel, None); + let compiled = compile_project_incremental(&db, sync.project, "main") + .expect("two-model project should compile"); + + // Confirm the fused global ops were actually emitted (inside the + // submodule), so the dispatch path under test is exercised. + let fused = &compiled.bytecode_profile().fused_histogram; + assert!( + fused.get("BinGlobalVar").copied().unwrap_or(0) >= 1, + "expected a BinGlobalVar in the submodule's fused stream; got {fused:?}" + ); + assert!( + fused.get("BinVarGlobal").copied().unwrap_or(0) >= 1, + "expected a BinVarGlobal in the submodule's fused stream; got {fused:?}" + ); + + let mut vm = Vm::new(compiled).unwrap(); + vm.run_to_end().unwrap(); + let results = vm.into_results(); + let series = crate::test_common::collect_results(&results); + + // Submodule variables are reported under their qualified (middot-joined) + // `subĀ·` names. A module_off-relative read of TIME/DT would yield + // other numbers. + let gv = series + .get("sub\u{b7}gv") + .unwrap_or_else(|| panic!("missing sub\u{b7}gv; have {:?}", series.keys())); + let vg = series.get("sub\u{b7}vg").expect("missing sub\u{b7}vg"); + assert_eq!( + gv[0], 3.0, + "(TIME - b) - c = (10 - 5) - 2 -- TIME must be the global, not curr[module_off]" + ); + assert_eq!( + vg[0], 0.5, + "(b / DT) - c = (5 / 2) - 2 -- DT must be the global, not curr[module_off+1]" + ); +} + +#[test] +fn test_vm_reset_produces_identical_results() { + let tp = pop_model(); + let compiled = build_compiled(&tp); + + // First run + let mut vm1 = Vm::new(compiled.clone()).unwrap(); + vm1.run_to_end().unwrap(); + let results1 = vm1.into_results(); + + // Second fresh VM from same compiled + let mut vm2 = Vm::new(compiled.clone()).unwrap(); + vm2.run_to_end().unwrap(); + let results2 = vm2.into_results(); + + // Third: run, reset, run again + let mut vm3 = Vm::new(compiled).unwrap(); + vm3.run_to_end().unwrap(); + vm3.reset(); + vm3.run_to_end().unwrap(); + let results3 = vm3.into_results(); + + let pop_off = *results1.offsets.get(&*canonicalize("population")).unwrap(); + for step in 0..results1.step_count { + let idx = step * results1.step_size + pop_off; + let v1 = results1.data[idx]; + let v2 = results2.data[idx]; + let v3 = results3.data[idx]; + assert!( + (v1 - v2).abs() < 1e-10, + "fresh VMs should match at step {step}: {v1} vs {v2}" + ); + assert!( + (v1 - v3).abs() < 1e-10, + "reset VM should match fresh at step {step}: {v1} vs {v3}" + ); + } +} + +#[test] +fn test_vm_reset_after_partial_run() { + let tp = pop_model(); + let compiled = build_compiled(&tp); + + // Full run for reference + let mut vm_ref = Vm::new(compiled.clone()).unwrap(); + vm_ref.run_to_end().unwrap(); + let ref_results = vm_ref.into_results(); + + // Partial run, then reset and full run + let mut vm = Vm::new(compiled).unwrap(); + vm.run_to(50.0).unwrap(); + vm.reset(); + vm.run_to_end().unwrap(); + let results = vm.into_results(); + + let pop_off = *ref_results + .offsets + .get(&*canonicalize("population")) + .unwrap(); + for step in 0..ref_results.step_count { + let idx = step * ref_results.step_size + pop_off; + let v_ref = ref_results.data[idx]; + let v = results.data[idx]; + assert!( + (v_ref - v).abs() < 1e-10, + "reset-after-partial should match fresh at step {step}: {v_ref} vs {v}" + ); + } +} + +#[test] +fn test_vm_reset_clears_previous_snapshot() { + let tp = TestProject::new("reset_prev_snapshot") + .with_sim_time(0.0, 3.0, 1.0) + .aux("x", "1", None) + .aux("prev_x", "PREVIOUS(x)", None); + let compiled = build_compiled(&tp); + + let mut vm = Vm::new(compiled).unwrap(); + vm.run_to_end().unwrap(); + vm.reset(); + vm.run_to_end().unwrap(); + let results = vm.into_results(); + + let prev_off = *results.offsets.get(&*canonicalize("prev_x")).unwrap(); + let t0_prev = results.data[prev_off]; + assert!( + (t0_prev - 0.0).abs() < 1e-10, + "PREVIOUS(x) at t=0 after reset should be 0, got {t0_prev}" + ); +} + +#[test] +fn test_previous_in_initials_vm() { + let tp = TestProject::new("previous_in_initials") + .with_sim_time(0.0, 2.0, 1.0) + .aux("x", "5", None) + .stock("s", "PREVIOUS(x)", &[], &[], None); + + let vm = tp.run_vm().expect("vm should run"); + let vm_s = vm.get("s").expect("vm missing s"); + + // PREVIOUS(x) returns 0 at the initial timestep (the default value) + assert!( + (vm_s[0] - 0.0).abs() < 1e-10, + "stock initial PREVIOUS(x) should be 0.0 at t=0, got {}", + vm_s[0] + ); +} + +#[test] +fn test_init_on_module_backed_var_freezes_initial_value() { + let tp = TestProject::new("init_module_backed") + .with_sim_time(0.0, 4.0, 1.0) + .aux("x", "TIME", None) + .aux("delayed", "PREVIOUS(x, 99)", None) + .aux("frozen", "INIT(delayed)", None); + + let vm = tp.run_vm().expect("VM should run"); + let frozen_vals = vm.get("frozen").expect("frozen not in results"); + for (step, val) in frozen_vals.iter().enumerate() { + assert!( + (val - 99.0).abs() < 1e-10, + "frozen should be 99.0 at every step, got {val} at step {step}" + ); + } +} + +#[test] +fn test_compiled_simulation_clone_produces_equivalent_vm() { + let tp = pop_model(); + let compiled = build_compiled(&tp); + let compiled_clone = compiled.clone(); + + let mut vm1 = Vm::new(compiled).unwrap(); + vm1.run_to_end().unwrap(); + let results1 = vm1.into_results(); + + let mut vm2 = Vm::new(compiled_clone).unwrap(); + vm2.run_to_end().unwrap(); + let results2 = vm2.into_results(); + + let pop_off = *results1.offsets.get(&*canonicalize("population")).unwrap(); + for step in 0..results1.step_count { + let idx = step * results1.step_size + pop_off; + assert!( + (results1.data[idx] - results2.data[idx]).abs() < 1e-10, + "cloned compiled should produce identical results at step {step}" + ); + } +} + +#[test] +fn test_run_initials_then_run_to_end_matches_single_call() { + let tp = pop_model(); + let compiled = build_compiled(&tp); + + // VM A: single run_to_end + let mut vm_a = Vm::new(compiled.clone()).unwrap(); + vm_a.run_to_end().unwrap(); + let results_a = vm_a.into_results(); + + // VM B: run_initials then run_to_end + let mut vm_b = Vm::new(compiled).unwrap(); + vm_b.run_initials().unwrap(); + vm_b.run_to_end().unwrap(); + let results_b = vm_b.into_results(); + + let pop_off = *results_a.offsets.get(&*canonicalize("population")).unwrap(); + for step in 0..results_a.step_count { + let idx = step * results_a.step_size + pop_off; + assert!( + (results_a.data[idx] - results_b.data[idx]).abs() < 1e-10, + "run_initials+run_to_end should match single run_to_end at step {step}" + ); + } +} + +#[test] +fn test_run_initials_is_idempotent() { + let tp = pop_model(); + let compiled = build_compiled(&tp); + + let mut vm = Vm::new(compiled).unwrap(); + vm.run_initials().unwrap(); + vm.run_initials().unwrap(); // second call should be no-op + vm.run_to_end().unwrap(); + let results = vm.into_results(); + + let pop_off = *results.offsets.get(&*canonicalize("population")).unwrap(); + let initial_pop = results.data[pop_off]; + assert_eq!(initial_pop, 100.0, "population initial should be 100"); +} + +#[test] +fn test_run_initials_sets_correct_values() { + // Use a model where the aux is a stock dependency so it's in initials + let tp = TestProject::new("initials_check") + .with_sim_time(0.0, 10.0, 1.0) + .aux("rate", "0.1", None) + .flow("inflow", "0", None) + .stock("s", "rate * 1000", &["inflow"], &[], None); + + let compiled = build_compiled(&tp); + let mut vm = Vm::new(compiled).unwrap(); + vm.run_initials().unwrap(); + + let s_off = vm.get_offset(&Ident::new("s")).unwrap(); + let rate_off = vm.get_offset(&Ident::new("rate")).unwrap(); + + assert_eq!( + vm.get_value_now(s_off), + 100.0, + "stock initial = rate*1000 = 100" + ); + assert_eq!( + vm.get_value_now(rate_off), + 0.1, + "rate is a stock dependency, so it's in initials" + ); + assert_eq!( + vm.get_value_now(TIME_OFF), + 0.0, + "time should be 0 after initials" + ); +} + +#[test] +fn test_get_series_after_run_to_end() { + let tp = pop_model(); + let compiled = build_compiled(&tp); + + let mut vm = Vm::new(compiled).unwrap(); + vm.run_to_end().unwrap(); + + let series = vm.get_series(&Ident::new("population")).unwrap(); + // With start=0, stop=100, save_step=1: 101 steps (0,1,...,100) + assert_eq!(series.len(), 101, "should have 101 data points"); + assert_eq!(series[0], 100.0, "initial population should be 100"); + // Population should grow (birth_rate > death_rate for pop=100) + assert!( + series[100] > series[0], + "population should grow: final={} > initial={}", + series[100], + series[0] + ); +} + +#[test] +fn test_get_series_after_partial_run() { + let tp = pop_model(); + let compiled = build_compiled(&tp); + + let mut vm = Vm::new(compiled).unwrap(); + vm.run_to(50.0).unwrap(); + + let series = vm.get_series(&Ident::new("population")).unwrap(); + // With start=0, stop=100 but run_to(50): should have 51 steps (0..=50) + assert_eq!( + series.len(), + 51, + "should have 51 data points for run_to(50)" + ); + assert_eq!(series[0], 100.0, "initial population should be 100"); + + // After reset, the VM should still work + vm.reset(); + vm.run_to_end().unwrap(); + let full_series = vm.get_series(&Ident::new("population")).unwrap(); + assert_eq!( + full_series.len(), + 101, + "full run after reset should have 101 points" + ); +} + +#[test] +fn test_get_series_after_run_initials_only() { + let tp = pop_model(); + let compiled = build_compiled(&tp); + + let mut vm = Vm::new(compiled).unwrap(); + vm.run_initials().unwrap(); + + let series = vm.get_series(&Ident::new("population")).unwrap(); + assert_eq!( + series.len(), + 1, + "after run_initials only, series should have 1 element" + ); + assert_eq!( + series[0], 100.0, + "the single element should be the initial value" + ); +} + +#[test] +fn test_get_series_unknown_variable() { + let tp = pop_model(); + let compiled = build_compiled(&tp); + + let mut vm = Vm::new(compiled).unwrap(); + vm.run_to_end().unwrap(); + + assert!( + vm.get_series(&Ident::new("nonexistent_var")).is_none(), + "unknown variable should return None" + ); +} + +#[test] +fn test_get_series_before_any_run() { + let tp = pop_model(); + let compiled = build_compiled(&tp); + + let vm = Vm::new(compiled).unwrap(); + let series = vm.get_series(&Ident::new("population")).unwrap(); + assert!(series.is_empty(), "before any run, series should be empty"); +} diff --git a/src/simlin-engine/src/wasmgen/module.rs b/src/simlin-engine/src/wasmgen/module.rs index 40edd10a1..fabd10286 100644 --- a/src/simlin-engine/src/wasmgen/module.rs +++ b/src/simlin-engine/src/wasmgen/module.rs @@ -1238,6 +1238,43 @@ fn emit_run_to( f.instruction(&I::Br(0)); // continue f.instruction(&I::End); // end loop f.instruction(&I::End); // end block + + // After a mid-interval stop, refresh `curr`'s flow/aux/constant slots at the + // resting state -- but ONLY when `curr` was advanced (`saved < n_chunks`). + // + // The `curr[TIME] > target` break fires *after* the save+advance tail, which + // copies only the stock offsets `next -> curr` and steps the time, leaving the + // non-stock slots holding the previous step's values (a one-step lag versus the + // advanced time + stocks). A mid-run `getValue` of a flow/aux would otherwise + // read that lagged value, so one root `flows(0)` re-eval makes the live curr + // chunk self-consistent at the resting time and identical to the VM's resting + // curr (#625). At that advanced break `prev_values` holds the *last completed* + // step (one before the resting time), so the re-eval's `PREVIOUS(x)` correctly + // reads `x(t-dt)`. + // + // The guard skips the re-eval when the slab is full (`saved >= n_chunks`), + // which is exactly the break paths that do NOT advance `curr`: the post-save + // exhaustion break and the top-of-loop full-slab guard. There `curr` is already + // the just-saved, fully-evaluated `t=stop` row, so the re-eval is unnecessary + // for flows/auxes -- and actively WRONG for a `PREVIOUS` aux: `prev_values` was + // snapshotted to that same `t=stop` row (the per-step snapshot runs after + // flows), so a re-eval would resolve `PREVIOUS(x)` to `x(stop)` instead of + // `x(stop-dt)`, corrupting the live curr a host reads via `getValue` and + // diverging from the committed series + the VM. Skipping also keeps a resumed + // `run_to` on a full slab a strict no-op. This mirrors the VM's + // `curr_chunk != next_chunk` guard ("re-eval only when curr was advanced"). + // The re-eval touches only `curr` (the saved rows were already committed) and + // does NOT snapshot `prev_values`, so a resume's `PREVIOUS` still sees the last + // completed step. Unlike the VM there is no chunk aliasing: `curr` is always + // the fixed `CURR_BASE` region. + f.instruction(&I::GlobalGet(G_SAVED)); + f.instruction(&I::I32Const(regions.n_chunks as i32)); + f.instruction(&I::I32LtS); + f.instruction(&I::If(BlockType::Empty)); + f.instruction(&I::I32Const(0)); + f.instruction(&I::Call(f_flows)); + f.instruction(&I::End); // end if + f.instruction(&I::End); // end function f } @@ -5255,6 +5292,154 @@ mod tests { } } + /// Reconcile #625: after a `run_to(t)` that stops mid-interval, the live curr + /// chunk must be fully self-consistent at the resting time -- every flow / aux / + /// constant evaluated for the same time and stocks `curr` holds -- and + /// identical across the VM and wasm. Previously the integration loop broke + /// right after an advance, so non-stock slots lagged a step (wasm) or held + /// stale garbage including 0 for constants (VM); both backends now re-evaluate + /// root flows once at the resting `curr` after the overshoot break. + #[test] + fn mid_run_curr_is_self_consistent_and_matches_vm() { + // `doubled = level * 2` is a flow-phase aux that varies every step (it + // tracks the growing stock), so a one-step flow/aux lag is observable; a + // constant flow like `inflow` would hide the lag. `prev_level` also covers + // a PREVIOUS aux mid-run: at the advanced resting point its re-eval reads + // the last completed step's snapshot, which IS the previous timestep -- so + // both backends must agree on it too (the `level` of the step before). + let datamodel = crate::test_common::TestProject::new("midrun") + .with_sim_time(0.0, 10.0, 1.0) + .aux("inflow_rate", "2", None) + .stock("level", "0", &["inflow"], &[], None) + .flow("inflow", "inflow_rate", None) + .aux("doubled", "level * 2", None) + .aux("prev_level", "PREVIOUS(level, 0)", None) + .build_datamodel(); + let sim = compile_sim(&datamodel, "main"); + let artifact = compile_simulation(&sim).expect("wasm codegen"); + let level_off = layout_offset(&artifact, "level"); + let doubled_off = layout_offset(&artifact, "doubled"); + + // A mid-interval target: run_to(4.5) rests at t=5 (level=10) on both. + let target = 4.5_f64; + + 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(); + store + .invoke_simple_typed::<(), ()>(run_initials, ()) + .expect("run_initials"); + store + .invoke_simple_typed::<(f64,), ()>(run_to, (target,)) + .expect("run_to"); + + let mut vm = Vm::new(compile_sim(&datamodel, "main")).expect("vm"); + vm.run_to(target).expect("vm run_to"); + + // Every variable's live curr value agrees between the two backends -- not + // just the stocks + reserved time vars, but the flows/auxes/constants too. + for (name, off) in &artifact.layout.var_offsets { + let ident = Ident::::from_str_unchecked(name); + let Some(vm_off) = vm.get_offset(&ident) else { + continue; + }; + let wasm_val = read_curr_slot(&mut store, inst, *off); + let vm_val = vm.get_value_now(vm_off); + assert!( + (wasm_val - vm_val).abs() < 1e-9, + "{name} mid-run curr mismatch: wasm={wasm_val} vm={vm_val}" + ); + } + + // And the curr chunk is internally self-consistent at the resting time: + // doubled == level * 2 (level=10 at t=5, so doubled=20), not the lagged 16. + let wasm_level = read_curr_slot(&mut store, inst, level_off); + let wasm_doubled = read_curr_slot(&mut store, inst, doubled_off); + assert!( + (wasm_doubled - wasm_level * 2.0).abs() < 1e-9 && (wasm_doubled - 20.0).abs() < 1e-9, + "wasm curr not self-consistent: doubled={wasm_doubled} level={wasm_level} (expected 20)" + ); + } + + /// Regression (#632 review, P2): the post-loop `flows(0)` re-eval must be + /// SKIPPED after a full / at-stop run, or it corrupts PREVIOUS-using auxes in + /// the live curr chunk. A full run breaks via the slab-exhaustion path, which + /// does NOT advance curr: curr is the just-saved `t=stop` row, and + /// `prev_values` was already snapshotted to that same row (the per-step + /// snapshot runs after the step's flows). A re-eval would then resolve + /// `PREVIOUS(x)` against curr's own snapshot -> `x(stop)` instead of + /// `x(stop-dt)`. Since the wasm host's `getValue` reads the live curr, it would + /// diverge from the committed series and from the VM (which reads the last + /// results row). The `saved < n_chunks` guard skips the re-eval exactly when + /// curr was not advanced (the slab is full), mirroring the VM's + /// `curr_chunk != next_chunk`. Only flows/auxes built on PREVIOUS expose this, + /// so the constant-only teacup parity tests miss it. + #[test] + fn full_run_previous_aux_curr_matches_series_and_vm() { + // level grows 2/step from 0; prev_level(t) = level(t-dt). At t=stop=5 the + // correct prev_level is level(4) = 8 -- NOT level(5) = 10. + let datamodel = crate::test_common::TestProject::new("prev_full") + .with_sim_time(0.0, 5.0, 1.0) + .aux("rate", "2", None) + .stock("level", "0", &["inflow"], &[], None) + .flow("inflow", "rate", None) + .aux("prev_level", "PREVIOUS(level, 0)", None) + .build_datamodel(); + let sim = compile_sim(&datamodel, "main"); + let artifact = compile_simulation(&sim).expect("wasm codegen"); + let prev_off = layout_offset(&artifact, "prev_level"); + let n_slots = artifact.layout.n_slots; + let n_chunks = artifact.layout.n_chunks; + + // Full run via the single-shot `run` export (reset; 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 = store + .instance_export(inst, "run") + .unwrap() + .as_func() + .unwrap(); + store.invoke_simple_typed::<(), ()>(run, ()).expect("run"); + + // The live curr value (what the host's getValue reads) must equal the last + // committed series row -- not the re-eval'd self-snapshot. + let curr_prev = read_curr_slot(&mut store, inst, prev_off); + let slab = read_slab(&mut store, inst, &artifact.layout); + let last_series_prev = slab[(n_chunks - 1) * n_slots + prev_off]; + assert!( + (curr_prev - last_series_prev).abs() < 1e-9, + "live curr prev_level ({curr_prev}) must equal the last saved row ({last_series_prev})" + ); + + // And both equal the VM's last results row (= level(4) = 8). + let mut vm = Vm::new(compile_sim(&datamodel, "main")).expect("vm"); + vm.run_to_end().expect("vm run"); + let vm_results = vm.into_results(); + let vm_off = vm_results.offsets[&Ident::::from_str_unchecked("prev_level")]; + let vm_last = vm_results.data[(vm_results.step_count - 1) * vm_results.step_size + vm_off]; + assert!( + (curr_prev - vm_last).abs() < 1e-9 && (curr_prev - 8.0).abs() < 1e-9, + "wasm curr prev_level={curr_prev} vm last={vm_last} (expected 8 = level at t=4)" + ); + } + /// 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