Skip to main content

vj_sim/determinism/
mod.rs

1//! DETERMINISM (`vj-sim::determinism`) — cross-track contract and, as of
2//! Task 3, its v1 implementation: the pluggable ordering/rollback interface
3//! the scheduler hands ready-sets to (scheduler.md §6, ARCHITECTURE §4). The
4//! scheduler "never itself assigns threads or implements rollback" — every
5//! concrete controller (the v1 degenerate sequential oracle, and later WOCB)
6//! is this track's own implementation, not materialized in `sched`.
7//!
8//! **SC-008 module-boundary note:** `sched` (regions/wheel — Tasks 4–5) and
9//! `determinism` (this trait plus [`SequentialController`]) are both
10//! re-exported from the same `vj-sim` crate root, satisfying "expose all
11//! five named responsibilities (regions, time wheel, `DeterminismController`
12//! trait, SoA arena, undo-log) as part of the same module boundary" for the
13//! two of the five this track owns; the SoA arena and undo-log are STATE's
14//! (`crate::state`), cited here, not re-implemented.
15//!
16//! ## WOCB tension — how this wave resolves it (DT-033)
17//!
18//! ARCHITECTURE.md §4's staged WOCB plan resolves the tension between a
19//! future multi-threaded optimistic Time-Warp kernel and the bit-identical-
20//! determinism-across-thread-counts MUST-HAVE gate by shipping v1 as
21//! deliberately conservative: `LOOKAHEAD_CYCLES = 0` (no per-region
22//! optimism — nothing this wave ever runs ahead of `gvt()`, since
23//! [`SequentialController::gvt`] always equals the `now` last passed to
24//! `partition`), one canonical event key ([`CanonicalEventKey`], Task 2's
25//! derived total order, `sched::order::sort_ready_set` applies it to every
26//! ready set before a controller ever sees it), and a GVT-gated,
27//! canonically-ordered commit stream (`sched::IoSink`/`DeferIoDispatcher`,
28//! Task 16) that a debugger, RUNTIME, and FST would all consume identically.
29//! Concretely, as implemented (not aspiration):
30//! - [`SequentialController`] is the only [`DeterminismController`] this
31//!   wave ships, and it never spawns a thread
32//!   (`sequential::tests::dt006_no_thread_spawn_or_rollback_code_outside_
33//!   determinism_module`) — with exactly one thread, a same-thread-count
34//!   "sweep" passes trivially, because there is no second thread count to
35//!   diverge from.
36//! - `sched::resume::tests::resume_is_never_called_concurrently_for_the_
37//!   same_process_id` (Task 12) already proves `ResumeDriver`'s own locking
38//!   discipline holds under genuine OS-thread parallelism — the seam a real
39//!   multi-threaded controller will dispatch through — and
40//!   `sched::order::tests::sort_ready_set_is_insertion_order_independent`
41//!   (Task 2, this wave's SC-013 reduction) proves the canonical key, not
42//!   physical arrival order, determines ready-set order, the necessary
43//!   precondition for any thread count to converge to it.
44//! - The real `{1,4,8,16,31}`-thread-count blake3 bit-diff gate itself
45//!   (SC-013's literal acceptance) is `vj-harness`'s Tier-A/nightly job
46//!   (`sched::order`'s own "SC-013 this-wave reduction" note, Task 20's
47//!   DT-028 split) once a real multi-threaded controller exists — not this
48//!   track, not this wave. This wave's own closest substitute is this
49//!   module's `sequential_and_levelized_controllers_converge_to_the_same_
50//!   canonical_effect_order` test (`tests/region_order_fixture.rs`): two
51//!   controllers with genuinely different internal dispatch logic, diffed
52//!   against `SequentialController`'s canonically-sorted output as ground
53//!   truth (SC-009).
54//! - [`SequentialController::rollback_count_handle`] backs
55//!   `dt033_conservative_v1_default_produces_zero_rollback_events`
56//!   (`tests/region_order_fixture.rs`): a full run of Task 21's 3-process
57//!   fixture through a real `Engine` produces zero rollback events, the
58//!   literal DT-001/DT-033 acceptance text — honestly a property of this
59//!   controller's *design* (it has no rollback code path to trigger) rather
60//!   than of anything this specific fixture exercises, exactly like
61//!   `dt006_...`'s scan above; both are real regression guards against a
62//!   future edit reintroducing what DT-007 says must not exist here.
63//!
64//! ## SC-130 pluggability review
65//!
66//! SC-130 requires that this wave's execution model — the 5-region
67//! stratified kernel, this trait, and the canonical event-key scheme — must
68//! not preclude adding a static-scheduling/levelized fast-path controller
69//! later, even though v1 defers that fast path. Walking
70//! [`DeterminismController`]'s 5 dispatch-relevant methods
71//! (`kind` is a Task 18 testability tag only, never part of any ordering/
72//! commit decision, so it is excluded from this argument):
73//! - `partition` already receives the **full, canonically-sorted** ready set
74//!   (`sched::order::sort_ready_set` runs before any controller sees it,
75//!   Task 2/DT-005) and returns `Vec<WorkItem>` in whatever order the
76//!   controller chooses — a levelized controller can statically sort by
77//!   level instead of trusting the caller's pass-through order, exactly what
78//!   this module's own `hypothetical_levelized_controller_compiles_against_
79//!   the_unmodified_trait` test below (and `tests/region_order_fixture.rs`'s
80//!   `FakeLevelizedController`) do, against the trait **as shipped**, with
81//!   zero trait edits.
82//! - `on_read`/`on_write` are no-ops a levelized controller is free to
83//!   ignore entirely: static levelization computes its schedule from the
84//!   design's own dependency structure at elaboration time, not from
85//!   runtime conflict tracking, so it never needs to call into either hook
86//!   for anything.
87//! - `gvt` can simply track the last `now` it was handed, the same
88//!   `Mutex<SchedTime>` pattern [`SequentialController::gvt`] already uses —
89//!   a levelized controller that never runs ahead of `now` has the same
90//!   trivial `gvt() == now` obligation.
91//! - `drain_and_resolve` can run its levels **in parallel internally**
92//!   (`rayon`/`crossbeam-channel`, SC-112) without the trait, `Engine`, or
93//!   any caller needing to know — the trait's signature (`Vec<WorkItem> ->
94//!   ResolvedBatch`) says nothing about how the work is actually executed.
95//! - `Engine::set_determinism_controller` (Task 18) already accepts any `Box<
96//!   dyn DeterminismController>`, proven by `sched::control::tests::set_
97//!   determinism_controller_overrides_the_default` (a second, independent
98//!   implementer swapped in at runtime) and this module's own
99//!   `hypothetical_levelized_controller_compiles_against_the_unmodified_
100//!   trait` test, which additionally round-trips a levelized stand-in
101//!   through that exact seam.
102//!
103//! **One residual risk, accepted not hidden:** `docs/design/scheduler.md`'s
104//! own Decision Record Conflicts #3 already flags that "static levelization
105//! [is] deferred... a multi-threaded event kernel from v1... the
106//! multi-threading goal will visibly under-deliver on comb-dominated
107//! netlists until static levelization lands post-v1" — this review proves
108//! the *seam* is ready for that later controller, not that v1 already
109//! delivers its performance.
110//!
111//! ## DT-028 — CI tiering policy for the Gate-1 thread-count sweep (Task 20)
112//!
113//! DT-028 requires an explicit, recorded CI policy for Gate 1's thread-count
114//! sweep, not an implicit default. Recorded here (this crate is where the
115//! tiebreak the sweep diffs against is published, `tiebreak`) because
116//! `vj-harness`'s own CI YAML is a different track's file, out of this
117//! crate's scope:
118//! - The full `{1, 4, 8, 16, 31}`-thread-count blake3 bit-diff sweep
119//!   (SC-013's literal acceptance) is **nightly-only** — it requires a real
120//!   multi-threaded [`DeterminismController`], which this wave does not
121//!   ship ([`SequentialController`] never spawns a thread, see
122//!   `sequential::tests::dt006_no_thread_spawn_or_rollback_code_outside_
123//!   determinism_module`).
124//! - A cheap **Tier-A subset is PR-blocking**: `{1, 4}` thread counts only,
125//!   satisfied this wave by two checks already in this crate —
126//!   `sched::resume::tests::resume_is_never_called_concurrently_for_the_
127//!   same_process_id` (Task 12's real 8-OS-thread reentrancy stress test)
128//!   plus a 2-controller [`committed_effect_tiebreak`] ordering
129//!   check (this module's own tests and `tests/region_order_fixture.rs`'s
130//!   `sequential_and_levelized_controllers_converge_to_the_same_canonical_
131//!   effect_order`, Task 22).
132//!
133//! See [`committed_effect_tiebreak`] for DT-017's fixed ordering rule and
134//! DT-027's "Gate 1 is blocked until this is published" dependency.
135
136use vj_abi::Region;
137
138use crate::sched::{PendingActivation, ProcessId, SchedTime};
139use crate::state::{FourStateValue, StateCellId};
140
141mod kind_guard;
142mod policy;
143#[cfg(test)]
144pub(crate) mod scan_support;
145mod sequential;
146mod tiebreak;
147pub use kind_guard::*;
148pub use policy::*;
149pub use sequential::SequentialController;
150pub use tiebreak::committed_effect_tiebreak;
151
152/// Same representation as `sched::ActivationId` (both `u64`) — re-exported
153/// rather than independently redeclared (Task 1 review fix, docs/CONTRACTS.md
154/// r3 item 2) so there is exactly one `ActivationId` type in the crate, not
155/// two definitionally-identical-but-nominally-distinct ones.
156pub use crate::sched::ActivationId;
157
158/// The one ordering scheme used everywhere (ARCHITECTURE §4 item 3, the
159/// faceoffs.md WOCB-verdict graft): intra-region delta-cycle commit, inter-region
160/// barrier delivery, and the side-effect/VPI/FST commit stream all key on this
161/// tuple.
162///
163/// `derive(PartialOrd, Ord)` is intentional and is DT-010's total order: field
164/// declaration order below already matches the 5-tuple's own significance
165/// order (`vtime, region_ordinal, reconv_iter, canonical_process_id,
166/// local_send_seq`), so the lexicographic order the derive produces *is* the
167/// canonical order — comparing/sorting `CanonicalEventKey` values (e.g.
168/// `sched::order::WriteBuffer::resolve_last_write_wins`'s last-write-wins
169/// resolution, SC-010/SC-012) never needs a hand-written `Ord` impl or a
170/// second key type. Reordering these fields would silently change the total
171/// order this type defines — don't.
172#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
173pub struct CanonicalEventKey {
174    pub vtime: SchedTime,
175    pub region_ordinal: u32,
176    pub reconv_iter: u32,
177    pub canonical_process_id: ProcessId,
178    pub local_send_seq: u64,
179}
180
181/// Review fix: `activation.id` (`sched::PendingActivation::id`) is the defined
182/// correlation between a `WorkItem` handed out by `partition()` and the
183/// `ActivationId` that `on_read`/`on_write`/`ResolvedBatch::completed` expect
184/// — those hooks previously had no way to name the activation a `WorkItem`
185/// came from. `key.reconv_iter` (from [`CanonicalEventKey`]) must equal
186/// `activation.reconv_iter` (from `PendingActivation`) for the `WorkItem` that
187/// produced it; both are threaded through independently because
188/// `PendingActivation` is scheduler-owned ready-set data and
189/// `CanonicalEventKey` is the WOCB-verdict ordering key, but they describe the
190/// same delta-loop pass.
191#[derive(Clone, Copy, Debug)]
192pub struct WorkItem {
193    pub activation: PendingActivation,
194    pub key: CanonicalEventKey,
195}
196
197#[derive(Clone, Debug, Default)]
198pub struct ResolvedBatch {
199    pub completed: Vec<ActivationId>,
200}
201
202/// A pluggable rollback/ordering mechanism (scheduler.md §6; ledger SC-008,
203/// SC-009, SC-013, SC-030, SC-074, SC-076). v1 ships a degenerate sequential
204/// controller (`gvt() == now` always, no rollback) as its own test oracle; WOCB
205/// plugs in here per-region once each region's own cross-thread-count fuzz
206/// harness is green (ARCHITECTURE §4).
207pub trait DeterminismController: Send + Sync {
208    fn partition(
209        &self,
210        ready: &[PendingActivation],
211        region: Region,
212        now: SchedTime,
213    ) -> Vec<WorkItem>;
214    /// `act` is the `ActivationId` of the `WorkItem` (`partition()`'s own
215    /// output, `WorkItem.activation.id`) currently executing the read/write.
216    fn on_read(&self, act: ActivationId, cell: StateCellId, version: u64);
217    fn on_write(&self, act: ActivationId, cell: StateCellId, prior: FourStateValue, version: u64);
218    /// The commit horizon: nothing at/after this may be observed externally.
219    fn gvt(&self) -> SchedTime;
220    fn drain_and_resolve(&self, work: Vec<WorkItem>) -> ResolvedBatch;
221    /// **NEW (Task 18, `wA-sched` plan, SC-083's `Engine::controller_kind`
222    /// testability getter — see `sched::control`'s deviations note).** A
223    /// short, stable, human-readable tag identifying the concrete controller
224    /// implementation, with no other production use (never parsed, never
225    /// part of any ordering/commit decision) — added purely so
226    /// `Engine::controller_kind()` can prove, from outside, which concrete
227    /// `Box<dyn DeterminismController>` is currently installed (the default
228    /// `SequentialController` vs. a caller-supplied override), without this
229    /// crate resorting to `Any`/downcasting for a testability-only need.
230    /// `determinism` is owned by the same `sched` track as Task 18
231    /// (`docs/CONTRACTS.md`), so this is an in-track, additive trait
232    /// extension, not a foreign-contract change.
233    fn kind(&self) -> &'static str;
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use crate::sched::ResumeArg;
240    use vj_abi::Region;
241
242    /// Review fix: prove a `WorkItem`'s `ActivationId` (what `on_read`/
243    /// `on_write`/`ResolvedBatch::completed` expect) is actually recoverable
244    /// from what `partition()` hands back, via `activation.id`.
245    #[test]
246    fn work_item_activation_id_correlates_back_to_the_activation() {
247        let now = SchedTime { ticks: 5 };
248        let activation = PendingActivation {
249            id: 99,
250            proc: 1,
251            arg: ResumeArg::Start,
252            reconv_iter: 0,
253            logical_order: (now, Region::Active, 0),
254        };
255        let key = CanonicalEventKey {
256            vtime: now,
257            region_ordinal: Region::Active as u32,
258            reconv_iter: activation.reconv_iter,
259            canonical_process_id: activation.proc,
260            local_send_seq: 0,
261        };
262        let item = WorkItem { activation, key };
263        let resolved = ResolvedBatch {
264            completed: vec![item.activation.id],
265        };
266        assert_eq!(resolved.completed[0], item.activation.id);
267        assert_eq!(item.key.reconv_iter, item.activation.reconv_iter);
268    }
269
270    #[test]
271    fn canonical_event_key_is_a_plain_totally_ordered_tuple() {
272        let k1 = CanonicalEventKey {
273            vtime: SchedTime { ticks: 0 },
274            region_ordinal: Region::Active as u32,
275            reconv_iter: 0,
276            canonical_process_id: 0,
277            local_send_seq: 0,
278        };
279        let k2 = k1;
280        assert_eq!(k1, k2);
281        assert_eq!(k1.region_ordinal, 0);
282    }
283
284    /// Task 1 review fix (SC-129/SC-008): a compile-level proof that
285    /// `DeterminismController::partition`'s `now` and `gvt`'s return type are
286    /// `sched::SchedTime`, not the ABI-facing `vj_abi::SimTime`. This would
287    /// fail to compile against the old `SimTime`-typed signatures (`SchedTime`
288    /// has no `delta` field and does not coerce to `SimTime`), which is the
289    /// actual guard.
290    #[test]
291    fn determinism_controller_trait_uses_sched_time_for_now_and_gvt() {
292        struct NoopController {
293            gvt: SchedTime,
294        }
295        impl DeterminismController for NoopController {
296            fn partition(
297                &self,
298                _ready: &[PendingActivation],
299                _region: Region,
300                _now: SchedTime,
301            ) -> Vec<WorkItem> {
302                vec![]
303            }
304            fn on_read(&self, _act: ActivationId, _cell: StateCellId, _version: u64) {}
305            fn on_write(
306                &self,
307                _act: ActivationId,
308                _cell: StateCellId,
309                _prior: FourStateValue,
310                _version: u64,
311            ) {
312            }
313            fn gvt(&self) -> SchedTime {
314                self.gvt
315            }
316            fn drain_and_resolve(&self, _work: Vec<WorkItem>) -> ResolvedBatch {
317                ResolvedBatch::default()
318            }
319            fn kind(&self) -> &'static str {
320                "noop"
321            }
322        }
323
324        let controller = NoopController {
325            gvt: SchedTime { ticks: 7 },
326        };
327        let out = controller.partition(&[], Region::Active, SchedTime { ticks: 7 });
328        assert!(out.is_empty());
329        assert_eq!(controller.gvt(), SchedTime { ticks: 7 });
330    }
331
332    /// Task 22 (SC-130) — the actual executable proof, not only the doc
333    /// review above: a **second**, test-only `DeterminismController` whose
334    /// `partition` sorts by a fake "level" (`canonical_process_id % 3`,
335    /// standing in for a real static levelization) instead of executing
336    /// sequentially, compiles and satisfies the trait with zero changes to
337    /// this file's trait definition, and round-trips through `Engine::
338    /// set_determinism_controller` without error. This
339    /// `FakeLevelizedController` is local to this test and independently
340    /// declared from `tests/region_order_fixture.rs`'s own copy (see that
341    /// file's doc comment: an integration test cannot reach a private
342    /// in-crate test type, so both are separate instantiations of the same
343    /// trivial shape, not one implementation shared across a crate
344    /// boundary — the established pattern this plan already uses for
345    /// `TestArena`/`FakeArena`).
346    #[test]
347    fn hypothetical_levelized_controller_compiles_against_the_unmodified_trait() {
348        struct FakeLevelizedController;
349
350        impl DeterminismController for FakeLevelizedController {
351            fn partition(
352                &self,
353                ready: &[PendingActivation],
354                region: Region,
355                _now: SchedTime,
356            ) -> Vec<WorkItem> {
357                let mut sorted: Vec<PendingActivation> = ready.to_vec();
358                sorted.sort_by_key(|a| a.proc % 3);
359                sorted
360                    .into_iter()
361                    .enumerate()
362                    .map(|(i, activation)| {
363                        let key = CanonicalEventKey {
364                            vtime: activation.logical_order.0,
365                            region_ordinal: crate::sched::region_ordinal(region),
366                            reconv_iter: activation.reconv_iter,
367                            canonical_process_id: activation.proc,
368                            local_send_seq: i as u64,
369                        };
370                        WorkItem { activation, key }
371                    })
372                    .collect()
373            }
374            fn on_read(&self, _act: ActivationId, _cell: StateCellId, _version: u64) {}
375            fn on_write(
376                &self,
377                _act: ActivationId,
378                _cell: StateCellId,
379                _prior: FourStateValue,
380                _version: u64,
381            ) {
382            }
383            fn gvt(&self) -> SchedTime {
384                SchedTime { ticks: 0 }
385            }
386            fn drain_and_resolve(&self, work: Vec<WorkItem>) -> ResolvedBatch {
387                ResolvedBatch {
388                    completed: work.iter().map(|w| w.activation.id).collect(),
389                }
390            }
391            fn kind(&self) -> &'static str {
392                "fake-levelized"
393            }
394        }
395
396        // Round-trip through Engine::set_determinism_controller (Task 18) --
397        // needs a minimal SignalAccess/IoSink pair Engine::new requires;
398        // independently declared (Engine's own #[cfg(test)] fixtures are
399        // private to sched::control, unreachable from here).
400        struct NoopArena;
401        impl crate::state::SignalAccess for NoopArena {
402            fn read(&self, _cell: StateCellId) -> FourStateValue {
403                FourStateValue::Narrow(0)
404            }
405            fn write(&mut self, _cell: StateCellId, _value: FourStateValue) -> bool {
406                true
407            }
408            fn subscribe(
409                &mut self,
410                _cell: StateCellId,
411                _role: crate::state::ListenerRole,
412                _sink: Box<dyn crate::state::ValueChangeSink + Send>,
413            ) {
414            }
415        }
416        struct NoopIoSink;
417        impl crate::sched::IoSink for NoopIoSink {
418            fn enqueue(
419                &self,
420                _at: SchedTime,
421                _region: Region,
422                _seq: u64,
423                cb: crate::sched::DeferredIoCallback,
424            ) {
425                drop(cb);
426            }
427        }
428
429        use crate::sched::SchedulerControl;
430        let mut engine = crate::sched::Engine::new(Box::new(NoopArena), Box::new(NoopIoSink));
431        assert_eq!(
432            engine.controller_kind(),
433            "sequential",
434            "engine must still default to SequentialController before this test installs \
435             the hypothetical levelized one"
436        );
437        engine.set_determinism_controller(Box::new(FakeLevelizedController));
438        assert_eq!(
439            engine.controller_kind(),
440            "fake-levelized",
441            "set_determinism_controller must have genuinely swapped the installed \
442             controller -- this is the round-trip proof, not just a compile check"
443        );
444    }
445
446    /// Task 22 (SC-042 review fix, sixth review pass) — the crate-wide
447    /// completion of Task 7/8's own single-file checks: `notify_change` is
448    /// called from exactly two sanctioned production sites in the whole
449    /// crate (`notify.rs`'s `commit_nba`, `ops.rs`'s
450    /// `commit_blocking_write`), never a third ad-hoc caller.
451    ///
452    /// **Extended, B-sched Task 6 (ledger SC-069):** `control.rs`'s
453    /// `Engine::advance_time_to` is now a real, SANCTIONED third call site —
454    /// a delayed named-event trigger (`->>#N my_event;`'s wheel-fired
455    /// `TimerAction::TriggerEvent`) fans out through this exact same
456    /// `notify_change` path (never a second, hand-rolled matching
457    /// implementation of its own), the literal reduction this task's own
458    /// plan text names ("the exact same fan-out `EngineNotifySink::
459    /// notify_change` already does for an ordinary committed write"). This
460    /// is additive to SC-042's own guarantee, not a weakening of it:
461    /// `SensitivityTable::matches` (the one canonical matching
462    /// implementation SC-042 actually protects) still has exactly one
463    /// production consumer function, `notify_change` itself — this gate now
464    /// pins the count at exactly THREE call sites of that one function,
465    /// still rejecting any fourth, still-ad-hoc caller.
466    ///
467    /// **Deviation from the plan's literal scan (documented here and in
468    /// `docs/CONTRACTS.md`'s r3 Deviations section, item 10):** a raw
469    /// substring count of `".notify_change("` over `scan_support::
470    /// strip_cfg_test_blocks`'s output alone over-counts against this
471    /// crate's *real* source: `notify.rs`'s and `ops.rs`'s own top-of-file
472    /// doc comments (`//! ... .notify_change(\` call site`,
473    /// `/// calls \`sink.notify_change(..)\` ...`) quote the exact call
474    /// pattern in prose, outside any `#[cfg(test)]` block, which
475    /// `strip_cfg_test_blocks` never touches (it only strips test *code*
476    /// blocks, not comments). Left as a plain substring count, this test
477    /// would read a count of 6 (3 in each of `notify.rs`/`ops.rs`: two doc
478    /// mentions plus the one real call), never `2`, on the current,
479    /// already-shipped, correct source — an unconditional false failure, not
480    /// a real defect. Fixed by additionally stripping every line whose
481    /// trimmed text starts with `//` (covers `//`, `///`, `//!`) before
482    /// counting, so only text that could actually execute is scanned. This
483    /// does not weaken the check: a real call site is never written as a
484    /// same-line trailing comment in this crate (verified: every real
485    /// `.notify_change(` call above sits on its own statement line), so no
486    /// production call this test needs to catch is hidden by comment
487    /// stripping; a doc comment merely mentioning the pattern (exactly what
488    /// trips the unpatched version) is correctly excluded either way.
489    #[test]
490    fn notify_change_has_exactly_three_call_sites_in_the_crate() {
491        let sched_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/sched");
492        let mut hits: Vec<(String, usize)> = Vec::new();
493        scan_dir_for_notify_change_calls(&sched_dir, &mut hits);
494
495        assert!(
496            !hits.is_empty(),
497            "scan matched no files under {sched_dir:?} -- the scan itself is broken"
498        );
499
500        let total: usize = hits.iter().map(|(_, n)| n).sum();
501        assert_eq!(
502            total, 3,
503            "expected exactly three production `.notify_change(` call sites crate-wide \
504             (SC-042 + B-sched Task 6's SC-069 addition); found: {hits:?}"
505        );
506
507        let count_for = |suffix: &str| -> usize {
508            hits.iter()
509                .filter(|(f, _)| f.ends_with(suffix))
510                .map(|(_, n)| *n)
511                .sum()
512        };
513        assert_eq!(
514            count_for("notify.rs"),
515            1,
516            "notify.rs (Task 7's commit_nba) must be exactly one of the three sanctioned \
517             call sites: {hits:?}"
518        );
519        assert_eq!(
520            count_for("ops.rs"),
521            1,
522            "ops.rs (Task 8's commit_blocking_write) must be exactly one of the three \
523             sanctioned call sites: {hits:?}"
524        );
525        assert_eq!(
526            count_for("control.rs"),
527            1,
528            "control.rs (B-sched Task 6's advance_time_to, SC-069's TriggerEvent fan-out) \
529             must be exactly one of the three sanctioned call sites: {hits:?}"
530        );
531        for (file, n) in &hits {
532            if !file.ends_with("notify.rs")
533                && !file.ends_with("ops.rs")
534                && !file.ends_with("control.rs")
535            {
536                assert_eq!(
537                    *n, 0,
538                    "unexpected production `.notify_change(` call site outside \
539                     notify.rs/ops.rs/control.rs: {file} (SC-042: a fourth ad-hoc caller is a \
540                     design defect) -- full scan: {hits:?}"
541                );
542            }
543        }
544    }
545
546    /// Strips every line whose trimmed text starts with `//` -- see this
547    /// module's own doc comment on `notify_change_has_exactly_three_call_
548    /// sites_in_the_crate` for why this is layered on top of (never a
549    /// replacement for) `scan_support::strip_cfg_test_blocks`.
550    fn strip_line_comments(src: &str) -> String {
551        src.lines()
552            .filter(|line| !line.trim_start().starts_with("//"))
553            .collect::<Vec<_>>()
554            .join("\n")
555    }
556
557    fn scan_dir_for_notify_change_calls(dir: &std::path::Path, hits: &mut Vec<(String, usize)>) {
558        for entry in std::fs::read_dir(dir).expect("read sched/ dir for notify_change scan") {
559            let entry = entry.expect("dir entry for notify_change scan");
560            let path = entry.path();
561            if path.is_dir() {
562                scan_dir_for_notify_change_calls(&path, hits);
563                continue;
564            }
565            if path.extension().is_none_or(|e| e != "rs") {
566                continue;
567            }
568            let src = std::fs::read_to_string(&path)
569                .unwrap_or_else(|e| panic!("read {path:?} for notify_change scan: {e}"));
570            let production_only = strip_line_comments(&scan_support::strip_cfg_test_blocks(&src));
571            let count = production_only.matches(".notify_change(").count();
572            hits.push((path.display().to_string(), count));
573        }
574    }
575}