Skip to main content

sv_sim_sim/state/
region_check.rs

1//! Task 15 — Region-disjoint arena sub-ranges & single-authoritative-storage
2//! (ledger ST-033, ST-122).
3//!
4//! **Scope note (from the plan):** the *real* static driver-graph clustering
5//! pass that would produce a genuine per-region write-set partition is a
6//! `netlist`/`analysis`-track deliverable that does not exist yet (no owning
7//! track this wave — see `docs/CONTRACTS.md`'s `sv-sim-sim::analysis` row).
8//! This task does not itself allocate region-disjoint arena sub-ranges (that
9//! would require the arena to know region assignments *before* the layout
10//! builder runs, an ordering this crate cannot assume yet); it delivers the
11//! *checkable invariant* — [`check_region_disjoint`] — wired to the real
12//! region-assigning pass whenever `netlist::analysis` lands it in a later
13//! wave, plus the runtime guard for **external** writes ([`ForceObserved`])
14//! that ST-122's own acceptance text calls out as the one path that must
15//! stay serialized rather than ever performing a direct disjoint-range write
16//! (ARCHITECTURE §9: "structurally-impossible cross-partition writes is
17//! weaker than it reads").
18//!
19//! **Deviation (binding per this task's own unresolved-review-findings
20//! list):** the plan's Files list for this task names only
21//! `state/region_check.rs` (new) and `state/mod.rs`, but the described
22//! behavior and this task's own `force_driver_notifies_every_registered_
23//! force_observed_listener` test require Task 14's `driver::force_driver`/
24//! `release_driver` (`state/driver.rs`) to actually call the
25//! [`ForceObserved`] listeners registered here, and the registry itself
26//! (`StateEngine::force_observed`) needs a direct field to live in
27//! (`state/instance.rs`) — the same "every resource STATE owns is a direct
28//! field" discipline Task 14's own `trireg_charge` field already establishes
29//! there. Both are edited as part of this task, documented here rather than
30//! left implicit, matching the pattern Task 8's retrofit of Task 7's
31//! `StateEngine` (`state/instance.rs`, listed under Files there) already
32//! set.
33//!
34//! **`ForceObserved` vs. `docs/CONTRACTS.md`'s `analysis`-owned
35//! `ForceObservedAction`/`StaticAnalysisView::on_force_observed`:** the
36//! `sv-sim-sim::analysis` contract row names a *static-analysis-facing*
37//! ForceObserved hook (owned by the `netlist`/`analysis` track, invalidating
38//! *that* track's own independence classification). The plan's own text for
39//! this task defines a second, STATE-owned trait of the same name/method
40//! shape, for a narrower purpose: the concrete runtime call site
41//! `force_driver`/`release_driver` invoke directly, with no dependency on
42//! `netlist`/`analysis` (which this crate does not depend on this wave).
43//! Once `netlist::analysis` lands its own real hook, reconciling the two
44//! (this trait becoming that hook's STATE-side registration point, or being
45//! subsumed by it) is that future wave's job, not this one's — flagged here
46//! rather than left as an unexplained name collision.
47
48use std::collections::{HashMap, HashSet};
49
50use sv_sim_abi::Region;
51
52use super::StateCellId;
53use super::instance::StateEngine;
54
55/// This task's stand-in for the netlist/analysis track's not-yet-built
56/// static clustering pass's real `RegionId`/write-set output (ST-122) — a
57/// real caller eventually supplies these from that pass; this wave's own
58/// tests hand-build them directly.
59#[derive(Clone, Debug)]
60pub struct RegionAssignment {
61    pub region: Region,
62    pub cells: HashSet<StateCellId>,
63}
64
65/// The loud, typed diagnosis [`check_region_disjoint`] returns instead of a
66/// generic "overlap detected" (ARCHITECTURE §7 item 9's "loud, typed,
67/// diagnosable" discipline applied here): names the offending cell and both
68/// of the (in `assignments`-slice-order) conflicting regions it was claimed
69/// by.
70#[derive(Clone, Copy, Debug, PartialEq, Eq)]
71pub struct RegionOverlapError {
72    pub cell: StateCellId,
73    pub regions: (Region, Region),
74}
75
76/// A build-time check (ST-122) confirming no [`StateCellId`] appears in more
77/// than one `assignments` entry's `cells` set. **Does not** itself allocate
78/// region-disjoint arena sub-ranges (see this module's top doc comment) —
79/// this function's only job is the checkable invariant. `engine` is accepted
80/// (matching the plan's pinned signature and this crate's convention of
81/// every region-facing query taking the owning `StateEngine`) but this
82/// wave's check is purely a property of `assignments` itself — a future
83/// wave that also validates each `StateCellId` against `engine`'s own
84/// allocated cell table (`StateEngine::alloc_cell`) can extend this function
85/// without changing its signature.
86pub fn check_region_disjoint(
87    _engine: &StateEngine,
88    assignments: &[RegionAssignment],
89) -> Result<(), RegionOverlapError> {
90    let mut owner: HashMap<StateCellId, Region> = HashMap::new();
91    for assignment in assignments {
92        for &cell in &assignment.cells {
93            match owner.get(&cell) {
94                Some(&prior_region) => {
95                    return Err(RegionOverlapError {
96                        cell,
97                        regions: (prior_region, assignment.region),
98                    });
99                }
100                None => {
101                    owner.insert(cell, assignment.region);
102                }
103            }
104        }
105    }
106    Ok(())
107}
108
109/// The corresponding runtime guard for **external** writes (ST-122's own
110/// acceptance text: "an external write (force/release/vpi_put_value) never
111/// performs a direct disjoint-range write, only a serialized
112/// `ForceObserved`/`on_force_observed` path"). `driver::force_driver`/
113/// `release_driver` (Task 14, retrofitted by this task) each call every
114/// listener registered via [`StateEngine::register_force_observed`], so a
115/// future partitioning/independence assumption can invalidate itself the
116/// instant an external `force`/`vpi_put_value` touches a region-crossing
117/// net, rather than trusting a now-stale static classification (ARCHITECTURE
118/// §4/§9).
119pub trait ForceObserved {
120    fn on_force_observed(&mut self, net: StateCellId);
121}
122
123impl StateEngine {
124    /// Registers `listener` to be notified (in registration order) on every
125    /// future `force`/`release` (`driver::force_driver`/`release_driver`).
126    /// No upper bound on how many listeners can be registered (unlike
127    /// `Dispatch`'s fixed 3-role table, ST-074) — `force`/`release` are rare,
128    /// external, out-of-band operations, not the per-event hot path ST-074's
129    /// no-heap-growth discipline targets.
130    pub fn register_force_observed(&mut self, listener: Box<dyn ForceObserved>) {
131        self.force_observed.push(listener);
132    }
133
134    /// Fans `on_force_observed(net)` out to every registered listener, in
135    /// registration order. `pub(crate)`: the only callers are
136    /// `driver::force_driver`/`release_driver` — never part of this crate's
137    /// public API surface on its own.
138    pub(crate) fn notify_force_observed(&mut self, net: StateCellId) {
139        for listener in &mut self.force_observed {
140            listener.on_force_observed(net);
141        }
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use crate::state::driver::{
149        DriveStrength, DriveStrengthPair, DriverEntry, DriverSet, resolve_net_step,
150    };
151    use crate::state::instance::TemplateRegistry;
152    use crate::state::write::SlotLocation;
153    use crate::state::{FourStateValue, NetKind, SignalAccess, SlotClass};
154    use std::cell::RefCell;
155    use std::rc::Rc;
156    use sv_sim_abi::Bit4;
157
158    fn fresh_engine() -> StateEngine {
159        StateEngine::new(TemplateRegistry::new())
160    }
161
162    /// Allocates a fresh, zero-initialized 1-bit inline cell.
163    fn scalar_cell(engine: &mut StateEngine) -> StateCellId {
164        // SAFETY: `engine` is a local, single-owned value with no other live
165        // borrow of the inline plane, and every test here is single-threaded
166        // with no concurrent allocation.
167        let word_offset = unsafe { engine.arena.alloc_inline_word() };
168        let loc = SlotLocation {
169            class: SlotClass::Inline,
170            word_offset,
171            bit_width: 1,
172            elem_word_count: 1,
173            row_stride_words: 0,
174            num_rows: 0,
175        };
176        engine.alloc_cell(loc)
177    }
178
179    /// A scalar cell pre-seeded (via a real `commit_write`) to drive `bit`.
180    fn seeded_scalar_cell(engine: &mut StateEngine, bit: Bit4) -> StateCellId {
181        let cell = scalar_cell(engine);
182        let (v, u) = bit.to_val_unk();
183        let word = (v as u128) | ((u as u128) << 64);
184        engine.commit_write(cell, FourStateValue::Narrow(word), &mut ());
185        cell
186    }
187
188    fn strong_pair() -> DriveStrengthPair {
189        DriveStrengthPair {
190            s0: DriveStrength::Strong,
191            s1: DriveStrength::Strong,
192        }
193    }
194
195    /// ST-122: 2 cells, each claimed by exactly one, distinct
196    /// `RegionAssignment` — no overlap, the check must pass.
197    #[test]
198    fn disjoint_region_assignments_pass_the_check() {
199        let mut engine = fresh_engine();
200        let cell_a = scalar_cell(&mut engine);
201        let cell_b = scalar_cell(&mut engine);
202
203        let assignments = vec![
204            RegionAssignment {
205                region: Region::Active,
206                cells: HashSet::from([cell_a]),
207            },
208            RegionAssignment {
209                region: Region::Nba,
210                cells: HashSet::from([cell_b]),
211            },
212        ];
213
214        assert_eq!(check_region_disjoint(&engine, &assignments), Ok(()));
215    }
216
217    /// ST-122: the same `StateCellId` present in both `RegionAssignment`s'
218    /// `cells` sets must fail, naming exactly the two conflicting `Region`s
219    /// (not a generic "overlap detected" with no diagnosis) — a check that
220    /// merely returned `Err(())`, or that silently picked one region and
221    /// ignored the conflict, would fail this test's field assertions below.
222    #[test]
223    fn a_cell_claimed_by_two_regions_fails_with_the_offending_cell_named() {
224        let mut engine = fresh_engine();
225        let shared_cell = scalar_cell(&mut engine);
226        let solo_cell = scalar_cell(&mut engine);
227
228        let assignments = vec![
229            RegionAssignment {
230                region: Region::Active,
231                cells: HashSet::from([shared_cell, solo_cell]),
232            },
233            RegionAssignment {
234                region: Region::Nba,
235                cells: HashSet::from([shared_cell]),
236            },
237        ];
238
239        let err = check_region_disjoint(&engine, &assignments)
240            .expect_err("a cell present in two RegionAssignments must be rejected");
241        assert_eq!(err.cell, shared_cell);
242        assert_eq!(err.regions, (Region::Active, Region::Nba));
243    }
244
245    /// ST-122's external-write half: register 2 `ForceObserved` spies on
246    /// `StateEngine`; call `driver::force_driver` (Task 14) on some net;
247    /// assert both spies recorded exactly one `on_force_observed(net)` call.
248    /// Fails if the external write path bypasses the notification (the
249    /// silent independence-assumption-staleness bug ARCHITECTURE §9 flags).
250    #[test]
251    fn force_driver_notifies_every_registered_force_observed_listener() {
252        struct Spy {
253            calls: Rc<RefCell<Vec<StateCellId>>>,
254        }
255        impl ForceObserved for Spy {
256            fn on_force_observed(&mut self, net: StateCellId) {
257                self.calls.borrow_mut().push(net);
258            }
259        }
260
261        let mut engine = fresh_engine();
262        let resolved = scalar_cell(&mut engine);
263        let mut set = DriverSet {
264            resolved,
265            drivers: vec![],
266            net_kind: NetKind::Wire,
267        };
268
269        let spy1_calls = Rc::new(RefCell::new(Vec::new()));
270        let spy2_calls = Rc::new(RefCell::new(Vec::new()));
271        engine.register_force_observed(Box::new(Spy {
272            calls: spy1_calls.clone(),
273        }));
274        engine.register_force_observed(Box::new(Spy {
275            calls: spy2_calls.clone(),
276        }));
277
278        let (v, u) = Bit4::One.to_val_unk();
279        let one = FourStateValue::Narrow((v as u128) | ((u as u128) << 64));
280        crate::state::driver::force_driver(&mut engine, &mut set, resolved, one);
281
282        assert_eq!(*spy1_calls.borrow(), vec![resolved]);
283        assert_eq!(*spy2_calls.borrow(), vec![resolved]);
284    }
285
286    /// ST-033: write a value via `driver::resolve_net_step` (Task 14, an
287    /// ordinary `Wire` `DriverSet`, not `UserDefined`), read it back both via
288    /// `SignalAccess::read` (Task 9) and via `Arena` field access directly
289    /// (test-only, since it's the same crate); assert bit-identical and,
290    /// more importantly, assert mutating through one path (a second
291    /// `commit_write`) is immediately visible through the other with no
292    /// manual "sync" step — the structural absence of a sync step is what
293    /// "single authoritative location" means operationally.
294    #[test]
295    fn no_second_authoritative_copy_exists_for_a_cell_read_through_two_apis() {
296        let mut engine = fresh_engine();
297        let resolved = scalar_cell(&mut engine);
298        let driver_cell = seeded_scalar_cell(&mut engine, Bit4::One);
299        let set = DriverSet {
300            resolved,
301            drivers: vec![DriverEntry {
302                source: driver_cell,
303                strength: strong_pair(),
304                forced: false,
305            }],
306            net_kind: NetKind::Wire,
307        };
308        resolve_net_step(&mut engine, &set, &mut ()).unwrap();
309
310        let word_offset = engine.cells[resolved.0 as usize].word_offset;
311
312        let via_signal_access = match engine.read(resolved) {
313            FourStateValue::Narrow(w) => w,
314            FourStateValue::Wide { .. } => panic!("expected Narrow"),
315        };
316        let via_arena_field = engine.arena.inline()[word_offset as usize];
317        assert_eq!(
318            via_signal_access, via_arena_field,
319            "both read APIs must observe the exact same storage"
320        );
321
322        // Mutate through a second, independent `commit_write` call and prove
323        // both read paths reflect it immediately, with no manual sync step.
324        let (v, u) = Bit4::Zero.to_val_unk();
325        let new_word = (v as u128) | ((u as u128) << 64);
326        engine.commit_write(resolved, FourStateValue::Narrow(new_word), &mut ());
327
328        let via_signal_access_after = match engine.read(resolved) {
329            FourStateValue::Narrow(w) => w,
330            FourStateValue::Wide { .. } => panic!("expected Narrow"),
331        };
332        let via_arena_field_after = engine.arena.inline()[word_offset as usize];
333        assert_eq!(via_signal_access_after, new_word);
334        assert_eq!(via_arena_field_after, new_word);
335        assert_eq!(via_signal_access_after, via_arena_field_after);
336    }
337}