Skip to main content

vj_sim/state/
gvt.rs

1//! Task 12 — GVT gating: speculative-immediate for Scheduler, committed-only
2//! for Waveform/VPI/`$display` (ledger ST-075, ST-078, ST-081).
3//!
4//! Two asymmetric visibility rules, both already wired at the primitives
5//! this task assembles:
6//!
7//! - **Scheduler** (ST-075): notified eagerly, unconditionally, the instant
8//!   `write::commit_write` sees a real change — never gated on
9//!   [`StateEngine::advance_gvt`] at all. See `Dispatch::notify_scheduler`'s
10//!   own doc comment (`state::dispatch`) for why this is correct even once a
11//!   future wave enables real speculation (ARCHITECTURE §4): in v1's
12//!   conservative `LOOKAHEAD_CYCLES = 0` engine there is no outstanding
13//!   speculation to hide, and the scheduler's own re-execution-then-possible-
14//!   rollback *is* what speculation means once it exists.
15//! - **Waveform/VPI/`$display`** (ST-078): observe committed changes
16//!   exclusively through [`StateEngine::drain_committed_changes`] below,
17//!   which filters every registered worker thread's speculative buffer
18//!   (`write::VcState`, Task 8/9) down to records with `at <= up_to` — a
19//!   value written but not yet passed by the caller-supplied GVT watermark
20//!   is invisible through this path even though a direct
21//!   [`super::SignalAccess::read`] would already show it (that distinction
22//!   is this row's entire point, and is what
23//!   `display_style_observation_never_sees_a_pre_gvt_value_even_if_committed_later`
24//!   below proves).
25//!
26//! [`StateEngine::advance_gvt`] (ST-081) is a pure setter with no dependency
27//! on any scheduler-internal type — STATE only buffers/gates on GVT, it
28//! never computes GVT itself; that computation is ENGINE's job, pushed in
29//! from outside this crate.
30
31use vj_abi::SimTime;
32
33use super::instance::StateEngine;
34use super::write::VcRecord;
35
36impl StateEngine {
37    /// ST-081: a pure setter (`self.vc.set_gvt_watermark(gvt)`) — no
38    /// dependency on any scheduler-internal type, callable with a bare
39    /// `SimTime` the caller (ENGINE, later) computed however it likes. See
40    /// `write::VcState`'s `gvt_watermark` field doc comment for why this
41    /// field exists even though `drain_committed_changes` below takes its
42    /// own explicit `up_to` parameter rather than reading this field back.
43    ///
44    /// **Task 20 (ST-124) — open joint sizing risk with ENGINE, not bounded
45    /// by this crate alone.** Every un-drained speculative VC record between
46    /// two `advance_gvt` calls stays buffered (`write::VcState`'s per-thread
47    /// buffer, Task 8/9): this is only bounded in practice if the caller
48    /// (ENGINE) advances GVT "cheaply and frequently enough" relative to the
49    /// write rate. STATE has no way to observe or enforce ENGINE's actual
50    /// GVT-advancement cadence from inside this crate — a coarse-grained
51    /// cadence (e.g. only at major time-step boundaries) could make the
52    /// buffering cost far larger than assumed here. Tracked as an open risk
53    /// (`docs/design/state.md` Risks/Open Questions), not silently assumed
54    /// safe; restated in `docs/CONTRACTS.md`'s "Wave A — wA-state notes"
55    /// section so ENGINE's own wave can pick it up without re-deriving it
56    /// from this doc comment.
57    pub fn advance_gvt(&mut self, gvt: SimTime) {
58        self.vc.set_gvt_watermark(gvt);
59    }
60
61    /// ST-078, ST-081: drains every registered worker thread's speculative
62    /// buffer (review fix — this plan's header "cross-thread visibility"
63    /// decision) of records with `at <= up_to`, merged by `(at, registration
64    /// index)` — see `write::VcState::drain_committed_changes`'s own doc
65    /// comment for the full removal/merge algorithm this forwards to.
66    /// Waveform/VPI/`$display`-style consumers are meant to be the only
67    /// callers of this method in a real simulation run.
68    ///
69    /// See [`StateEngine::advance_gvt`]'s doc comment (ST-124) for the
70    /// buffer-growth sizing risk this method's drain rate must keep bounded.
71    pub fn drain_committed_changes(&mut self, up_to: SimTime) -> Vec<VcRecord> {
72        self.vc.drain_committed_changes(up_to)
73    }
74
75    /// Whether value changes are logged for [`Self::drain_committed_changes`]
76    /// (on by default). A run whose observers read STATE directly and never
77    /// drain must turn the log off, or it grows with every change of the run
78    /// (ST-124: 70-130 GB on a few minutes of a real design).
79    pub fn set_change_log(&mut self, on: bool) {
80        self.vc.set_record_changes(on);
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87    use crate::state::instance::TemplateRegistry;
88    use crate::state::write::SlotLocation;
89    use crate::state::{
90        EdgeKind, FourStateValue, ListenerRole, SignalAccess, SlotClass, StateCellId,
91        ValueChangeSink,
92    };
93    use std::sync::{Arc, Mutex};
94    use vj_abi::Region;
95
96    fn fresh_engine() -> StateEngine {
97        StateEngine::new(TemplateRegistry::new())
98    }
99
100    /// Allocates one inline cell (mirrors `write::tests::inline_cell`,
101    /// duplicated locally per this crate's existing per-task-module test
102    /// helper convention).
103    fn inline_cell(engine: &mut StateEngine) -> StateCellId {
104        // SAFETY: local, single-owned engine; no other live borrow of the
105        // inline plane, and every test below performs no concurrent
106        // allocation on the same engine (the one cross-thread test
107        // allocates both cells before spawning any thread).
108        let word_offset = unsafe { engine.arena.alloc_inline_word() };
109        let loc = SlotLocation {
110            class: SlotClass::Inline,
111            word_offset,
112            bit_width: 8,
113            elem_word_count: 1,
114            row_stride_words: 0,
115            num_rows: 0,
116        };
117        engine.alloc_cell(loc)
118    }
119
120    fn narrow_word(val: u64, unk: u64) -> u128 {
121        (val as u128) | ((unk as u128) << 64)
122    }
123
124    fn t(units: i64) -> SimTime {
125        SimTime {
126            sim_units: units,
127            delta: 0,
128        }
129    }
130
131    /// Records every `notify_change` call it receives into a shared log.
132    struct SpySink {
133        log: Arc<Mutex<Vec<(StateCellId, SimTime)>>>,
134    }
135
136    impl ValueChangeSink for SpySink {
137        fn notify_change(
138            &mut self,
139            cell: StateCellId,
140            _edge: EdgeKind,
141            at: SimTime,
142            _region: Region,
143        ) {
144            self.log.lock().unwrap().push((cell, at));
145        }
146    }
147
148    /// ST-075: register a scheduler spy; `commit_write` a real change
149    /// *without* ever calling `advance_gvt`; the spy has already recorded
150    /// the call. Fails if scheduler notification were routed through the
151    /// same GVT-gated path as Waveform/VPI (it would never fire here, since
152    /// `advance_gvt`/`drain_committed_changes` are never called in this
153    /// test at all).
154    #[test]
155    fn scheduler_listener_fires_immediately_without_waiting_for_advance_gvt() {
156        let mut engine = fresh_engine();
157        let cell = inline_cell(&mut engine);
158        let log = Arc::new(Mutex::new(Vec::new()));
159        engine.subscribe(
160            cell,
161            ListenerRole::Scheduler,
162            Box::new(SpySink { log: log.clone() }),
163        );
164
165        let result = engine.commit_write(cell, FourStateValue::Narrow(narrow_word(5, 0)), &mut ());
166        assert!(result.changed);
167
168        let calls = log.lock().unwrap();
169        assert_eq!(
170            calls.len(),
171            1,
172            "the Scheduler listener must fire immediately on commit_write, with no advance_gvt call anywhere in this test"
173        );
174        assert_eq!(calls[0].0, cell);
175    }
176
177    /// ST-124: with the log off a write still lands but leaves no record;
178    /// turned back on, records resume.
179    #[test]
180    fn a_disabled_change_log_records_nothing() {
181        let mut engine = fresh_engine();
182        let cell = inline_cell(&mut engine);
183        engine.set_change_log(false);
184        engine.set_current_time(t(1));
185        let r = engine.commit_write(cell, FourStateValue::Narrow(narrow_word(5, 0)), &mut ());
186        assert!(r.changed, "the write itself still lands");
187        assert!(engine.drain_committed_changes(t(100)).is_empty());
188        engine.set_change_log(true);
189        let r = engine.commit_write(cell, FourStateValue::Narrow(narrow_word(6, 0)), &mut ());
190        assert!(r.changed);
191        assert_eq!(engine.drain_committed_changes(t(100)).len(), 1);
192    }
193
194    /// ST-078, ST-081: two writes at different `SimTime`s (stamped via
195    /// `set_current_time` before each `commit_write`, per this plan's own
196    /// "how STATE learns now" design); `drain_committed_changes` with an
197    /// `up_to` between the two `at`s returns only the earlier record, a
198    /// repeat call with the same `up_to` returns nothing further (already
199    /// drained), and the later record only appears once a later
200    /// `drain_committed_changes` call raises `up_to` past it. Fails if
201    /// gating compares the wrong direction (`>=` vs `<=`) or if drain
202    /// doesn't actually remove what it returns.
203    #[test]
204    fn drain_committed_changes_excludes_records_after_the_watermark() {
205        let mut engine = fresh_engine();
206        let cell_a = inline_cell(&mut engine);
207        let cell_b = inline_cell(&mut engine);
208
209        engine.set_current_time(t(5));
210        let ra = engine.commit_write(cell_a, FourStateValue::Narrow(narrow_word(1, 0)), &mut ());
211        assert!(ra.changed);
212
213        engine.set_current_time(t(10));
214        let rb = engine.commit_write(cell_b, FourStateValue::Narrow(narrow_word(1, 0)), &mut ());
215        assert!(rb.changed);
216
217        engine.advance_gvt(t(7));
218        let drained = engine.drain_committed_changes(t(7));
219        assert_eq!(
220            drained.len(),
221            1,
222            "only cell_a's at=5 record is <= the up_to=7 watermark"
223        );
224        assert_eq!(drained[0].cell, cell_a);
225        assert_eq!(drained[0].at, t(5));
226
227        let drained_again = engine.drain_committed_changes(t(7));
228        assert!(
229            drained_again.is_empty(),
230            "a second drain with the same up_to must not re-return an already-drained record"
231        );
232
233        engine.advance_gvt(t(10));
234        let drained_b = engine.drain_committed_changes(t(10));
235        assert_eq!(
236            drained_b.len(),
237            1,
238            "cell_b's at=10 record becomes visible once up_to reaches it"
239        );
240        assert_eq!(drained_b[0].cell, cell_b);
241        assert_eq!(drained_b[0].at, t(10));
242    }
243
244    /// ST-081: a type-level proof, deliberately written with no
245    /// `crate::sched`/`crate::determinism` import anywhere in this function
246    /// (nor in this module) — `advance_gvt`'s signature takes only
247    /// `SimTime`. Also asserts the setter actually stores the value (via
248    /// `VcState`'s `#[cfg(test)]` getter), so this is a real, failable test,
249    /// not merely a compile-only gate.
250    #[test]
251    fn advance_gvt_has_no_dependency_on_scheduler_internal_state() {
252        let mut engine = fresh_engine();
253        engine.advance_gvt(t(42));
254        assert_eq!(
255            engine.vc.gvt_watermark(),
256            t(42),
257            "advance_gvt must actually store the watermark it was given"
258        );
259    }
260
261    /// ST-078: a value written via `commit_write` is immediately visible via
262    /// `SignalAccess::read` (a direct-state query, not the commit-stream
263    /// path this row is about — that's expected and fine), but is *not*
264    /// visible via `drain_committed_changes` until `up_to` reaches its `at`.
265    /// Fails if the commit-stream path leaked a pre-watermark value, making
266    /// the gating-vs-direct-read distinction explicit and regression-tested.
267    #[test]
268    fn display_style_observation_never_sees_a_pre_gvt_value_even_if_committed_later() {
269        let mut engine = fresh_engine();
270        let cell = inline_cell(&mut engine);
271
272        engine.set_current_time(t(5));
273        let result = engine.commit_write(cell, FourStateValue::Narrow(narrow_word(9, 0)), &mut ());
274        assert!(result.changed);
275
276        match engine.read(cell) {
277            FourStateValue::Narrow(w) => assert_eq!(
278                w,
279                narrow_word(9, 0),
280                "a direct SignalAccess::read already sees the new value -- that's fine, it's not the commit-stream path"
281            ),
282            FourStateValue::Wide { .. } => panic!("expected Narrow"),
283        }
284
285        let too_early = engine.drain_committed_changes(t(3));
286        assert!(
287            too_early.is_empty(),
288            "drain_committed_changes must reflect nothing past its own watermark, even though read() already shows the new value"
289        );
290    }
291
292    /// Review fix, closes the "no task can ever exercise this" gap: build a
293    /// `StateEngine` on the main thread, allocate two cells; spawn real OS
294    /// thread T1, which writes cell A and is `join`ed to completion; spawn
295    /// real OS thread T2 (T1's thread -- and its `thread_local!` storage --
296    /// no longer exists at this point), which writes cell B and is `join`ed;
297    /// back on the *main* thread (a third, distinct OS thread from both T1
298    /// and T2), `drain_committed_changes` must see **both** records. Fails
299    /// (returns only one, or neither) if `drain_committed_changes` read only
300    /// a single `thread_local!` buffer instead of iterating the
301    /// `worker_buffers` registry -- the exact bug this plan's header
302    /// "cross-thread visibility" decision exists to fix, and one no
303    /// single-threaded test above can ever catch.
304    #[test]
305    fn drain_committed_changes_sees_records_appended_from_a_different_real_os_thread() {
306        let mut engine = fresh_engine();
307        let cell_a = inline_cell(&mut engine);
308        let cell_b = inline_cell(&mut engine);
309
310        /// A bare pointer wrapper made `Send` on purpose: `*mut StateEngine`
311        /// itself is not `Send`, but this test's own structure (T1 fully
312        /// `join`ed before T2 is even spawned, and the main thread never
313        /// touches `engine` again until both have finished) guarantees at
314        /// most one thread ever dereferences the pointee at a time, so no
315        /// two threads ever hold this pointer concurrently.
316        struct SendPtr(*mut StateEngine);
317        // SAFETY: see this struct's own doc comment above -- this test's
318        // control flow (strict join-before-next-spawn, no concurrent access
319        // from the main thread) is what makes sending this pointer across
320        // threads sound.
321        unsafe impl Send for SendPtr {}
322
323        let ptr1 = SendPtr(&mut engine as *mut StateEngine);
324        let t1 = std::thread::spawn(move || {
325            let ptr1 = ptr1;
326            // SAFETY: `SendPtr`'s doc comment above -- no other thread
327            // touches `engine` while T1 runs.
328            let engine_ref = unsafe { &mut *ptr1.0 };
329            engine_ref.set_current_time(t(1));
330            engine_ref.commit_write(cell_a, FourStateValue::Narrow(narrow_word(1, 0)), &mut ());
331        });
332        t1.join().unwrap();
333
334        let ptr2 = SendPtr(&mut engine as *mut StateEngine);
335        let t2 = std::thread::spawn(move || {
336            let ptr2 = ptr2;
337            // SAFETY: T1 has already been `join`ed (its OS thread, and its
338            // `thread_local!` storage, no longer exists), and the main
339            // thread has not touched `engine` in between -- still only one
340            // thread holding the pointer.
341            let engine_ref = unsafe { &mut *ptr2.0 };
342            engine_ref.set_current_time(t(2));
343            engine_ref.commit_write(cell_b, FourStateValue::Narrow(narrow_word(1, 0)), &mut ());
344        });
345        t2.join().unwrap();
346
347        // Back on the main thread -- a third, distinct OS thread from both
348        // T1 and T2.
349        engine.advance_gvt(t(5));
350        let drained = engine.drain_committed_changes(t(5));
351        let cells: Vec<StateCellId> = drained.iter().map(|r| r.cell).collect();
352        assert!(
353            cells.contains(&cell_a),
354            "T1's committed change must be visible from the draining thread"
355        );
356        assert!(
357            cells.contains(&cell_b),
358            "T2's committed change must be visible from the draining thread"
359        );
360        assert_eq!(
361            drained.len(),
362            2,
363            "both worker threads' buffers must be drained, not just one"
364        );
365    }
366}