Skip to main content

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}