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}