vj_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 `vj-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//! `vj-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 vj_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 vj_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}