Skip to main content

sv_sim_sim/sched/
guard.rs

1//! SCHED::GUARD — delta-loop livelock guard & policy (ledger SC-079, SC-080,
2//! DT-050).
3//!
4//! The outer driver's Active/Inactive/NBA convergence loop (Task 21's
5//! `run_until`) must call [`DeltaGuard::tick`] on every delta-loop iteration
6//! within a timestep, hard-bounding how many convergence iterations a single
7//! timestep may take (SC-079).
8//!
9//! **Product decision (SC-080/DT-050) — this doc comment is the "design doc
10//! or config that states the chosen policy" acceptance requires:** the
11//! livelock policy is a **hard error, never warn-and-truncate**, with a
12//! default threshold of [`DEFAULT_MAX_DELTA_ITERATIONS`]. A silently
13//! truncated delta loop under a determinism gate would non-deterministically
14//! vary observed behavior by exactly how much of the oscillation got
15//! truncated (dependent on where `max` happens to land relative to the
16//! oscillation's own period) — strictly worse than a loud, reproducible
17//! abort that fails the same way every time for the same design, and it also
18//! gates GVT advancement under a speculative controller (an unbounded loop
19//! both hangs the simulation and starves GVT, deadlocking rollback on top of
20//! the ordinary hang — DT-050). There is no code path anywhere in this
21//! module that logs-and-continues past `max`: [`DeltaGuardError`] is a real
22//! `std::error::Error`, not a `Result<T, ()>`-swallowing warn path.
23//!
24//! `reset` is scoped to once per *timestep* (called by the outer driver when
25//! `now` advances), never once per delta iteration — a long-but-legitimately-
26//! converging multi-timestep run must not be penalized by a guard that never
27//! resets.
28
29use crate::sched::SchedTime;
30
31/// Default max Active/Inactive/NBA delta-loop iterations per timestep before
32/// [`DeltaGuard::tick`] hard-errors. See this module's own doc comment for
33/// the hard-error-vs-warn-and-truncate policy record (SC-080/DT-050). Large
34/// enough for a finite process that spends millions of `#0` steps at one time
35/// (ivtest `pow_ca_signed` does ~1M), small enough to report a true livelock
36/// in seconds.
37pub const DEFAULT_MAX_DELTA_ITERATIONS: u32 = 5_000_000;
38
39/// [`DeltaGuard::tick`]'s one error variant: the delta loop exceeded its
40/// configured bound within the current timestep (SC-079). A hard error by
41/// construction — there is no `Ok`-with-a-warning path past `max`.
42#[derive(Debug)]
43pub enum DeltaGuardError {
44    LivelockExceeded { iterations: u32, at: SchedTime },
45}
46
47impl std::fmt::Display for DeltaGuardError {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        match self {
50            DeltaGuardError::LivelockExceeded { iterations, at } => write!(
51                f,
52                "delta-loop livelock guard exceeded after {iterations} iterations at {at:?} \
53                 (hard error per SC-080/DT-050 policy — this guard never warns-and-truncates)"
54            ),
55        }
56    }
57}
58
59impl std::error::Error for DeltaGuardError {}
60
61/// Bounds the Active/Inactive/NBA convergence loop within one timestep
62/// (SC-079). The outer driver must call [`Self::tick`] on every delta-loop
63/// iteration, and [`Self::reset`] exactly once per timestep (never per delta
64/// iteration — DT-050).
65pub struct DeltaGuard {
66    max: u32,
67    count: u32,
68}
69
70impl DeltaGuard {
71    pub fn new(max: u32) -> Self {
72        DeltaGuard { max, count: 0 }
73    }
74
75    /// Call once per delta-loop iteration. Increments the iteration count
76    /// and returns `Err(DeltaGuardError::LivelockExceeded { .. })` once that
77    /// count exceeds `max` — never truncates, never logs-and-continues
78    /// (SC-080/DT-050's hard-error policy).
79    pub fn tick(&mut self, at: SchedTime) -> Result<(), DeltaGuardError> {
80        self.count += 1;
81        if self.count > self.max {
82            Err(DeltaGuardError::LivelockExceeded {
83                iterations: self.count,
84                at,
85            })
86        } else {
87            Ok(())
88        }
89    }
90
91    /// Call once per *timestep* — when the outer driver's `now` advances —
92    /// never once per delta-loop iteration. Resetting mid-timestep would let
93    /// a genuine livelock hide behind an unrelated reset call, which is
94    /// exactly what this guard exists to prevent.
95    pub fn reset(&mut self) {
96        self.count = 0;
97    }
98
99    /// **NEW (Task 21, `wA-sched` plan; ledger SC-007) — the current
100    /// iteration count since the last [`Self::reset`]**, read-only and
101    /// side-effect-free (unlike [`Self::tick`], never increments, never
102    /// errors). Lets the outer driver (`sched::control::Engine::run_until`)
103    /// capture a per-timestep high-water mark for black-box integration
104    /// tests to observe (`Engine::max_delta_iterations_in_a_timestep`) —
105    /// SC-007's acceptance requires proving the delta loop actually
106    /// converges (a bounded number of passes), not just that it eventually
107    /// does *something*; with `count` private and no getter, no test outside
108    /// this crate's own `#[cfg(test)]` unit tests could observe that bound
109    /// at all.
110    pub fn count(&self) -> u32 {
111        self.count
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use crate::sched::{PendingActivation, RegionQueues, ResumeArg};
119    use sv_sim_abi::Region;
120
121    fn t(ticks: u64) -> SchedTime {
122        SchedTime { ticks }
123    }
124
125    /// SC-079: exactly `max` ticks succeed; the `max + 1`th errors, and the
126    /// error's `iterations` is exactly `max + 1` (fails on an off-by-one in
127    /// either direction).
128    #[test]
129    fn guard_allows_exactly_max_iterations_then_errors() {
130        let mut guard = DeltaGuard::new(3);
131        assert!(guard.tick(t(0)).is_ok());
132        assert!(guard.tick(t(0)).is_ok());
133        assert!(guard.tick(t(0)).is_ok());
134        match guard.tick(t(0)) {
135            Err(DeltaGuardError::LivelockExceeded { iterations, .. }) => {
136                assert_eq!(iterations, 4);
137            }
138            Ok(()) => panic!("expected the 4th tick past max=3 to error, got Ok(())"),
139        }
140    }
141
142    /// SC-079: models `always @* x = ~x;` — a process that re-arms itself
143    /// into `Region::Active` every delta iteration, forever, unless
144    /// something bounds the loop. If `DeltaGuard::tick` never errors, this
145    /// test hangs against its own bounded `for` wrapper rather than silently
146    /// passing — the strongest "terminates rather than hangs" assertion a
147    /// unit test can make of a broken guard.
148    #[test]
149    fn adversarial_zero_delay_oscillation_terminates_via_the_guard_not_a_hang() {
150        let mut queues = RegionQueues::new();
151        let mut guard = DeltaGuard::new(DEFAULT_MAX_DELTA_ITERATIONS);
152        let now = t(0);
153
154        let mut stopped_via_guard = false;
155        for i in 0..=(DEFAULT_MAX_DELTA_ITERATIONS as u64 + 1) {
156            let self_re_arming_activation = PendingActivation {
157                id: i,
158                proc: 0,
159                arg: ResumeArg::Start,
160                reconv_iter: 0,
161                logical_order: (now, Region::Active, i),
162            };
163            queues
164                .push(Region::Active, self_re_arming_activation)
165                .expect("Region::Active always has a queue");
166            if guard.tick(now).is_err() {
167                stopped_via_guard = true;
168                break;
169            }
170        }
171        assert!(
172            stopped_via_guard,
173            "guard never errored within DEFAULT_MAX_DELTA_ITERATIONS + 1 iterations — \
174             a broken guard would have hung this test instead of reaching this assertion"
175        );
176    }
177
178    /// DT-050: `reset` is scoped per timestep, not per delta iteration.
179    #[test]
180    fn reset_is_scoped_per_timestep_not_per_delta_iteration() {
181        let max = 5;
182
183        // A long-but-legitimately-converging run across two timesteps must
184        // not be penalized: max-1 ticks, reset (models the outer driver's
185        // `now` advancing to a new timestep), then max-1 more ticks — neither
186        // burst alone reaches max, so neither should error.
187        let mut guard = DeltaGuard::new(max);
188        for _ in 0..max - 1 {
189            assert!(guard.tick(t(0)).is_ok());
190        }
191        guard.reset();
192        for _ in 0..max - 1 {
193            assert!(guard.tick(t(1)).is_ok());
194        }
195
196        // Without an intervening reset, two max-sized bursts must compound:
197        // the second burst errors immediately, proving reset is load-bearing
198        // and not a no-op that happens to make the first sub-test pass.
199        let mut guard2 = DeltaGuard::new(max);
200        for _ in 0..max {
201            assert!(guard2.tick(t(0)).is_ok());
202        }
203        assert!(
204            guard2.tick(t(1)).is_err(),
205            "a second max-sized burst without an intervening reset must error immediately"
206        );
207    }
208
209    /// SC-007 (Task 21) — `count()` tracks ticks since the last `reset()`,
210    /// is read-only (repeated calls don't change it), and `reset()` zeroes
211    /// it — the exact contract `Engine::max_delta_iterations_in_a_timestep`
212    /// depends on.
213    #[test]
214    fn count_reflects_ticks_since_last_reset_and_reset_zeroes_it() {
215        let mut guard = DeltaGuard::new(10);
216        assert_eq!(guard.count(), 0);
217        guard.tick(t(0)).unwrap();
218        assert_eq!(guard.count(), 1);
219        guard.tick(t(0)).unwrap();
220        guard.tick(t(0)).unwrap();
221        assert_eq!(
222            guard.count(),
223            3,
224            "count must reflect every tick, not just the last one"
225        );
226        // Read-only: calling it again doesn't itself mutate anything.
227        assert_eq!(guard.count(), 3);
228        guard.reset();
229        assert_eq!(
230            guard.count(),
231            0,
232            "reset must zero the count count() reports"
233        );
234    }
235
236    /// SC-080: this test itself is the "design doc or config states the
237    /// chosen policy" acceptance check — assert the documented default
238    /// matches the constant, and that `DeltaGuardError` is a real hard-error
239    /// type (not a `Result<T, ()>`-swallowing warn path).
240    #[test]
241    fn default_threshold_is_documented_and_matches_the_constant() {
242        assert_eq!(DEFAULT_MAX_DELTA_ITERATIONS, 5_000_000);
243
244        let err = DeltaGuardError::LivelockExceeded {
245            iterations: DEFAULT_MAX_DELTA_ITERATIONS + 1,
246            at: t(0),
247        };
248        match err {
249            DeltaGuardError::LivelockExceeded { iterations, at } => {
250                assert_eq!(iterations, DEFAULT_MAX_DELTA_ITERATIONS + 1);
251                assert_eq!(at, t(0));
252            }
253        }
254        // Compile-level witness that this is a real `std::error::Error`,
255        // not a bare marker type.
256        let e2 = DeltaGuardError::LivelockExceeded {
257            iterations: 1,
258            at: t(0),
259        };
260        let _: &dyn std::error::Error = &e2;
261        assert!(!format!("{e2}").is_empty());
262    }
263}