Skip to main content

vj_sim/state/
event.rs

1//! STATE::EVENT — `event` variable ("trigger pulse") cells (R-run Task 16,
2//! moved from `2026-09-27-wave-B-state.md` Task 5; ledger ST-111).
3//!
4//! **Deviation, documented (ST-111's own acceptance text explicitly leaves
5//! the exact mechanism an "open joint decision with STATE/NETS" — this is
6//! STATE's chosen resolution, not a bilaterally closed one):** rather than
7//! adding a new [`super::SlotClass::Event`] variant (which would force a
8//! mechanical ripple through every exhaustive `SlotClass` match in
9//! `write.rs`/`rollback.rs`/`accessor_impl.rs`/`dedup.rs`/`instance.rs`, the
10//! same shape Task 1 already does for row-addressing but for no actual
11//! storage-shape benefit — an event has no persistent value at all), an
12//! event variable is an ordinary 1-bit `SlotClass::Inline` cell whose
13//! [`StateCellId`] is additionally tracked in a dedicated
14//! `StateEngine::event_cells` set. This satisfies "a `StateCellId`-keyed
15//! mechanism" literally, keeps every existing exhaustive match unchanged,
16//! and is exactly as testable.
17//!
18//! Names no `sv_lang`/`sv_lang_kinds` item — reachable with or without this
19//! crate's `frontend` feature (CONTRACTS D4), matching `sched::EventTerm`/
20//! `sched::SensitivityTable`, the scheduler-side consumers of this cell.
21
22use super::write::{ChangeResult, PreWriteHook, SlotLocation};
23use super::{FourStateValue, SignalAccess, SlotClass, StateCellId};
24use crate::state::instance::StateEngine;
25
26/// Width of a design-declared `event` variable's cell (`state::layout`,
27/// `vj::run::signal_slots_of`) -- a 2-state `SlotClass::Wide` cell
28/// that compiled code (`vj-codegen::expr::lower_event_trigger`) treats
29/// as two 64-bit halves: the low half COUNTS triggers, the high half holds
30/// `tick + 1` of the most recent trigger (0 = never), which is what
31/// `e.triggered` compares against the current time. Counting rather than
32/// toggling matters because blocking writes are flushed once at segment end:
33/// two toggles in one segment would cancel and report no change.
34///
35/// This is separate from the 1-bit [`StateEngine::alloc_event_cell`] model
36/// below, which the compiled pipeline does not use.
37pub const EVENT_CELL_WIDTH: u32 = 128;
38
39impl StateEngine {
40    /// ST-111: allocates a fresh event-variable cell — a real, ordinary
41    /// 1-bit `SlotClass::Inline` cell (via [`super::arena::Arena::alloc_inline_word`],
42    /// which zero-initializes its backing word, so the cell decodes to
43    /// logic `0`/"not triggered" immediately after allocation with no
44    /// further write needed) — and records its [`StateCellId`] in
45    /// `self.event_cells`, the one thing that distinguishes it from an
46    /// ordinary 1-bit variable cell (see this module's own doc comment for
47    /// why no new `SlotClass` variant exists for this).
48    pub fn alloc_event_cell(&mut self) -> StateCellId {
49        // SAFETY: `&mut self` guarantees no other call into this
50        // `StateEngine` (and therefore this `Arena`) races this allocation,
51        // and nothing in this crate holds a live `&[u128]`/`&mut [u128]`
52        // borrow of the inline plane across this call — the same
53        // single-logical-writer discipline every other `alloc_inline_word`
54        // call site in this crate (e.g. `allocate_instance`) already relies
55        // on.
56        let word_offset = unsafe { self.arena.alloc_inline_word() };
57        let loc = SlotLocation {
58            class: SlotClass::Inline,
59            word_offset,
60            bit_width: 1,
61            elem_word_count: 1,
62            row_stride_words: 0,
63            num_rows: 0,
64        };
65        let cell = self.alloc_cell(loc);
66        self.event_cells.insert(cell);
67        cell
68    }
69
70    /// ST-111: the trigger pulse — an ordinary [`StateEngine::commit_write`]
71    /// (so the real VC/`notify_change`/`Dispatch::notify_scheduler` path
72    /// fires, which is exactly what wakes a process sensitive to
73    /// `sched::EventTerm::NamedEventTrigger(cell)` via
74    /// `sched::SensitivityTable`'s already-landed "any real change wakes
75    /// it" handling — see `sched::sensitivity::SensitivityTable::install_term`'s
76    /// `NamedEventTrigger` arm). Writes the logical complement of the cell's
77    /// current value (an event cell's own "value" carries no LRM meaning —
78    /// only the transition matters — so flipping it, rather than always
79    /// writing the same constant, guarantees `commit_write`'s own
80    /// old-vs-new diff always reports a real change, even across repeated
81    /// triggers with no intervening clear).
82    ///
83    /// # Panics
84    /// In debug builds, if `cell` was never allocated via
85    /// [`Self::alloc_event_cell`] — a caller triggering an ordinary
86    /// (non-event) cell through this path is a programming error, not a
87    /// silently-tolerated case, matching this crate's "loud, never silent"
88    /// discipline (ARCHITECTURE §7 item 9).
89    pub fn trigger_event(
90        &mut self,
91        cell: StateCellId,
92        hook: &mut dyn PreWriteHook,
93    ) -> ChangeResult {
94        debug_assert!(
95            self.event_cells.contains(&cell),
96            "trigger_event: {cell:?} was never allocated via StateEngine::alloc_event_cell -- \
97             an ordinary variable/net cell has no LRM 'trigger' semantics to fire"
98        );
99        let current = match self.read(cell) {
100            FourStateValue::Narrow(word) => (word as u64) & 1 != 0,
101            FourStateValue::Wide { .. } => unreachable!(
102                "event cell {cell:?} decoded as FourStateValue::Wide -- \
103                 alloc_event_cell only ever allocates SlotClass::Inline cells"
104            ),
105        };
106        let next = FourStateValue::Narrow(if current { 0u128 } else { 1u128 });
107        self.commit_write(cell, next, hook)
108    }
109
110    /// ST-111: LRM `.triggered`'s "valid only through the current time
111    /// step" — clears every tracked event cell back to `0`, via
112    /// [`StateEngine::commit_write_no_hook`] (no `VcRecord` append, no
113    /// scheduler notification on the clearing edge: clearing is not itself
114    /// an LRM-observable event — see [`Self::commit_write_no_hook`]'s own
115    /// doc comment for why that primitive is the one that skips both).
116    pub fn clear_event_pulses(&mut self) {
117        // Collect first: `commit_write_no_hook` takes `&mut self`, so this
118        // avoids borrowing `self.event_cells` and `self` mutably at once.
119        let cells: Vec<StateCellId> = self.event_cells.iter().copied().collect();
120        for cell in cells {
121            self.commit_write_no_hook(cell, FourStateValue::Narrow(0u128));
122        }
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use crate::state::instance::TemplateRegistry;
130
131    fn fresh_engine() -> StateEngine {
132        StateEngine::new(TemplateRegistry::new())
133    }
134
135    #[test]
136    fn alloc_event_cell_starts_at_zero_and_is_tracked() {
137        let mut engine = fresh_engine();
138        let ev = engine.alloc_event_cell();
139        assert!(engine.event_cells.contains(&ev));
140        match engine.read(ev) {
141            FourStateValue::Narrow(w) => assert_eq!(w as u64 & 1, 0),
142            FourStateValue::Wide { .. } => panic!("expected Narrow"),
143        }
144    }
145
146    #[test]
147    fn trigger_event_flips_the_cell_and_reports_a_change() {
148        let mut engine = fresh_engine();
149        let ev = engine.alloc_event_cell();
150        let result = engine.trigger_event(ev, &mut ());
151        assert!(result.changed);
152        match engine.read(ev) {
153            FourStateValue::Narrow(w) => assert_eq!(w as u64 & 1, 1),
154            FourStateValue::Wide { .. } => panic!("expected Narrow"),
155        }
156    }
157
158    /// Mutation-testing regression (2026-09-28): `trigger_event` must write
159    /// the logical COMPLEMENT of the cell's current value, never a constant
160    /// -- a mutant that always writes `1` still passes the single-trigger
161    /// test above (a fresh cell starts at `0`, so writing a constant `1` is
162    /// indistinguishable from flipping it), but silently drops the wake on
163    /// every SECOND consecutive trigger with no intervening
164    /// `clear_event_pulses` in between: `commit_write`'s own before/after
165    /// diff sees `1 -> 1` (no change), so `sched::sensitivity`'s "any real
166    /// change wakes it" mechanism this function's own doc comment describes
167    /// never fires on that second trigger. A real `-> ev;` statement inside
168    /// a tight loop with no intervening `@(ev)`/`.triggered` clear is exactly
169    /// this shape (LRM 1800-2023 SS15.5.1 places no such restriction on how
170    /// often `->` may fire).
171    #[test]
172    fn trigger_event_reports_a_change_on_every_consecutive_trigger_with_no_intervening_clear() {
173        let mut engine = fresh_engine();
174        let ev = engine.alloc_event_cell();
175
176        let first = engine.trigger_event(ev, &mut ());
177        assert!(first.changed, "first trigger (0 -> 1) must report a change");
178
179        let second = engine.trigger_event(ev, &mut ());
180        assert!(
181            second.changed,
182            "second consecutive trigger with no intervening clear must ALSO report a change \
183             (it must flip 1 -> 0, never re-write the same value 1 -> 1)"
184        );
185        match engine.read(ev) {
186            FourStateValue::Narrow(w) => assert_eq!(
187                w as u64 & 1,
188                0,
189                "the second trigger must flip the cell back to 0"
190            ),
191            FourStateValue::Wide { .. } => panic!("expected Narrow"),
192        }
193
194        let third = engine.trigger_event(ev, &mut ());
195        assert!(
196            third.changed,
197            "a third consecutive trigger must flip 0 -> 1 and report a change again"
198        );
199    }
200
201    #[test]
202    fn clear_event_pulses_resets_every_tracked_cell_to_zero() {
203        let mut engine = fresh_engine();
204        let a = engine.alloc_event_cell();
205        let b = engine.alloc_event_cell();
206        engine.trigger_event(a, &mut ());
207        engine.trigger_event(b, &mut ());
208        engine.clear_event_pulses();
209        for cell in [a, b] {
210            match engine.read(cell) {
211                FourStateValue::Narrow(w) => assert_eq!(w as u64 & 1, 0),
212                FourStateValue::Wide { .. } => panic!("expected Narrow"),
213            }
214        }
215    }
216}