vj_sim/sched/region_callback.rs
1//! SCHED::REGION_CALLBACK — Task 4 (`docs/superpowers/plans/2026-09-27-
2//! wave-B-sched.md`): the `register_region_callback`/`RegionCallback`
3//! surface `docs/design/runtime.md` §6 specifies for RUNTIME's
4//! `$monitor`/`$strobe`/`cbReadOnlySynch`/`cbReadWriteSynch` delivery
5//! (ledger SC-090), plus the scheduler-side half of SC-020 (`$strobe`/
6//! `$monitor` must sample the final, post-delta-convergence value exactly
7//! once per settled timestep — see `sched::control::Engine::
8//! drain_region_callbacks`'s own doc comment for exactly where that
9//! sampling point is pinned; the remainder of SC-020, RUNTIME actually
10//! deferring its own argument re-evaluation, is RT-047/RT-036, B-runtime's
11//! own open ledger rows, out of this track's ownership).
12//!
13//! **`1step` placement (SC-123), pinned here:** `1step` is *not*
14//! `Suspend::Delay(0)` — `Delay(0)` re-enters `Region::Inactive`, which runs
15//! strictly *before* `Region::Postponed`, the wrong side of the boundary
16//! (`docs/design/scheduler.md`'s own Open Questions section flagged this
17//! exact placement as unconfirmed). A process suspended on `1step` timing
18//! instead registers itself as a [`RegionCallback`] for `Region::Postponed`
19//! at the current [`SchedTime`]: its `invoke` runs strictly after every
20//! *ordinary* `Region::Postponed` callback registered before it
21//! (registration order — the same guarantee
22//! `sched::control::Engine::drain_region_callbacks` gives any two
23//! same-region callbacks), placing its continuation logically after this
24//! timestep's Postponed region has fully settled, at the same logical time
25//! — never racing another Postponed callback and never waiting for the next
26//! tick. No SV-source construct lowers to this path yet this wave (no
27//! `vj-codegen` track emits `OneStepDelayControl` this wave, confirmed
28//! by this task's own audit), so this decision is pinned here and proved
29//! against a synthetic mock-process fixture —
30//! `sched::control::tests::sc123_1step_continuation_runs_after_every_
31//! ordinary_postponed_callback_at_the_same_tick` — rather than an SV-source
32//! end-to-end one.
33
34use vj_abi::Region;
35
36use crate::sched::SchedTime;
37use crate::state::{FourStateValue, StateCellId};
38
39/// `Engine::push_region_callback`'s one typed-error variant — the
40/// `region_callback` analogue of [`crate::sched::RegionQueuePushError`]
41/// (review fix, Task 4 r1): only `Region::Reactive` and `Region::Postponed`
42/// are ever drained by `sched::control::Engine::drain_region_callbacks`
43/// (called from `run_until`/`step_region` for exactly those two regions —
44/// see that method's own doc comment). A callback registered for
45/// `Region::Active`, `Region::Inactive` or `Region::Nba` would otherwise be
46/// pushed into `Engine::region_callbacks` and never drained: not an error,
47/// not a panic, not a log — just a silent leak that never fires. Mirroring
48/// `RegionQueuePushError`'s own established precedent for exactly this
49/// shape (a `Region`-typed parameter where not every variant is actually
50/// serviceable), this must be a loud typed `Result`, never a
51/// `debug_assert!` (which compiles to nothing in release builds and so
52/// reproduces the exact silent-drop this type exists to prevent).
53#[derive(Clone, Copy, PartialEq, Eq, Debug)]
54pub enum RegionCallbackRegionError {
55 /// `region` is not one `Engine::drain_region_callbacks` ever drains.
56 /// Carries the offending region so the caller-bug diagnostic names it.
57 NotDrained(Region),
58}
59
60impl std::fmt::Display for RegionCallbackRegionError {
61 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 match self {
63 RegionCallbackRegionError::NotDrained(region) => {
64 write!(
65 f,
66 "{region:?} is never drained by Engine::drain_region_callbacks (only \
67 Region::Reactive and Region::Postponed are); a callback registered for it \
68 would never fire"
69 )
70 }
71 }
72 }
73}
74
75impl std::error::Error for RegionCallbackRegionError {}
76
77/// The read-only view a [`RegionCallback`] gets. Deliberately narrower than
78/// [`crate::sched::SchedulerOps`] — no writes, no `defer_io`:
79/// `docs/design/runtime.md` §6's own "read back the current values" framing
80/// means a region callback observes already-settled state, it never drives
81/// further simulation.
82pub trait SchedulerCtx {
83 fn now(&self) -> SchedTime;
84 fn read(&self, cell: StateCellId) -> FourStateValue;
85 /// Registers `cb` for `region` again, from inside a running callback --
86 /// it fires at that region of the NEXT timestep (this timestep's drain
87 /// already took every callback that was due). `$monitor` re-arms itself
88 /// this way to run at the end of every timestep. A pending callback
89 /// never keeps a simulation alive: `run_until` still ends when the time
90 /// wheel is empty.
91 fn rearm(&self, region: vj_abi::Region, cb: Box<dyn RegionCallback>);
92 /// Whether a `$monitor` watched cell changed since the last call (see
93 /// `SchedulerOps::set_monitor_watch`); clears the flag.
94 fn take_monitor_changed(&self) -> bool;
95}
96
97/// A callback the scheduler invokes exactly once, at a pinned
98/// `(SchedTime, Region)` boundary (see
99/// [`crate::sched::SchedulerOps::register_region_callback`] and
100/// `sched::control::Engine::drain_region_callbacks`'s own doc comment for
101/// exactly when).
102pub trait RegionCallback: Send {
103 fn invoke(&mut self, ctx: &dyn SchedulerCtx);
104}
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109 use std::sync::{Arc, Mutex};
110
111 struct RecordingCallback {
112 seen: Arc<Mutex<Vec<FourStateValue>>>,
113 cell: StateCellId,
114 }
115
116 impl RegionCallback for RecordingCallback {
117 fn invoke(&mut self, ctx: &dyn SchedulerCtx) {
118 self.seen.lock().unwrap().push(ctx.read(self.cell));
119 }
120 }
121
122 struct FixedCtx {
123 now: SchedTime,
124 cell: StateCellId,
125 value: FourStateValue,
126 }
127
128 impl SchedulerCtx for FixedCtx {
129 fn now(&self) -> SchedTime {
130 self.now
131 }
132
133 fn read(&self, cell: StateCellId) -> FourStateValue {
134 assert_eq!(
135 cell, self.cell,
136 "this fixed test ctx only ever answers for the one cell it was built with"
137 );
138 self.value.clone()
139 }
140
141 fn rearm(&self, _region: vj_abi::Region, _cb: Box<dyn RegionCallback>) {
142 panic!("this fixed test ctx is never asked to re-arm a callback");
143 }
144
145 fn take_monitor_changed(&self) -> bool {
146 panic!("this fixed test ctx has no $monitor");
147 }
148 }
149
150 /// Proves the trait mechanics compile and actually flow a `SchedulerCtx`
151 /// read through to `invoke` (not merely that the types exist) — the
152 /// Engine-integration half (real drain ordering/timing) lives in
153 /// `sched::control::tests` per this task's own plan, since it needs a
154 /// real `Engine`/`SchedulerOps`.
155 #[test]
156 fn region_callback_invoke_receives_the_ctx_and_can_read_the_named_cell() {
157 let cell = StateCellId(7);
158 let seen = Arc::new(Mutex::new(Vec::new()));
159 let mut cb = RecordingCallback {
160 seen: seen.clone(),
161 cell,
162 };
163 let ctx = FixedCtx {
164 now: SchedTime { ticks: 3 },
165 cell,
166 value: FourStateValue::Narrow(42),
167 };
168
169 cb.invoke(&ctx);
170
171 let recorded = seen.lock().unwrap();
172 assert_eq!(recorded.len(), 1, "invoke must call through exactly once");
173 assert!(matches!(recorded[0], FourStateValue::Narrow(42)));
174 }
175}