sv_sim_sim/sched/nba.rs
1//! SCHED::NBA — the nonblocking-assignment (`<=`) staging log (scheduler.md §1/§3
2//! Key Decisions/Key Data Structures; ARCHITECTURE §6.3; ledger SC-034, SC-035,
3//! SC-036, SC-037).
4//!
5//! `NbaLog` is the *only* place a `<=` write goes before it becomes visible.
6//! `stage` never touches signal storage — it has no `&mut dyn SignalAccess`
7//! parameter to call through even if it wanted to (SC-036) — so reading a cell
8//! immediately after staging an NBA write to it, in the same process, still
9//! observes the pre-assignment value (SC-034). The log accumulates every
10//! staged write, unapplied, for the whole timestep; only the outer driver's
11//! NBA-region commit pass (Task 7's `commit_nba`, once Active and Inactive are
12//! both empty) ever calls [`NbaLog::take`] and applies the drained batch, which
13//! is what makes multiple processes' NBA writes in one timestep become visible
14//! together rather than one at a time (SC-035) — this module's own
15//! contribution to that property is that nothing here has any partial-apply
16//! path: every entry sits inert in `entries` until a single `take()` drains
17//! all of them at once. The full SC-035 atomic-visibility acceptance test
18//! (multiple processes' writes becoming visible together, not incrementally)
19//! needs a real commit pass to observe "visible", so it is Task 7's
20//! `commit_nba` test, not this module's; see this task's own commit message
21//! for the exact split.
22//!
23//! `NbaLog` is cleared on every `now` advance (SC-037) — this module provides
24//! the primitive (`clear`); Task 21's outer driver calls it at the right point
25//! in the delta cycle.
26
27use crate::sched::ProcessId;
28use crate::state::{FourStateValue, StateCellId};
29
30/// One staged nonblocking-assignment write (SC-037's exact field list, `bytes`
31/// renamed `value: FourStateValue` to reuse the already-contracted STATE type
32/// rather than inventing a `FourStateBits` alias).
33#[derive(Clone, Debug)]
34pub struct NbaUpdate {
35 pub cell: StateCellId,
36 pub value: FourStateValue,
37 pub source: ProcessId,
38 pub order: u32,
39}
40
41/// The flat per-timestep NBA staging log (SC-034/SC-035/SC-036/SC-037).
42///
43/// Structurally cannot apply a write: `stage`'s signature takes no `&mut dyn
44/// SignalAccess` (or any other write-capable handle), so there is no call
45/// this type could make even if its body were wrong — the only way an
46/// `NbaUpdate` ever reaches signal storage is via [`NbaLog::take`] plus code
47/// *outside* this module (Task 7's `commit_nba`) that actually writes it
48/// (SC-036).
49#[derive(Debug, Default)]
50pub struct NbaLog {
51 entries: Vec<NbaUpdate>,
52 masked: Vec<MaskedNbaUpdate>,
53 /// Shared staging counter for `entries` and `masked`, so the commit pass
54 /// can apply one cell's whole and partial writes in staging order.
55 next_order: u32,
56}
57
58/// A staged nonblocking write to PART of a cell -- the bits set in `mask`
59/// (`q[3:0] <= v`, `{a, b} <= v`) -- and/or to one row of a memory cell
60/// (`mem[i] <= v`, `row: Some(i)`). `value` carries the new bits already
61/// shifted into position; bits outside `mask` are ignored. `mask` has one
62/// word per 64 bits of the cell (or row) -- or, when `window` is `Some(w)`
63/// (a write to part of a huge vector), one word per word of the span
64/// starting at plane word `w`, `value` and `mask` covering only that span.
65#[derive(Clone, Debug)]
66pub struct MaskedNbaUpdate {
67 pub cell: StateCellId,
68 pub row: Option<u64>,
69 pub value: FourStateValue,
70 pub mask: Vec<u64>,
71 pub window: Option<u32>,
72 pub source: ProcessId,
73 pub order: u32,
74 /// A nonblocking event trigger (`->> e`, LRM 15.5.2): the update is not a
75 /// value to merge but "trigger the named event" -- the commit pass bumps
76 /// the event cell's trigger count and stamps it with the commit time
77 /// (`value` and `mask` are unused).
78 pub pulse: bool,
79 /// A nonblocking write of one element of the queue or dynamic array
80 /// whose handle the cell (or row) holds (`a[2][k] <= v`, LRM 1800-2023
81 /// 10.4.2, 7.10): the commit pass looks the handle up *then*, so an
82 /// out-of-range `index` is ignored at update time. `mask` carries the
83 /// element's words; `value` is unused.
84 pub coll: Option<CollWrite>,
85 /// A nonblocking write of (part of) a class property
86 /// (`obj.p <= v`, `obj.v[3:0] <= v`, LRM 1800-2023 10.4.2, 8.x): the
87 /// update names no cell, only the object, which the staged write keeps
88 /// alive until it commits. `value` and `mask` are unused.
89 pub obj: Option<ObjWrite>,
90}
91
92/// The property bits a staged class-property write replaces (see
93/// [`MaskedNbaUpdate::obj`]). Holds one reference on `h`, released when the
94/// write commits ([`ObjWrite::commit`]).
95#[derive(Clone, Debug)]
96pub struct ObjWrite {
97 pub h: u64,
98 pub offset: usize,
99 /// The property's packed words, the new bits already in position.
100 pub val: Vec<u64>,
101 /// The bits of `val` that are written; the rest of the property stays.
102 pub mask: Vec<u64>,
103 /// Not a property write: a nonblocking trigger (`->> e`, LRM 15.5.1) of
104 /// class event `h` (`sched::class_event`); `offset`, `val` and `mask` are
105 /// unused and `h` is no object (nothing is retained).
106 pub class_event: bool,
107}
108
109impl ObjWrite {
110 /// Retains the object for the write's lifetime.
111 pub fn new(h: u64, offset: usize, val: Vec<u64>, mask: Vec<u64>) -> Self {
112 crate::objects::retain(h);
113 Self {
114 h,
115 offset,
116 val,
117 mask,
118 class_event: false,
119 }
120 }
121
122 /// A staged `->> e` of class event `id`.
123 pub fn class_event(id: u64) -> Self {
124 Self {
125 h: id,
126 offset: 0,
127 val: Vec::new(),
128 mask: Vec::new(),
129 class_event: true,
130 }
131 }
132
133 /// Merges the bits into the property (a no-op if the object is gone, which
134 /// the retain prevents) and drops the reference.
135 pub fn commit(self) {
136 debug_assert!(
137 !self.class_event,
138 "a class-event trigger commits via commit_nba"
139 );
140 crate::objects::field_merge(self.h, self.offset, &self.val, &self.mask);
141 crate::objects::release(self.h);
142 }
143}
144
145/// The element a staged collection write targets (see
146/// [`MaskedNbaUpdate::coll`]).
147#[derive(Clone, Debug)]
148pub struct CollWrite {
149 pub shape: crate::collections::Shape,
150 pub index: i64,
151 /// An associative array's key (`m[k] <= v`, evaluated when staged):
152 /// the element is found by it, not by `index`.
153 pub key: Option<crate::collections::Key>,
154 /// A queue: writing `index == size` appends.
155 pub queue: bool,
156}
157
158impl NbaLog {
159 pub fn new() -> Self {
160 Self::default()
161 }
162
163 fn bump_order(&mut self) -> u32 {
164 let order = self.next_order;
165 self.next_order += 1;
166 order
167 }
168
169 /// Appends a staged whole-cell write, ordered after every write staged
170 /// before it (SC-034 — staging only, this call never applies anything).
171 pub fn stage(&mut self, cell: StateCellId, value: FourStateValue, source: ProcessId) {
172 let order = self.bump_order();
173 self.entries.push(NbaUpdate {
174 cell,
175 value,
176 source,
177 order,
178 });
179 }
180
181 /// Appends a staged partial / memory-row write (see [`MaskedNbaUpdate`]).
182 pub fn stage_masked(
183 &mut self,
184 cell: StateCellId,
185 row: Option<u64>,
186 value: FourStateValue,
187 mask: Vec<u64>,
188 window: Option<u32>,
189 source: ProcessId,
190 ) {
191 let order = self.bump_order();
192 self.masked.push(MaskedNbaUpdate {
193 cell,
194 row,
195 value,
196 mask,
197 window,
198 source,
199 order,
200 pulse: false,
201 coll: None,
202 obj: None,
203 });
204 }
205
206 /// Appends a staged nonblocking write of element `coll.index` (`elem`:
207 /// its words) of the collection whose handle `cell` (row `row`) holds.
208 pub fn stage_coll(
209 &mut self,
210 cell: StateCellId,
211 row: Option<u64>,
212 elem: Vec<u64>,
213 coll: CollWrite,
214 source: ProcessId,
215 ) {
216 let order = self.bump_order();
217 self.masked.push(MaskedNbaUpdate {
218 cell,
219 row,
220 value: FourStateValue::Narrow(0),
221 mask: elem,
222 window: None,
223 source,
224 order,
225 pulse: false,
226 coll: Some(coll),
227 obj: None,
228 });
229 }
230
231 /// Appends a staged nonblocking write of class-property bits.
232 pub fn stage_obj(&mut self, w: ObjWrite, source: ProcessId) {
233 let order = self.bump_order();
234 self.masked.push(MaskedNbaUpdate {
235 cell: StateCellId(0),
236 row: None,
237 value: FourStateValue::Narrow(0),
238 mask: Vec::new(),
239 window: None,
240 source,
241 order,
242 pulse: false,
243 coll: None,
244 obj: Some(w),
245 });
246 }
247
248 /// Appends a staged nonblocking event trigger (`->> e`) of event `cell`
249 /// (or of row `row` of an event array's memory cell), ordered with every
250 /// other staged write.
251 pub fn stage_pulse(&mut self, cell: StateCellId, row: Option<u64>, source: ProcessId) {
252 let order = self.bump_order();
253 self.masked.push(MaskedNbaUpdate {
254 cell,
255 row,
256 value: FourStateValue::Narrow(0),
257 mask: Vec::new(),
258 window: None,
259 source,
260 order,
261 pulse: true,
262 coll: None,
263 obj: None,
264 });
265 }
266
267 pub fn is_empty(&self) -> bool {
268 self.entries.is_empty() && self.masked.is_empty()
269 }
270
271 pub fn len(&self) -> usize {
272 self.entries.len() + self.masked.len()
273 }
274
275 /// Empties the log (SC-037: called by the outer driver on `now` advance).
276 /// Idempotent — clearing an already-empty log is a no-op, never a panic.
277 pub fn clear(&mut self) {
278 self.entries.clear();
279 self.masked.clear();
280 self.next_order = 0;
281 }
282
283 /// Drains and returns every staged whole-cell entry (what Task 7's
284 /// commit pass consumes). A genuine drain, not a clone: a second `take()`
285 /// with no intervening `stage()` returns `[]`, so the same entries can
286 /// never be committed twice. Resets the order counter only once the
287 /// partial writes are drained too (`take_masked`).
288 pub fn take(&mut self) -> Vec<NbaUpdate> {
289 if self.masked.is_empty() {
290 self.next_order = 0;
291 }
292 std::mem::take(&mut self.entries)
293 }
294
295 /// Drains every staged partial / memory-row write.
296 pub fn take_masked(&mut self) -> Vec<MaskedNbaUpdate> {
297 if self.entries.is_empty() {
298 self.next_order = 0;
299 }
300 std::mem::take(&mut self.masked)
301 }
302}
303
304#[cfg(test)]
305mod tests {
306 // Deliberately only these two `state` types, never `SignalAccess` — see
307 // `no_direct_apply_path_exists_on_nba_log` below, which points at this
308 // `use` list as the compile-time half of its proof.
309 use crate::state::{FourStateValue, StateCellId};
310
311 use super::{NbaLog, NbaUpdate};
312
313 #[test]
314 fn stage_appends_without_applying_to_any_state() {
315 let mut log = NbaLog::new();
316 assert!(log.is_empty());
317
318 log.stage(StateCellId(1), FourStateValue::Narrow(5), 100);
319 log.stage(StateCellId(1), FourStateValue::Narrow(9), 200);
320
321 // SC-034's acceptance, at the log level: nothing above could have
322 // changed a read path — `stage`'s own signature has no write-capable
323 // parameter, so this assertion is backed by a type-level guarantee,
324 // not just today's behavior.
325 assert_eq!(log.len(), 2);
326 assert!(!log.is_empty());
327
328 let entries = log.take();
329 assert_eq!(entries.len(), 2);
330 assert_eq!(entries[0].source, 100);
331 assert_eq!(entries[1].source, 200);
332 assert!(matches!(entries[0].value, FourStateValue::Narrow(5)));
333 assert!(matches!(entries[1].value, FourStateValue::Narrow(9)));
334 }
335
336 #[test]
337 fn order_field_is_populated_in_strict_push_order() {
338 let mut log = NbaLog::new();
339 log.stage(StateCellId(7), FourStateValue::Narrow(1), 1);
340 log.stage(StateCellId(3), FourStateValue::Narrow(2), 2);
341 log.stage(StateCellId(7), FourStateValue::Narrow(3), 1);
342
343 let entries = log.take();
344 assert_eq!(entries[0].order, 0);
345 assert_eq!(entries[1].order, 1);
346 assert_eq!(entries[2].order, 2);
347 }
348
349 #[test]
350 fn clear_empties_the_log_and_is_idempotent() {
351 let mut log = NbaLog::new();
352 log.stage(StateCellId(1), FourStateValue::Narrow(1), 1);
353 log.stage(StateCellId(2), FourStateValue::Narrow(2), 1);
354 assert_eq!(log.len(), 2);
355
356 log.clear();
357 assert!(log.is_empty());
358 assert_eq!(log.len(), 0);
359
360 // Idempotent: clearing an already-empty log must not panic.
361 log.clear();
362 assert!(log.is_empty());
363 }
364
365 #[test]
366 fn take_drains_exactly_once() {
367 let mut log = NbaLog::new();
368 log.stage(StateCellId(1), FourStateValue::Narrow(1), 1);
369 log.stage(StateCellId(2), FourStateValue::Narrow(2), 2);
370
371 let first = log.take();
372 assert_eq!(first.len(), 2);
373 assert!(log.is_empty());
374
375 // A second take with no intervening stage must return nothing —
376 // fails if `take` cloned instead of draining (double-commit hazard).
377 let second = log.take();
378 assert!(second.is_empty());
379 }
380
381 /// SC-036, negative/compile-level: this test file's own `use` list (see
382 /// the top of this module) only names `StateCellId`/`FourStateValue`,
383 /// never `SignalAccess` — a reviewer can point at that as evidence
384 /// `NbaLog` cannot call a write API even if it wanted to. The runtime-
385 /// checkable half of the same claim: `NbaLog` is exactly its two staging
386 /// `Vec`s plus the shared order counter -- no embedded arena pointer or
387 /// other write handle.
388 #[test]
389 fn no_direct_apply_path_exists_on_nba_log() {
390 #[allow(dead_code)]
391 struct ExpectedShape {
392 entries: Vec<NbaUpdate>,
393 masked: Vec<super::MaskedNbaUpdate>,
394 next_order: u32,
395 }
396 assert_eq!(
397 std::mem::size_of::<NbaLog>(),
398 std::mem::size_of::<ExpectedShape>()
399 );
400 }
401
402 /// SC-035's staging-layer contribution: several processes staging in the
403 /// same timestep all land inertly in the log — nothing here has a partial-
404 /// apply path, so nothing becomes visible until one `take()` drains the
405 /// whole batch at once for the (Task 7) commit pass to apply together.
406 /// The full "visible atomically, not incrementally" acceptance requires an
407 /// actual commit/apply step and is Task 7's `commit_nba` test — this test
408 /// is the achievable proxy at this layer: staging never applies, no
409 /// matter how many processes or writes are interleaved first.
410 #[test]
411 fn several_processes_staging_in_one_timestep_stay_unapplied_until_one_take() {
412 let mut log = NbaLog::new();
413 log.stage(StateCellId(1), FourStateValue::Narrow(10), 1);
414 log.stage(StateCellId(2), FourStateValue::Narrow(20), 2);
415 log.stage(StateCellId(1), FourStateValue::Narrow(30), 3);
416 log.stage(StateCellId(3), FourStateValue::Narrow(40), 4);
417
418 // All four stay staged together — no incremental drain happened as a
419 // side effect of any individual `stage` call.
420 assert_eq!(log.len(), 4);
421
422 let batch = log.take();
423 assert_eq!(
424 batch.len(),
425 4,
426 "the whole timestep's writes drain as one batch"
427 );
428 assert!(log.is_empty());
429 }
430}