Skip to main content

vj_sim/sched/
process_table.rs

1//! Process table & descriptor storage — Task 11 ("Ready-set unit of work &
2//! `ProcessTable`", `docs/superpowers/plans/2026-09-23-wave-A-sched.md`;
3//! ledger SC-057, SC-058, SC-063).
4//!
5//! `ProcessDescriptor` (per registered process), `ProcessTable::register`/
6//! `get`, and the `Mutex`/`AtomicBool` interior-mutability shape were shipped
7//! by an earlier commit (`555998c`/`3c69852`, scoped to `["SC-061"]` — making
8//! Task 12's `ResumeDriver::resume_for` dispatch `resume()` calls from
9//! multiple OS threads while still guaranteeing mutual exclusion per process,
10//! via `ProcessDescriptor::compiled`'s own `Mutex`, never an external `&mut`
11//! borrow of the whole table; see `resume.rs`'s module doc). This commit adds
12//! the rest of Task 11: `ProcessTable` is now genuinely `slotmap::SlotMap`-
13//! backed (SC-063's literal requirement, not just behaviorally equivalent to
14//! it — a `Vec` did not satisfy it no matter how correct its behavior was),
15//! with `register`'s/`get`'s public signatures unchanged so `resume.rs`
16//! required no edit; `WorkUnit` (SC-057); and `form_ready_set` (SC-058, with
17//! one documented signature deviation — see its own doc comment — since
18//! Task 10's `QueuedGate` does not exist yet in this tree).
19
20use std::sync::Mutex;
21use std::sync::atomic::AtomicBool;
22
23use vj_abi::Region;
24
25use super::{
26    CompiledProcess, JoinKind, PendingActivation, ProcessId, RegionQueuePushError, RegionQueues,
27    ResumeArg,
28};
29use crate::process::WaitSpec;
30
31/// One resumable process's scheduler-owned bookkeeping (scheduler.md §5; ledger
32/// SC-063's named shape, plus the fork/join fields Task 11/D1a add and the
33/// `Mutex`/`AtomicBool` review fix Task 11 records for SC-061 — see the plan's
34/// Task 11 "Review fix (SC-061 concurrency, Task 12)" note).
35pub struct ProcessDescriptor {
36    /// The compiled kernel this descriptor drives. `Mutex`-wrapped (not a bare
37    /// `Box`) so `ResumeDriver` can reach it through a *shared* `&ProcessTable`
38    /// and still get exclusive `&mut` access for the duration of one `resume()`
39    /// call — the actual SC-061 guarantee (`Mutex<T>: Sync` since
40    /// `CompiledProcess: Send`).
41    pub compiled: Mutex<Box<dyn CompiledProcess>>,
42    pub queued_this_pass: AtomicBool,
43    /// `ResumeDriver::start`'s once-only CAS (SC-059, Task 12, `resume.rs`).
44    /// Not written by this file.
45    pub started: AtomicBool,
46    pub wait_state: Option<WaitSpec>,
47    pub fork_parent: Option<ProcessId>,
48    /// The name(s) a `disable <label>;` anywhere may single this live
49    /// process out by (LRM 9.6.2) -- set once, at `Suspend::ForkJoin`
50    /// handling time, from the spawning `SpawnSpec::disable_name` this
51    /// process's own fork branch carried (`sched::control::Engine`'s own
52    /// `Suspend::ForkJoin` arm, its one writer); empty for a process no
53    /// label could ever single out (an unnamed branch of an unnamed fork,
54    /// or a process not spawned by a fork at all). `Engine::
55    /// collect_disable_named` is its one reader.
56    pub disable_names: Vec<String>,
57    /// Every live-or-recently-live immediate child of this process, across
58    /// ALL of its forks (LRM 9.6.1: `wait fork` waits for every immediate
59    /// child, 9.6.3: `disable fork` ends every descendant), oldest fork
60    /// first. `handle_fork` prunes entries already reclaimed.
61    pub fork_children: Vec<ProcessId>,
62    /// How many forks this process has executed: the generation of its
63    /// MOST RECENT fork. The join bookkeeping below (`fork_join_kind`,
64    /// `fork_remaining`, `fork_any_signaled`) belongs to that fork only; a
65    /// child of an earlier fork (`fork_group` != this) never touches it.
66    pub fork_gen: u64,
67    /// On a fork child: the parent's `fork_gen` at the moment this child was
68    /// spawned, i.e. which fork of the parent it belongs to.
69    pub fork_group: u64,
70    pub registration_order: u64,
71    pub fork_join_kind: Option<JoinKind>,
72    pub fork_remaining: u32,
73    pub fork_any_signaled: bool,
74    /// Task 9 (B-sched wave, SC-125): `Some(n)` while this process is
75    /// suspended via `Suspend::WaitFork`, counting how many of its own
76    /// `fork_children` were still live (not yet `Suspend::Done` + reclaimed)
77    /// at the moment `Engine::apply_suspend` installed this wait
78    /// (`apply_wait_fork`, `sched/control.rs`). Independent of
79    /// `fork_join_kind`/`fork_remaining` above (a real `wait fork;` is
80    /// always preceded by a `fork ... join_none`, which leaves
81    /// `fork_join_kind` permanently at `Some(JoinKind::None)` -- see
82    /// `handle_fork`'s own doc comment -- so there is no real overlap
83    /// between the two mechanisms). `None` when not currently waiting;
84    /// cleared back to `None` by `ResumeDriver::on_child_done` the instant
85    /// it reaches 0 and the parent has been notified via
86    /// `ResumeArg::AllForkChildrenDone`.
87    pub wait_fork_remaining: Option<u32>,
88    /// Task 9 (SC-125): `Some((order, next_index))` while this process is
89    /// suspended via `Suspend::WaitOrder(order)` (`apply_wait_order`,
90    /// `sched/control.rs`) -- `next_index` is the index into `order` of the
91    /// next completion this parent expects. `None` when not currently
92    /// waiting on an ordered join.
93    pub wait_order: Option<(Vec<ProcessId>, usize)>,
94    /// Task 9 (SC-125): latched `true` the first time a `wait_order`-
95    /// suspended parent observes an out-of-order completion
96    /// (`ResumeDriver::on_child_done`). Once set, no later completion can
97    /// ever satisfy this parent's wait -- every subsequent `on_child_done`
98    /// call against it returns `Ok(None)`, never re-fires the violation and
99    /// never silently accepts a reordering. The violation itself is
100    /// surfaced exactly once, as `Err(ResumeError::WaitOrderViolated(child))`
101    /// from the `on_child_done` call that first set this flag.
102    pub wait_order_violated: bool,
103    /// Task 9 (A2-part2, CG-102): this process's own `ProcessSideTable::
104    /// region_id`, threaded from `SchedulerControl::register_process`'s new
105    /// parameter (`sched/control.rs`'s own doc comment) -- defaults to `0`
106    /// for a process registered directly through `ProcessTable::register`
107    /// (fork children, and every Task 11/12-owned unit test in this file/
108    /// `finals.rs`/`resume.rs`, none of which has any notion of `region_id`
109    /// -- see `set_region_id`'s own doc comment for why this field's
110    /// initial value is set here rather than by widening `register`'s own
111    /// signature), which is also v1's own real, correct value for every one
112    /// of those processes (ARCHITECTURE §4 item 1: a single conservative
113    /// partition).
114    pub region_id: u32,
115    /// Classes plan Task 13 (LRM 9.7): `true` between `p.suspend()` and
116    /// `p.resume()`. A suspended process's activations are parked (see
117    /// `parked`) instead of dispatched.
118    pub suspended: bool,
119    /// The activation argument that arrived while `suspended`; replayed by
120    /// `p.resume()`. Later arrivals coalesce into it (the last one wins).
121    pub parked: Option<ResumeArg>,
122    /// `Some(t)` while this process is blocked in `t.await()`.
123    pub awaiting: Option<ProcessId>,
124    /// Classes plan Task 17 (LRM 15.3): `true` while this process is blocked
125    /// in a synchronisation-object call (`semaphore.get`) that the matching
126    /// `put` has not yet released ([`super::ProcessRequest::SyncBlock`]/
127    /// `SyncWake`).
128    pub sync_blocked: bool,
129    /// `true` while the process is yielded on a wait (`#`, `@`, `wait`,
130    /// `join`, ...) -- `process::WAITING`. Maintained only once some `process`
131    /// handle exists (`Engine::any_tracked`), so programs that never use the
132    /// class pay nothing.
133    pub waiting: bool,
134    /// The event wait this process is blocked in (`Suspend::WaitEvents`),
135    /// kept while `waiting` so `p.suspend()` can desensitise it (LRM 9.7.4)
136    /// and `p.resume()` resensitise it (LRM 9.7.5). Maintained under the
137    /// same gate as `waiting`.
138    pub wait_set: Option<super::EventWaitSet>,
139    /// `true` while `wait_set`'s dynamic sensitivity is removed because the
140    /// process is suspended; `resume()` reinstalls it.
141    pub desensitized: bool,
142    /// Whether this process runs in the Reactive region (LRM 17.7.3: a
143    /// checker's procedures, continuous assignments and output formal
144    /// connections are "scheduled in the Reactive region, similarly to
145    /// programs"). Every wake-up of such a process -- a sensitivity match,
146    /// including the one a write made by another Reactive process causes --
147    /// lands in the Reactive queue instead of Active. `false` for every
148    /// design process.
149    pub reactive: bool,
150    /// A program `initial` thread or a fork descendant of one (LRM 24.3).
151    pub program_thread: bool,
152}
153
154impl ProcessDescriptor {
155    fn new(compiled: Box<dyn CompiledProcess>, registration_order: u64) -> Self {
156        ProcessDescriptor {
157            compiled: Mutex::new(compiled),
158            queued_this_pass: AtomicBool::new(false),
159            started: AtomicBool::new(false),
160            wait_state: None,
161            fork_parent: None,
162            disable_names: Vec::new(),
163            fork_children: Vec::new(),
164            fork_gen: 0,
165            fork_group: 0,
166            registration_order,
167            fork_join_kind: None,
168            fork_remaining: 0,
169            fork_any_signaled: false,
170            wait_fork_remaining: None,
171            wait_order: None,
172            wait_order_violated: false,
173            region_id: 0,
174            suspended: false,
175            parked: None,
176            awaiting: None,
177            sync_blocked: false,
178            waiting: false,
179            wait_set: None,
180            desensitized: false,
181            reactive: false,
182            program_thread: false,
183        }
184    }
185}
186
187slotmap::new_key_type! {
188    /// The `SlotMap`'s own internal key type (SC-063's literal "ProcessTable
189    /// is a `SlotMap<ProcessId, ProcessDescriptor{...}>`" requirement). Never
190    /// exposed outside this module — `ProcessId` (a plain, stable, densely-
191    /// assigned `u32`) remains the only process identity every other module
192    /// (`resume.rs`, `queues.rs`, `sched/mod.rs`'s own types) sees, via the
193    /// side index below.
194    pub struct ProcessKey;
195}
196
197/// Process storage, genuinely `slotmap::SlotMap`-backed (SC-063). `ProcessId`
198/// (`u32`) stays the crate-visible identity: v1 never frees/reuses a slot
199/// (fork children get fresh ids from the same monotonic sequence; nothing
200/// calls `SlotMap::remove`), so `order_to_key[id as usize]` is a stable,
201/// densely-assigned `ProcessId -> ProcessKey` side index — the same "assigned
202/// order equals registration order, for the lifetime of a run" invariant the
203/// previous `Vec`-backed slice already relied on, preserved exactly across
204/// this migration.
205#[derive(Default)]
206pub struct ProcessTable {
207    slots: slotmap::SlotMap<ProcessKey, ProcessDescriptor>,
208    order_to_key: Vec<ProcessKey>,
209}
210
211impl ProcessTable {
212    pub fn new() -> Self {
213        ProcessTable {
214            slots: slotmap::SlotMap::with_key(),
215            order_to_key: Vec::new(),
216        }
217    }
218
219    /// Registers a new process, assigning it the next `ProcessId` and
220    /// `registration_order` (monotonic, never reused — v1 never frees a slot,
221    /// matching Task 11's documented assumption that `ProcessId` assignment
222    /// order equals `registration_order` for the lifetime of a run).
223    /// Signature unchanged by the slotmap migration — `resume.rs` (Task 12)
224    /// keeps compiling with no edit of its own.
225    pub fn register(&mut self, compiled: Box<dyn CompiledProcess>) -> ProcessId {
226        let id = self.order_to_key.len() as ProcessId;
227        let key = self
228            .slots
229            .insert(ProcessDescriptor::new(compiled, id as u64));
230        self.order_to_key.push(key);
231        id
232    }
233
234    /// How many `ProcessId`s were ever assigned: ids are dense, `0..count()`.
235    pub fn count(&self) -> usize {
236        self.order_to_key.len()
237    }
238
239    /// Shared-reference lookup (Task 11's review fix) — lets `ResumeDriver`
240    /// reach a descriptor's `Mutex`/`AtomicBool` fields through a shared
241    /// `&ProcessTable`, never requiring `&mut` access to the whole table.
242    /// Signature unchanged by the slotmap migration.
243    pub fn get(&self, id: ProcessId) -> Option<&ProcessDescriptor> {
244        let key = *self.order_to_key.get(id as usize)?;
245        self.slots.get(key)
246    }
247
248    /// Mutable lookup — **added by Task 12** (a minimal, same-track,
249    /// additive extension to this Task-11-owned file; recorded in
250    /// `docs/CONTRACTS.md`/this commit's deviations, not a rewrite of
251    /// anything Task 11 shipped). `handle_fork`/`on_child_done` mutate
252    /// `fork_children`/`fork_parent`/`fork_join_kind`/`fork_remaining`/
253    /// `fork_any_signaled` directly — plain, non-`Mutex` fields per Task
254    /// 11/D1a — which needs exclusive access to the whole table via
255    /// `&mut ProcessTable`. This does not reopen SC-061: Task 12's review
256    /// fix note (resume.rs) already establishes this wave never calls
257    /// `handle_fork`/`on_child_done` concurrently with `resume_for`/`start`
258    /// (v1's default `SequentialController` dispatches everything from one
259    /// thread; only the SC-061 stress test deliberately drives `resume_for`
260    /// from many, and never touches fork state).
261    pub fn get_mut(&mut self, id: ProcessId) -> Option<&mut ProcessDescriptor> {
262        let key = *self.order_to_key.get(id as usize)?;
263        self.slots.get_mut(key)
264    }
265
266    /// **Task 9 (A2-part2, CG-102).** Sets `id`'s own `region_id`, called by
267    /// `SchedulerControl::register_process` (`sched/control.rs`) right after
268    /// `register` -- a small, additive, same-track extension of this Task-
269    /// 11-owned file (mirroring Task 12's own `get_mut` precedent, recorded
270    /// there identically), rather than widening `register`'s own signature:
271    /// `register`'s direct callers (`finals.rs`, `resume.rs`, this file's
272    /// own unit tests, fork-child minting) have no notion of `region_id` at
273    /// all and are unrelated to CG-102's scheduler-dispatch scope, so
274    /// leaving `register`'s arity untouched keeps this a minimal, targeted
275    /// addition instead of churning every one of those call sites for a
276    /// field they'd only ever pass `0` for anyway (`ProcessDescriptor::new`
277    /// already defaults `region_id` to that same real v1 value).
278    pub fn set_region_id(&mut self, id: ProcessId, region_id: u32) {
279        if let Some(desc) = self.get_mut(id) {
280            desc.region_id = region_id;
281        }
282    }
283
284    /// Marks `id` as a Reactive-region process ([`ProcessDescriptor::reactive`]).
285    pub fn set_reactive(&mut self, id: ProcessId) {
286        if let Some(desc) = self.get_mut(id) {
287            desc.reactive = true;
288        }
289    }
290
291    /// Whether any live fork child still belongs to a program thread
292    /// (LRM 24.3).
293    pub fn any_live_program_child(&self) -> bool {
294        self.slots
295            .values()
296            .any(|d| d.program_thread && d.fork_parent.is_some())
297    }
298
299    /// Whether `id` runs in the Reactive region. `false` for an unknown id.
300    pub fn is_reactive(&self, id: ProcessId) -> bool {
301        self.get(id).is_some_and(|d| d.reactive)
302    }
303
304    /// **Task 9 (A2-part2, CG-102).** `region_id(&self, id: ProcessId) ->
305    /// u32` -- the plan's own literal getter shape. Returns `0` (v1's own
306    /// real, correct single-conservative-partition value, not a sentinel)
307    /// for an unknown `id` rather than panicking or returning `Option`: a
308    /// caller reaching this (e.g. `dispatch_ready_set`) only ever holds
309    /// `ProcessId`s this same `Engine` minted, so an unknown id here would
310    /// already indicate a bug in the caller's own bookkeeping, not a
311    /// legitimate "no region_id set" state this getter needs to
312    /// distinguish.
313    pub fn region_id(&self, id: ProcessId) -> u32 {
314        self.get(id).map(|d| d.region_id).unwrap_or(0)
315    }
316
317    /// **Task 7 (B-sched wave, ledger SC-062): the `SlotMap::remove`
318    /// wrapper this file's own module doc comment already promised** ("...
319    /// stable... until eventually calls `SlotMap::remove` -- a forward
320    /// reference to work that was never done" -- now done). Removes `id`'s
321    /// descriptor and returns it; a later `get`/`get_mut` for the same `id`
322    /// returns `None`.
323    ///
324    /// **Deviation from the plan's literal text (recorded here and in
325    /// `docs/CONTRACTS.md`):** the plan additionally describes tombstoning
326    /// `id`'s own `order_to_key` side-index slot. That is unnecessary and
327    /// deliberately not done: `slotmap::SlotMap::remove` already bumps the
328    /// removed slot's internal generation, so the *same* `ProcessKey`
329    /// value still sitting in `order_to_key[id]` no longer resolves via
330    /// `self.slots.get`/`get_mut` -- this is the entire reason Task 11
331    /// required a genuine `SlotMap` (SC-063) rather than a `Vec` in the
332    /// first place. Adding a second, hand-maintained tombstone layer on
333    /// top of that would only be able to drift out of sync with the
334    /// `SlotMap`'s own source of truth; leaving `order_to_key` untouched
335    /// keeps exactly one source of truth for liveness, and a stale/reused
336    /// `ProcessId` still fails loudly via `get`'s existing `Option` return,
337    /// exactly as the plan's own acceptance requires.
338    pub fn remove(&mut self, id: ProcessId) -> Option<ProcessDescriptor> {
339        let key = *self.order_to_key.get(id as usize)?;
340        self.slots.remove(key)
341    }
342}
343
344/// The scheduler's work-item type: wraps exactly one `resume()` invocation
345/// (SC-057 — "the unit of parallel/schedulable work is one process
346/// activation... within one activation the compiled kernel runs to
347/// completion on one thread and never yields mid-statement"). Distinct from
348/// `determinism::WorkItem` (which additionally carries the ordering key) —
349/// `WorkUnit` is the pre-ordering, "what to run" half.
350#[derive(Clone, Copy, Debug)]
351pub struct WorkUnit {
352    pub proc: ProcessId,
353    pub arg: ResumeArg,
354}
355
356/// Collects every activation ready at one `(SchedTime, Region)` into a single
357/// ready-set object before dispatch (SC-058). Drains `region`'s queue (Task
358/// 4's `RegionQueues`) and hands back the un-sorted `Vec` — Task 2's
359/// `sort_ready_set` is applied by the caller, kept separate so this
360/// function's contract is purely "collect what's ready".
361///
362/// `Region::Nba` has no queue of its own (Task 4/6 — NBA is a staging log,
363/// not a `RegionQueues` member), so it is rejected with the same typed
364/// `RegionQueuePushError::NbaHasNoQueue` `RegionQueues::push` already uses,
365/// exhaustive over all 7 `Region` variants with no wildcard arm — never a
366/// silently-returned empty `Vec`, which a caller could not distinguish from
367/// "genuinely nothing ready this pass".
368///
369/// **Deviation from the plan's literal signature** (recorded in
370/// `docs/CONTRACTS.md` and this task's commit): the plan names a third
371/// parameter, `gate: &QueuedGate`, to reset each drained activation's dedup
372/// flag as it's pulled. `QueuedGate` is Task 10's own type
373/// (`docs/superpowers/plans/2026-09-23-wave-A-sched.md` Task 10, ledger
374/// SC-055/SC-056) and does not exist in this tree as of this commit — Task
375/// 10 has not been implemented yet, and SC-055/SC-056 are not in this task's
376/// assigned ledger scope (`["SC-057","SC-058","SC-063"]`). SC-058's own
377/// literal acceptance text ("all processes ready at the same (time,region)
378/// are collected into one ready-set object before dispatch") says nothing
379/// about a dedup-flag reset, so this task implements exactly that, self-
380/// contained. Task 10, when it lands, is expected to extend this function's
381/// signature (or wrap it) to also reset each drained activation's dedup
382/// entry — either via its own `QueuedGate` or via the `queued_this_pass`
383/// `AtomicBool` already present on each activation's `ProcessDescriptor`
384/// (`ProcessTable::get(act.proc)`), which this task deliberately leaves
385/// untouched rather than inventing Task 10's own type ahead of its task.
386pub fn form_ready_set(
387    queues: &mut RegionQueues,
388    region: Region,
389) -> Result<Vec<PendingActivation>, RegionQueuePushError> {
390    match region {
391        Region::Active => Ok(queues.drain_active()),
392        Region::Inactive => Ok(queues.drain_inactive()),
393        Region::Reactive => Ok(queues.drain_reactive_once()),
394        Region::Postponed => Ok(queues.drain_postponed_once()),
395        Region::Nba => Err(RegionQueuePushError::NbaHasNoQueue),
396        // sva T1 (§1.4, review fix, blocking finding 2): `Region::Preponed`
397        // is queue-less for the identical reason `Region::Nba` is -- a live
398        // SVA thread/snapshot push is never dispatched through
399        // `RegionQueues`/`ProcessTable` as a `PendingActivation` at all.
400        Region::Preponed => Err(RegionQueuePushError::PreponedHasNoQueue),
401        // sva T2 (review fix, blocking finding 2), Observed's half: equally
402        // queue-less -- a live SVA thread is owned by the automaton engine's
403        // own thread table (a later task), never dispatched here either.
404        Region::Observed => Err(RegionQueuePushError::ObservedHasNoQueue),
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411    use crate::sched::{ResumeArg, SchedulerOps, Suspend};
412    use std::sync::atomic::Ordering;
413    use vj_abi::SimHandle;
414
415    struct NoopProcess;
416    impl CompiledProcess for NoopProcess {
417        fn resume(
418            &mut self,
419            _state: SimHandle,
420            _arg: ResumeArg,
421            _ops: &dyn SchedulerOps,
422        ) -> Suspend {
423            Suspend::Done
424        }
425    }
426
427    #[test]
428    fn register_assigns_ids_in_call_order() {
429        let mut table = ProcessTable::new();
430        let a = table.register(Box::new(NoopProcess));
431        let b = table.register(Box::new(NoopProcess));
432        let c = table.register(Box::new(NoopProcess));
433        assert_eq!([a, b, c], [0, 1, 2]);
434        assert_eq!(table.get(a).unwrap().registration_order, 0);
435        assert_eq!(table.get(b).unwrap().registration_order, 1);
436        assert_eq!(table.get(c).unwrap().registration_order, 2);
437    }
438
439    #[test]
440    fn get_returns_none_for_an_unregistered_id() {
441        let mut table = ProcessTable::new();
442        let only = table.register(Box::new(NoopProcess));
443        assert!(table.get(only).is_some());
444        assert!(table.get(only + 1).is_none());
445    }
446
447    #[test]
448    fn new_descriptor_starts_unqueued_not_started_and_with_no_fork_state() {
449        let mut table = ProcessTable::new();
450        let id = table.register(Box::new(NoopProcess));
451        let desc = table.get(id).unwrap();
452        assert!(!desc.queued_this_pass.load(Ordering::Relaxed));
453        assert!(!desc.started.load(Ordering::Relaxed));
454        assert!(desc.wait_state.is_none());
455        assert!(desc.fork_parent.is_none());
456        assert!(desc.fork_children.is_empty());
457        assert!(desc.fork_join_kind.is_none());
458        assert_eq!(desc.fork_remaining, 0);
459        assert!(!desc.fork_any_signaled);
460        // Task 9 (SC-125): a freshly registered process is not mid-`wait
461        // fork`/`wait_order` either.
462        assert!(desc.wait_fork_remaining.is_none());
463        assert!(desc.wait_order.is_none());
464        assert!(!desc.wait_order_violated);
465        // `compiled` is reachable only through the lock, never as a bare
466        // `Box` — proves it is `Mutex<Box<dyn CompiledProcess>>`.
467        let _guard = desc.compiled.lock().unwrap();
468    }
469
470    // --- Task 11 remaining work (SC-057, SC-058, SC-063) ---
471
472    /// SC-063 — the slotmap migration's own regression proof beyond re-running
473    /// the tests above: register 5 processes, assert the returned `ProcessId`s
474    /// are still usable as stable, densely-assigned keys matching the
475    /// pre-migration `Vec`-index behavior exactly. Fails if the
476    /// `ProcessId <-> ProcessKey` side-index has an off-by-one or reordering
477    /// bug the `Vec`-backed version couldn't have had.
478    #[test]
479    fn slotmap_migration_preserves_ids_and_registration_order() {
480        let mut table = ProcessTable::new();
481        let ids: Vec<ProcessId> = (0..5)
482            .map(|_| table.register(Box::new(NoopProcess)))
483            .collect();
484        assert_eq!(ids, vec![0, 1, 2, 3, 4]);
485        for id in ids {
486            assert_eq!(
487                table.get(id).unwrap().registration_order,
488                id as u64,
489                "ProcessId {id} must map to the descriptor registered at that order"
490            );
491        }
492    }
493
494    /// SC-063, review fix — a behavior-preserving migration alone doesn't
495    /// prove the literal type requirement, since a `Vec` and a fresh
496    /// `SlotMap` are behaviorally indistinguishable from outside when
497    /// nothing is ever removed. Source-text scan (no shell plumbing, pure
498    /// Rust `std::fs::read_to_string`), and — unlike the first version of
499    /// this test — genuinely non-tautological:
500    ///
501    /// - It strips every `#[cfg(test)]` body before scanning, via the
502    ///   shared `determinism::scan_support::strip_cfg_test_blocks` (the
503    ///   same technique, reused rather than reimplemented, that Task 3's
504    ///   `dt006_no_thread_spawn_or_rollback_code_outside_determinism_module`
505    ///   and Task 22's `notify_change` scan already use, for the identical
506    ///   reason). Without this, the scan reads its *own* assertion's string
507    ///   literal back and is trivially true regardless of `ProcessTable`'s
508    ///   real storage.
509    /// - It searches for the field declaration's exact type signature
510    ///   (`"slots: slotmap::SlotMap<ProcessKey"`), not the generic phrase
511    ///   `"slotmap::SlotMap"`. That phrase also appears, correctly, in this
512    ///   module's own doc comments describing the migration (this doc
513    ///   comment included) — a scan for just the phrase would keep passing
514    ///   even after `slots` regressed to `Vec<ProcessDescriptor>`, as long
515    ///   as the prose describing the (no-longer-true) migration was left
516    ///   stale, which is exactly the kind of edit that "happens to keep all
517    ///   other tests green".
518    ///
519    /// `scan_is_genuinely_slotmap_backed` below is shared with
520    /// `slotmap_scan_rejects_a_reverted_vec_even_with_stale_doc_comment_mentions`,
521    /// which proves this check actually fails on a reverted file — the
522    /// thing the original version of this test could never do.
523    fn scan_is_genuinely_slotmap_backed(src: &str) -> bool {
524        let production_only = crate::determinism::scan_support::strip_cfg_test_blocks(src);
525        production_only.contains("slots: slotmap::SlotMap<ProcessKey")
526    }
527
528    #[test]
529    fn process_table_is_genuinely_slotmap_backed() {
530        let src = std::fs::read_to_string(concat!(
531            env!("CARGO_MANIFEST_DIR"),
532            "/src/sched/process_table.rs"
533        ))
534        .expect("must be able to read process_table.rs's own source");
535        assert!(
536            scan_is_genuinely_slotmap_backed(&src),
537            "ProcessTable's `slots` field must be genuinely SlotMap-backed (SC-063), \
538             not just behaviorally equivalent to one -- scanning production code \
539             only (test bodies stripped) for the field's actual declared type"
540        );
541    }
542
543    /// Review-fix regression proof: confirms the check above is not
544    /// tautological by feeding `scan_is_genuinely_slotmap_backed` a
545    /// synthetic "reverted to `Vec`" source that still contains the
546    /// generic phrase `"slotmap::SlotMap"` both in a stale doc comment and
547    /// inside a `#[cfg(test)]` body (exactly the shape that made the
548    /// pre-fix version of this gate unable to ever fail) and asserts the
549    /// real check correctly rejects it.
550    #[test]
551    fn slotmap_scan_rejects_a_reverted_vec_even_with_stale_doc_comment_mentions() {
552        let reverted_src = concat!(
553            "//! Process storage, genuinely `slotmap::SlotMap`-backed (SC-063).\n",
554            "pub struct ProcessTable {\n",
555            "    slots: Vec<ProcessDescriptor>,\n",
556            "}\n",
557            "#[cfg(test)]\n",
558            "mod tests {\n",
559            "    #[test]\n",
560            "    fn t() {\n",
561            "        assert!(\"x\".contains(\"slotmap::SlotMap\"));\n",
562            "    }\n",
563            "}\n",
564        );
565        // Sanity check on the fixture itself: the generic phrase IS present
566        // (in both the doc comment and the #[cfg(test)] body) -- this is
567        // exactly what made the pre-fix gate tautologically true.
568        assert!(reverted_src.contains("slotmap::SlotMap"));
569        assert!(
570            !scan_is_genuinely_slotmap_backed(reverted_src),
571            "a source with `slots: Vec<...>` must be rejected even though the \
572             generic phrase \"slotmap::SlotMap\" still appears in a doc comment \
573             and in a #[cfg(test)] body"
574        );
575    }
576
577    /// Positive control for the same helper, kept separate from the
578    /// filesystem-reading test above: guards against the marker string
579    /// itself silently drifting out of sync with the real field
580    /// declaration on line 92 (e.g. a whitespace/formatting change) by
581    /// checking it against that exact declaration text in isolation.
582    #[test]
583    fn slotmap_scan_accepts_the_actual_current_field_declaration() {
584        assert!(scan_is_genuinely_slotmap_backed(
585            "slots: slotmap::SlotMap<ProcessKey, ProcessDescriptor>,"
586        ));
587    }
588
589    /// SC-058 — push 4 activations at the same `(SchedTime, Region::Active)`
590    /// via `RegionQueues::push` (Task 4); calling `form_ready_set` must
591    /// collect all of them into one `Vec` (not 4 separate calls/objects).
592    #[test]
593    fn form_ready_set_collects_all_activations_at_one_time_region_as_one_object() {
594        use crate::sched::{PendingActivation, RegionQueues, ResumeArg, SchedTime};
595        use vj_abi::Region;
596
597        let mut queues = RegionQueues::new();
598        for i in 0..4u64 {
599            queues
600                .push(
601                    Region::Active,
602                    PendingActivation {
603                        id: i,
604                        proc: i as u32,
605                        arg: ResumeArg::Start,
606                        reconv_iter: 0,
607                        logical_order: (SchedTime { ticks: 5 }, Region::Active, i),
608                    },
609                )
610                .unwrap();
611        }
612
613        let ready = form_ready_set(&mut queues, Region::Active).unwrap();
614        assert_eq!(
615            ready.len(),
616            4,
617            "all processes ready at the same (time, region) must be collected \
618             into one ready-set object before dispatch"
619        );
620    }
621
622    /// SC-058 negative half — `Region::Nba` has no queue of its own (it is
623    /// the NBA staging log, Task 6), so `form_ready_set` must reject it with
624    /// the same typed error `RegionQueues::push` already uses, never a
625    /// silent empty `Vec` that could be confused with "genuinely nothing
626    /// ready this pass".
627    #[test]
628    fn form_ready_set_rejects_nba_with_a_typed_error_not_a_silent_empty_vec() {
629        use crate::sched::{RegionQueuePushError, RegionQueues};
630        use vj_abi::Region;
631
632        let mut queues = RegionQueues::new();
633        let result = form_ready_set(&mut queues, Region::Nba);
634        assert_eq!(result.unwrap_err(), RegionQueuePushError::NbaHasNoQueue);
635    }
636
637    /// sva T1 (§1.4, review fix, blocking finding 2): the identical negative
638    /// half for `Region::Preponed` -- a pure snapshot push with no ready
639    /// processes of its own, never dispatched through `form_ready_set`.
640    #[test]
641    fn form_ready_set_preponed_returns_named_error() {
642        use crate::sched::{RegionQueuePushError, RegionQueues};
643        use vj_abi::Region;
644
645        let mut queues = RegionQueues::new();
646        let result = form_ready_set(&mut queues, Region::Preponed);
647        assert_eq!(
648            result.unwrap_err(),
649            RegionQueuePushError::PreponedHasNoQueue
650        );
651    }
652
653    /// sva T2 (review fix, blocking finding 2), Observed's half: the
654    /// identical negative half for `Region::Observed` -- a live SVA thread
655    /// owned by the automaton engine's own thread table, never dispatched
656    /// through `form_ready_set`.
657    #[test]
658    fn form_ready_set_observed_returns_named_error() {
659        use crate::sched::{RegionQueuePushError, RegionQueues};
660        use vj_abi::Region;
661
662        let mut queues = RegionQueues::new();
663        let result = form_ready_set(&mut queues, Region::Observed);
664        assert_eq!(
665            result.unwrap_err(),
666            RegionQueuePushError::ObservedHasNoQueue
667        );
668    }
669
670    /// SC-057 — a type-level test: `WorkUnit` has no field representing a
671    /// partial statement offset (only `proc`+`arg`); combined with
672    /// `CompiledProcess::resume`'s own signature (unchanged: `&mut self,
673    /// state, arg, ops) -> Suspend`), there is no API in this module that
674    /// could invoke half of a `resume()` call. Checked by `WorkUnit`'s field
675    /// list via exhaustive destructuring (a third field added later without
676    /// updating this test would fail to compile).
677    #[test]
678    fn work_unit_wraps_exactly_one_resume_invocation_no_mid_statement_split() {
679        use crate::sched::ResumeArg;
680
681        let unit = WorkUnit {
682            proc: 7,
683            arg: ResumeArg::Start,
684        };
685        let WorkUnit { proc, arg } = unit;
686        assert_eq!(proc, 7);
687        assert!(matches!(arg, ResumeArg::Start));
688    }
689
690    /// Task 9 (A2-part2, CG-102): a freshly registered process defaults to
691    /// `region_id == 0` (v1's own real value, not a placeholder -- see
692    /// `ProcessDescriptor::region_id`'s own doc comment), and `set_region_id`
693    /// genuinely stores whatever value it is given, read back unchanged by
694    /// `region_id` -- fails if either getter/setter ignores its argument.
695    #[test]
696    fn region_id_defaults_to_zero_and_set_region_id_is_read_back_by_region_id() {
697        let mut table = ProcessTable::new();
698        let id = table.register(Box::new(NoopProcess));
699        assert_eq!(table.region_id(id), 0);
700
701        table.set_region_id(id, 9);
702        assert_eq!(
703            table.region_id(id),
704            9,
705            "set_region_id/region_id must round-trip a real value, not stay pinned at 0"
706        );
707    }
708
709    /// `region_id` on an unregistered id returns the same real v1 default
710    /// (`0`) rather than panicking -- see its own doc comment.
711    #[test]
712    fn region_id_of_an_unregistered_id_is_zero_not_a_panic() {
713        let table = ProcessTable::new();
714        assert_eq!(table.region_id(42), 0);
715    }
716
717    // --- Task 7 (B-sched wave, ledger SC-062) ---
718
719    /// The literal SC-062 acceptance clause this task closes: "the child's
720    /// slot is reclaimed" -- `remove` must drop the descriptor from the
721    /// `SlotMap` itself (not merely mark it dead some other way), proven by
722    /// a `get` afterward returning `None`. Fails if `remove` is a no-op, or
723    /// if it only clears some other field while leaving the slot live.
724    #[test]
725    fn remove_drops_the_descriptor_and_a_later_get_returns_none() {
726        let mut table = ProcessTable::new();
727        let a = table.register(Box::new(NoopProcess));
728        let b = table.register(Box::new(NoopProcess));
729
730        assert!(table.get(a).is_some());
731        let removed = table.remove(a);
732        assert!(
733            removed.is_some(),
734            "remove must return the removed descriptor, not None, for a live id"
735        );
736        assert!(
737            table.get(a).is_none(),
738            "a later get() for a removed ProcessId must return None -- the slot \
739             itself must be gone from the SlotMap, not just logically ignored"
740        );
741        // A sibling process registered before the removed one must be
742        // completely unaffected -- proves `remove` does not disturb the
743        // rest of the table (e.g. by shifting indices the way a `Vec::
744        // remove` would).
745        assert!(
746            table.get(b).is_some(),
747            "removing one process must not affect an unrelated, still-live one"
748        );
749    }
750
751    /// A second `remove` of the same, already-removed id is a harmless
752    /// `None`, never a panic -- `SlotMap::remove`'s own generational-key
753    /// semantics already guarantee this, but this pins it as this crate's
754    /// own contract too (never a silent double-free-shaped bug surfacing
755    /// as a panic instead of a typed `None`).
756    #[test]
757    fn removing_an_already_removed_id_returns_none_not_a_panic() {
758        let mut table = ProcessTable::new();
759        let a = table.register(Box::new(NoopProcess));
760        assert!(table.remove(a).is_some());
761        assert!(table.remove(a).is_none());
762    }
763
764    /// `remove` of an id that was never registered at all returns `None`,
765    /// never a panic -- mirrors `get`'s own established posture for an
766    /// out-of-range id.
767    #[test]
768    fn removing_an_unregistered_id_returns_none_not_a_panic() {
769        let mut table = ProcessTable::new();
770        assert!(table.remove(42).is_none());
771    }
772}