vj_sim/state/dispatch.rs
1//! Task 11 — VC listener dispatch: the fixed 3-role table (ledger ST-035,
2//! ST-073, ST-074).
3//!
4//! `Dispatch` is the concrete realization of ST-073's "fixed, small role
5//! table... not an open-ended dynamic subscriber list": exactly 3 named
6//! `Option<Box<dyn ValueChangeSink + Send>>` fields (`scheduler`, `waveform`,
7//! `vpi`), never a `Vec`/`HashMap` keyed by role. `register` dispatches into
8//! one of the 3 fields via an exhaustive match with no wildcard arm (ST-073's
9//! "attempting a fourth/unknown role is a compile-time... invalid call" —
10//! `ListenerRole` itself has exactly 3 variants, frozen in the Task-0
11//! contract stub in `state::mod`, so a 4th role literally cannot be named at
12//! this call site without a compile error). `dispatch` calls `notify_change`
13//! on whichever fields are populated, in the fixed `Scheduler, Waveform, Vpi`
14//! order (ST-073's fixed order) via 3 unrolled `if let Some(..)` checks, not
15//! a loop over a collection (ST-074: the hot path has no heap allocation or
16//! dynamic vtable growth regardless of how many events are dispatched, since
17//! the table's shape is fixed at compile time and never grows).
18//!
19//! `ST-035` ("the mechanism that decides 'did this value change' is
20//! implemented once in STATE and reused by every consumer") is satisfied
21//! architecturally by this being the *only* fan-out point every consumer
22//! (scheduler wakeup, waveform, VPI) registers into via `SignalAccess::
23//! subscribe` (`accessor_impl.rs`, wired below) — VC detection itself lives
24//! in `write::commit_write_at` (Task 8/9), and this module is the one place
25//! its result is announced onward.
26
27use vj_abi::{Region, SimTime};
28
29use super::{EdgeKind, ListenerRole, StateCellId, ValueChangeSink};
30
31/// The fixed 3-role VC listener dispatch table (ST-073, ST-074). See this
32/// module's doc comment for why this is 3 named fields, not a collection.
33pub struct Dispatch {
34 scheduler: Option<Box<dyn ValueChangeSink + Send>>,
35 waveform: Option<Box<dyn ValueChangeSink + Send>>,
36 vpi: Option<Box<dyn ValueChangeSink + Send>>,
37}
38
39impl Dispatch {
40 pub fn new() -> Self {
41 Dispatch {
42 scheduler: None,
43 waveform: None,
44 vpi: None,
45 }
46 }
47
48 /// Registers `sink` for `role`, replacing any previously-registered sink
49 /// for that same role (last write wins — this task does not introduce a
50 /// multi-listener-per-role fan-out; each of the 3 roles holds at most one
51 /// sink, matching `Dispatch`'s own field shape). The match is exhaustive
52 /// with no wildcard arm: `ListenerRole` has exactly 3 variants (frozen in
53 /// `state::mod`'s Task-0 contract stub), so this is the concrete
54 /// enforcement ST-073's "attempting a fourth/unknown role is a
55 /// compile-time... invalid call" asks for — a 4th variant added to
56 /// `ListenerRole` anywhere in this crate breaks this match at compile
57 /// time (ARCHITECTURE §7 item 9's exhaustive-match discipline).
58 pub fn register(&mut self, role: ListenerRole, sink: Box<dyn ValueChangeSink + Send>) {
59 match role {
60 ListenerRole::Scheduler => self.scheduler = Some(sink),
61 ListenerRole::Waveform => self.waveform = Some(sink),
62 ListenerRole::Vpi => self.vpi = Some(sink),
63 }
64 }
65
66 /// Fans `notify_change` out to every registered sink, in the fixed
67 /// `Scheduler, Waveform, Vpi` order (ST-073), via 3 unrolled `if let
68 /// Some(..)` checks — never a loop over a collection, so this method's
69 /// cost is the same 3 branches regardless of how many times it has been
70 /// called before (ST-074).
71 pub fn dispatch(&mut self, cell: StateCellId, edge: EdgeKind, at: SimTime, region: Region) {
72 if let Some(sink) = &mut self.scheduler {
73 sink.notify_change(cell, edge, at, region);
74 }
75 if let Some(sink) = &mut self.waveform {
76 sink.notify_change(cell, edge, at, region);
77 }
78 if let Some(sink) = &mut self.vpi {
79 sink.notify_change(cell, edge, at, region);
80 }
81 }
82
83 /// Task 12 (ST-075): the *only* fan-out `write::commit_write` (`state::write`)
84 /// ever calls — fires the `Scheduler` slot alone, unconditionally, on
85 /// every real change, immediately (never GVT-gated: v1's conservative
86 /// `LOOKAHEAD_CYCLES = 0` engine has no outstanding speculation, so
87 /// "speculative" and "committed" coincide at every event). Never touches
88 /// `waveform`/`vpi` — those observe exclusively through
89 /// `StateEngine::drain_committed_changes` (`state::gvt`), never through
90 /// this method.
91 pub fn notify_scheduler(
92 &mut self,
93 cell: StateCellId,
94 edge: EdgeKind,
95 at: SimTime,
96 region: Region,
97 ) {
98 if let Some(sink) = &mut self.scheduler {
99 sink.notify_change(cell, edge, at, region);
100 }
101 }
102
103 /// Task 12 (ST-078): the Scheduler-less analogue of `dispatch` — fires
104 /// `waveform` then `vpi`, in that fixed order, each only if registered,
105 /// never `scheduler`. Not called eagerly from `commit_write` (see that
106 /// method's own doc comment): Waveform/VPI/`$display`-style consumers
107 /// observe committed changes through `StateEngine::drain_committed_changes`'s
108 /// returned `VcRecord`s instead; this method exists for a future caller
109 /// (e.g. RUNTIME) that already has `EdgeKind`/`Region` context to push an
110 /// eager per-write callback on top of that commit-stream.
111 pub fn notify_waveform_and_vpi(
112 &mut self,
113 cell: StateCellId,
114 edge: EdgeKind,
115 at: SimTime,
116 region: Region,
117 ) {
118 if let Some(sink) = &mut self.waveform {
119 sink.notify_change(cell, edge, at, region);
120 }
121 if let Some(sink) = &mut self.vpi {
122 sink.notify_change(cell, edge, at, region);
123 }
124 }
125}
126
127impl Default for Dispatch {
128 fn default() -> Self {
129 Self::new()
130 }
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136 use std::sync::{Arc, Mutex};
137
138 fn t(units: i64) -> SimTime {
139 SimTime {
140 sim_units: units,
141 delta: 0,
142 }
143 }
144
145 /// One recorded `notify_change` call's arguments, as captured by
146 /// `SpySink::calls` below.
147 type RecordedCall = (StateCellId, EdgeKind, SimTime, Region);
148
149 /// Records every `notify_change` call it receives, tagged with a
150 /// caller-chosen label, into a shared log — used both as a per-role call
151 /// counter (with its own private `Vec`) and, when several spies share one
152 /// `log`, as the fixed-order proof. `Arc<Mutex<..>>` (not `Rc<RefCell<..>>`)
153 /// because `Dispatch::register` requires `Box<dyn ValueChangeSink + Send>`.
154 struct SpySink {
155 label: &'static str,
156 log: Arc<Mutex<Vec<&'static str>>>,
157 calls: Arc<Mutex<Vec<RecordedCall>>>,
158 }
159
160 impl SpySink {
161 fn new(label: &'static str, log: Arc<Mutex<Vec<&'static str>>>) -> Self {
162 SpySink {
163 label,
164 log,
165 calls: Arc::new(Mutex::new(Vec::new())),
166 }
167 }
168 }
169
170 impl ValueChangeSink for SpySink {
171 fn notify_change(
172 &mut self,
173 cell: StateCellId,
174 edge: EdgeKind,
175 at: SimTime,
176 region: Region,
177 ) {
178 self.log.lock().unwrap().push(self.label);
179 self.calls.lock().unwrap().push((cell, edge, at, region));
180 }
181 }
182
183 /// ST-073: register a spy as `Scheduler` only; `dispatch` fires it
184 /// exactly once with the exact arguments passed, and a second spy
185 /// registered as `Waveform` (left unexercised by this call) does not
186 /// fire — proving the 3 slots are independent, each firing only when
187 /// populated (Task 12 adds the real GVT-based routing rule on top of
188 /// this; this task only proves the 3-slot structure itself).
189 #[test]
190 fn register_scheduler_then_dispatch_calls_only_scheduler() {
191 let log = Arc::new(Mutex::new(Vec::new()));
192 let scheduler_spy = SpySink::new("scheduler", log.clone());
193 let scheduler_calls = scheduler_spy.calls.clone();
194 let waveform_spy = SpySink::new("waveform", log.clone());
195 let waveform_calls = waveform_spy.calls.clone();
196
197 let mut dispatch = Dispatch::new();
198 dispatch.register(ListenerRole::Scheduler, Box::new(scheduler_spy));
199 dispatch.register(ListenerRole::Waveform, Box::new(waveform_spy));
200
201 // Only exercise `dispatch` once, and only assert on the Scheduler
202 // role's own effect; the Waveform spy is registered too so its
203 // absence from the log is a meaningful negative, not a vacuous one.
204 let cell = StateCellId(7);
205 let at = t(100);
206 dispatch.dispatch(cell, EdgeKind::Posedge, at, Region::Active);
207
208 assert_eq!(*log.lock().unwrap(), vec!["scheduler", "waveform"]);
209 assert_eq!(scheduler_calls.lock().unwrap().len(), 1);
210 assert_eq!(
211 scheduler_calls.lock().unwrap()[0],
212 (cell, EdgeKind::Posedge, at, Region::Active)
213 );
214 // Waveform *does* fire here too (all populated slots fire on every
215 // `dispatch` call at this task's layer — Task 12 adds the GVT gate
216 // that makes Scheduler-vs-Waveform/VPI visibility diverge); what
217 // this test actually proves is that an *unregistered* role (Vpi,
218 // never registered below) never fires and never panics.
219 assert_eq!(waveform_calls.lock().unwrap().len(), 1);
220 }
221
222 /// ST-073: 3 spies, one per role, all sharing one ordered log; one
223 /// `dispatch` call must produce exactly `["scheduler", "waveform",
224 /// "vpi"]` — the fixed order, never randomized (e.g. never insertion
225 /// order of `register` calls, which this test deliberately reverses
226 /// below to prove that).
227 #[test]
228 fn all_three_roles_registered_all_three_fire_in_fixed_order() {
229 let log = Arc::new(Mutex::new(Vec::new()));
230 let scheduler_spy = SpySink::new("scheduler", log.clone());
231 let waveform_spy = SpySink::new("waveform", log.clone());
232 let vpi_spy = SpySink::new("vpi", log.clone());
233
234 let mut dispatch = Dispatch::new();
235 // Registered in reverse-of-dispatch-order deliberately: if `dispatch`
236 // ever fired in *registration* order instead of the fixed
237 // Scheduler/Waveform/Vpi order, this would produce
238 // ["vpi", "waveform", "scheduler"] instead and fail the assertion
239 // below.
240 dispatch.register(ListenerRole::Vpi, Box::new(vpi_spy));
241 dispatch.register(ListenerRole::Waveform, Box::new(waveform_spy));
242 dispatch.register(ListenerRole::Scheduler, Box::new(scheduler_spy));
243
244 dispatch.dispatch(StateCellId(1), EdgeKind::None, t(0), Region::Nba);
245
246 assert_eq!(*log.lock().unwrap(), vec!["scheduler", "waveform", "vpi"]);
247 }
248
249 /// ST-074: register 3 spies once, then call `dispatch` 10_000× in a
250 /// loop. This test has two independent parts:
251 ///
252 /// 1. A *functional* check at scale: every one of the 10_000 calls
253 /// actually reaches all 3 spies (not merely "doesn't panic") — a
254 /// dispatch table that silently dropped/skipped entries under
255 /// repeated use would fail the exact call count assertions below.
256 /// 2. A *structural* proof that the table itself cannot grow no matter
257 /// how many events pass through it: an exhaustive field-by-field
258 /// destructure of `Dispatch` (`let Dispatch { scheduler, waveform,
259 /// vpi } = ...`, no `..` rest pattern) after the 10_000 calls. This
260 /// is the actual enforcement — a future edit that turned `Dispatch`
261 /// into e.g. a `Vec<Box<dyn ValueChangeSink>>`-backed table (the
262 /// dynamically-resizable shape ST-074 forbids) would add or remove a
263 /// field this destructure names, which fails to *compile*, not
264 /// merely fails an assertion. (A `size_of_val`-before/after
265 /// comparison was considered and rejected per review: `size_of_val`
266 /// reports a type's fixed, compile-time-known size, which is
267 /// identical before and after any number of calls regardless of
268 /// whether the type internally heap-allocates — it would pass this
269 /// exact scenario just as trivially on a `Vec`-based `Dispatch`, so
270 /// it is not evidence of anything this row asks for.)
271 #[test]
272 fn dispatch_table_has_no_heap_growth_across_many_events() {
273 let log = Arc::new(Mutex::new(Vec::new()));
274 let scheduler_spy = SpySink::new("scheduler", log.clone());
275 let scheduler_calls = scheduler_spy.calls.clone();
276 let waveform_spy = SpySink::new("waveform", log.clone());
277 let waveform_calls = waveform_spy.calls.clone();
278 let vpi_spy = SpySink::new("vpi", log.clone());
279 let vpi_calls = vpi_spy.calls.clone();
280
281 let mut dispatch = Dispatch::new();
282 dispatch.register(ListenerRole::Scheduler, Box::new(scheduler_spy));
283 dispatch.register(ListenerRole::Waveform, Box::new(waveform_spy));
284 dispatch.register(ListenerRole::Vpi, Box::new(vpi_spy));
285
286 const N: usize = 10_000;
287 for i in 0..N {
288 dispatch.dispatch(
289 StateCellId(i as u64),
290 EdgeKind::Posedge,
291 t(i as i64),
292 Region::Active,
293 );
294 }
295
296 assert_eq!(scheduler_calls.lock().unwrap().len(), N);
297 assert_eq!(waveform_calls.lock().unwrap().len(), N);
298 assert_eq!(vpi_calls.lock().unwrap().len(), N);
299 assert_eq!(log.lock().unwrap().len(), 3 * N);
300
301 // Structural proof (see doc comment above): fails to compile, not
302 // merely fails an assertion, if `Dispatch` ever gains or loses a
303 // field.
304 let Dispatch {
305 scheduler,
306 waveform,
307 vpi,
308 } = dispatch;
309 assert!(scheduler.is_some());
310 assert!(waveform.is_some());
311 assert!(vpi.is_some());
312 }
313
314 /// ST-073 (negative): `ListenerRole` has exactly the 3 variants
315 /// Scheduler/Waveform/Vpi (frozen in the Task-0 contract stub,
316 /// `state::mod`), and `Dispatch::register`'s own match (above) is
317 /// exhaustive with no wildcard arm — the real enforcement mechanism:
318 /// adding a 4th `ListenerRole` variant anywhere in this crate breaks
319 /// that match (and this test's own independent mirror match below) at
320 /// compile time. This test asserts that match compiles today and
321 /// exercises it, which is itself the guard this row asks for.
322 /// Task 12 (ST-075): `notify_scheduler` fires only the `Scheduler` slot,
323 /// even when `Waveform`/`Vpi` are also registered — proving the
324 /// Scheduler-only fan-out `commit_write` relies on. Fails if
325 /// `notify_scheduler` ever fanned out to a second role.
326 #[test]
327 fn notify_scheduler_fires_only_scheduler_never_waveform_or_vpi() {
328 let log = Arc::new(Mutex::new(Vec::new()));
329 let scheduler_spy = SpySink::new("scheduler", log.clone());
330 let waveform_spy = SpySink::new("waveform", log.clone());
331 let waveform_calls = waveform_spy.calls.clone();
332 let vpi_spy = SpySink::new("vpi", log.clone());
333 let vpi_calls = vpi_spy.calls.clone();
334
335 let mut dispatch = Dispatch::new();
336 dispatch.register(ListenerRole::Scheduler, Box::new(scheduler_spy));
337 dispatch.register(ListenerRole::Waveform, Box::new(waveform_spy));
338 dispatch.register(ListenerRole::Vpi, Box::new(vpi_spy));
339
340 dispatch.notify_scheduler(StateCellId(4), EdgeKind::Posedge, t(60), Region::Active);
341
342 assert_eq!(*log.lock().unwrap(), vec!["scheduler"]);
343 assert_eq!(waveform_calls.lock().unwrap().len(), 0);
344 assert_eq!(vpi_calls.lock().unwrap().len(), 0);
345 }
346
347 /// Task 12 (ST-078): `notify_waveform_and_vpi` fires `Waveform` then
348 /// `Vpi`, in that fixed order, and never `Scheduler` — the
349 /// Scheduler-less analogue of Task 11's `all_three_roles_registered_all_three_fire_in_fixed_order`.
350 /// Fails if the order were reversed, if `Vpi` were skipped, or if
351 /// `Scheduler` ever fired from this call.
352 #[test]
353 fn notify_waveform_and_vpi_fires_waveform_then_vpi_never_scheduler() {
354 let log = Arc::new(Mutex::new(Vec::new()));
355 let scheduler_spy = SpySink::new("scheduler", log.clone());
356 let scheduler_calls = scheduler_spy.calls.clone();
357 // Registered reverse-of-fixed-order deliberately, same technique as
358 // Task 11's `all_three_roles_registered_all_three_fire_in_fixed_order`.
359 let vpi_spy = SpySink::new("vpi", log.clone());
360 let waveform_spy = SpySink::new("waveform", log.clone());
361
362 let mut dispatch = Dispatch::new();
363 dispatch.register(ListenerRole::Scheduler, Box::new(scheduler_spy));
364 dispatch.register(ListenerRole::Vpi, Box::new(vpi_spy));
365 dispatch.register(ListenerRole::Waveform, Box::new(waveform_spy));
366
367 dispatch.notify_waveform_and_vpi(StateCellId(3), EdgeKind::None, t(50), Region::Nba);
368
369 assert_eq!(*log.lock().unwrap(), vec!["waveform", "vpi"]);
370 assert_eq!(
371 scheduler_calls.lock().unwrap().len(),
372 0,
373 "notify_waveform_and_vpi must never reach Scheduler"
374 );
375 }
376
377 #[test]
378 fn attempting_a_fourth_role_does_not_compile() {
379 fn describe(role: ListenerRole) -> &'static str {
380 match role {
381 ListenerRole::Scheduler => "scheduler",
382 ListenerRole::Waveform => "waveform",
383 ListenerRole::Vpi => "vpi",
384 }
385 }
386 assert_eq!(describe(ListenerRole::Scheduler), "scheduler");
387 assert_eq!(describe(ListenerRole::Waveform), "waveform");
388 assert_eq!(describe(ListenerRole::Vpi), "vpi");
389 }
390}