Skip to main content

vj_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}