Skip to main content

sv_sim_sim/sched/
queues.rs

1//! SCHED::QUEUES — region queues & the Active/Inactive/NBA delta-cycle
2//! convergence primitives (scheduler.md §1; ARCHITECTURE §6.3; ledger SC-017,
3//! SC-018, SC-019, SC-021, SC-022).
4//!
5//! This module owns the four region queues (`Active`, `Inactive`, `Reactive`,
6//! `Postponed` — NBA has no queue of its own, it is Task 6's staging log) and
7//! the primitives an outer driver (Task 21's `run_until`) composes into the
8//! actual Active/Inactive/NBA fixpoint loop and the Reactive/Postponed
9//! exactly-once-per-timestep passes. This module deliberately does **not**
10//! drain Active/Inactive to a fixpoint internally — that orchestration,
11//! including the livelock guard (Task 15), stays external and pluggable
12//! (this task's own acceptance note).
13
14use std::collections::VecDeque;
15
16use sv_sim_abi::Region;
17
18use crate::sched::PendingActivation;
19
20/// `push`'s one typed-error variant (SC-036's "loud, typed, never silent"
21/// posture, applied here to NBA's non-queue). Never a `debug_assert!` — that
22/// compiles to nothing in release builds, which is exactly the silent-drop
23/// this type exists to prevent (see [`RegionQueues::push`]'s doc comment).
24#[derive(Clone, Copy, PartialEq, Eq, Debug)]
25pub enum RegionQueuePushError {
26    /// NBA has no queue of its own — it is [`crate::sched`]'s NBA staging log
27    /// (Task 6), not a `RegionQueues` member. Pushing `Region::Nba` here is
28    /// always a caller bug, never a legitimate no-op.
29    NbaHasNoQueue,
30    /// **sva T1 (§1.4, review fix, blocking finding 2).** `Region::Preponed`
31    /// has no queue of its own either — it is a pure snapshot push
32    /// (`state::snapshot::SnapshotRing`), fired as an unconditional inline
33    /// side effect at time-slot entry (`sched::control::Engine::run_until`/
34    /// `step_region`), never a region a `CompiledProcess` is "ready" in.
35    /// Pushing `Region::Preponed` here is always a caller bug, the same
36    /// class `NbaHasNoQueue` already names for `Region::Nba`.
37    PreponedHasNoQueue,
38    /// **sva T2 (§1.4, review fix, blocking finding 2), Observed's half.**
39    /// `Region::Observed` has no queue of its own either -- a live SVA
40    /// thread is owned by the automaton engine's own thread table (a later
41    /// task), never dispatched through `RegionQueues`/`ProcessTable` as a
42    /// `PendingActivation`: the Observed-region engine step is reached only
43    /// through the region-callback mechanism (`Engine::push_region_
44    /// callback`/`drain_region_callbacks`), never a queued activation.
45    /// Pushing `Region::Observed` here is always a caller bug, the same
46    /// class `NbaHasNoQueue`/`PreponedHasNoQueue` already name.
47    ObservedHasNoQueue,
48}
49
50impl std::fmt::Display for RegionQueuePushError {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        match self {
53            RegionQueuePushError::NbaHasNoQueue => {
54                write!(
55                    f,
56                    "Region::Nba has no RegionQueues queue; route NBA writes through the NBA staging log (Task 6) instead"
57                )
58            }
59            RegionQueuePushError::PreponedHasNoQueue => {
60                write!(
61                    f,
62                    "Region::Preponed has no RegionQueues queue; it is a pure snapshot push \
63                     (state::snapshot::SnapshotRing), never a region with ready processes"
64                )
65            }
66            RegionQueuePushError::ObservedHasNoQueue => {
67                write!(
68                    f,
69                    "Region::Observed has no RegionQueues queue; a live SVA thread is owned by \
70                     the automaton engine's own thread table, never a region with ready \
71                     processes dispatched as a PendingActivation"
72                )
73            }
74        }
75    }
76}
77
78impl std::error::Error for RegionQueuePushError {}
79
80/// The four region queues (SC-017..SC-022). `active`/`inactive` are
81/// `VecDeque` (FIFO-drained, and re-populated *during* draining by whatever
82/// processes the drained batch — that re-population is what makes the outer
83/// fixpoint loop possible); `reactive`/`postponed` are plain `Vec`s since
84/// they are drained exactly once per timestep and never need FIFO semantics
85/// mid-drain (SC-018, SC-019).
86#[derive(Default)]
87pub struct RegionQueues {
88    active: VecDeque<PendingActivation>,
89    inactive: VecDeque<PendingActivation>,
90    reactive: Vec<PendingActivation>,
91    /// LRM 4.4.2.7 Re-Inactive: a `#0` taken by a Reactive-region process.
92    /// Not a [`Region`] variant (the ABI enum stays at seven); the scheduler
93    /// drains it directly once Reactive is empty.
94    re_inactive: VecDeque<PendingActivation>,
95    postponed: Vec<PendingActivation>,
96}
97
98impl RegionQueues {
99    pub fn new() -> Self {
100        RegionQueues::default()
101    }
102
103    /// Routes `act` to its region's queue. Exhaustive `match region` over all
104    /// 7 [`Region`] variants, no wildcard arm: `Active`/`Inactive`/
105    /// `Reactive`/`Postponed` route to their own queue and return `Ok(())`;
106    /// `Nba`/`Preponed`/`Observed` return their own named
107    /// `Err(RegionQueuePushError)` variant rather than a `debug_assert!`
108    /// (which compiles to nothing — and so silently drops the activation
109    /// with no queue, no panic, no error — in a release build). `push` must
110    /// return `Result` so misuse is loud in every build profile and every
111    /// caller (Tasks 11, 13, 15, 21) must handle/propagate it.
112    pub fn push(
113        &mut self,
114        region: Region,
115        act: PendingActivation,
116    ) -> Result<(), RegionQueuePushError> {
117        match region {
118            Region::Active => {
119                self.active.push_back(act);
120                Ok(())
121            }
122            Region::Inactive => {
123                self.inactive.push_back(act);
124                Ok(())
125            }
126            Region::Nba => Err(RegionQueuePushError::NbaHasNoQueue),
127            Region::Reactive => {
128                self.reactive.push(act);
129                Ok(())
130            }
131            Region::Postponed => {
132                self.postponed.push(act);
133                Ok(())
134            }
135            Region::Preponed => Err(RegionQueuePushError::PreponedHasNoQueue),
136            // sva T2 (§1.4, review fix, blocking finding 2), Observed's
137            // half: queue-less for the identical reason `Preponed` is.
138            Region::Observed => Err(RegionQueuePushError::ObservedHasNoQueue),
139        }
140    }
141
142    /// Pops everything currently queued in Active, preserving FIFO order.
143    /// Pushes made *during* draining (by whatever calls this) land in the
144    /// queue for the *next* `drain_active` call, not this one — this is what
145    /// makes the outer fixpoint loop possible.
146    pub fn drain_active(&mut self) -> Vec<PendingActivation> {
147        self.active.drain(..).collect()
148    }
149
150    /// Pops everything currently queued in Inactive, preserving FIFO order.
151    /// Same "not this call's own later pushes" semantics as
152    /// [`Self::drain_active`].
153    pub fn drain_inactive(&mut self) -> Vec<PendingActivation> {
154        self.inactive.drain(..).collect()
155    }
156
157    /// `true` only once both Active and Inactive are empty *at the moment of
158    /// the call* — callers must re-check after each drain round, since a
159    /// drain's own processing may re-populate either queue (SC-017, SC-022's
160    /// joint fixpoint).
161    pub fn active_and_inactive_both_empty(&self) -> bool {
162        self.active.is_empty() && self.inactive.is_empty()
163    }
164
165    /// `true` when nothing is queued in Reactive at the moment of the call
166    /// (LRM 4.5: the Reactive region runs until it is empty, and a process it
167    /// wakes in Reactive -- a checker's -- joins it).
168    pub fn reactive_is_empty(&self) -> bool {
169        self.reactive.is_empty()
170    }
171
172    /// Pops everything currently queued in Reactive. Intended to be called
173    /// exactly once per timestep by the outer driver (Task 21 wires the
174    /// "exactly once" part; this module only provides the primitive and
175    /// proves it drains everything present, including zero items, without
176    /// erroring). Returns `[]`, never panics, when Reactive is empty (SC-018,
177    /// SC-019 — "usually-empty... drained exactly once per timestep even when
178    /// empty" must not need special-casing at call sites).
179    pub fn drain_reactive_once(&mut self) -> Vec<PendingActivation> {
180        std::mem::take(&mut self.reactive)
181    }
182
183    /// Queues `act` in the Re-Inactive region (LRM 4.4.2.7).
184    pub fn push_re_inactive(&mut self, act: PendingActivation) {
185        self.re_inactive.push_back(act);
186    }
187
188    /// Pops everything currently queued in Re-Inactive, FIFO.
189    pub fn drain_re_inactive(&mut self) -> Vec<PendingActivation> {
190        self.re_inactive.drain(..).collect()
191    }
192
193    /// `true` when nothing is queued in Re-Inactive.
194    pub fn re_inactive_is_empty(&self) -> bool {
195        self.re_inactive.is_empty()
196    }
197
198    /// Pops everything currently queued in Postponed. Same "exactly once,
199    /// even when empty, never errors" semantics as
200    /// [`Self::drain_reactive_once`].
201    pub fn drain_postponed_once(&mut self) -> Vec<PendingActivation> {
202        std::mem::take(&mut self.postponed)
203    }
204}
205
206/// Pure helper: normalizes `Suspend::Delay(0)` to land in `Region::Inactive`
207/// rather than re-entering the time wheel (SC-021 — "Delay(0) normalized here
208/// rather than re-entering the wheel"; this helper IS that normalization
209/// point, called by Task 5's wheel-arming code before a wheel insert is even
210/// attempted). Only `Region::Active` redirects: a caller already targeting
211/// `Inactive` (or any other region) with a zero delay is left alone — Inactive
212/// itself never re-redirects, so there is no infinite Active->Inactive bounce.
213pub fn redirect_delay_zero_to_inactive(region: Region, delay_ticks: u64) -> Region {
214    if region == Region::Active && delay_ticks == 0 {
215        Region::Inactive
216    } else {
217        region
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use crate::sched::{ResumeArg, SchedTime};
225
226    fn act(id: u64, proc: u32, ticks: u64, region: Region, reg_order: u64) -> PendingActivation {
227        PendingActivation {
228            id,
229            proc,
230            arg: ResumeArg::Start,
231            reconv_iter: 0,
232            logical_order: (SchedTime { ticks }, region, reg_order),
233        }
234    }
235
236    #[test]
237    fn active_drains_fifo_and_is_empty_after() {
238        let mut q = RegionQueues::new();
239        q.push(Region::Active, act(1, 1, 0, Region::Active, 0))
240            .unwrap();
241        q.push(Region::Active, act(2, 2, 0, Region::Active, 1))
242            .unwrap();
243        q.push(Region::Active, act(3, 3, 0, Region::Active, 2))
244            .unwrap();
245
246        let drained = q.drain_active();
247        let ids: Vec<u64> = drained.iter().map(|a| a.id).collect();
248        assert_eq!(ids, vec![1, 2, 3], "drain must preserve FIFO push order");
249
250        assert!(
251            q.drain_active().is_empty(),
252            "a second drain must return [] — the queue must be cleared, not just iterated"
253        );
254    }
255
256    #[test]
257    fn push_to_nba_returns_a_typed_error_never_silently_drops() {
258        let mut q = RegionQueues::new();
259        let a = act(1, 1, 0, Region::Nba, 0);
260        let result = q.push(Region::Nba, a);
261        assert_eq!(result, Err(RegionQueuePushError::NbaHasNoQueue));
262
263        // No queue's contents may have changed — the rejection must be a
264        // loud, typed, side-effect-free error, not a debug_assert!-in-debug/
265        // silent-drop-in-release gate.
266        assert!(q.drain_active().is_empty());
267        assert!(q.drain_inactive().is_empty());
268        assert!(q.drain_reactive_once().is_empty());
269        assert!(q.drain_postponed_once().is_empty());
270    }
271
272    /// sva T1 (§1.4, review fix, blocking finding 2): `Region::Preponed` is
273    /// the second queue-less region, mirroring `Region::Nba`'s own existing
274    /// `push_to_nba_returns_a_typed_error_never_silently_drops` test exactly
275    /// -- a named, typed `Result::Err`, never a silent no-op or a panic.
276    #[test]
277    fn region_queues_push_preponed_returns_named_error() {
278        let mut q = RegionQueues::new();
279        let a = act(1, 1, 0, Region::Preponed, 0);
280        let result = q.push(Region::Preponed, a);
281        assert_eq!(result, Err(RegionQueuePushError::PreponedHasNoQueue));
282
283        // No queue's contents may have changed.
284        assert!(q.drain_active().is_empty());
285        assert!(q.drain_inactive().is_empty());
286        assert!(q.drain_reactive_once().is_empty());
287        assert!(q.drain_postponed_once().is_empty());
288    }
289
290    /// sva T2 (§1.4, review fix, blocking finding 2), Observed's half:
291    /// mirrors `region_queues_push_preponed_returns_named_error` exactly --
292    /// a named, typed `Result::Err`, never a silent no-op or a panic.
293    #[test]
294    fn region_queues_push_observed_returns_named_error() {
295        let mut q = RegionQueues::new();
296        let a = act(1, 1, 0, Region::Observed, 0);
297        let result = q.push(Region::Observed, a);
298        assert_eq!(result, Err(RegionQueuePushError::ObservedHasNoQueue));
299
300        // No queue's contents may have changed.
301        assert!(q.drain_active().is_empty());
302        assert!(q.drain_inactive().is_empty());
303        assert!(q.drain_reactive_once().is_empty());
304        assert!(q.drain_postponed_once().is_empty());
305    }
306
307    #[test]
308    fn inactive_is_purely_a_landing_zone_for_delay_zero() {
309        assert_eq!(
310            redirect_delay_zero_to_inactive(Region::Active, 0),
311            Region::Inactive
312        );
313        assert_eq!(
314            redirect_delay_zero_to_inactive(Region::Active, 1),
315            Region::Active
316        );
317        assert_eq!(
318            redirect_delay_zero_to_inactive(Region::Nba, 0),
319            Region::Nba,
320            "only Active redirects — Inactive itself never re-redirects, no infinite bounce"
321        );
322    }
323
324    #[test]
325    fn active_inactive_reach_a_joint_fixpoint() {
326        // Round 1: one item queued in Active.
327        let mut q = RegionQueues::new();
328        q.push(Region::Active, act(1, 1, 0, Region::Active, 0))
329            .unwrap();
330        assert!(
331            !q.active_and_inactive_both_empty(),
332            "work is queued — the pair must not report converged yet"
333        );
334
335        // Processing the Active batch pushes a new item to Inactive.
336        let batch = q.drain_active();
337        assert_eq!(batch.len(), 1);
338        q.push(Region::Inactive, act(2, 2, 0, Region::Inactive, 0))
339            .unwrap();
340        assert!(
341            !q.active_and_inactive_both_empty(),
342            "Inactive was just re-populated — must not be computed from a stale snapshot"
343        );
344
345        // Processing the Inactive batch pushes a new item back to Active.
346        let batch = q.drain_inactive();
347        assert_eq!(batch.len(), 1);
348        q.push(Region::Active, act(3, 3, 0, Region::Active, 0))
349            .unwrap();
350        assert!(
351            !q.active_and_inactive_both_empty(),
352            "Active was just re-populated by Inactive's own processing — the loop must keep re-checking both queues"
353        );
354
355        // Round 3: drain Active with no further pushes; the pair converges.
356        let batch = q.drain_active();
357        assert_eq!(batch.len(), 1);
358        assert!(
359            q.active_and_inactive_both_empty(),
360            "both queues are now empty and nothing re-populated them — this is the fixpoint"
361        );
362    }
363
364    #[test]
365    fn reactive_and_postponed_drain_exactly_once_even_when_empty() {
366        let mut q = RegionQueues::new();
367        assert!(q.drain_reactive_once().is_empty());
368        assert!(q.drain_postponed_once().is_empty());
369    }
370
371    #[test]
372    fn activity_scheduled_during_reactive_does_not_rerun_reactive_this_timestep() {
373        let mut q = RegionQueues::new();
374        q.push(Region::Reactive, act(1, 1, 0, Region::Reactive, 0))
375            .unwrap();
376
377        let drained = q.drain_reactive_once();
378        assert_eq!(drained.len(), 1);
379
380        // While "processing" the drained item, new activity is scheduled
381        // into Reactive again.
382        q.push(Region::Reactive, act(2, 2, 0, Region::Reactive, 0))
383            .unwrap();
384
385        // This module exposes no "drain reactive again" method for the same
386        // timestep — only the outer driver (Task 21) may call
387        // drain_reactive_once once per timestep. The newly-pushed item must
388        // still be sitting in the queue (genuinely deferred to next
389        // timestep), never lost.
390        let drained_again = q.drain_reactive_once();
391        assert_eq!(
392            drained_again.iter().map(|a| a.id).collect::<Vec<_>>(),
393            vec![2],
394            "the item pushed during processing must survive to the next drain, not be lost"
395        );
396    }
397}