vj_sim/sched/repeat.rs
1//! SCHED::REPEAT — `EventTerm::Repeat` engine semantics: tracks how many
2//! times a `repeat(N)@(...)`-shaped dynamic wait's *inner* term must fire
3//! before the waiting process actually resumes (scheduler.md §4; ARCHITECTURE
4//! §6.5; ledger SC-004, SC-005, SC-052, SC-053).
5//!
6//! `SensitivityTable::install_term`'s `Repeat` arm (wave A) installs the
7//! inner term at the *outer* term's index, so every firing of the inner edge
8//! reaches `EngineNotifySink::notify_change` exactly as an ordinary dynamic
9//! match — indistinguishable, at that layer, from a plain `Edge` term firing
10//! once. [`RepeatCounters`] is the missing piece: keyed by `(ProcessId,
11//! term_index)` (never just `ProcessId` —
12//! `two_different_term_indices_for_the_same_process_count_independently`
13//! below proves a process can have more than one live `Repeat` term at once),
14//! it decrements a live per-firing count and reports whether the process
15//! should actually resume yet.
16//!
17//! **SC-053's reduction (documented here, the one place `EventTerm::Repeat`'s
18//! own doc comment in `sched/mod.rs` points back to):** `##N` against a
19//! clocking block's bound clock is semantically `repeat(N)@(posedge
20//! <bound clock's cell>)` — this module counts live *firings* of the inner
21//! term, never ticks or a precomputed absolute time, so "the same compiled
22//! `##N` produces different absolute tick timing when its clock's period
23//! changes dynamically at runtime" falls out for free: there is no cached
24//! tick offset for a period change to invalidate, because none is ever
25//! computed. See `sched::control`'s
26//! `sc053_repeat_based_wait_re_reads_the_clocks_live_period_not_a_cached_tick_offset`
27//! for the scheduler-level (`Engine`-driven) proof of this against a
28//! deliberately irregular (non-periodic) clock.
29
30use rustc_hash::FxHashMap as HashMap;
31use std::sync::Mutex;
32
33use crate::sched::ProcessId;
34
35/// [`RepeatCounters::on_term_fired`]'s report.
36#[derive(Clone, Copy, PartialEq, Eq, Debug)]
37pub enum RepeatOutcome {
38 /// The inner term fired, but fewer than `declared_total` times so far —
39 /// the caller must NOT resume the process: leave its sensitivity entry
40 /// installed and swallow this firing (no enqueue).
41 StillWaiting,
42 /// This firing was the `declared_total`th — the caller may resume the
43 /// process. The counter for this `(proc, term_index)` is removed.
44 Satisfied,
45}
46
47/// Live decrementing counters for every `(ProcessId, term_index)` pair
48/// currently mid-`repeat(N)@(...)` wait. `Mutex`-guarded so `Engine` (which
49/// reaches this only through a shared `&self` inside `EngineNotifySink`) can
50/// call [`Self::on_term_fired`]/[`Self::clear`] from a `&self`-only context —
51/// the same pattern every other `sched` shared-mutable-state field already
52/// uses (`NbaLog`, `RegionQueues`, ...).
53pub struct RepeatCounters {
54 counters: Mutex<HashMap<(ProcessId, usize), u64>>,
55}
56
57impl RepeatCounters {
58 pub fn new() -> Self {
59 Self {
60 counters: Mutex::new(HashMap::default()),
61 }
62 }
63
64 /// Looks up `(proc, term_index)`'s live remaining count, initializing it
65 /// to `declared_total` on first sight (the first firing after the
66 /// process suspended), decrements by 1, and reports the outcome. At `0`
67 /// post-decrement the entry is removed — so a *later*, unrelated
68 /// `repeat(N)@(...)` wait re-installed at the same `term_index` starts
69 /// fresh rather than inheriting a stale zero (see
70 /// `clear_removes_only_the_named_processs_entries` for the companion
71 /// "no stale residue after an explicit clear" proof).
72 pub fn on_term_fired(
73 &self,
74 proc: ProcessId,
75 term_index: usize,
76 declared_total: u64,
77 ) -> RepeatOutcome {
78 let mut counters = self.counters.lock().expect("RepeatCounters mutex poisoned");
79 let remaining = counters.entry((proc, term_index)).or_insert(declared_total);
80 *remaining = remaining.saturating_sub(1);
81 if *remaining == 0 {
82 counters.remove(&(proc, term_index));
83 RepeatOutcome::Satisfied
84 } else {
85 RepeatOutcome::StillWaiting
86 }
87 }
88
89 /// Removes every counter entry belonging to `proc`, regardless of
90 /// `term_index` — called from the same place `SensitivityTable::
91 /// remove_dynamic` already is (`sched::control`'s `dispatch_ready_set`),
92 /// so a process that resumes via any path (an ordinary event match,
93 /// `Disabled`, ...) never leaves a stale partial count behind for a
94 /// future, unrelated dynamic wait reusing the same `term_index`.
95 pub fn clear(&self, proc: ProcessId) {
96 let mut counters = self.counters.lock().expect("RepeatCounters mutex poisoned");
97 counters.retain(|&(p, _), _| p != proc);
98 }
99}
100
101impl Default for RepeatCounters {
102 fn default() -> Self {
103 Self::new()
104 }
105}
106
107#[cfg(test)]
108mod tests {
109 use super::*;
110
111 #[test]
112 fn first_and_second_firing_of_a_three_count_repeat_report_still_waiting_third_reports_satisfied()
113 {
114 let counters = RepeatCounters::new();
115 let proc: ProcessId = 1;
116 assert_eq!(
117 counters.on_term_fired(proc, 0, 3),
118 RepeatOutcome::StillWaiting
119 );
120 assert_eq!(
121 counters.on_term_fired(proc, 0, 3),
122 RepeatOutcome::StillWaiting
123 );
124 assert_eq!(counters.on_term_fired(proc, 0, 3), RepeatOutcome::Satisfied);
125 }
126
127 #[test]
128 fn two_different_term_indices_for_the_same_process_count_independently() {
129 let counters = RepeatCounters::new();
130 let proc: ProcessId = 7;
131 // term 0 needs 2, term 1 needs 3, interleaved.
132 assert_eq!(
133 counters.on_term_fired(proc, 0, 2),
134 RepeatOutcome::StillWaiting
135 );
136 assert_eq!(
137 counters.on_term_fired(proc, 1, 3),
138 RepeatOutcome::StillWaiting
139 );
140 assert_eq!(
141 counters.on_term_fired(proc, 1, 3),
142 RepeatOutcome::StillWaiting
143 );
144 assert_eq!(
145 counters.on_term_fired(proc, 0, 2),
146 RepeatOutcome::Satisfied,
147 "term 0 must reach Satisfied at its own declared count (2), unaffected by \
148 term 1's firings"
149 );
150 assert_eq!(
151 counters.on_term_fired(proc, 1, 3),
152 RepeatOutcome::Satisfied,
153 "term 1 must reach Satisfied at its own declared count (3)"
154 );
155 }
156
157 #[test]
158 fn clear_removes_only_the_named_processs_entries() {
159 let counters = RepeatCounters::new();
160 let proc_a: ProcessId = 1;
161 let proc_b: ProcessId = 2;
162 assert_eq!(
163 counters.on_term_fired(proc_a, 0, 3),
164 RepeatOutcome::StillWaiting
165 );
166 assert_eq!(
167 counters.on_term_fired(proc_b, 0, 5),
168 RepeatOutcome::StillWaiting
169 );
170
171 counters.clear(proc_a);
172
173 // proc_a restarts at declared_total - 1 (not continuing a stale
174 // partial count of "1 remaining out of 3").
175 assert_eq!(
176 counters.on_term_fired(proc_a, 0, 3),
177 RepeatOutcome::StillWaiting
178 );
179 assert_eq!(
180 counters.on_term_fired(proc_a, 0, 3),
181 RepeatOutcome::StillWaiting
182 );
183 assert_eq!(
184 counters.on_term_fired(proc_a, 0, 3),
185 RepeatOutcome::Satisfied
186 );
187
188 // proc_b's own entry (1 fired of 5) must be untouched by proc_a's
189 // clear.
190 for _ in 0..3 {
191 assert_eq!(
192 counters.on_term_fired(proc_b, 0, 5),
193 RepeatOutcome::StillWaiting,
194 "proc_b's own in-progress count must survive proc_a's clear untouched"
195 );
196 }
197 assert_eq!(
198 counters.on_term_fired(proc_b, 0, 5),
199 RepeatOutcome::Satisfied
200 );
201 }
202}