Skip to main content

vj_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 vj_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}