Skip to main content

vj_sim/sched/
mod.rs

1//! SCHED (`vj-sim::sched`) — cross-track contract only: the event
2//! scheduler/kernel-control surface shared with STATE, A2-PROCESS, DETERMINISM,
3//! RUNTIME and the debugger (scheduler.md). Real event-queue/time-wheel
4//! implementation belongs to the `sched` track, not this wave.
5
6use vj_abi::{Region, SimHandle, SimTime};
7
8use crate::process::{PredicateFn, StmtId};
9use crate::state::{EdgeKind, FourStateValue, StateCellId};
10
11pub mod assert_control;
12pub mod class_event;
13mod control;
14mod dedup;
15mod deferred;
16mod finals;
17mod guard;
18pub mod host;
19mod io_gate;
20mod kernel_adapter;
21pub mod mailbox;
22pub mod maturation;
23mod nba;
24mod notify;
25mod ops;
26mod order;
27pub mod proc_assert;
28pub mod process_rng;
29mod process_table;
30mod queues;
31mod region_callback;
32mod repeat;
33mod reschedule_req_proposal;
34mod resume;
35pub mod semaphore;
36mod sensitivity;
37mod subscribe;
38pub mod thread_hooks;
39mod violation;
40mod wheel;
41pub use control::*;
42pub use dedup::*;
43pub use deferred::{
44    deferred_call_for, deferred_cancel_for, deferred_report, deferred_report_for,
45    set_deferred_writer,
46};
47pub use finals::*;
48pub use guard::*;
49pub use io_gate::*;
50pub use kernel_adapter::*;
51pub use nba::*;
52pub use notify::*;
53pub use ops::*;
54pub use order::*;
55pub use process_table::*;
56pub use queues::*;
57pub use region_callback::*;
58pub use repeat::*;
59pub use resume::*;
60pub use sensitivity::*;
61pub use subscribe::*;
62pub use thread_hooks::{
63    ThreadEvent, ThreadHook, fire_thread_event, set_thread_hook, thread_hook_installed,
64};
65pub use violation::{
66    DEFERRED_MODE_CANCEL, DEFERRED_MODE_FINAL, DEFERRED_MODE_OBSERVED, ViolationType,
67    set_violation_writer, violation_report, violation_report_for,
68};
69pub use wheel::*;
70
71pub type ProcessId = u32;
72pub type ActivationId = u64;
73
74/// Ticks at the compilation's minimum time precision (SC-064).
75pub type SimTick = u64;
76
77/// The scheduler-internal logical-time value (scheduler.md §2; ledger SC-023,
78/// SC-033, SC-129). **Deliberately a different type from the ABI-facing
79/// `vj_abi::SimTime{sim_units, delta}`** (docs/CONTRACTS.md r3 item 1):
80/// `delta` is not a field here and never will be — it is purely a local
81/// iteration count of the inner Active/Inactive/NBA convergence loop
82/// (`reconv_iter` on [`PendingActivation`]/`CanonicalEventKey`), discarded
83/// once that loop converges (SC-023). The only way to get from this type to
84/// the ABI's is [`SchedTime::to_abi_display`], the one documented conversion
85/// point (SC-129) — it never runs the other way, and nothing derives a
86/// `delta` from `ticks` internally.
87#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
88pub struct SchedTime {
89    pub ticks: SimTick,
90}
91
92const _: () = assert!(core::mem::size_of::<SchedTime>() == 8);
93
94impl SchedTime {
95    /// The one documented `SchedTime` -> `vj_abi::SimTime` conversion
96    /// point (SC-129), used only at the ABI/display boundary. `delta` is
97    /// always the caller's own delta-loop iteration counter — never read from
98    /// or derived from `self.ticks` — because the internal type never carries
99    /// one (SC-023).
100    pub fn to_abi_display(self, delta: u32) -> vj_abi::SimTime {
101        vj_abi::SimTime {
102            sim_units: self.ticks as i64,
103            delta,
104        }
105    }
106}
107
108/// The reconciled 5-region ABI discriminant, exhaustively matched (SC-016,
109/// SC-117): pins `region_ordinal` to `Region`'s own `#[repr(u32)]`
110/// discriminant rather than a second, hand-maintained numbering that could
111/// drift from it. No wildcard arm — adding a 6th `Region` variant fails this
112/// match to compile.
113pub fn region_ordinal(region: Region) -> u32 {
114    match region {
115        Region::Active => Region::Active as u32,
116        Region::Inactive => Region::Inactive as u32,
117        Region::Nba => Region::Nba as u32,
118        Region::Reactive => Region::Reactive as u32,
119        Region::Postponed => Region::Postponed as u32,
120        // sva T1 (§1.4, review fix, blocking finding 2): mechanical, like
121        // every other arm above.
122        Region::Preponed => Region::Preponed as u32,
123        // sva T2 (§1.4, review fix, blocking finding 2), Observed's half:
124        // equally mechanical.
125        Region::Observed => Region::Observed as u32,
126    }
127}
128
129/// What woke a `CompiledProcess::resume()` call (scheduler.md §5).
130#[derive(Clone, Copy, Debug)]
131pub enum ResumeArg {
132    Start,
133    TimeElapsed,
134    EventFired {
135        term_index: usize,
136    },
137    ForkChildDone {
138        child: ProcessId,
139    },
140    Disabled,
141    /// **NEW (Task 9, B-sched wave, ledger SC-125).** `Suspend::WaitFork`'s
142    /// resolution: every `ProcessId` live in the resuming process's own
143    /// `fork_children` at the moment it suspended has now reached
144    /// `Suspend::Done` and been reclaimed.
145    AllForkChildrenDone,
146    /// **NEW (Task 9, SC-125).** `Suspend::WaitOrder(order)`'s resolution:
147    /// every `ProcessId` in `order` completed, in exactly that declared
148    /// order.
149    WaitOrderSatisfied,
150}
151
152/// `BlockStatement`'s three join semantics (`StatementBlockKind` mirror; ledger
153/// PR-086 and the fork/join family).
154#[derive(Clone, Copy, PartialEq, Eq, Debug)]
155pub enum JoinKind {
156    All,
157    Any,
158    None,
159}
160
161/// One `@(...)`/`wait(...)` disjunct (scheduler.md §4/§5; ledger SC-048..SC-051,
162/// SC-065).
163#[derive(Clone, Debug)]
164pub enum EventTerm {
165    Edge {
166        cell: StateCellId,
167        edge: EdgeKind,
168        iff: Option<PredicateFn>,
169    },
170    ImplicitAny(Vec<StateCellId>),
171    /// A wait on a bit/part select of one cell (`@(v[3])`, `@(posedge
172    /// v[5:4])`, LRM 9.4.2): it fires on an update that changed bits `[lsb,
173    /// lsb + width)` of `cell` (any change when `edge` is `None`, else the
174    /// transition of bit `lsb` classified as `edge`). The update is judged on
175    /// its own old/new words (`ChangeDelta`), so a bit that is set and cleared
176    /// again by two updates in one time step wakes the waiter once per
177    /// update, exactly as the LRM's update events do (4.9.4).
178    Select {
179        cell: StateCellId,
180        edge: EdgeKind,
181        lsb: u32,
182        width: u32,
183    },
184    /// **Also the resolution for `BlockEventListControl` (`@(block.
185    /// triggered)`), SC-054's own "kept as a hook-only `EventTerm` mapping
186    /// in v1" acceptance, resolved here rather than by adding a 5th
187    /// variant:** `@(block.triggered)` needs no new `EventTerm` shape at
188    /// all -- codegen reserves one synthetic `StateCellId` per named
189    /// statement block and fires it (through the same `TimerAction::
190    /// TriggerEvent`/direct `notify_change` fan-out an ordinary `->`/`->>`
191    /// trigger already uses, `sched::wheel`'s own SC-069 doc comment)
192    /// exactly when that block completes -- i.e. `.triggered` is modeled as
193    /// an ordinary named event whose trigger site is block-exit instead of
194    /// an explicit `->` statement. `NamedEventTrigger`'s own
195    /// scheduler-side consumption (installing an any-change dynamic entry
196    /// keyed by the cell, waking every waiter on a real fire) is already
197    /// real and tested (Task 5, `sched::sensitivity::tests::
198    /// named_event_trigger_term_installs_an_any_change_dynamic_entry`) --
199    /// what remains for `@(block.triggered)`
200    /// specifically is purely whether codegen (B-process, out of this
201    /// track's ownership) actually emits the block-exit trigger call; this
202    /// scheduler-side half never hits a silent mis-simulation regardless
203    /// (SC-054's own negative acceptance clause), since an unfired trigger
204    /// cell simply never wakes anyone -- observably a hang/timeout, never a
205    /// wrong value.
206    NamedEventTrigger(StateCellId),
207    /// `repeat(count)@(...)` (mirrors slang's `RepeatedEventControl{expr,
208    /// event}`; ledger SC-052). `SensitivityTable::install_term` installs
209    /// only `inner`'s own edge at *this* term's index — the count itself is
210    /// resume-orchestration state, tracked live by
211    /// [`RepeatCounters`](crate::sched::RepeatCounters) (`sched/repeat.rs`),
212    /// not by the sensitivity table.
213    ///
214    /// **`##N`/clocking-block cycle delays reduce to this term, with no
215    /// second mechanism (SC-004, SC-005, SC-053):** per ARCHITECTURE §6.5,
216    /// `CycleDelayControl` is resolved to absolute ticks *at runtime*, not
217    /// pre-resolved by codegen/elaboration — `##N` relative to a clocking
218    /// block's bound clock is semantically `Repeat{count: N, inner:
219    /// Edge{cell: <bound clock's cell>, edge: Posedge, iff: None}}`. Because
220    /// `RepeatCounters` counts live firings of `inner`'s real signal rather
221    /// than precomputing an absolute tick offset, "the same compiled `##N`
222    /// delay produces different absolute tick timing when its associated
223    /// clock's period changes dynamically at runtime" (SC-053's own
224    /// differentiating acceptance clause) holds by construction: there is no
225    /// cached tick offset to go stale. See `sched::repeat`'s own module doc
226    /// comment and `sched::control`'s
227    /// `sc053_repeat_based_wait_re_reads_the_clocks_live_period_not_a_cached_tick_offset`
228    /// for the proof against a deliberately irregular (non-periodic) clock.
229    Repeat {
230        count: u64,
231        inner: Box<EventTerm>,
232    },
233}
234
235/// `WaitKind::Any` is SV's only real semantics; kept as an explicit list for
236/// `##N`/`repeat` (scheduler.md).
237#[derive(Clone, Debug, Default)]
238pub struct EventWaitSet {
239    pub terms: Vec<EventTerm>,
240    /// `true` for `wait(expr)`: the resumed process re-checks its condition
241    /// before running on, so a wake delivered while the process was
242    /// suspended (`process::suspend`, LRM 9.7.4) can be parked and replayed
243    /// by `resume()` harmlessly. `false` for an `@(...)` event control,
244    /// where the LRM desensitises a suspended waiter: an event that fires
245    /// during the suspension is missed, not replayed.
246    pub rechecks_on_wake: bool,
247}
248
249/// What a `CompiledProcess::resume()` call returns to the in-process scheduler
250/// orchestration loop. See [`crate::process::WaitSpec`] for the lower-level,
251/// codegen/ABI-facing sibling of this type and the reconciliation note there.
252///
253/// **Fork/join (RESOLVED, docs/CONTRACTS.md "Resolved design decisions" D1).**
254/// A `fork` hands the scheduler *spawn descriptors*, not process identities and
255/// not process objects. The three design docs disagreed three ways
256/// (scheduler.md: `Vec<Box<dyn CompiledProcess>>`; the r2 placeholder:
257/// `Vec<ProcessId>`; process.md §7: an undefined `ProcessHandle`):
258/// - `Vec<ProcessId>` is wrong: the children do not exist yet when the parent
259///   suspends, so nothing could have assigned those ids.
260/// - `Box<dyn CompiledProcess>` contradicts ARCHITECTURE §5's frozen ABI (a
261///   process is an `extern "C"` `ProcessFn` entry in `VJ_PROCESS_TABLE`,
262///   not a Rust trait object) and cannot satisfy `Suspend: Clone`.
263///
264/// Fork branches are statically known code: codegen emits each branch as its
265/// own process-table kernel. So the parent returns one [`SpawnSpec`] per
266/// branch; the scheduler registers each, assigns the child its [`ProcessId`],
267/// records the ids in the parent's `ForkFrame::children`, sets
268/// `ForkFrame::remaining` per [`JoinKind`] (All = n, Any = 1, None = 0 — the
269/// parent resumes immediately), and later reports each completion back via
270/// `ResumeArg::ForkChildDone { child }`. Children reach variables declared in
271/// the parent's enclosing scopes through [`SpawnSpec::parent`] (LRM 9.3.2).
272/// Rolling back a parent that has already spawned live children must also
273/// roll back / kill those children — owned by the determinism rows, not this
274/// type.
275#[derive(Clone, Debug)]
276pub enum Suspend {
277    Delay(u64),
278    WaitEvents(EventWaitSet),
279    ForkJoin(JoinKind, Vec<SpawnSpec>),
280    /// **NEW (Task 9, B-sched wave, ledger SC-125) — scheduler-side half of
281    /// `wait fork;`.** Blocks the calling process until every `ProcessId`
282    /// currently live in its own `ProcessDescriptor::fork_children`
283    /// (populated by an earlier `fork ... join_none`) has reached
284    /// `Suspend::Done` and been reclaimed; resumes via
285    /// `ResumeArg::AllForkChildrenDone`. See `sched::control::Engine::
286    /// apply_wait_fork` and `sched::resume::ResumeDriver::on_child_done`'s
287    /// own doc comments for the mechanism. `disable fork;` is deliberately
288    /// **not** a `Suspend` variant here -- it never suspends the caller, so
289    /// it is instead a synchronous `SchedulerOps::disable_fork` call (see
290    /// that method's own doc comment).
291    WaitFork,
292    /// **NEW (Task 9, SC-125) — scheduler-side half of `wait_order(a, b,
293    /// c);`.** Blocks the calling process until every `ProcessId` in
294    /// `order` has completed, in exactly that declared order; resumes via
295    /// `ResumeArg::WaitOrderSatisfied` on success. An out-of-order
296    /// completion never silently satisfies the wait or panics -- it is
297    /// surfaced as `sched::resume::ResumeError::WaitOrderViolated` (see
298    /// that type's own doc comment).
299    WaitOrder(Vec<ProcessId>),
300    /// Task 8 (AB-085): a barrier-wait suspend -- the process yields until
301    /// every parallel lane has reached the same GVT boundary. Deliberately
302    /// carries no payload and needs no new coroutine/stack mechanism: it
303    /// reuses the exact same suspend/resume dispatch every other `Suspend`
304    /// variant goes through (`apply_suspend` in `sched::control` handles it
305    /// by delegating to the `Delay(0)` arm's existing code path -- see that
306    /// function's own doc comment). The real GVT-barrier semantics (actually
307    /// gating resumption on every parallel lane's progress, not just
308    /// resolving at the next delta step) belong to the determinism/parallel-
309    /// engine track and are not implemented by this wave; this variant only
310    /// wires the ABI-level suspend reason into the existing dispatch so a
311    /// compiled process can report it.
312    WaitForGvt,
313    /// Classes plan Task 13 (LRM 9.7): the process yields because it just
314    /// queued a [`ProcessRequest`] through [`SchedulerOps::process_request`]
315    /// (`p.await()` / `p.suspend()` called as a statement). Unlike every
316    /// other variant it carries no wake condition of its own: the engine
317    /// resumes the process immediately unless the request left it
318    /// self-suspended or awaiting another process (see
319    /// `sched::control::Engine::apply_by_request`).
320    ByRequest,
321    Done,
322}
323
324/// `process::state` (LRM 9.7), in the enum's declaration order -- the
325/// discriminant IS the value `p.status()` returns.
326#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
327pub enum ProcessState {
328    Finished = 0,
329    Running = 1,
330    Waiting = 2,
331    Suspended = 3,
332    Killed = 4,
333}
334
335/// One `process` class method call that mutates another process (or this
336/// one), queued by [`SchedulerOps::process_request`] and applied by the
337/// engine at the next suspend point -- the same drain-at-`apply_suspend`
338/// shape `disable fork` uses, because `SchedulerOps` methods take `&self`.
339#[derive(Clone, Copy, PartialEq, Eq, Debug)]
340pub enum ProcessRequest {
341    Kill(ProcessId),
342    Suspend(ProcessId),
343    Resume(ProcessId),
344    Await(ProcessId),
345    /// Classes plan Task 17 (LRM 15.3): the requester called a blocking
346    /// synchronisation-object method (`semaphore.get`) that must wait; it
347    /// stays yielded after its `Suspend::ByRequest` until a
348    /// [`ProcessRequest::SyncWake`] names it.
349    SyncBlock,
350    /// The named process's blocked `get` has been granted its keys: it runs
351    /// again. Queued by the `put` (or kill) that released it.
352    SyncWake(ProcessId),
353}
354
355/// One fork branch the scheduler must instantiate (see [`Suspend::ForkJoin`]).
356///
357/// Not `Copy` (unlike every other scheduler ABI value): [`Self::disable_name`]
358/// is a real, possibly-nonempty `String`, not FFI/ABI data -- this struct
359/// stays a plain, compile-time-constant Rust value (per its own module's
360/// `decode_reschedule_with_fork_table`/`fork_table` doc comments), never
361/// crosses an `extern "C"` boundary, so losing `Copy` costs nothing a real
362/// caller observes; every construction site this adds a field to is a
363/// struct literal, so a missed one is a compile error, never a silent gap.
364#[derive(Clone, PartialEq, Eq, Debug)]
365pub struct SpawnSpec {
366    /// Index into the compiled kernel's `VJ_PROCESS_TABLE` of the kernel
367    /// codegen emitted for this fork branch.
368    pub kernel: u32,
369    /// Resume point (segment / `resume_pc`) at which the child starts.
370    pub entry_pc: u32,
371    /// The spawning process; the child's activation record links to it for
372    /// access to variables declared in enclosing scopes (LRM 9.3.2).
373    pub parent: ProcessId,
374    /// The name(s) a `disable` elsewhere may target this branch by (LRM
375    /// 9.6.2): the enclosing `fork : NAME` label, if any, and/or this
376    /// branch's own named outermost block, if it has one -- both resolved at
377    /// compile time by `vj_sim::process::cfg`'s fork-branch walk. Empty
378    /// for an unnamed branch of an unnamed fork, which no `disable` can ever
379    /// single out. `Engine::apply_suspend`'s `Suspend::ForkJoin` handling
380    /// tags each newly minted child's `ProcessDescriptor::disable_names`
381    /// with this list the moment `handle_fork` hands back its `ProcessId`.
382    pub disable_name: Vec<String>,
383}
384
385/// One resumable process (scheduler.md §5): the scheduler owns zero per-process
386/// program-counter state — it lives in STATE-arena-allocated, A2-process-owned
387/// `ActivationRecord` storage.
388///
389/// **SC-127 scheduler-side resolution (Task 8, B-sched wave):** the
390/// scheduler places no restriction on *where*, syntactically or
391/// call-stack-wise, a [`Suspend`] value is constructed inside `resume()`.
392/// `resume()` is a plain function returning a plain value — the dispatch
393/// loop (`sched::control::Engine::apply_suspend`) matches only on the
394/// *returned* `Suspend` variant, never on how deep in the call stack it was
395/// built or what code ran to build it. A DPI-imported-task call site that
396/// itself calls back into blocking SV (LRM 35.5.6) is therefore
397/// representable today, with zero scheduler-side changes, exactly like this
398/// module's own `sc127_a_mock_process_may_suspend_from_a_point_that_
399/// stands_in_for_a_dpi_imported_task_callback` test constructs: a `Suspend`
400/// returned from several stack frames deep inside ordinary Rust calls
401/// dispatches and completes identically to one returned directly from
402/// `resume`'s own top-level match arm. **What this does *not* resolve** —
403/// whether `vj-sim::process`'s CFG builder / `analysis::dpi` actually
404/// choose to treat a given DPI-imported-task call site as a suspend point at
405/// all (i.e. which call sites qualify, and whether codegen ever emits one),
406/// per SC-127's own acceptance clause (a) vs. (b) — is that track's decision,
407/// not `sched`'s, and is cross-referenced here rather than silently assumed
408/// resolved.
409pub trait CompiledProcess: Send {
410    fn resume(&mut self, state: SimHandle, arg: ResumeArg, ops: &dyn SchedulerOps) -> Suspend;
411
412    /// Builds the real child process for one branch of a `fork` this process
413    /// just suspended on (`Suspend::ForkJoin`, PR-085). `None` means this
414    /// process has no compiled branch kernels to offer (every hand-written
415    /// test process); `Engine` then falls back to its inert placeholder
416    /// child. A compiled kernel process overrides this so `vj run`
417    /// actually executes each branch body.
418    fn mint_fork_child(&self, _spec: &SpawnSpec) -> Option<Box<dyn CompiledProcess>> {
419        None
420    }
421
422    /// The instance whose block and task names a `disable <label>;` run by
423    /// this process (or a fork branch of it) ends the activities of (LRM
424    /// 9.6.2); `None` for a process that has none (hand-written test
425    /// processes, fork branches).
426    fn disable_instance(&self) -> Option<u32> {
427        None
428    }
429
430    /// Where this process, suspended, resumes if its activity inside the
431    /// named block or task activation `label` is disabled: the block's (or the
432    /// call's) end. `None` when it is not inside `label`, or has finished.
433    fn disable_exit(&self, _label: &str) -> Option<u32> {
434        None
435    }
436
437    /// Whether the process has a disable scope `label` at all, whether or not
438    /// it is inside it now (or has finished): a `disable` of that block ends
439    /// the fork branches it left running (LRM 9.6.2).
440    fn owns_disable_scope(&self, _label: &str) -> bool {
441        false
442    }
443
444    /// Makes the process's next resume continue at `pc`
445    /// ([`Self::disable_exit`]). Returns whether it was blocked joining the
446    /// processes it forked (`join`, `join_any`, `wait fork`, `wait_order`).
447    fn redirect_resume(&mut self, _pc: u32) -> bool {
448        false
449    }
450}
451
452/// A deferred, GVT-gated I/O callback (see [`SchedulerOps::defer_io`]).
453pub type DeferredIoCallback = Box<dyn FnOnce(&mut dyn IoCtx) + Send>;
454
455/// The scheduler-provided operations a running kernel/process calls back into
456/// (scheduler.md's `SchedulerOps`; ledger SC-036, SC-042, SC-044, SC-070, SC-075,
457/// SC-116).
458pub trait SchedulerOps {
459    fn now(&self) -> SchedTime;
460    fn current_region(&self) -> Region;
461    /// Any concrete implementation MUST call [`NbaLog::stage`] and MUST NOT
462    /// provide any other write path for a nonblocking (`<=`) assignment —
463    /// this is the textual pin for SC-036 (no direct-apply path for NBA
464    /// writes anywhere in the scheduler), made enforceable by Task 8's
465    /// mock `SchedulerOps` test.
466    fn stage_nba(&self, cell: StateCellId, value: FourStateValue, source: ProcessId);
467    fn commit_blocking_write(&self, cell: StateCellId, value: FourStateValue);
468    /// The future simulation ticks with something scheduled (timed process
469    /// wake-ups, delayed nonblocking writes), ascending: the time queue a
470    /// VPI application iterates (IEEE 1800-2023 37.81). Empty where the
471    /// implementation has no time wheel.
472    fn pending_ticks(&self) -> Vec<u64> {
473        Vec::new()
474    }
475    /// The processes waiting on `cell` now (a suspended `@`/`wait` or a
476    /// static sensitivity), for VPI's `vpiWaitingProcesses` (IEEE 1800-2023
477    /// 37.27). Empty where the implementation tracks no sensitivity.
478    fn waiting_processes(&self, _cell: StateCellId) -> Vec<ProcessId> {
479        Vec::new()
480    }
481    fn request_finish(&self, code: i32);
482    fn defer_io(&self, at: SchedTime, region: Region, cb: DeferredIoCallback);
483    /// **NEW (Task 4, ledger SC-090/SC-123).** Registers `cb` to run exactly
484    /// once, at the first point `region` is drained with `now >= at` (see
485    /// `region_callback`'s own module doc for the `1step` placement this
486    /// pins and `sched::control::Engine::drain_region_callbacks`'s doc
487    /// comment for exactly when that drain happens relative to
488    /// `dispatch_ready_set`). The one real production implementation is
489    /// `EngineOps` (`sched/control.rs`); every other `SchedulerOps` test
490    /// double in this crate implements this as a no-op, matching this
491    /// trait's own established pattern for methods a given test double's
492    /// own tests don't exercise.
493    fn register_region_callback(&self, region: Region, at: SchedTime, cb: Box<dyn RegionCallback>);
494
495    /// **NEW (Task 9, B-sched wave, ledger SC-125).** `disable fork;`'s
496    /// scheduler-side half: recursively terminates every live descendant
497    /// `proc` has spawned via `fork` (its own `fork_children`, and theirs,
498    /// ...) WITHOUT suspending `proc` itself -- called mid-`resume()`,
499    /// synchronously, the same call shape as the already-real
500    /// [`Self::commit_blocking_write`].
501    ///
502    /// **Deliberately given a default (no-op) body, unlike
503    /// [`Self::register_region_callback`] (Task 4), which this trait's own
504    /// established pattern otherwise favors (every implementor updated
505    /// exhaustively):** several of `SchedulerOps`'s other implementors live
506    /// in sibling tracks' own concurrently-landing files this wave
507    /// (`vj-codegen/tests/*.rs`, `vj-runtime::runtime_ctx`) that
508    /// this task's own file list does not name and must not touch (this
509    /// wave's binding rule 7 -- "keep edits outside sched/ and the files
510    /// your plan names minimal"). The one real production override is
511    /// `EngineOps` (`sched/control.rs`); every test double in this crate
512    /// that does not exercise `disable fork` inherits this default,
513    /// unchanged.
514    fn disable_fork(&self, _proc: ProcessId) {}
515
516    /// Starts `proc` as a process of its own in the Reactive region, with no
517    /// parent: the thread LRM 1800-2023 16.11 schedules for a task a sequence
518    /// match item calls ("scheduled in the Reactive region"). Deferred like
519    /// [`Self::disable_fork`] (the table mutation needs `&mut Engine`); the
520    /// Reactive set drains until the process, and what it wakes, has run.
521    /// Default no-op: a test double that never spawns it inherits it.
522    fn spawn_detached(&self, _proc: Box<dyn CompiledProcess>) {}
523
524    /// `disable f;` executed by `proc`, a branch of the fork block `f`
525    /// (LRM 1800-2023 §9.6.2): every other branch of that fork ends, with
526    /// all descendants (`proc`'s own too), and the parent's join is
527    /// satisfied once `proc` itself finishes -- which it does right after.
528    /// Default no-op for the same reason as [`Self::disable_fork`].
529    fn disable_fork_block(&self, _proc: ProcessId) {}
530
531    /// `disable <name>;` where `<name>` is not an enclosing named block of
532    /// `requester`'s own statement tree, not its own fork
533    /// ([`Self::disable_fork_block`] already covers that case), and not a
534    /// procedural-assertion label: a fork-spawned branch's label, reachable
535    /// from `requester` because `requester` is the branch's parent, a
536    /// sibling branch, or a descendant of an ancestor fork (LRM 9.6.2:
537    /// disabling a named block terminates all its activity wherever it
538    /// executes, including a nested fork and tasks called from it).
539    /// Queues the kill of every live, currently-tagged ([`super::SpawnSpec::
540    /// disable_name`]) match reachable from `requester`'s own top-level fork
541    /// ancestor -- deferred exactly like [`Self::disable_fork`] (the actual
542    /// table mutation cannot happen synchronously inside this `&self` call).
543    /// Returns `true` iff `requester` itself is one of the matches, or
544    /// descends from one (the compiled caller must leave its own kernel at
545    /// once, mirroring [`Self::process_request`]'s identical `Kill` case) --
546    /// `requester` is never actually killed through this call (its own CFG,
547    /// via the abort flag, is what ends it; only OTHER matches/descendants
548    /// are force-killed). Default false/no-op for the same reason as
549    /// [`Self::disable_fork`].
550    fn disable_named(&self, _requester: ProcessId, _label: &str) -> bool {
551        false
552    }
553
554    /// Classes plan Task 13 (LRM 9.7): `requester` called a `process` class
555    /// method that mutates `req`'s target. The request is queued and applied
556    /// by the engine at the requester's next suspend (or, for `Kill`, ends the
557    /// target synchronously enough that [`Self::process_state`] already
558    /// reports it). Returns `true` iff the request terminates `requester`
559    /// itself (it is, or descends from, the kill target) -- the caller then
560    /// leaves its kernel at once. Default no-op for scheduler test doubles.
561    fn process_request(&self, _requester: ProcessId, _req: ProcessRequest) -> bool {
562        false
563    }
564    /// `p.status()` for the process `target` (LRM 9.7). A test double with no
565    /// process table reports every process `Running`.
566    fn process_state(&self, _target: ProcessId) -> ProcessState {
567        ProcessState::Running
568    }
569    /// Marks `proc` as one a `process` handle exists for, so its exit is
570    /// recorded (`FINISHED` vs `KILLED`) after the scheduler reclaims it.
571    /// Default no-op for the same reason as [`Self::disable_fork`].
572    fn process_track(&self, _proc: ProcessId) {}
573
574    /// Resumes `proc`, a process parked with no sensitivity (an empty event
575    /// list), in the current time step with `ResumeArg::EventFired { term }`.
576    /// The `expect` statement's wake (LRM 1800-2023 §16.17): the assertion
577    /// engine, running in the Observed region, reports the outcome and the
578    /// blocked process continues in this same step. Default no-op for the
579    /// same reason as [`Self::disable_fork`].
580    fn wake_process(&self, _proc: ProcessId, _term: usize) {}
581    /// `$monitor` (LRM 21.2.3): the cells of the active monitor's argument
582    /// signals. Any value change to one of them -- even a transient one
583    /// that ends where it started within the timestep -- marks the monitor
584    /// changed; `SchedulerCtx::take_monitor_changed` reads-and-clears that
585    /// at the end of the timestep. Replaces any previous watch set and marks
586    /// changed (a new `$monitor` always prints once). Default no-op, like
587    /// `disable_fork`, for scheduler test doubles.
588    fn set_monitor_watch(&self, _cells: Vec<StateCellId>) {}
589    /// A blocking write to one ROW of a memory (whole-array `cell`), already
590    /// committed to STATE by the caller, changed the stored value: wake
591    /// processes sensitive to the memory (`ops::notify_blocking_change`,
592    /// any-change edge). Default no-op, like `disable_fork`, for scheduler
593    /// test doubles.
594    fn notify_blocking_row_change(&self, _cell: StateCellId) {}
595    /// A windowed blocking commit to a huge signal (`state::huge_commit`),
596    /// already stored in STATE by the caller, changed its value: wake the
597    /// processes sensitive to `cell` with `edge` (the lsb edge). Default
598    /// no-op, like `disable_fork`, for scheduler test doubles. `delta` is the
599    /// changed plane words, present only when [`Self::wants_delta`] said a
600    /// select waiter needs them.
601    fn notify_blocking_wide_change(
602        &self,
603        _cell: StateCellId,
604        _edge: EdgeKind,
605        _delta: Option<crate::state::ChangeDelta>,
606    ) {
607    }
608    /// Whether a process waits on a bit/part select of `cell`, so a windowed
609    /// commit must report its changed words (see
610    /// [`crate::state::ValueChangeSink::wants_delta`]). Default `false`.
611    fn wants_delta(&self, _cell: StateCellId) -> bool {
612        false
613    }
614    /// Stages a partial / memory-row nonblocking write
615    /// (`nba::MaskedNbaUpdate`). The one real implementation is `EngineOps`;
616    /// a test double that never compiles such a write never reaches this.
617    fn stage_nba_masked(
618        &self,
619        cell: StateCellId,
620        row: Option<u64>,
621        value: FourStateValue,
622        mask: Vec<u64>,
623        source: ProcessId,
624    ) {
625        let _ = (row, value, mask, source);
626        unreachable!("stage_nba_masked({cell:?}) on a SchedulerOps without an NBA log")
627    }
628    /// [`Self::stage_nba_masked`] for an intra-assignment-delayed
629    /// nonblocking write (`q <= #d v`, LRM 1800-2023 §10.4.2): staged into
630    /// the NBA log when the time step `delay` ticks from now begins, so it
631    /// commits in that step's NBA region. A zero delay stages now.
632    fn stage_nba_masked_delayed(
633        &self,
634        cell: StateCellId,
635        row: Option<u64>,
636        value: FourStateValue,
637        mask: Vec<u64>,
638        source: ProcessId,
639        delay: u64,
640    ) {
641        let _ = (row, value, mask, source, delay);
642        unreachable!("stage_nba_masked_delayed({cell:?}) on a SchedulerOps without an NBA log")
643    }
644    /// Stages a nonblocking write of one element of the collection whose
645    /// handle `cell` (row `row`) holds (`nba::MaskedNbaUpdate::coll`),
646    /// `delay` ticks from now (zero stages now).
647    fn stage_nba_coll(
648        &self,
649        cell: StateCellId,
650        row: Option<u64>,
651        elem: Vec<u64>,
652        coll: nba::CollWrite,
653        source: ProcessId,
654        delay: u64,
655    ) {
656        let _ = (row, elem, coll, source, delay);
657        unreachable!("stage_nba_coll({cell:?}) on a SchedulerOps without an NBA log")
658    }
659    /// Stages a nonblocking write of class-property bits
660    /// (`nba::MaskedNbaUpdate::obj`), `delay` ticks from now (zero stages now).
661    fn stage_nba_obj(&self, w: nba::ObjWrite, source: ProcessId, delay: u64) {
662        let _ = (w, source, delay);
663        unreachable!("stage_nba_obj on a SchedulerOps without an NBA log")
664    }
665    /// A clocking-block synchronous drive is about to stage writes (LRM
666    /// 14.16: they update in the Re-NBA region): turns the Re-NBA region on
667    /// for a design with no Reactive process. Default: nothing to turn on.
668    fn note_reactive_drive(&self) {}
669    /// A clocking block's event was just triggered (its event counter
670    /// `counter` bumped): a synchronous drive executing later in this time
671    /// step is coincident with it (LRM 14.16). Default: nothing to record.
672    fn note_clocking_event(&self, counter: StateCellId) {
673        let _ = counter;
674    }
675    /// Stages a nonblocking event trigger (`->> e` / `->> #d e`, LRM
676    /// 1800-2023 15.5.2): at the NBA region of the step `delay` ticks from now
677    /// (a zero delay: this step) the event cell (row `row` of an event array)
678    /// is triggered.
679    fn stage_nba_pulse(&self, cell: StateCellId, row: Option<u64>, source: ProcessId, delay: u64) {
680        let _ = (row, source, delay);
681        unreachable!("stage_nba_pulse({cell:?}) on a SchedulerOps without an NBA log")
682    }
683    /// Stages a windowed partial write to a huge vector (`mask`/`value` span
684    /// only the plane words from `window` on; see
685    /// [`nba::MaskedNbaUpdate::window`]), delayed by `delay` ticks (zero
686    /// stages now).
687    fn stage_nba_window(
688        &self,
689        cell: StateCellId,
690        window: u32,
691        value: FourStateValue,
692        mask: Vec<u64>,
693        source: ProcessId,
694        delay: u64,
695    ) {
696        let _ = (window, value, mask, source, delay);
697        unreachable!("stage_nba_window({cell:?}) on a SchedulerOps without an NBA log")
698    }
699}
700
701/// Reserved hook for RUNTIME's `defer_io` callback context (scheduler.md names
702/// `IoCtx` as RUNTIME's concern; the concrete fields are RUNTIME's to define).
703pub trait IoCtx {}
704
705/// Codegen-supplied read-set metadata for `always_comb`/`always_latch`/
706/// `always_ff` (scheduler.md `StaticSensitivity`; ledger CG-079, SC-067,
707/// NL-036..NL-038, NL-045).
708#[derive(Clone, Debug, Default)]
709pub struct StaticSensitivity {
710    pub edges: Vec<(StateCellId, Option<EdgeKind>)>,
711}
712
713/// The canonical, thread-count-independent ordering key entry (scheduler.md §6;
714/// ledger SC-014, SC-029, DT-005, DT-042).
715///
716/// Fixed vs. scheduler.md's literal `logical_order: (SimTime, Region, u64)`
717/// 3-tuple (review fix, DT-042): DT-042 itself records that scheduler.md's
718/// design "describes discarding [the delta/reconvergence-iteration count]
719/// once the delta loop converges, which conflicts with ARCHITECTURE §4's
720/// 5-tuple key requiring it everywhere" — and per this workspace's spec
721/// precedence (ARCHITECTURE.md §4 wins over docs/design/*.md), `reconv_iter`
722/// must be threaded through every `PendingActivation`. Without it, two
723/// activations of the same process in two different delta-loop reconvergence
724/// passes at the same `(SchedTime, Region)` compared equal under the old
725/// 3-tuple, collapsing a real ordering distinction DT-042/SC-013 require.
726/// (The tuple's first field is now `SchedTime`, not `vj_abi::SimTime` —
727/// Task 1, SC-023/SC-033/SC-129: see `SchedTime`'s own doc comment.)
728/// `id` is new for the same review pass: `determinism::DeterminismController
729/// ::on_read`/`on_write` and `ResolvedBatch::completed` all key on
730/// `ActivationId`, but nothing previously let a `PendingActivation`/`WorkItem`
731/// report which `ActivationId` it is — `id` is that missing correlation.
732#[derive(Clone, Copy, Debug)]
733pub struct PendingActivation {
734    pub id: ActivationId,
735    pub proc: ProcessId,
736    pub arg: ResumeArg,
737    /// The delta/reconvergence-loop iteration count within `(SchedTime, Region)`
738    /// — the canonical 5-tuple's third field (ARCHITECTURE §4 item 3; DT-042).
739    pub reconv_iter: u32,
740    pub logical_order: (SchedTime, Region, u64),
741}
742
743#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
744pub struct BreakpointId(pub u32);
745
746#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
747pub struct CheckpointHandle(pub u64);
748
749/// (debugger.md `BreakCondition`; ledger CG-153, SC-096..SC-100.)
750#[derive(Clone, Debug)]
751pub enum BreakCondition {
752    Time(SimTime),
753    StmtHit {
754        stmt: StmtId,
755        cond: Option<PredicateFn>,
756    },
757    Watch {
758        cell: StateCellId,
759        edge: EdgeKind,
760        cond: Option<PredicateFn>,
761    },
762    ProcessSuspend(ProcessId),
763}
764
765#[derive(Clone, Copy, PartialEq, Eq, Debug)]
766pub enum SuspendReason {
767    WaitTime,
768    WaitEvent,
769    WaitFork,
770    Blocked,
771}
772
773#[derive(Clone, Copy, PartialEq, Eq, Debug)]
774pub enum ProcessRunState {
775    Running,
776    Suspended(SuspendReason),
777}
778
779#[derive(Clone, Copy, Debug)]
780pub struct ProcessSnapshot {
781    pub id: ProcessId,
782    pub state: ProcessRunState,
783    pub current: StmtId,
784}
785
786#[derive(Clone, Debug)]
787pub struct KernelSnapshot {
788    pub gvt: SimTime,
789    pub processes: Vec<ProcessSnapshot>,
790}
791
792#[derive(Clone, Copy, Debug)]
793pub struct SpeculationBound {
794    /// `0` == fully conservative; the checked-in v1 default per ARCHITECTURE §4
795    /// item 1 (ledger DB-033).
796    pub max_lookahead_cycles: u32,
797}
798
799#[derive(Clone, Copy, PartialEq, Eq, Debug)]
800pub enum RewindError {
801    PastFossilHorizon,
802    LayoutMismatch,
803}
804
805/// Implemented BY the engine (sched+determinism), called by `vj-debug`
806/// (debugger.md: "Breakpoints/watchpoints are kernel-evaluated... Arming any
807/// breakpoint bounds speculation to conservative for that session"; ledger
808/// SC-094, SC-095, SC-097, SC-100, DB-002, DB-014, CheckpointHandle: SC-105,
809/// DB-029, DB-030, DB-033, DB-046, DB-048).
810pub trait KernelControl: Send {
811    fn arm(&mut self, cond: BreakCondition) -> BreakpointId;
812    fn disarm(&mut self, id: BreakpointId);
813    fn snapshot(&self) -> KernelSnapshot;
814    fn gvt(&self) -> SimTime;
815    fn checkpoint(&mut self) -> CheckpointHandle;
816    fn rewind(&mut self, cp: CheckpointHandle) -> Result<(), RewindError>;
817    fn set_speculation_bound(&mut self, bound: SpeculationBound);
818}
819
820#[cfg(test)]
821mod tests {
822    use super::*;
823
824    #[test]
825    fn pending_activation_logical_order_is_a_plain_copy_tuple() {
826        let a = PendingActivation {
827            id: 42,
828            proc: 3,
829            arg: ResumeArg::Start,
830            reconv_iter: 0,
831            logical_order: (SchedTime { ticks: 10 }, Region::Active, 7),
832        };
833        let b = a;
834        assert_eq!(a.proc, b.proc);
835        assert_eq!(a.id, b.id);
836        assert_eq!(a.logical_order.2, 7);
837    }
838
839    /// Review fix: two activations of the same process at the same
840    /// `(SimTime, Region)` but different delta-loop reconvergence passes must
841    /// be distinguishable — this is the whole point of DT-042's `reconv_iter`.
842    #[test]
843    fn reconv_iter_distinguishes_same_process_same_tick_different_passes() {
844        let base = SchedTime { ticks: 10 };
845        let pass0 = PendingActivation {
846            id: 1,
847            proc: 3,
848            arg: ResumeArg::TimeElapsed,
849            reconv_iter: 0,
850            logical_order: (base, Region::Active, 7),
851        };
852        let pass1 = PendingActivation {
853            reconv_iter: 1,
854            ..pass0
855        };
856        assert_eq!(pass0.logical_order, pass1.logical_order);
857        assert_ne!(
858            pass0.reconv_iter, pass1.reconv_iter,
859            "same 3-tuple logical_order must still be distinguishable via reconv_iter"
860        );
861    }
862
863    // --- Task 1 (SC-016, SC-023, SC-033, SC-064, SC-117, SC-129) ---
864
865    #[test]
866    fn sched_time_has_no_delta_field_and_is_integer_only() {
867        let t = SchedTime { ticks: 10 };
868        let t2 = SchedTime { ticks: t.ticks + 5 };
869        assert_eq!(t2.ticks, 15);
870        assert_eq!(core::mem::size_of::<SchedTime>(), 8);
871    }
872
873    #[test]
874    fn sched_time_to_abi_display_only_populates_delta_from_the_given_counter() {
875        let t = SchedTime { ticks: 7 };
876        let a = t.to_abi_display(3);
877        assert_eq!(
878            a,
879            vj_abi::SimTime {
880                sim_units: 7,
881                delta: 3,
882            }
883        );
884        let b = t.to_abi_display(9);
885        assert_eq!(a.sim_units, b.sim_units);
886        assert_ne!(
887            a.delta, b.delta,
888            "delta must come from the caller's counter, not be derived from ticks"
889        );
890    }
891
892    #[test]
893    fn pending_activation_logical_order_uses_sched_time_not_abi_simtime() {
894        let a = PendingActivation {
895            id: 1,
896            proc: 0,
897            arg: ResumeArg::Start,
898            reconv_iter: 0,
899            logical_order: (SchedTime { ticks: 1 }, Region::Active, 0),
900        };
901        assert_eq!(a.logical_order.0.ticks, 1);
902    }
903
904    /// sva T1 (§1.4, review fix, blocking finding 2): was
905    /// `region_has_exactly_five_variants_no_preponed_observed_re_inactive_re_nba`
906    /// -- `Preponed` is now a real, additive 6th variant (`Observed`/
907    /// `Re-Inactive`/`Re-NBA` stay unimplemented).
908    ///
909    /// sva T2: renamed again -- `Observed` is now a real, additive 7th
910    /// variant too (`Re-Inactive`/`Re-NBA` stay unimplemented per §1.4's own
911    /// documented simplification).
912    #[test]
913    fn region_has_exactly_seven_variants_preponed_and_observed_added_re_inactive_re_nba_still_not()
914    {
915        let all = [
916            Region::Active,
917            Region::Inactive,
918            Region::Nba,
919            Region::Reactive,
920            Region::Postponed,
921            Region::Preponed,
922            Region::Observed,
923        ];
924        for r in all {
925            let _ = region_ordinal(r);
926        }
927        assert_eq!(all.len(), 7);
928    }
929
930    #[test]
931    fn region_ordinal_matches_discriminant_for_every_variant() {
932        for r in [
933            Region::Active,
934            Region::Inactive,
935            Region::Nba,
936            Region::Reactive,
937            Region::Postponed,
938            Region::Preponed,
939            Region::Observed,
940        ] {
941            assert_eq!(region_ordinal(r), r as u32);
942        }
943    }
944
945    #[test]
946    fn sc064_type_aliases_have_exact_underlying_representations() {
947        assert_eq!(core::mem::size_of::<SimTick>(), 8);
948        assert_eq!(core::mem::size_of::<ProcessId>(), 4);
949        assert_eq!(core::mem::size_of::<ActivationId>(), 8);
950        assert_eq!(core::mem::size_of::<crate::state::StateCellId>(), 8);
951        assert_eq!(crate::state::StateCellId(42).0, 42);
952    }
953
954    // --- Task 8 (SC-127: DPI-imported-task suspend-point ABI proof) ---
955
956    /// SC-127's scheduler-side half: proves, constructively, that
957    /// `CompiledProcess::resume`'s ABI imposes no structural restriction on
958    /// *where* a `Suspend` value is constructed — a DPI-imported-task
959    /// callback that blocks by calling back into SV is representable today
960    /// with zero scheduler-side special-casing. `DpiCallbackWaiter::resume`
961    /// stands in for "control has been handed to a foreign DPI-imported
962    /// task, which itself calls back into blocking SV": it returns
963    /// `Suspend::Delay(5)` from three stack frames deep inside ordinary Rust
964    /// helper calls, never directly from `resume`'s own top-level match arm
965    /// (the scheduler ABI is agnostic to *where* in Rust call-stack terms a
966    /// `Suspend` value was constructed — it only ever sees the returned
967    /// value). Drives this through the real `Engine`/`SchedulerControl`/
968    /// `ResumeDriver` dispatch loop, using zero DPI-specific engine API
969    /// (there is none), and asserts it dispatches and completes exactly like
970    /// any other delay-suspending process. Fails if a future change ever
971    /// special-cases suspend-point construction by call-stack shape (e.g.
972    /// panicking, dropping the suspend, or resuming with a different
973    /// `ResumeArg`), which would be exactly the "silently mis-scheduling"
974    /// failure SC-127's own acceptance clause (b) warns against for anything
975    /// that does not take option (a) explicitly.
976    #[test]
977    fn sc127_a_mock_process_may_suspend_from_a_point_that_stands_in_for_a_dpi_imported_task_callback()
978     {
979        struct NoopArena;
980        impl crate::state::SignalAccess for NoopArena {
981            fn read(&self, _cell: crate::state::StateCellId) -> FourStateValue {
982                FourStateValue::Narrow(0)
983            }
984            fn write(&mut self, _cell: crate::state::StateCellId, _value: FourStateValue) -> bool {
985                false
986            }
987            fn subscribe(
988                &mut self,
989                _cell: crate::state::StateCellId,
990                _role: crate::state::ListenerRole,
991                _sink: Box<dyn crate::state::ValueChangeSink + Send>,
992            ) {
993            }
994        }
995
996        struct NoopIoSink;
997        impl IoSink for NoopIoSink {
998            fn enqueue(&self, _at: SchedTime, _region: Region, _seq: u64, _cb: DeferredIoCallback) {
999            }
1000        }
1001
1002        // Three stack frames deep -- the suspend point is nowhere near
1003        // `resume`'s own top-level match arm, standing in for control having
1004        // passed through a foreign (DPI) call stack before a callback into
1005        // blocking SV constructs the `Suspend` value.
1006        fn dpi_frame_3(delay: u64) -> Suspend {
1007            Suspend::Delay(delay)
1008        }
1009        fn dpi_frame_2(delay: u64) -> Suspend {
1010            dpi_frame_3(delay)
1011        }
1012        fn dpi_frame_1(delay: u64) -> Suspend {
1013            dpi_frame_2(delay)
1014        }
1015
1016        struct DpiCallbackWaiter {
1017            log: std::sync::Arc<std::sync::Mutex<Vec<ResumeArg>>>,
1018        }
1019        impl CompiledProcess for DpiCallbackWaiter {
1020            fn resume(
1021                &mut self,
1022                _state: SimHandle,
1023                arg: ResumeArg,
1024                _ops: &dyn SchedulerOps,
1025            ) -> Suspend {
1026                self.log.lock().unwrap().push(arg);
1027                match arg {
1028                    ResumeArg::Start => dpi_frame_1(5),
1029                    _ => Suspend::Done,
1030                }
1031            }
1032        }
1033
1034        let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1035        let mut engine = Engine::new(Box::new(NoopArena), Box::new(NoopIoSink));
1036        engine.register_process(
1037            Box::new(DpiCallbackWaiter { log: log.clone() }),
1038            ProcessKind::Initial,
1039            None,
1040            0,
1041        );
1042
1043        let result = engine.run_until(None);
1044
1045        assert!(
1046            matches!(result, RunResult::Completed),
1047            "a delay suspended from deep inside a DPI-callback-shaped call stack must \
1048             still let the run reach quiescence like any other process, got {:?}",
1049            result
1050        );
1051        assert_eq!(engine.now(), SchedTime { ticks: 5 });
1052        let log = log.lock().unwrap();
1053        assert_eq!(
1054            log.len(),
1055            2,
1056            "must resume exactly twice: Start (which suspends via the DPI-shaped \
1057             callback) and TimeElapsed (which completes)"
1058        );
1059        assert!(matches!(log[0], ResumeArg::Start));
1060        assert!(
1061            matches!(log[1], ResumeArg::TimeElapsed),
1062            "the deeply-nested Suspend::Delay(5) must resume the process via an \
1063             ordinary TimeElapsed wakeup -- no DPI-specific ResumeArg variant exists \
1064             or is needed"
1065        );
1066    }
1067
1068    // --- Task 8 (AB-085) ---
1069
1070    /// Type-level check only, NOT proof of AB-085's dispatch-delegation
1071    /// claim: this asserts that `Suspend::WaitForGvt` needs no more than a
1072    /// label in a plain, single-depth `match` -- i.e. it fits the enum's
1073    /// existing shape rather than requiring a new variant-specific branch
1074    /// structure -- and, because the `label` match below has no wildcard
1075    /// arm, that a new `Suspend` variant added later without updating every
1076    /// such match is a compile error, not a silently-passing gate. It does
1077    /// NOT exercise `sched::control::Engine::apply_suspend` and so proves
1078    /// nothing about whether `WaitForGvt` actually delegates to the
1079    /// `Delay(0)` code path at runtime; that behavioral claim is covered by
1080    /// `apply_suspend_wait_for_gvt_delegates_to_the_same_path_as_delay_zero`
1081    /// in `sched::control`'s test module, which drives a real process
1082    /// through the real `Engine` and compares the observed outcome against
1083    /// `Delay(0)`'s.
1084    #[test]
1085    fn wait_for_gvt_fits_the_suspend_enums_existing_match_shape() {
1086        fn label(s: &Suspend) -> &'static str {
1087            match s {
1088                Suspend::Delay(_) => "delay",
1089                Suspend::WaitEvents(_) => "wait_events",
1090                Suspend::ForkJoin(_, _) => "fork_join",
1091                Suspend::WaitFork => "wait_fork",
1092                Suspend::WaitOrder(_) => "wait_order",
1093                Suspend::WaitForGvt => "wait_for_gvt",
1094                Suspend::ByRequest => "by_request",
1095                Suspend::Done => "done",
1096            }
1097        }
1098
1099        assert_eq!(label(&Suspend::WaitForGvt), "wait_for_gvt");
1100        // The other variants still label correctly through the same match --
1101        // proving WaitForGvt didn't need a different match shape.
1102        assert_eq!(label(&Suspend::Delay(0)), "delay");
1103        assert_eq!(
1104            label(&Suspend::WaitEvents(EventWaitSet::default())),
1105            "wait_events"
1106        );
1107        assert_eq!(
1108            label(&Suspend::ForkJoin(JoinKind::All, Vec::new())),
1109            "fork_join"
1110        );
1111        assert_eq!(label(&Suspend::WaitFork), "wait_fork");
1112        assert_eq!(label(&Suspend::WaitOrder(Vec::new())), "wait_order");
1113        assert_eq!(label(&Suspend::Done), "done");
1114    }
1115}