sv_sim_sim/determinism/sequential.rs
1//! DETERMINISM::SEQUENTIAL — the v1 default `DeterminismController`: a
2//! degenerate, strictly-sequential dispatcher (ledger SC-009, DT-004, DT-006,
3//! DT-007) that runs the given, already-canonically-sorted (Task 2, DT-005)
4//! ready set in the order it was handed, on the caller's own thread, with
5//! `gvt() == now` always and no rollback ever. It is also v1's own
6//! correctness oracle: its committed output is the ground truth a later real
7//! WOCB controller's output is diffed against (Task 22's cross-controller
8//! test).
9
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::sync::{Arc, Mutex};
12
13use sv_sim_abi::Region;
14
15use super::{ActivationId, CanonicalEventKey, DeterminismController, ResolvedBatch, WorkItem};
16use crate::sched::{PendingActivation, SchedTime, region_ordinal};
17use crate::state::{FourStateValue, StateCellId};
18
19/// The v1 degenerate sequential controller (SC-009/DT-007): a single global
20/// `Mutex<SchedTime>` for `gvt` and one monotonically increasing
21/// `local_send_seq` counter. Never spawns a thread (DT-006) and never
22/// records a conflict set — `on_read`/`on_write` are no-ops because a
23/// strictly sequential dispatcher has nothing to detect a conflict against;
24/// that absence of any rollback machinery *is* DT-007's "no rollback ever
25/// needed" made concrete, not an oversight.
26pub struct SequentialController {
27 gvt: Mutex<SchedTime>,
28 next_seq: AtomicU64,
29 /// **Task 22 (DT-033) testability hook — see [`Self::record_rollback`]'s
30 /// own doc comment for why this can only ever read `0` in this wave, and
31 /// why that is still a real, not-vacuous regression guard.** `Arc`-backed
32 /// (not a bare `AtomicU64`) so a caller can clone a handle *before*
33 /// boxing this controller and handing it to `Engine::
34 /// set_determinism_controller` — `Engine` only ever exposes the boxed
35 /// `dyn DeterminismController` back out via `controller_kind()`'s string
36 /// tag, never the concrete type, so this is the one way a test outside
37 /// this module can still observe the counter after a run.
38 rollback_count: Arc<AtomicU64>,
39}
40
41impl SequentialController {
42 pub fn new() -> Self {
43 Self {
44 gvt: Mutex::new(SchedTime { ticks: 0 }),
45 next_seq: AtomicU64::new(0),
46 rollback_count: Arc::new(AtomicU64::new(0)),
47 }
48 }
49
50 /// The engine's `set_determinism_controller` fallback (Task 18) calls
51 /// this when no controller has been configured — SC-009's "selected by
52 /// default when no other controller is configured" half.
53 pub fn default_boxed() -> Box<dyn DeterminismController> {
54 Box::new(Self::new())
55 }
56
57 /// A clonable handle onto this controller's rollback-event counter,
58 /// readable after this controller has been moved into an `Engine` (see
59 /// [`Self::rollback_count`]'s doc comment for why this indirection is
60 /// necessary at all). Task 22's `dt033_conservative_v1_default_produces_
61 /// zero_rollback_events` integration test (`tests/region_order_fixture.rs`)
62 /// clones this *before* calling `set_determinism_controller`.
63 pub fn rollback_count_handle(&self) -> Arc<AtomicU64> {
64 self.rollback_count.clone()
65 }
66
67 /// **DT-033/DT-001's "zero rollback events" acceptance, made into a real
68 /// counter rather than only prose.** Nothing in this file's own
69 /// production code (`partition`/`on_read`/`on_write`/`gvt`/
70 /// `drain_and_resolve` above) calls this method anywhere — a strictly
71 /// sequential, single-thread dispatcher with `gvt() == now` always has no
72 /// conflict to roll back from by construction (DT-007), so this counter
73 /// can only ever read `0` for *this* controller in *this* wave. That is
74 /// not the same as the test being unable to fail: it is a real,
75 /// wired-through counter (not a hardcoded `0` the test reads), so a
76 /// future edit that mistakenly adds rollback-triggering logic to this
77 /// controller (e.g. a well-meaning "fix" that starts calling this from
78 /// `on_write` on a detected write-write conflict, forgetting `DT-007`'s
79 /// whole point is that this controller must never need to) trips this
80 /// counter and fails Task 22's test — the same "currently-always-passes,
81 /// genuinely-can-regress" shape as `dt006_no_thread_spawn_or_rollback_
82 /// code_outside_determinism_module` above.
83 pub fn record_rollback(&self) {
84 self.rollback_count.fetch_add(1, Ordering::SeqCst);
85 }
86}
87
88impl Default for SequentialController {
89 fn default() -> Self {
90 Self::new()
91 }
92}
93
94impl DeterminismController for SequentialController {
95 fn partition(
96 &self,
97 ready: &[PendingActivation],
98 region: Region,
99 now: SchedTime,
100 ) -> Vec<WorkItem> {
101 *self
102 .gvt
103 .lock()
104 .expect("SequentialController gvt mutex poisoned") = now;
105 ready
106 .iter()
107 .map(|activation| {
108 let seq = self.next_seq.fetch_add(1, Ordering::SeqCst);
109 let key = CanonicalEventKey {
110 vtime: activation.logical_order.0,
111 region_ordinal: region_ordinal(region),
112 reconv_iter: activation.reconv_iter,
113 canonical_process_id: activation.proc,
114 local_send_seq: seq,
115 };
116 WorkItem {
117 activation: *activation,
118 key,
119 }
120 })
121 .collect()
122 }
123
124 fn on_read(&self, _act: ActivationId, _cell: StateCellId, _version: u64) {}
125
126 fn on_write(
127 &self,
128 _act: ActivationId,
129 _cell: StateCellId,
130 _prior: FourStateValue,
131 _version: u64,
132 ) {
133 }
134
135 fn gvt(&self) -> SchedTime {
136 *self
137 .gvt
138 .lock()
139 .expect("SequentialController gvt mutex poisoned")
140 }
141
142 fn drain_and_resolve(&self, work: Vec<WorkItem>) -> ResolvedBatch {
143 ResolvedBatch {
144 completed: work.iter().map(|w| w.activation.id).collect(),
145 }
146 }
147
148 /// Task 18's `Engine::controller_kind()` testability tag (DT-007's
149 /// "selected by default when no other controller is configured" half,
150 /// made observable from outside).
151 fn kind(&self) -> &'static str {
152 "sequential"
153 }
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159 use crate::sched::{ProcessId, ResumeArg, canonical_key_of, sort_ready_set};
160
161 fn activation(
162 id: ActivationId,
163 proc: ProcessId,
164 ticks: u64,
165 reconv_iter: u32,
166 registration_order: u64,
167 ) -> PendingActivation {
168 PendingActivation {
169 id,
170 proc,
171 arg: ResumeArg::Start,
172 reconv_iter,
173 logical_order: (SchedTime { ticks }, Region::Active, registration_order),
174 }
175 }
176
177 #[test]
178 fn sequential_controller_gvt_equals_now_after_partition() {
179 let controller = SequentialController::new();
180 let out = controller.partition(&[], Region::Active, SchedTime { ticks: 9 });
181 assert!(out.is_empty());
182 assert_eq!(controller.gvt(), SchedTime { ticks: 9 });
183 }
184
185 #[test]
186 fn sequential_controller_never_reorders_the_given_ready_set() {
187 // Feed a pre-sorted 3-element ready set (Task 2's sort_ready_set)
188 // whose canonical (post-sort) proc order is NEITHER ascending NOR
189 // descending in numeric ProcessId ([20, 10, 30]), so a `partition`
190 // that secretly re-derives order from ProcessId -- ascending OR
191 // descending -- produces a different, and therefore caught, output.
192 // (Registration order 0,1,2 was previously assigned to procs
193 // 30,10,20 in construction order, which sorts to [10,20,30] --
194 // coincidentally ascending-by-ProcessId, so a "canonicalize by
195 // ascending ProcessId" bug would have passed undetected. Assigning
196 // registration order 0,1,2 to procs 20,10,30 instead makes the
197 // sorted sequence [20,10,30], which is monotonic in neither
198 // direction.)
199 let mut ready = vec![
200 activation(3, 30, 5, 0, 2),
201 activation(2, 20, 5, 0, 0),
202 activation(1, 10, 5, 0, 1),
203 ];
204 sort_ready_set(&mut ready);
205 // sort_ready_set orders by (time, region, reconv_iter, registration);
206 // all three share time/region/reconv_iter here, so registration order
207 // (0,1,2) decides: proc 20, then 10, then 30.
208 assert_eq!(
209 ready.iter().map(|a| a.proc).collect::<Vec<_>>(),
210 vec![20, 10, 30]
211 );
212
213 let controller = SequentialController::new();
214 let out = controller.partition(&ready, Region::Active, SchedTime { ticks: 5 });
215
216 let procs: Vec<ProcessId> = out.iter().map(|w| w.activation.proc).collect();
217 assert_eq!(
218 procs,
219 vec![20, 10, 30],
220 "partition must preserve the caller's given order, never reorder itself \
221 (this order is neither ascending nor descending in ProcessId, so a \
222 reorder-by-ProcessId bug in either direction is caught)"
223 );
224 for (item, act) in out.iter().zip(ready.iter()) {
225 let (vtime, region_ordinal, reconv_iter, proc) = canonical_key_of(act);
226 assert_eq!(item.key.vtime, vtime);
227 assert_eq!(item.key.region_ordinal, region_ordinal);
228 assert_eq!(item.key.reconv_iter, reconv_iter);
229 assert_eq!(item.key.canonical_process_id, proc);
230 }
231 }
232
233 #[test]
234 fn sequential_controller_assigns_strictly_increasing_local_send_seq() {
235 let controller = SequentialController::new();
236 let ready1 = vec![activation(1, 1, 1, 0, 0), activation(2, 2, 1, 0, 1)];
237 let ready2 = vec![activation(3, 3, 2, 0, 0)];
238
239 let out1 = controller.partition(&ready1, Region::Active, SchedTime { ticks: 1 });
240 let out2 = controller.partition(&ready2, Region::Active, SchedTime { ticks: 2 });
241
242 let seqs: Vec<u64> = out1
243 .iter()
244 .chain(out2.iter())
245 .map(|w| w.key.local_send_seq)
246 .collect();
247 let mut sorted = seqs.clone();
248 sorted.sort_unstable();
249 sorted.dedup();
250 assert_eq!(
251 seqs.len(),
252 sorted.len(),
253 "every local_send_seq across both partition calls must be unique: {seqs:?}"
254 );
255 assert!(
256 seqs.windows(2).all(|w| w[0] < w[1]),
257 "local_send_seq must be strictly increasing across successive partition \
258 calls, not reset per call: {seqs:?}"
259 );
260 }
261
262 /// Task 22 (DT-033) — proves the counter itself is real, wired-through
263 /// state (not a constant `0` the getter hardcodes): starts at `0`, a
264 /// `record_rollback()` call increments the *same* handle a caller cloned
265 /// beforehand, and a fresh `SequentialController` starts back at `0`
266 /// (the counter is per-instance, never shared across controllers). Fails
267 /// if `rollback_count_handle` returns a detached copy instead of a
268 /// shared `Arc`, or if `record_rollback` doesn't actually touch it.
269 #[test]
270 fn rollback_count_handle_is_zero_until_record_rollback_is_called() {
271 let controller = SequentialController::new();
272 let handle = controller.rollback_count_handle();
273 assert_eq!(handle.load(Ordering::SeqCst), 0);
274
275 controller.record_rollback();
276 assert_eq!(
277 handle.load(Ordering::SeqCst),
278 1,
279 "record_rollback must increment the same counter rollback_count_handle \
280 returns a clone of"
281 );
282 controller.record_rollback();
283 assert_eq!(handle.load(Ordering::SeqCst), 2);
284
285 let fresh = SequentialController::new();
286 assert_eq!(
287 fresh.rollback_count_handle().load(Ordering::SeqCst),
288 0,
289 "a new SequentialController's rollback counter must start at 0, not \
290 inherit a prior instance's count"
291 );
292 }
293
294 #[test]
295 fn sequential_controller_is_the_default_when_none_configured() {
296 let controller = SequentialController::default_boxed();
297 let out = controller.partition(&[], Region::Active, SchedTime { ticks: 3 });
298 assert!(out.is_empty());
299 assert_eq!(controller.gvt(), SchedTime { ticks: 3 });
300 }
301
302 /// DT-006: the scheduler's *production* dispatch/scheduling code never
303 /// spawns a thread or does its own rollback — an architectural
304 /// constraint on the code that ships, not a ban on the test-only OS-
305 /// thread concurrency stress tests (`resume.rs`'s SC-061 tests) that
306 /// prove a guarantee ABOUT that code. Scoped to production code only via
307 /// `scan_support::strip_cfg_test_blocks` (shared, not reimplemented, by
308 /// Task 22's crate-wide `notify_change` scan) so this test does not
309 /// falsely trip on those already-shipped, plan-mandated
310 /// `#[cfg(test)]`-only stress tests, and does not silently hide a real
311 /// production violation smuggled in next to a test module either.
312 #[test]
313 fn dt006_no_thread_spawn_or_rollback_code_outside_determinism_module() {
314 let sched_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/sched");
315 let mut scanned_files = 0usize;
316 let mut violations = Vec::new();
317 scan_dir_for_thread_spawn(&sched_dir, &mut scanned_files, &mut violations);
318
319 assert!(
320 scanned_files > 0,
321 "scan matched no files under {sched_dir:?} — the scan itself is broken"
322 );
323 assert!(
324 violations.is_empty(),
325 "found `thread::spawn` in production (non-#[cfg(test)]) code under \
326 sched/: {violations:?} — scheduler production code must never spawn a \
327 thread; that is exclusively the DeterminismController's job (DT-006)"
328 );
329 }
330
331 fn scan_dir_for_thread_spawn(
332 dir: &std::path::Path,
333 scanned_files: &mut usize,
334 violations: &mut Vec<String>,
335 ) {
336 for entry in std::fs::read_dir(dir).expect("read sched/ dir for DT-006 scan") {
337 let entry = entry.expect("dir entry for DT-006 scan");
338 let path = entry.path();
339 if path.is_dir() {
340 scan_dir_for_thread_spawn(&path, scanned_files, violations);
341 continue;
342 }
343 if path.extension().is_none_or(|e| e != "rs") {
344 continue;
345 }
346 *scanned_files += 1;
347 let src = std::fs::read_to_string(&path)
348 .unwrap_or_else(|e| panic!("read {path:?} for DT-006 scan: {e}"));
349 let production_only = crate::determinism::scan_support::strip_cfg_test_blocks(&src);
350 if production_only.contains("thread::spawn") {
351 violations.push(path.display().to_string());
352 }
353 }
354 }
355}