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}