Skip to main content

vj_sim/sched/
finals.rs

1//! `final` blocks: one dedicated post-simulation pass (Task 14,
2//! `docs/superpowers/plans/2026-09-23-wave-A-sched.md`; ledger SC-077, SC-078).
3//!
4//! **Scope:** `final` blocks (`ProceduralBlockSymbol` kind `Final`) run
5//! exactly once, in one dedicated pass strictly *after* `run_until` (Task 21)
6//! returns — never folded into the 5-region timestep loop (SC-077) — and in
7//! the same stable, canonical `registration_order` used everywhere else in
8//! this crate, since the LRM leaves inter-block final order unspecified
9//! (SC-078). [`run_finals`] carries no `Region`/`SchedTime` "current
10//! timestep" parameter anywhere in its signature: that is what structurally
11//! prevents it from being invoked *as part of* a region drain, all of which
12//! take a `Region` (`process_table::form_ready_set`, `RegionQueues::push`).
13//!
14//! **Deviation from the plan's literal signature** (recorded in
15//! `docs/CONTRACTS.md`'s r3 section, item 7): the plan names `run_finals`'s
16//! second parameter `table: &mut ProcessTable`, but Task 12's already-shipped
17//! `ResumeDriver::start` (`resume.rs`) takes a **shared** `&ProcessTable` (the
18//! SC-061 fix — see `resume.rs`'s module doc), so a `&mut` borrow here would
19//! not even compile against the real `start`. `run_finals` performs no table
20//! mutation of its own (registration already happened before this pass runs),
21//! so the minimal, additive fix is `table: &ProcessTable`.
22
23use vj_abi::SimHandle;
24
25use super::{ProcessId, ProcessTable, ResumeDriver, SchedulerOps};
26
27/// One registered `final` block candidate (scheduler.md §8; SC-077, SC-078).
28/// `registration_order` is the same crate-wide, monotonic, stable ordering
29/// space `ProcessDescriptor::registration_order` uses (Task 11) — SC-078's
30/// "one logical order, everywhere" invariant, not a second, `final`-local
31/// numbering.
32#[derive(Clone, Copy, Debug)]
33pub struct FinalProcess {
34    pub proc: ProcessId,
35    pub registration_order: u64,
36}
37
38/// Runs every registered `final` block exactly once, in `registration_order`
39/// (stable, ascending — SC-078), sorted in place by this call itself (a
40/// caller must not have to pre-sort `finals`). No `Region`/`SchedTime`
41/// parameter anywhere in this signature (SC-077 — structurally cannot be
42/// folded into the 5-region timestep loop, unlike every region-drain
43/// primitive in this crate).
44///
45/// Each final process is started via [`ResumeDriver::start`] (SC-059's
46/// "`resume(Start)` exactly once"), `.expect()`-ing Task 12's
47/// `Result<Suspend, ResumeError>`: a final process id came from this same
48/// `finals: Vec<FinalProcess>`, itself only ever populated from real
49/// `ProcessTable::register` calls, so `UnknownProcess`/`AlreadyStarted` here
50/// would mean this function's own caller passed a bogus or duplicate entry —
51/// a programmer error worth failing loudly on, not a runtime condition this
52/// function's signature needs to expose to its own caller.
53pub fn run_finals(
54    finals: &mut [FinalProcess],
55    table: &ProcessTable,
56    state: SimHandle,
57    ops: &dyn SchedulerOps,
58    driver: &ResumeDriver,
59) {
60    finals.sort_by_key(|f| f.registration_order);
61    for f in finals.iter() {
62        // A `final` block is a thread too (37.44): created as it starts and
63        // deleted when it returns (it cannot suspend).
64        super::fire_thread_event(
65            ops,
66            super::ThreadEvent::Start {
67                pid: f.proc,
68                parent: None,
69                spawn: None,
70            },
71        );
72        driver
73            .start(table, f.proc, state, ops)
74            .expect("registered final process must be startable");
75        super::fire_thread_event(
76            ops,
77            super::ThreadEvent::End {
78                pid: f.proc,
79                killed: false,
80            },
81        );
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use crate::sched::{
89        CompiledProcess, PendingActivation, RegionQueues, ResumeArg, SchedTime, Suspend,
90        form_ready_set,
91    };
92    use crate::state::{FourStateValue, StateCellId};
93    use std::sync::{Arc, Mutex};
94    use vj_abi::Region;
95
96    /// A no-op `SchedulerOps` double — no test in this file inspects any of
97    /// its call arguments, only the resulting `resume()` call log.
98    struct NoopOps;
99    impl SchedulerOps for NoopOps {
100        fn now(&self) -> SchedTime {
101            SchedTime { ticks: 0 }
102        }
103        fn current_region(&self) -> Region {
104            Region::Active
105        }
106        fn stage_nba(&self, _cell: StateCellId, _value: FourStateValue, _source: ProcessId) {}
107        fn commit_blocking_write(&self, _cell: StateCellId, _value: FourStateValue) {}
108        fn request_finish(&self, _code: i32) {}
109        fn defer_io(&self, _at: SchedTime, _region: Region, _cb: super::super::DeferredIoCallback) {
110        }
111        fn register_region_callback(
112            &self,
113            _region: Region,
114            _at: SchedTime,
115            _cb: Box<dyn super::super::RegionCallback>,
116        ) {
117        }
118    }
119
120    fn null_state() -> SimHandle {
121        SimHandle(std::ptr::null_mut())
122    }
123
124    /// Records `(tag, was_a_start_call)` into a shared log every time it is
125    /// resumed. `tag` lets a test identify *which* logical process ran
126    /// without needing to know its dynamically-assigned `ProcessId` — used
127    /// below to check `run_finals`'s actual dispatch order.
128    struct LoggingProcess {
129        tag: u64,
130        log: Arc<Mutex<Vec<(u64, bool)>>>,
131    }
132    impl CompiledProcess for LoggingProcess {
133        fn resume(
134            &mut self,
135            _state: SimHandle,
136            arg: ResumeArg,
137            _ops: &dyn SchedulerOps,
138        ) -> Suspend {
139            self.log
140                .lock()
141                .unwrap()
142                .push((self.tag, matches!(arg, ResumeArg::Start)));
143            Suspend::Done
144        }
145    }
146
147    fn act(
148        id: u64,
149        proc: ProcessId,
150        ticks: u64,
151        reg_order: u64,
152        arg: ResumeArg,
153    ) -> PendingActivation {
154        PendingActivation {
155            id,
156            proc,
157            arg,
158            reconv_iter: 0,
159            logical_order: (SchedTime { ticks }, Region::Active, reg_order),
160        }
161    }
162
163    /// SC-077 — structural half: `run_finals` takes no `Region`/`SchedTime`
164    /// parameter, so it cannot be called from any region-drain call site
165    /// (they all take `Region` — `form_ready_set`, `RegionQueues::push`).
166    /// Behavioral half: register 3 "final" candidates that are never pushed
167    /// into `RegionQueues`, run 2 unrelated Active-region
168    /// `form_ready_set`/dispatch cycles that never mention them, and assert
169    /// the final processes' own call log stays empty until `run_finals` is
170    /// explicitly invoked. Fails if anything in the ordinary dispatch path
171    /// (region push/drain, `ResumeDriver::start`/`resume_for`) implicitly
172    /// reaches a registered-but-unqueued "final" process.
173    #[test]
174    fn finals_do_not_run_during_the_timestep_loop() {
175        let final_log = Arc::new(Mutex::new(Vec::new()));
176        let loop_log = Arc::new(Mutex::new(Vec::new()));
177        let mut table = ProcessTable::new();
178        let driver = ResumeDriver;
179        let ops = NoopOps;
180
181        // 3 final candidates: registered, but never pushed into RegionQueues.
182        let mut finals = Vec::new();
183        for tag in 0..3u64 {
184            let proc = table.register(Box::new(LoggingProcess {
185                tag,
186                log: Arc::clone(&final_log),
187            }));
188            finals.push(FinalProcess {
189                proc,
190                registration_order: tag,
191            });
192        }
193
194        // 2 unrelated processes driven through the ordinary Active-region
195        // ready-set/dispatch path.
196        let loop_proc_a = table.register(Box::new(LoggingProcess {
197            tag: 100,
198            log: Arc::clone(&loop_log),
199        }));
200        let loop_proc_b = table.register(Box::new(LoggingProcess {
201            tag: 101,
202            log: Arc::clone(&loop_log),
203        }));
204
205        let mut queues = RegionQueues::new();
206
207        // Cycle 1: both loop processes' initial Start activation.
208        queues
209            .push(Region::Active, act(0, loop_proc_a, 0, 0, ResumeArg::Start))
210            .unwrap();
211        queues
212            .push(Region::Active, act(1, loop_proc_b, 0, 1, ResumeArg::Start))
213            .unwrap();
214        let ready = form_ready_set(&mut queues, Region::Active).unwrap();
215        assert_eq!(ready.len(), 2);
216        for a in &ready {
217            driver.start(&table, a.proc, null_state(), &ops).unwrap();
218        }
219
220        assert!(
221            final_log.lock().unwrap().is_empty(),
222            "final processes must not have been touched by cycle 1 of the \
223             ordinary timestep loop"
224        );
225
226        // Cycle 2: both loop processes re-activated later in the same
227        // (fictitious) run.
228        queues
229            .push(
230                Region::Active,
231                act(2, loop_proc_a, 5, 0, ResumeArg::TimeElapsed),
232            )
233            .unwrap();
234        queues
235            .push(
236                Region::Active,
237                act(3, loop_proc_b, 5, 1, ResumeArg::TimeElapsed),
238            )
239            .unwrap();
240        let ready = form_ready_set(&mut queues, Region::Active).unwrap();
241        assert_eq!(ready.len(), 2);
242        for a in &ready {
243            driver
244                .resume_for(&table, a.proc, ResumeArg::TimeElapsed, null_state(), &ops)
245                .unwrap();
246        }
247
248        assert!(
249            final_log.lock().unwrap().is_empty(),
250            "final processes must still be untouched after 2 full \
251             Active-region form_ready_set/dispatch cycles that never \
252             mentioned them"
253        );
254        assert_eq!(
255            loop_log.lock().unwrap().len(),
256            4,
257            "sanity: the 2 unrelated processes really were dispatched twice each"
258        );
259
260        // Only now, via the explicit dedicated pass, do the final processes run.
261        run_finals(&mut finals, &table, null_state(), &ops, &driver);
262        let final_log = final_log.lock().unwrap();
263        assert_eq!(
264            final_log.len(),
265            3,
266            "run_finals must start every registered final process exactly once"
267        );
268        assert!(
269            final_log.iter().all(|(_, is_start)| *is_start),
270            "every final process must be reached via resume(Start), not some \
271             other ResumeArg"
272        );
273    }
274
275    /// SC-078 — 4 `FinalProcess` entries built in a shuffled `Vec` order but
276    /// with fixed `registration_order` values `[3,1,0,2]`; `run_finals` must
277    /// dispatch them `0,1,2,3` regardless of the input `Vec`'s own order.
278    /// Run twice, each from a fresh shuffled input, and assert both runs
279    /// produce the identical call sequence (determinism across "runs").
280    /// Fails if `run_finals` trusts the input `Vec`'s order instead of
281    /// sorting by `registration_order`.
282    #[test]
283    fn finals_run_in_stable_registration_order_across_repeated_runs() {
284        fn run_once(orders_in_input_order: [u64; 4]) -> Vec<u64> {
285            let log = Arc::new(Mutex::new(Vec::new()));
286            let mut table = ProcessTable::new();
287            let driver = ResumeDriver;
288            let ops = NoopOps;
289
290            let mut finals = Vec::new();
291            for &registration_order in orders_in_input_order.iter() {
292                let proc = table.register(Box::new(LoggingProcess {
293                    tag: registration_order,
294                    log: Arc::clone(&log),
295                }));
296                finals.push(FinalProcess {
297                    proc,
298                    registration_order,
299                });
300            }
301
302            run_finals(&mut finals, &table, null_state(), &ops, &driver);
303            log.lock().unwrap().iter().map(|(tag, _)| *tag).collect()
304        }
305
306        let run1 = run_once([3, 1, 0, 2]);
307        assert_eq!(
308            run1,
309            vec![0, 1, 2, 3],
310            "run_finals must dispatch strictly by ascending registration_order"
311        );
312
313        let run2 = run_once([2, 0, 3, 1]);
314        assert_eq!(
315            run2,
316            vec![0, 1, 2, 3],
317            "a second run from a differently-shuffled input must produce the \
318             identical dispatch order (determinism across runs)"
319        );
320    }
321
322    /// SC-078 — two `FinalProcess` groups standing in for two different
323    /// hierarchical module instances, tagged with disjoint `registration_order`
324    /// ranges (`[5, 2]` and `[4, 1]`) and interleaved into one `finals: Vec`.
325    /// The merged run order must be purely numeric — `1, 2, 4, 5` — proving no
326    /// per-instance grouping leaks into the dispatch order (the LRM leaves
327    /// inter-block order unspecified only *within* the registration-order
328    /// space, and this crate's own policy is that there is exactly one such
329    /// space, never one per instance).
330    #[test]
331    fn finals_across_different_module_instances_still_use_one_global_order() {
332        let log = Arc::new(Mutex::new(Vec::new()));
333        let mut table = ProcessTable::new();
334        let driver = ResumeDriver;
335        let ops = NoopOps;
336
337        // Instance A's final blocks: registration_order 5, then 2.
338        // Instance B's final blocks: registration_order 4, then 1.
339        // Interleaved into one Vec in an order that matches neither instance's
340        // own internal order nor the final numeric order.
341        let instance_a = [5u64, 2u64];
342        let instance_b = [4u64, 1u64];
343        let interleaved: Vec<u64> =
344            vec![instance_a[0], instance_b[0], instance_a[1], instance_b[1]];
345
346        let mut finals = Vec::new();
347        for &registration_order in &interleaved {
348            let proc = table.register(Box::new(LoggingProcess {
349                tag: registration_order,
350                log: Arc::clone(&log),
351            }));
352            finals.push(FinalProcess {
353                proc,
354                registration_order,
355            });
356        }
357
358        run_finals(&mut finals, &table, null_state(), &ops, &driver);
359        let order: Vec<u64> = log.lock().unwrap().iter().map(|(tag, _)| *tag).collect();
360        assert_eq!(
361            order,
362            vec![1, 2, 4, 5],
363            "the merged run order must be purely numeric by registration_order \
364             -- no per-instance grouping may leak in"
365        );
366    }
367}