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}