sv_sim_sim/determinism/mod.rs
1//! DETERMINISM (`sv-sim-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 `sv-sim-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 `sv-sim-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//! `sv-sim-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 sv_sim_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 sv_sim_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 `sv_sim_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}