Skip to main content

vj_sim/sched/
control.rs

1//! SCHED::CONTROL — the `SchedulerControl` trait, the `Engine` type that
2//! assembles every earlier `sched` task's pieces, and the ABI entry-point
3//! mapping (Task 18, `docs/superpowers/plans/2026-09-23-wave-A-sched.md`;
4//! ledger SC-083, SC-116).
5//!
6//! **Deviations from the plan's literal text (recorded here and in
7//! `docs/CONTRACTS.md`'s r3 Deviations section, item 8):**
8//!
9//! 1. **Resolved (integration, SC-055/SC-056):** `Engine` owns the
10//!    [`QueuedGate`] Task 10 introduced. `EngineNotifySink::notify_change`
11//!    enqueues through [`sensitivity_match_to_enqueue`] (still Task 9's one
12//!    canonical [`SensitivityTable::matches`] underneath, so SC-042's "no
13//!    duplicate implementation" holds), and `drain_and_sort` calls
14//!    [`QueuedGate::reset_on_dequeue`] for every activation the instant it is
15//!    popped off a region queue, strictly before any `resume()`. A process
16//!    sensitive to several cells that change in one pass is queued once; a
17//!    re-trigger landing after its dequeue queues it again.
18//! 2. **`register_process`'s body does more than the plan's literal two
19//!    statements** (`table.register` + the conditional `install_static`
20//!    call — that half is unchanged and still the crate's one and only
21//!    production call site of `install_static`, SC-045). It additionally
22//!    branches on `kind`: a `ProcessKind::Final` process is queued into
23//!    `self.finals` (SC-077/SC-078 — `final` blocks run only in the
24//!    dedicated post-`run_until` pass, Task 14, never dispatched at t=0);
25//!    every other kind gets one `ResumeArg::Start` `PendingActivation` pushed
26//!    onto `Region::Active` for the current time. Nothing in this task's own
27//!    ledger scope or the plan's prose performs "initial dispatch of
28//!    `initial`/`always*` processes at t=0" (scheduler.md §1 item 1c)
29//!    anywhere else, and no other piece assembled so far in this crate does
30//!    either — so without this addition `run_until`/`step_region` would have
31//!    no path to ever call `resume(Start)` on anything, making SC-083's own
32//!    "single-step through regions" acceptance unsatisfiable. The plan's own
33//!    text pins only the `install_static` call count ("No other line...may
34//!    call `install_static` a second time"), not the body's total statement
35//!    count, so this is additive, not a violation of that specific pin.
36//! 3. **`Engine::run_until`/`step_region` are real, working implementations
37//!    this task ships (not stubs), but intentionally simplified relative to
38//!    the eventual full LRM-faithful loop**: `Suspend::WaitEvents` is wired
39//!    directly through [`SensitivityTable::install_dynamic`] (which takes an
40//!    `EventWaitSet` directly, exactly what `Suspend::WaitEvents` carries, so
41//!    no `WaitSpec` bridge is needed) — **and** `dispatch_ready_set` calls
42//!    [`SensitivityTable::remove_dynamic`] for exactly the two `ResumeArg`
43//!    variants (`EventFired`/`Disabled`) that terminate a dynamic wait,
44//!    before `apply_suspend` processes that same call's resulting `Suspend`
45//!    (review fix — an earlier draft installed but never removed, leaking
46//!    every dynamic wait's sensitivity-table row forever); `Suspend::ForkJoin`
47//!    is wired through [`ResumeDriver::handle_fork`] with every spawned child
48//!    backed by [`UnmintedForkChild`], an immediately-`Done` placeholder body
49//!    — no codegen exists this wave to mint a fork branch's real compiled
50//!    kernel (`SpawnSpec::kernel` indexes a `VJ_PROCESS_TABLE` that
51//!    doesn't exist yet), so this is D1a's already-established "interim v1
52//!    shadow, not a substitute" posture, extended from `ProcessDescriptor`
53//!    bookkeeping (Task 11/12) to the `Engine` dispatch loop itself. **Review
54//!    fix:** `apply_suspend`'s `Suspend::Done` arm now calls
55//!    [`ResumeDriver::on_child_done`] when the completing process is itself a
56//!    fork child (`ProcessDescriptor::fork_parent.is_some()`), and queues the
57//!    parent's `ResumeArg::ForkChildDone` activation onto `Region::Active`
58//!    when the join condition is satisfied — an earlier draft left this
59//!    entirely unwired, so `fork`/`join`/`join_any` silently hung forever
60//!    (the parent was never reactivated) despite the bookkeeping fields
61//!    themselves being real. **Resolved (B-sched wave Task 7, ledger
62//!    SC-062/SC-115):** two gaps this deviation note previously left open,
63//!    now closed:
64//!    - **Fork-child slot reclaim.** `Suspend::Done` is unconditionally
65//!      terminal for the completing process (scheduler.md §5): a fork
66//!      child's own `ProcessTable` slot is now reclaimed by
67//!      [`ResumeDriver::on_child_done`] itself (`resume.rs`'s own doc
68//!      comment) the instant it is called for that child — regardless of
69//!      whether that particular completion happens to satisfy/
70//!      partially-satisfy the parent's join — never left dangling in the
71//!      table forever the way it was before this task.
72//!    - **`JoinKind::None`'s "parent proceeds immediately, no waiting"
73//!      continuation is now wired**, *without* inventing a new
74//!      cross-track `ResumeArg` variant meaning "just continue"
75//!      (`ResumeArg` remains A2-process/codegen-owned,
76//!      `docs/CONTRACTS.md`'s ownership table, untouched by this task): the
77//!      `Suspend::ForkJoin` arm, for `JoinKind::None` specifically,
78//!      synchronously calls [`ResumeDriver::resume_for`] again for the
79//!      same `proc` (never [`ResumeDriver::start`] — `proc` already went
80//!      through `Start` once to reach this very `Suspend::ForkJoin` in the
81//!      first place, so a second `start()` would be rejected by its own
82//!      once-only CAS guard) with the existing `ResumeArg::TimeElapsed`
83//!      variant reused as the least-surprising "no special payload" choice,
84//!      right in the same `apply_suspend` call that registered the
85//!      children — a direct re-entry, never delivered as a resume-with-a-
86//!      reason, matching LRM's "proceeds without waiting" (there is
87//!      nothing to wait *for*). See `apply_suspend`'s own `Suspend::
88//!      ForkJoin` arm for the exact mechanism and
89//!      `sc115_join_none_children_run_detached_while_the_parent_proceeds_
90//!      without_waiting` for the closing proof. Task 21 (ledger SC-001/
91//!      SC-007, its own separate scope, "`Engine` stops being a skeleton")
92//!      remains the task chartered to replace `UnmintedForkChild` with real
93//!      fork-child kernels once codegen mints them; this task's own tests
94//!      exercise `Suspend::Done`/`Suspend::Delay`/`Suspend::WaitEvents`/
95//!      `Suspend::ForkJoin` (`JoinKind::All` and `JoinKind::None`)
96//!      end-to-end through `run_until`/`step_region`.
97//! 4. **`EngineOps::request_finish` writes directly into `engine.
98//!    finish_requested` (a `Mutex<Option<i32>>`), not through Task 16's
99//!    [`crate::sched::request_finish_as_deferred_effect`]/`DeferIoDispatcher`
100//!    path.** SC-075's literal text asks for `request_finish` to be "queued
101//!    as a defer_io-class effect gated on gvt()"; routing through the actual
102//!    `DeferIoDispatcher` here would require constructing a *fresh* one per
103//!    call (`Engine` has no persistent `DeferIoDispatcher` field — Task 18's
104//!    own `defer_io` deliberately bypasses it for the same reason, see this
105//!    task's own spec text on `EngineOps::defer_io`), which would silently
106//!    reset that fresh dispatcher's own `seq` counter to 0 on every call —
107//!    exactly the bug `EngineOps::defer_io` was designed to avoid by writing
108//!    straight against `Engine`'s own persistent `io_seq`. Task 21's plan
109//!    text explicitly reconciles this: for v1's `SequentialController`
110//!    (`gvt() == now` always), a per-timestep `finish_requested` check *is*
111//!    SC-075's GVT-gated position requirement, since gvt never lags `now` —
112//!    "the reduction is documented here rather than asserted silently".
113//!    Task 16's own `request_finish_as_deferred_effect` remains a separate,
114//!    independently-tested primitive; it is simply not what `EngineOps`
115//!    (the one concrete, production `SchedulerOps` this task adds) calls.
116//! 5. **New `SensitivityTable::entry_count`** (`sched/sensitivity.rs`, Task
117//!    9's file) and **new `Engine::test_sensitivity_entry_count`** — the
118//!    plan's own text for the SC-045 exactly-once test names a direct field
119//!    read (`self.sensitivity.table.values()...`) that cannot compile from
120//!    this module (`SensitivityTable::table` is private to `sensitivity.rs`);
121//!    `entry_count` is the minimal, in-track (same `sched` track, additive,
122//!    non-breaking) accessor that makes the literal test possible.
123//! 6. **New `DeltaGuard::count` getter (`sched/guard.rs`, Task 15's file)
124//!    and new, non-`#[cfg(test)]`, public `Engine::
125//!    max_delta_iterations_in_a_timestep`** (Task 21, ledger SC-007) — the
126//!    plan's own `region_order_fixture.rs` test text asks to assert "the
127//!    delta-loop guard's count never exceeded 3 in any one timestep", but
128//!    that integration test is a separate compilation unit under `tests/`
129//!    and cannot reach `Engine`'s private `guard` field or any `#[cfg(test)]`
130//!    accessor (those only exist in this crate's own unit-test build, per
131//!    this module's own established precedent for every other private-field
132//!    accessor above). `run_until` now records `DeltaGuard::count()`'s value
133//!    into a running high-water mark (`max_delta_iterations_seen`) once per
134//!    timestep, right before `guard.reset()` — additive, non-breaking, and
135//!    changes no existing behavior or contract type.
136
137use std::sync::Mutex;
138use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
139
140use vj_abi::{Region, SimHandle};
141
142use crate::determinism::{DeterminismController, SequentialController};
143use crate::sched::{
144    CompiledProcess, DEFAULT_MAX_DELTA_ITERATIONS, DeferredIoCallback, DeltaGuard, FinalProcess,
145    IoSink, JoinKind, NbaLog, PendingActivation, ProcessId, ProcessRequest, ProcessState,
146    ProcessTable, QueuedGate, RegionCallback, RegionCallbackRegionError, RegionQueues,
147    RepeatCounters, RepeatOutcome, ResumeArg, ResumeDriver, SchedTime, SchedulerCtx, SchedulerOps,
148    SensitivityTable, SimTick, SpawnSpec, StaticSensitivity, Suspend, TimeWheel, TimerAction,
149    form_ready_set, mailbox, semaphore, sort_ready_set,
150};
151use crate::state::{
152    ArenaSnapshot, ChangeDelta, EdgeKind, FourStateValue, SignalAccess, SnapshotRing, StateCellId,
153    ValueChangeSink,
154};
155
156/// Bound on queued synchronous continuations in one `resume_synchronously`
157/// drain before it is reported as a livelock.
158/// Label `$exit` hands `sv_rt_disable_named` (LRM 24.7): not a block name but
159/// "every program initial thread of the caller's program".
160pub const EXIT_PROGRAM_LABEL: &str = "$exit";
161
162const MAX_SYNC_RESUMES: u32 = 30_000;
163
164/// Fork branches one time step may spawn before it is reported as a livelock:
165/// each live branch is kept until it finishes, so a zero-delay loop that
166/// forks forever (`always fork #0; #1; join_any`) would otherwise exhaust
167/// memory long before the delta guard trips.
168const MAX_FORKS_PER_STEP: u32 = 50_000;
169
170/// `run_until`'s outcome (scheduler.md's `run_until`/`step_region` return
171/// types, refined only as much as this wave's tests need).
172#[derive(Clone, Debug)]
173pub enum RunResult {
174    /// The wheel emptied naturally with no `finish_requested`/deadline hit.
175    Completed,
176    /// `SchedulerOps::request_finish(code)` was observed at a timestep
177    /// boundary (SC-075).
178    Finished { code: i32 },
179    /// `deadline` was reached before the run otherwise completed.
180    Deadline,
181    /// The delta-loop guard tripped (SC-080/DT-050): a zero-delay loop that
182    /// never lets time advance. A named runtime error for the caller to
183    /// report, never a panic; the payload is the guard's message.
184    Livelock(String),
185}
186
187/// `step_region`'s outcome.
188#[derive(Clone, Copy, PartialEq, Eq, Debug)]
189pub enum StepResult {
190    /// The region cursor advanced (work was dispatched, or the wheel
191    /// advanced `now`).
192    Advanced,
193    /// Nothing is queued anywhere and the wheel is empty — the simulation is
194    /// quiescent.
195    Blocked,
196}
197
198/// Mirrors `slang::ast::ProceduralBlockSymbol::procedureKind`'s 6 relevant
199/// values 1:1 (scheduler.md's `ProceduralBlockKindMirror`, never previously
200/// typed — `docs/CONTRACTS.md` r3 item 4).
201#[derive(Clone, Copy, PartialEq, Eq, Debug)]
202pub enum ProcessKind {
203    Initial,
204    Always,
205    AlwaysComb,
206    AlwaysLatch,
207    AlwaysFF,
208    Final,
209}
210
211/// The debugger/VPI-facing control surface (SC-083): run-to-completion,
212/// run-until(time), single-region-step, and process registration.
213pub trait SchedulerControl {
214    /// **Task 9 (A2-part2, CG-102) added `region_id`:** the process's own
215    /// `ProcessSideTable::region_id` (`vj-codegen::process_lower`'s own
216    /// field), threaded through to `ProcessTable::set_region_id` so
217    /// `dispatch_ready_set` can later look it up per-activation via
218    /// `ProcessTable::region_id`. v1's one real production call site
219    /// (`vj::run::run_design`) always passes `0` (ARCHITECTURE §4
220    /// item 1's single-conservative-partition value -- the same "correct
221    /// degenerate v1 value, not a stub" posture `region_id`'s own producing-
222    /// side doc comment already documents).
223    fn register_process(
224        &mut self,
225        proc: Box<dyn CompiledProcess>,
226        kind: ProcessKind,
227        sensitivity: Option<StaticSensitivity>,
228        region_id: u32,
229    ) -> ProcessId;
230    fn run_until(&mut self, deadline: Option<SchedTime>) -> RunResult;
231    fn step_region(&mut self) -> StepResult;
232    fn run_finals(&mut self);
233    fn now(&self) -> SchedTime;
234    fn set_determinism_controller(&mut self, ctrl: Box<dyn DeterminismController>);
235}
236
237/// **Task 9 (A2-part2, CG-102).** The scheduler's own partition identity --
238/// distinct from `vj-sim::analysis::RegionId` (the WOCB static-analysis
239/// pass's own, unimplemented-this-wave partition-assignment output type):
240/// this is the *dispatch-side* value `partition_of` computes FROM a
241/// process's already-assigned `region_id`, never itself assigned by
242/// anything upstream.
243#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
244pub struct PartitionId(pub u32);
245
246/// **Task 9 (A2-part2, CG-102).** `PartitionId(region_id % partition_count.
247/// max(1))` -- a real, general function (directly unit-testable with
248/// `partition_count > 1` to prove it actually reads `region_id`, not a
249/// hardcoded-`0` stub). `dispatch_ready_set`'s one real v1 call site always
250/// passes `partition_count = 1` (ARCHITECTURE §4 item 1's single-
251/// conservative-partition value -- the same citation `region_id`'s own
252/// doc comment uses), so every real v1 dispatch still yields
253/// `PartitionId(0)` for everything -- the same "correct degenerate v1
254/// value, not a stub" posture applied to the *consuming* side, not only
255/// the *producing* side. `.max(1)` makes `partition_count == 0` a defined,
256/// non-panicking degenerate case (one partition) rather than a divide-by-
257/// zero, since nothing about this function's own contract requires a
258/// caller to have already proven `partition_count >= 1`.
259pub fn partition_of(region_id: u32, partition_count: u32) -> PartitionId {
260    PartitionId(region_id % partition_count.max(1))
261}
262
263/// One not-yet-fired [`Engine::region_callbacks`] registration (clippy
264/// `type_complexity`: named here rather than spelled out inline at every
265/// use site).
266type RegionCallbackEntry = (SchedTime, Region, Box<dyn RegionCallback>);
267
268/// A fork child whose real compiled body codegen has not minted yet this
269/// wave (see this module's doc comment, deviation 3): resumes and
270/// immediately reports `Suspend::Done`.
271struct UnmintedForkChild;
272
273impl CompiledProcess for UnmintedForkChild {
274    fn resume(&mut self, _state: SimHandle, _arg: ResumeArg, _ops: &dyn SchedulerOps) -> Suspend {
275        Suspend::Done
276    }
277}
278
279/// sva T1 (§1.3/§1.4): the Preponed-region snapshot ring, plus the closure
280/// that takes a fresh [`ArenaSnapshot`] of whatever concrete, STATE-owned
281/// arena this `Engine`'s own `arena: Box<dyn SignalAccess>` is really
282/// backed by. `sched` deliberately carries no concrete STATE dependency of
283/// its own (this module's doc comment, "sched borrows the SignalAccess
284/// contract but does not implement it") and `SignalAccess` itself exposes
285/// no snapshot/restore capability, so the snapshot-taking closure is
286/// supplied by whoever constructs a real, `StateEngine`-backed `Engine`
287/// (`vj-runtime`, once a later task wires a live design's ring depth in
288/// -- see `state::snapshot`'s own module doc comment, deviation note) via
289/// [`Engine::configure_preponed_ring`], never derived from `dyn
290/// SignalAccess` itself.
291struct PreponedRing {
292    ring: SnapshotRing,
293    source: Box<dyn Fn() -> ArenaSnapshot>,
294}
295
296/// A nonblocking write waiting out its intra-assignment delay
297/// (`Engine::delayed_nbas`).
298struct DelayedNba {
299    cell: StateCellId,
300    row: Option<u64>,
301    value: FourStateValue,
302    mask: Vec<u64>,
303    window: Option<u32>,
304    source: ProcessId,
305    /// A delayed nonblocking event trigger (`->> #d e`): staged as a pulse.
306    pulse: bool,
307    /// A delayed collection-element write (`MaskedNbaUpdate::coll`).
308    coll: Option<super::nba::CollWrite>,
309    /// A delayed class-property write (`MaskedNbaUpdate::obj`).
310    obj: Option<super::nba::ObjWrite>,
311    /// Staged by the Re-NBA region's commit (a Reactive process or a clocking
312    /// drive, LRM 14.16) rather than the NBA region's.
313    reactive: bool,
314}
315
316/// A clocking drive waiting for an edge of its clock signal (`output negedge
317/// #d q`, LRM 1800-2023 14.4): staged `after` ticks past the next `edge` of
318/// `cell`.
319struct EdgeNba {
320    cell: StateCellId,
321    edge: EdgeKind,
322    after: u64,
323    nba: DelayedNba,
324}
325
326impl DelayedNba {
327    /// Stages this write into the NBA or Re-NBA log it belongs to.
328    fn stage_into(self, engine: &Engine) {
329        let mut nba = if self.reactive {
330            &engine.re_nba
331        } else {
332            &engine.nba
333        }
334        .lock()
335        .unwrap();
336        if let Some(w) = self.obj {
337            nba.stage_obj(w, self.source);
338        } else if self.pulse {
339            nba.stage_pulse(self.cell, self.row, self.source);
340        } else if let Some(coll) = self.coll {
341            nba.stage_coll(self.cell, self.row, self.mask, coll, self.source);
342        } else {
343            nba.stage_masked(
344                self.cell,
345                self.row,
346                self.value,
347                self.mask,
348                self.window,
349                self.source,
350            );
351        }
352    }
353}
354
355impl EngineOps<'_> {
356    /// Parks a delayed nonblocking write until `delay` ticks from now, or, for
357    /// the [`EDGE_DELAY`] marker, until the edge the running clocking drive
358    /// named (LRM 14.4 edge output skew).
359    fn defer_nba(&self, delay: u64, d: DelayedNba) {
360        if delay == super::kernel_adapter::EDGE_DELAY {
361            let w = super::kernel_adapter::edge_drive()
362                .expect("an edge-delayed write outside an edge-skewed clocking drive");
363            // 14.16: a drive to the block's own event that executes in the
364            // time step the event was triggered in is coincident with it.
365            if w.on_event
366                && self.engine.clocking_events.lock().unwrap().get(&w.cell) == Some(&self.now.ticks)
367            {
368                if w.after == 0 {
369                    d.stage_into(self.engine);
370                } else {
371                    self.engine
372                        .delayed_nbas
373                        .lock()
374                        .unwrap()
375                        .entry(self.now.ticks + w.after)
376                        .or_default()
377                        .push(d);
378                }
379                return;
380            }
381            self.engine.edge_nbas.lock().unwrap().push(EdgeNba {
382                cell: w.cell,
383                edge: w.edge,
384                after: w.after,
385                nba: d,
386            });
387            self.engine.has_edge_nbas.store(true, Ordering::Relaxed);
388            return;
389        }
390        self.engine
391            .delayed_nbas
392            .lock()
393            .unwrap()
394            .entry(self.now.ticks + delay)
395            .or_default()
396            .push(d);
397    }
398}
399
400/// The first type in the crate that assembles every earlier task's pieces
401/// together.
402pub struct Engine {
403    queues: Mutex<RegionQueues>,
404    wheel: TimeWheel,
405    nba: Mutex<NbaLog>,
406    /// The Re-NBA region's log (LRM 4.4.2.6 / 4.5): nonblocking assignments
407    /// staged by Reactive-region processes (program blocks, checkers), committed
408    /// only once Re-Active has drained ([`Self::commit_re_nba_once`]). Empty and
409    /// untouched for a design without them.
410    re_nba: Mutex<NbaLog>,
411    /// Whether any Reactive process is registered; gates the per-stage lookup
412    /// that routes an NBA to `re_nba`.
413    has_reactive: std::sync::atomic::AtomicBool,
414    /// Intra-assignment-delayed nonblocking writes (`q <= #d v`), by the
415    /// tick whose time step stages them into `nba` (in staging order).
416    delayed_nbas: Mutex<std::collections::BTreeMap<SimTick, Vec<DelayedNba>>>,
417    /// Edge-skewed clocking drives (`output negedge q`, LRM 14.4) waiting for
418    /// their clock edge; empty (and `has_edge_nbas` false) without any.
419    edge_nbas: Mutex<Vec<EdgeNba>>,
420    /// The tick each clocking block's event counter (by cell) was last bumped
421    /// at ([`SchedulerOps::note_clocking_event`]); empty without clocking blocks.
422    clocking_events: Mutex<std::collections::HashMap<StateCellId, u64>>,
423    has_edge_nbas: std::sync::atomic::AtomicBool,
424    sensitivity: SensitivityTable,
425    /// Per-pass "already queued" dedup (SC-055/SC-056): set when a
426    /// sensitivity match enqueues a process, cleared when that activation is
427    /// popped off its region queue.
428    gate: QueuedGate,
429    /// **NEW (Task 3, ledger SC-052).** Live decrementing counts for every
430    /// `(ProcessId, term_index)` pair mid-`repeat(N)@(...)` wait — see
431    /// `RepeatCounters`'s own doc comment (`sched/repeat.rs`).
432    /// `EngineNotifySink::notify_change` consults it before ever enqueueing
433    /// a `Repeat`-sourced match, and `dispatch_ready_set`'s dynamic-wait-
434    /// resumed cleanup calls `RepeatCounters::clear` alongside
435    /// `SensitivityTable::remove_dynamic`.
436    repeats: RepeatCounters,
437    table: ProcessTable,
438    driver: ResumeDriver,
439    finals: Vec<FinalProcess>,
440    guard: DeltaGuard,
441    /// `sched` borrows the `SignalAccess` *contract* but does not implement
442    /// it — the real arena is STATE-owned and does not exist this wave.
443    arena: Mutex<Box<dyn SignalAccess>>,
444    io_sink: Box<dyn IoSink>,
445    /// Persists for `Engine`'s whole lifetime — `EngineOps::defer_io`
446    /// deliberately writes here directly rather than through a
447    /// freshly-constructed `DeferIoDispatcher` per pass (see this module's
448    /// doc comment, deviation 4).
449    io_seq: AtomicU64,
450    /// Ids for `PendingActivation`s this `Engine` mints (both
451    /// `register_process`'s own initial-dispatch activation and every
452    /// `EngineNotifySink`-minted wakeup).
453    activation_seq: AtomicU64,
454    /// `resume_synchronously`'s trampoline: `Some(queue)` while a drain is
455    /// running (a nested request is queued, not recursed into, so a process
456    /// that forks `join_none` in a zero-delay loop cannot overflow the stack).
457    sync_queue: Option<std::collections::VecDeque<(ProcessId, ResumeArg)>>,
458    /// Set when one trampoline drain exceeded `MAX_SYNC_RESUMES`: a process
459    /// that never blocks (`always fork ... join_none join`), reported by
460    /// `run_until` as `RunResult::Livelock`.
461    sync_livelock: Option<String>,
462    /// Fork branches spawned since time last advanced (`MAX_FORKS_PER_STEP`).
463    forks_this_step: u32,
464    /// `request_finish`'s target; checked once per timestep by `run_until`,
465    /// never mid-pass (SC-075).
466    finish_requested: Mutex<Option<i32>>,
467    /// LRM 24.3: the program blocks' `initial` threads that have not ended
468    /// (see [`Engine::mark_program_initial`]); `None` when the design has no
469    /// program initial, so such designs pay nothing.
470    program_roots: Option<std::collections::HashSet<ProcessId>>,
471    controller: Box<dyn DeterminismController>,
472    now: SchedTime,
473    current_region: Region,
474    /// Called once at the end of every timestep, after the Postponed region
475    /// (waveform dumping samples signal values here -- `$dumpvars`).
476    timestep_end_hook: Option<Box<dyn FnMut(SchedTime)>>,
477    /// The embedder's scheduling hooks (`super::host`), if any.
478    host: Option<Box<dyn super::host::Host>>,
479    /// The tick of the last slot the host's slot-begin hook ran for.
480    host_slot: Option<SimTick>,
481    /// **NEW (Task 21, ledger SC-007) — a monotonically-nondecreasing
482    /// high-water mark of [`DeltaGuard::count`], captured once per timestep
483    /// (in `run_until`, just before `guard.reset()`), never per delta
484    /// iteration.** See [`Engine::max_delta_iterations_in_a_timestep`]'s own
485    /// doc comment for why this exists: SC-007's acceptance requires
486    /// evidence that the Active/Inactive/NBA delta loop actually converges
487    /// (a bounded pass count) for a real design, and `DeltaGuard::count`
488    /// itself was private with no getter before this task, so nothing
489    /// outside this crate's own `#[cfg(test)]` unit tests could observe that
490    /// bound at all — `region_order_fixture.rs` (a separate compilation
491    /// unit under `tests/`) needs exactly this.
492    max_delta_iterations_seen: AtomicU32,
493    /// **NEW (Task 4, ledger SC-090/SC-123/SC-020).** Every not-yet-fired
494    /// `register_region_callback` registration, as `(fire_at, region, cb)`,
495    /// in registration order (a plain `Vec`, not a priority structure — per-
496    /// timestep callback volume is small, and `region` already partitions
497    /// `Reactive` from `Postponed`, the same rationale `RegionQueues` itself
498    /// uses). [`Self::drain_region_callbacks`] removes+fires every entry
499    /// whose `region` matches and whose `fire_at <= self.now`, in the order
500    /// they appear here (registration order) — never resorted.
501    region_callbacks: Mutex<Vec<RegionCallbackEntry>>,
502    /// **NEW (Task 9, B-sched wave, ledger SC-125).** `ProcessId`s
503    /// `EngineOps::disable_fork` queued mid-`resume()` -- `SchedulerOps`
504    /// methods only ever get `&self`, so the actual recursive
505    /// `ProcessTable` mutation is deferred to
506    /// [`Self::drain_pending_disable_fork`], called at the top of
507    /// [`Self::apply_suspend`] (see that method's own doc comment).
508    pending_disable_fork: Mutex<Vec<ProcessId>>,
509    /// The processes `SchedulerOps::spawn_detached` queued (16.11): registered,
510    /// to start in Reactive, by [`Self::drain_pending_spawns`].
511    pending_spawns: Mutex<Vec<Box<dyn CompiledProcess>>>,
512    /// `disable f;` requests from a branch of fork `f` (see
513    /// `SchedulerOps::disable_fork_block`), drained with
514    /// `pending_disable_fork`.
515    pending_disable_block: Mutex<Vec<ProcessId>>,
516    /// `SchedulerOps::disable_named` requests: `(requester, targets)`, where
517    /// `targets` is the already-resolved (at queue time, synchronously --
518    /// see that method's own doc comment) list of live `ProcessId`s a
519    /// cross-process/fork-sibling `disable <label>;` reached. Drained by
520    /// [`Self::drain_pending_disable_named`], the same `&self`-queue /
521    /// `&mut self`-drain split every other `pending_disable_*` field uses.
522    pending_disable_named: Mutex<Vec<(ProcessId, Vec<ProcessId>)>>,
523    /// `SchedulerOps::disable_named` requests for the block or task activation
524    /// `label` of the requester's instance (LRM 9.6.2): `(requester, label)`,
525    /// applied by [`Self::drain_pending_disable_scope`].
526    pending_disable_scope: Mutex<Vec<(ProcessId, String)>>,
527    /// For a process a `disable` redirected (`Self::redirect_process`): the
528    /// id of that redirect's activation. Activations of the process queued
529    /// before it (a delay's timer, an event wake) are stale and dropped at
530    /// dispatch.
531    activation_floor: rustc_hash::FxHashMap<ProcessId, u64>,
532    /// For a process a dispatched `EventFired` activation resumed: the next
533    /// activation id at that moment. Another `EventFired` of the process with
534    /// a smaller id was queued while the wait that activation ended was still
535    /// installed -- a write by an earlier item of the same pass to a second
536    /// signal the wait names (`@(a[j])` also waits on `j`), after the dequeue
537    /// cleared the dedup flag -- so it is the same trigger (LRM 9.4.2: the
538    /// process was already scheduled) and is dropped at dispatch; delivered, it
539    /// would resume the process's NEXT wait early or hit a finished process.
540    /// A wake with an id at or above the floor was queued for a later wait.
541    /// The dispatch also clears the process's dedup flag (see the call site):
542    /// the dropped wake held it set, which would otherwise swallow the first
543    /// real wake of the next wait.
544    event_floor: rustc_hash::FxHashMap<ProcessId, u64>,
545    /// Processes `disable fork;` terminated -- their still-queued
546    /// activations are dropped at dispatch instead of treated as unknown ids.
547    killed: std::collections::HashSet<ProcessId>,
548    /// Classes plan Task 13 (LRM 9.7): `process` class requests queued by
549    /// `EngineOps::process_request` (`(requester, request)`, in call order),
550    /// applied by [`Self::drain_pending_process_requests`] at the top of
551    /// [`Self::apply_suspend`] -- the same `&self`-queue / `&mut self`-drain
552    /// split `pending_disable_fork` uses.
553    pending_process_requests: Mutex<Vec<(ProcessId, ProcessRequest)>>,
554    /// Processes a `process` handle exists for (`SchedulerOps::process_track`).
555    /// Only these get an [`Self::ended`] entry when they end.
556    tracked: Mutex<std::collections::HashSet<ProcessId>>,
557    /// Set by the first `process_track`; gates the per-suspend `waiting`
558    /// bookkeeping so a program that never uses `process` pays nothing.
559    any_tracked: AtomicBool,
560    /// How a tracked process ended. Fork children are reclaimed from the
561    /// table the moment they finish, so this map is the only record that
562    /// answers `status()` and `await()` for them afterwards.
563    ended: std::collections::HashMap<ProcessId, ProcessState>,
564    /// `awaiters[t]`: processes blocked in `t.await()`, woken when `t` ends.
565    awaiters: std::collections::HashMap<ProcessId, Vec<ProcessId>>,
566    /// The active `$monitor`'s argument cells and its changed-since-last-
567    /// print flag (see `SchedulerOps::set_monitor_watch`).
568    monitor_watch: Mutex<std::collections::HashSet<StateCellId>>,
569    /// Mirrors `!monitor_watch.is_empty()` so `notify_change` skips the lock.
570    any_monitor_watch: AtomicBool,
571    monitor_changed: AtomicBool,
572    /// **NEW (sva T1, LRM 16.5.1/16.5.2).** `None` for a design with no
573    /// assertion/clocking-block/`$past` site at all (the "pays nothing"
574    /// guard; see [`PreponedRing`]'s own doc comment and
575    /// `state::snapshot::SnapshotRing`'s). Pushed to once per time step by
576    /// [`Self::push_preponed_snapshot`].
577    preponed: Mutex<Option<PreponedRing>>,
578    /// Classes plan Task 14: the instance scope [`Self::register_process`]
579    /// seeds a new process from (see [`Engine::set_registration_scope`]).
580    registration_scope: Option<u32>,
581    /// Whether the process being registered under `registration_scope` runs a
582    /// static declaration initializer rather than being a thread (see
583    /// [`Engine::set_registration_static_initializer`]).
584    registration_static_initializer: bool,
585    /// **Test-only spy (Task 9, A2-part2, CG-102).** Records every
586    /// `(ProcessId, region_id)` pair `dispatch_ready_set` actually looked up
587    /// via `ProcessTable::region_id` during one dispatch pass --
588    /// `dispatch_ready_set_looks_up_partition_of_using_each_readied_
589    /// processs_own_stored_region_id`'s own seam, proving the field flows
590    /// from registration through to dispatch rather than being
591    /// stored-but-never-read. Only exists in `cfg(test)` builds (this
592    /// crate's own unit tests, which have `&mut Engine`/private-field
593    /// access an external `tests/` crate does not) -- never part of this
594    /// type's real, non-test shape.
595    #[cfg(test)]
596    region_id_queries_seen: Vec<(ProcessId, u32)>,
597}
598
599/// The concrete `SchedulerOps` implementation, constructed once per
600/// region-pass (see this module's doc comment / `dispatch_ready_set`). `now`/
601/// `region`/`reconv_iter` are genuinely constant for every `WorkItem` one
602/// region-pass dispatches.
603struct EngineOps<'e> {
604    engine: &'e Engine,
605    now: SchedTime,
606    region: Region,
607    reconv_iter: u32,
608}
609
610impl SchedulerOps for EngineOps<'_> {
611    fn now(&self) -> SchedTime {
612        self.now
613    }
614
615    fn pending_ticks(&self) -> Vec<u64> {
616        let mut ticks = self.engine.wheel.pending_ticks();
617        ticks.extend(self.engine.delayed_nbas.lock().unwrap().keys().copied());
618        ticks.into_iter().filter(|&t| t > self.now.ticks).collect()
619    }
620
621    fn waiting_processes(&self, cell: StateCellId) -> Vec<ProcessId> {
622        self.engine.sensitivity.waiting_on(cell)
623    }
624
625    fn current_region(&self) -> Region {
626        self.region
627    }
628
629    fn note_reactive_drive(&self) {
630        self.engine.has_reactive.store(true, Ordering::Relaxed);
631    }
632
633    fn note_clocking_event(&self, counter: StateCellId) {
634        self.engine
635            .clocking_events
636            .lock()
637            .unwrap()
638            .insert(counter, self.now.ticks);
639    }
640
641    fn stage_nba(&self, cell: StateCellId, value: FourStateValue, source: ProcessId) {
642        self.engine
643            .nba_for(source)
644            .lock()
645            .unwrap()
646            .stage(cell, value, source);
647    }
648
649    fn stage_nba_masked(
650        &self,
651        cell: StateCellId,
652        row: Option<u64>,
653        value: FourStateValue,
654        mask: Vec<u64>,
655        source: ProcessId,
656    ) {
657        self.engine
658            .nba_for(source)
659            .lock()
660            .unwrap()
661            .stage_masked(cell, row, value, mask, None, source);
662    }
663
664    fn stage_nba_window(
665        &self,
666        cell: StateCellId,
667        window: u32,
668        value: FourStateValue,
669        mask: Vec<u64>,
670        source: ProcessId,
671        delay: u64,
672    ) {
673        if delay == 0 {
674            self.engine.nba_for(source).lock().unwrap().stage_masked(
675                cell,
676                None,
677                value,
678                mask,
679                Some(window),
680                source,
681            );
682            return;
683        }
684        self.defer_nba(
685            delay,
686            DelayedNba {
687                cell,
688                row: None,
689                value,
690                mask,
691                window: Some(window),
692                source,
693                pulse: false,
694                coll: None,
695                obj: None,
696                reactive: self.engine.stages_reactive(source),
697            },
698        );
699    }
700
701    fn stage_nba_masked_delayed(
702        &self,
703        cell: StateCellId,
704        row: Option<u64>,
705        value: FourStateValue,
706        mask: Vec<u64>,
707        source: ProcessId,
708        delay: u64,
709    ) {
710        if delay == 0 {
711            return self.stage_nba_masked(cell, row, value, mask, source);
712        }
713        self.defer_nba(
714            delay,
715            DelayedNba {
716                cell,
717                row,
718                value,
719                mask,
720                window: None,
721                source,
722                pulse: false,
723                coll: None,
724                obj: None,
725                reactive: self.engine.stages_reactive(source),
726            },
727        );
728    }
729
730    fn stage_nba_coll(
731        &self,
732        cell: StateCellId,
733        row: Option<u64>,
734        elem: Vec<u64>,
735        coll: super::nba::CollWrite,
736        source: ProcessId,
737        delay: u64,
738    ) {
739        if delay == 0 {
740            self.engine
741                .nba_for(source)
742                .lock()
743                .unwrap()
744                .stage_coll(cell, row, elem, coll, source);
745            return;
746        }
747        self.defer_nba(
748            delay,
749            DelayedNba {
750                cell,
751                row,
752                value: FourStateValue::Narrow(0),
753                mask: elem,
754                window: None,
755                source,
756                pulse: false,
757                coll: Some(coll),
758                obj: None,
759                reactive: self.engine.stages_reactive(source),
760            },
761        );
762    }
763
764    fn stage_nba_obj(&self, w: super::nba::ObjWrite, source: ProcessId, delay: u64) {
765        if delay == 0 {
766            self.engine
767                .nba_for(source)
768                .lock()
769                .unwrap()
770                .stage_obj(w, source);
771            return;
772        }
773        self.defer_nba(
774            delay,
775            DelayedNba {
776                cell: StateCellId(0),
777                row: None,
778                value: FourStateValue::Narrow(0),
779                mask: Vec::new(),
780                window: None,
781                source,
782                pulse: false,
783                coll: None,
784                obj: Some(w),
785                reactive: self.engine.stages_reactive(source),
786            },
787        );
788    }
789
790    fn stage_nba_pulse(&self, cell: StateCellId, row: Option<u64>, source: ProcessId, delay: u64) {
791        if delay == 0 {
792            self.engine
793                .nba_for(source)
794                .lock()
795                .unwrap()
796                .stage_pulse(cell, row, source);
797            return;
798        }
799        self.defer_nba(
800            delay,
801            DelayedNba {
802                cell,
803                row,
804                value: FourStateValue::Narrow(0),
805                mask: Vec::new(),
806                window: None,
807                source,
808                pulse: true,
809                coll: None,
810                obj: None,
811                reactive: self.engine.stages_reactive(source),
812            },
813        );
814    }
815
816    fn commit_blocking_write(&self, cell: StateCellId, value: FourStateValue) {
817        let mut arena = self.engine.arena.lock().unwrap();
818        let mut sink = EngineNotifySink {
819            engine: self.engine,
820            at: self.now,
821            reconv_iter: self.reconv_iter,
822            delta: None,
823        };
824        let _ = crate::sched::commit_blocking_write(cell, value, &mut **arena, self.now, &mut sink);
825    }
826
827    fn notify_blocking_row_change(&self, cell: StateCellId) {
828        let mut sink = EngineNotifySink {
829            engine: self.engine,
830            at: self.now,
831            reconv_iter: self.reconv_iter,
832            delta: None,
833        };
834        crate::sched::notify_blocking_change(cell, EdgeKind::None, self.now, &mut sink);
835        let mut arena = self.engine.arena.lock().unwrap();
836        crate::sched::notify_alias_changes(&mut **arena, self.now, &mut sink);
837    }
838
839    fn notify_blocking_wide_change(
840        &self,
841        cell: StateCellId,
842        edge: EdgeKind,
843        delta: Option<ChangeDelta>,
844    ) {
845        let mut sink = EngineNotifySink {
846            engine: self.engine,
847            at: self.now,
848            reconv_iter: self.reconv_iter,
849            delta,
850        };
851        crate::sched::notify_blocking_change(cell, edge, self.now, &mut sink);
852    }
853
854    fn wants_delta(&self, cell: StateCellId) -> bool {
855        self.engine.sensitivity.has_select(cell)
856    }
857
858    fn request_finish(&self, code: i32) {
859        // See this module's doc comment, deviation 4.
860        *self.engine.finish_requested.lock().unwrap() = Some(code);
861    }
862
863    fn defer_io(&self, at: SchedTime, region: Region, cb: DeferredIoCallback) {
864        // Textually identical to `DeferIoDispatcher::defer_io`'s body,
865        // deliberately not reused through one (see this module's doc
866        // comment / the plan's own text): a fresh `DeferIoDispatcher`
867        // reconstructed per pass would reset `seq` to 0 every pass.
868        let seq = self.engine.io_seq.fetch_add(1, Ordering::SeqCst);
869        self.engine.io_sink.enqueue(at, region, seq, cb);
870    }
871
872    /// **NEW (Task 4, ledger SC-090/SC-123).** The one real production
873    /// implementation — forwards straight to `Engine::push_region_callback`
874    /// (see that method's own doc comment for why it is a plain, always-
875    /// available `Engine` method rather than `#[cfg(test)]`-gated: this impl
876    /// is its one production call site, and this module's own test setup
877    /// code calls it directly too, for registrations made before `run_until`
878    /// starts rather than from inside a dispatched process's `resume()`).
879    ///
880    /// **Review fix (Task 4 r1):** the `SchedulerOps::register_region_callback`
881    /// trait signature is a cross-track contract (many `#[cfg(test)]` mock
882    /// implementations outside this crate, per that trait method's own doc
883    /// comment) and returns `()`, so it cannot forward
884    /// `push_region_callback`'s new typed `Result` to its caller. `.expect`
885    /// turns `RegionCallbackRegionError` into an immediate, loud panic here
886    /// instead — never a silent, forever-unfired registration — at this
887    /// trait's one production implementation.
888    fn register_region_callback(&self, region: Region, at: SchedTime, cb: Box<dyn RegionCallback>) {
889        self.engine.push_region_callback(at, region, cb).expect(
890            "SchedulerOps::register_region_callback: region must be Reactive, Postponed, or \
891                 Observed -- Active/Inactive/Nba/Preponed are never drained by \
892                 drain_region_callbacks and a callback registered for one would never fire",
893        );
894    }
895
896    /// **NEW (Task 9, ledger SC-125).** The one real override of
897    /// `SchedulerOps::disable_fork`'s default no-op (see that trait
898    /// method's own doc comment for why it is a default, not a required
899    /// method, this wave). Only queues `proc` into `Engine::
900    /// pending_disable_fork` -- this call only ever has `&self` (`EngineOps`
901    /// holds a shared `&'e Engine`), so the real recursive `ProcessTable`
902    /// mutation happens in `Engine::drain_pending_disable_fork`, called at
903    /// the top of `Engine::apply_suspend`, which always runs with `&mut
904    /// self` immediately after the `resume()` call (this one included)
905    /// that might have queued it.
906    fn disable_fork(&self, proc: ProcessId) {
907        self.engine.pending_disable_fork.lock().unwrap().push(proc);
908    }
909
910    fn disable_fork_block(&self, proc: ProcessId) {
911        self.engine.pending_disable_block.lock().unwrap().push(proc);
912    }
913
914    fn spawn_detached(&self, proc: Box<dyn CompiledProcess>) {
915        self.engine.pending_spawns.lock().unwrap().push(proc);
916    }
917
918    /// `SchedulerOps::disable_named`'s one real override: resolves the
919    /// target set and the `requester`-ends-too question synchronously (a
920    /// read of the still-untouched `ProcessTable`, exactly like
921    /// `process_request`'s own `ProcessRequest::Kill` arm), then queues the
922    /// resolved targets for `Engine::drain_pending_disable_named` to kill.
923    fn disable_named(&self, requester: ProcessId, label: &str) -> bool {
924        let root = self.engine.fork_root(requester);
925        let mut targets = Vec::new();
926        if label == EXIT_PROGRAM_LABEL {
927            // `$exit` (LRM 24.7) is only lowered inside a program, so the
928            // caller is itself a program thread and ends; the other threads
929            // of its program are found in `drain_pending_disable_scope` (the
930            // caller's compiled process is locked while it runs).
931            self.engine
932                .pending_disable_scope
933                .lock()
934                .unwrap()
935                .push((requester, label.to_string()));
936            return true;
937        }
938        self.engine.collect_disable_named(root, label, &mut targets);
939        let ends_caller = targets
940            .iter()
941            .any(|&t| t == requester || self.engine.is_in_fork_subtree(requester, t));
942        self.engine
943            .pending_disable_named
944            .lock()
945            .unwrap()
946            .push((requester, targets));
947        // ... and the processes of the instance suspended inside the block or
948        // task `label` (LRM 9.6.2) resume after it.
949        self.engine
950            .pending_disable_scope
951            .lock()
952            .unwrap()
953            .push((requester, label.to_string()));
954        ends_caller
955    }
956
957    fn wake_process(&self, proc: ProcessId, term: usize) {
958        let act = PendingActivation {
959            id: self.engine.activation_seq.fetch_add(1, Ordering::SeqCst),
960            proc,
961            arg: ResumeArg::EventFired { term_index: term },
962            reconv_iter: 0,
963            logical_order: (self.engine.now, Region::Active, proc as u64),
964        };
965        self.engine
966            .queues
967            .lock()
968            .unwrap()
969            .push(Region::Active, act)
970            .expect("Region::Active is never rejected by RegionQueues::push");
971    }
972
973    fn set_monitor_watch(&self, cells: Vec<StateCellId>) {
974        let mut watch = self.engine.monitor_watch.lock().unwrap();
975        *watch = cells.into_iter().collect();
976        self.engine
977            .any_monitor_watch
978            .store(!watch.is_empty(), Ordering::Relaxed);
979        drop(watch);
980        self.engine.monitor_changed.store(true, Ordering::SeqCst);
981    }
982
983    /// Classes plan Task 13: queues `req` (applied at the requester's next
984    /// `apply_suspend`) and reports whether it ends the requester itself.
985    fn process_request(&self, requester: ProcessId, req: ProcessRequest) -> bool {
986        let kills_requester = match req {
987            ProcessRequest::Kill(target) => self.engine.is_in_fork_subtree(requester, target),
988            ProcessRequest::Suspend(_)
989            | ProcessRequest::Resume(_)
990            | ProcessRequest::Await(_)
991            | ProcessRequest::SyncBlock
992            | ProcessRequest::SyncWake(_) => false,
993        };
994        self.engine
995            .pending_process_requests
996            .lock()
997            .unwrap()
998            .push((requester, req));
999        kills_requester
1000    }
1001
1002    fn process_state(&self, target: ProcessId) -> ProcessState {
1003        self.engine.effective_process_state(target)
1004    }
1005
1006    fn process_track(&self, proc: ProcessId) {
1007        self.engine.tracked.lock().unwrap().insert(proc);
1008        self.engine.any_tracked.store(true, Ordering::SeqCst);
1009    }
1010}
1011
1012/// The concrete [`SchedulerCtx`] implementation
1013/// [`Engine::drain_region_callbacks`] constructs to invoke each due
1014/// [`RegionCallback`] — read-only, backed by `Engine`'s own arena `Mutex`
1015/// (mirrors [`EngineNotifySink`]'s identical shape/rationale).
1016struct EngineSchedulerCtx<'e> {
1017    engine: &'e Engine,
1018    now: SchedTime,
1019}
1020
1021impl SchedulerCtx for EngineSchedulerCtx<'_> {
1022    fn now(&self) -> SchedTime {
1023        self.now
1024    }
1025
1026    fn read(&self, cell: StateCellId) -> FourStateValue {
1027        self.engine.arena.lock().unwrap().read(cell)
1028    }
1029
1030    fn rearm(&self, region: Region, cb: Box<dyn RegionCallback>) {
1031        self.engine
1032            .push_region_callback(self.now, region, cb)
1033            .expect("RegionCallback::invoke re-armed for a region Engine never drains");
1034    }
1035
1036    fn take_monitor_changed(&self) -> bool {
1037        self.engine.monitor_changed.swap(false, Ordering::SeqCst)
1038    }
1039}
1040
1041/// The concrete `ValueChangeSink` implementation `EngineOps::
1042/// commit_blocking_write` and `Engine::commit_nba_once` construct to receive
1043/// `notify_change` calls during a commit.
1044struct EngineNotifySink<'e> {
1045    engine: &'e Engine,
1046    at: SchedTime,
1047    reconv_iter: u32,
1048    /// The old/new words of the update the next `notify_change` reports, when
1049    /// a select waiter needs them (`ValueChangeSink::note_delta`).
1050    delta: Option<ChangeDelta>,
1051}
1052
1053impl ValueChangeSink for EngineNotifySink<'_> {
1054    fn wants_delta(&self, cell: StateCellId) -> bool {
1055        self.engine.sensitivity.has_select(cell)
1056    }
1057
1058    fn note_delta(&mut self, _cell: StateCellId, delta: ChangeDelta) {
1059        self.delta = Some(delta);
1060    }
1061
1062    /// **Task 3 (ledger SC-052) rewiring:** `SensitivityTable::matches` now
1063    /// yields `(ProcessId, term_index, repeat_total, iff)`, not just
1064    /// `ProcessId` -- this is the one production call site that needs the
1065    /// extra fields (SC-055's dedup gate does not, so this no longer
1066    /// delegates to `dedup::sensitivity_match_to_enqueue`, which only ever
1067    /// returns a bare `Vec<ProcessId>`; `SensitivityTable::matches` itself
1068    /// is still Task 9's one canonical matching implementation underneath --
1069    /// SC-042's "no duplicate implementation" is unaffected).
1070    ///
1071    /// **Task 5 (ledger SC-068) addition:** a match whose row carries
1072    /// `iff: Some(pred)` is filtered FIRST, before repeat-counting -- `pred`
1073    /// is invoked (via the same `SimHandle(null)` placeholder every other
1074    /// `CompiledProcess::resume()`/`RegionCallback` call site in this module
1075    /// already uses, since this crate holds no real ABI-facing state
1076    /// pointer of its own) and a `false` result drops the firing entirely,
1077    /// exactly as if the edge/cell condition had never matched at all: no
1078    /// `RepeatCounters::on_term_fired` call (an `iff`-false edge must not
1079    /// count toward a `repeat(N)@(... iff ...)`'s count either), no
1080    /// dedup-gate mark, no enqueue, and the sensitivity entry stays
1081    /// installed. This is `PredicateFn`'s settled, binding calling
1082    /// convention (`docs/CONTRACTS.md` D8) -- a bare
1083    /// `extern "C" fn(SimHandle) -> bool` produced per-callsite by codegen's
1084    /// `compile_iff_guard`, which reads whatever cell(s) it needs itself
1085    /// through the handle; this call site never passes a `StateCellId`
1086    /// alongside it.
1087    ///
1088    /// For each `iff`-passing (or `iff`-less) match whose row carries
1089    /// `repeat_total: Some(n)` (an `EventTerm::Repeat`-sourced row), the
1090    /// firing is routed through `RepeatCounters::on_term_fired` next: on
1091    /// `StillWaiting` this firing is swallowed -- no dedup-gate mark, no
1092    /// enqueue, and the sensitivity entry stays installed (nothing here or
1093    /// on any other path removes it for a non-terminal firing). Only a
1094    /// `repeat_total: None` row, or a `Some` row that just reported
1095    /// `Satisfied`, proceeds to the existing SC-055 dedup-gate-then-enqueue
1096    /// step.
1097    fn notify_change(
1098        &mut self,
1099        cell: StateCellId,
1100        edge: EdgeKind,
1101        _at: vj_abi::SimTime,
1102        _region: Region,
1103    ) {
1104        // Lock-free fast path: the watch set is empty unless a `$monitor` is
1105        // active, which is the common case for the ~10^7 calls of a long run.
1106        if self.engine.any_monitor_watch.load(Ordering::Relaxed)
1107            && self.engine.monitor_watch.lock().unwrap().contains(&cell)
1108        {
1109            self.engine.monitor_changed.store(true, Ordering::SeqCst);
1110            // Under LRM 21.2.3's expression-value test, re-evaluate the
1111            // monitor's arguments at this update (a glitch that restores the
1112            // value within the step still owes a display).
1113            crate::sched::kernel_adapter::monitor_probe();
1114        }
1115        if self.engine.has_edge_nbas.load(Ordering::Relaxed) {
1116            self.engine.fire_edge_nbas(cell, edge);
1117        }
1118        let handle = SimHandle(std::ptr::null_mut());
1119        // The process making this write is executing, not waiting: it is
1120        // never woken by its own change (LRM 1800-2023 4.7, 9.2.2.2: e.g. an
1121        // `always_comb` writing an array element it also reads must not
1122        // re-trigger itself through its static sensitivity).
1123        let writer = crate::sched::current_process();
1124        // Each woken process with the index of the wait term that woke it
1125        // (`wait_order` reads it, `sv_rt_fired_term`).
1126        let delta = self.delta.take();
1127        let matched: smallvec::SmallVec<[(ProcessId, usize); 8]> = self
1128            .engine
1129            .sensitivity
1130            .matches_delta(cell, edge, delta.as_ref())
1131            .filter(|(proc, _, _, _)| Some(*proc) != writer)
1132            .filter(|(_, _, _, iff)| match iff {
1133                Some(pred) => (pred.0)(handle),
1134                None => true,
1135            })
1136            // Every matched term is recorded for the resume guard of an event
1137            // list (`sv_rt_fired_mask`), including those of a process this
1138            // pass finds already queued: several members can fire in one wake
1139            // (LRM 9.4.2.3) and the guard must see them all.
1140            .inspect(|(proc, term_index, _, _)| {
1141                crate::sched::kernel_adapter::note_fired_term(*proc, *term_index);
1142            })
1143            .filter_map(
1144                |(proc, term_index, repeat_total, _iff)| match repeat_total {
1145                    Some(declared_total) => {
1146                        match self
1147                            .engine
1148                            .repeats
1149                            .on_term_fired(proc, term_index, declared_total)
1150                        {
1151                            RepeatOutcome::StillWaiting => None,
1152                            RepeatOutcome::Satisfied => Some((proc, term_index)),
1153                        }
1154                    }
1155                    None => Some((proc, term_index)),
1156                },
1157            )
1158            // SC-055: only processes not already queued this pass come back.
1159            .filter(|&(proc, _)| self.engine.gate.mark_queued_if_not_already(proc))
1160            .collect();
1161        if matched.is_empty() {
1162            return;
1163        }
1164        let mut queues = self.engine.queues.lock().unwrap();
1165        for (proc, term_index) in matched {
1166            let id = self.engine.activation_seq.fetch_add(1, Ordering::SeqCst);
1167            // LRM 17.7.3: a checker's processes are sensitive "in the Reactive
1168            // region" -- their wake-ups wait for Active/Inactive/NBA (and the
1169            // Observed region) to settle, so they see only settled values.
1170            let home = if self.engine.table.is_reactive(proc) {
1171                Region::Reactive
1172            } else {
1173                Region::Active
1174            };
1175            let act = PendingActivation {
1176                id,
1177                proc,
1178                arg: ResumeArg::EventFired { term_index },
1179                reconv_iter: self.reconv_iter,
1180                logical_order: (self.at, home, proc as u64),
1181            };
1182            // Active and Reactive are regions `RegionQueues::push` never
1183            // rejects (`RegionQueuePushError::NbaHasNoQueue` is the only
1184            // variant that can fire for them, and only for `Region::Nba`).
1185            queues.push(home, act).expect(
1186                "Region::Active and Region::Reactive are never rejected by RegionQueues::push",
1187            );
1188        }
1189    }
1190}
1191
1192impl Engine {
1193    /// `sched` borrows the `SignalAccess`/`IoSink` *contracts* but does not
1194    /// implement them this wave — dependency-injecting both is what lets
1195    /// `Engine` be genuinely constructible and testable without this crate
1196    /// depending on a STATE/RUNTIME implementation. `controller` starts as
1197    /// `SequentialController::default_boxed()` (Task 3), overridable via
1198    /// `set_determinism_controller`.
1199    pub fn new(arena: Box<dyn SignalAccess>, io: Box<dyn IoSink>) -> Self {
1200        Engine {
1201            queues: Mutex::new(RegionQueues::new()),
1202            wheel: TimeWheel::new(),
1203            nba: Mutex::new(NbaLog::new()),
1204            re_nba: Mutex::new(NbaLog::new()),
1205            has_reactive: std::sync::atomic::AtomicBool::new(false),
1206            delayed_nbas: Mutex::new(std::collections::BTreeMap::new()),
1207            edge_nbas: Mutex::new(Vec::new()),
1208            clocking_events: Mutex::new(std::collections::HashMap::new()),
1209            has_edge_nbas: std::sync::atomic::AtomicBool::new(false),
1210            sensitivity: SensitivityTable::new(),
1211            gate: QueuedGate::new(),
1212            repeats: RepeatCounters::new(),
1213            table: ProcessTable::new(),
1214            driver: ResumeDriver,
1215            finals: Vec::new(),
1216            guard: DeltaGuard::new(DEFAULT_MAX_DELTA_ITERATIONS),
1217            arena: Mutex::new(arena),
1218            io_sink: io,
1219            io_seq: AtomicU64::new(0),
1220            activation_seq: AtomicU64::new(0),
1221            sync_queue: None,
1222            sync_livelock: None,
1223            forks_this_step: 0,
1224            finish_requested: Mutex::new(None),
1225            program_roots: None,
1226            controller: SequentialController::default_boxed(),
1227            now: SchedTime { ticks: 0 },
1228            current_region: Region::Active,
1229            timestep_end_hook: None,
1230            host: None,
1231            host_slot: None,
1232            max_delta_iterations_seen: AtomicU32::new(0),
1233            region_callbacks: Mutex::new(Vec::new()),
1234            pending_disable_fork: Mutex::new(Vec::new()),
1235            pending_spawns: Mutex::new(Vec::new()),
1236            pending_disable_block: Mutex::new(Vec::new()),
1237            pending_disable_named: Mutex::new(Vec::new()),
1238            pending_disable_scope: Mutex::new(Vec::new()),
1239            activation_floor: rustc_hash::FxHashMap::default(),
1240            event_floor: rustc_hash::FxHashMap::default(),
1241            killed: std::collections::HashSet::new(),
1242            pending_process_requests: Mutex::new(Vec::new()),
1243            tracked: Mutex::new(std::collections::HashSet::new()),
1244            any_tracked: AtomicBool::new(false),
1245            ended: std::collections::HashMap::new(),
1246            awaiters: std::collections::HashMap::new(),
1247            monitor_watch: Mutex::new(std::collections::HashSet::new()),
1248            any_monitor_watch: AtomicBool::new(false),
1249            monitor_changed: AtomicBool::new(false),
1250            preponed: Mutex::new(None),
1251            registration_scope: None,
1252            registration_static_initializer: false,
1253            #[cfg(test)]
1254            region_id_queries_seen: Vec::new(),
1255        }
1256    }
1257
1258    /// Classes plan Task 14 (LRM 18.14.1): names the instance scope whose
1259    /// initialization RNG seeds every process registered from now on
1260    /// (`None`, the default, seeds nothing: the process draws from the
1261    /// unseeded default like any process with no known parent). The
1262    /// production caller sets it around the user's procedural blocks only,
1263    /// so the infrastructure processes registered elsewhere (net resolvers,
1264    /// samplers, ...) never advance an instance's initialization RNG and so
1265    /// never shift the seed any user process gets.
1266    pub fn set_registration_scope(&mut self, scope: Option<u32>) {
1267        self.registration_scope = scope;
1268    }
1269
1270    /// While set (and a [`Self::set_registration_scope`] scope is active), a
1271    /// process registered runs a static declaration initializer (`int x =
1272    /// f();`, `C h = new;`) rather than being a thread of its own: LRM 18.14.1
1273    /// gives such code no thread, so it draws from the instance's
1274    /// initialization RNG directly ([`process_rng::alias_init`]) instead of
1275    /// being seeded from it.
1276    pub fn set_registration_static_initializer(&mut self, on: bool) {
1277        self.registration_static_initializer = on;
1278    }
1279
1280    /// Installs the end-of-timestep hook (see the field's doc comment),
1281    /// replacing any previous one.
1282    pub fn set_timestep_end_hook(&mut self, hook: Box<dyn FnMut(SchedTime)>) {
1283        self.timestep_end_hook = Some(hook);
1284    }
1285
1286    /// **NEW (sva T1, LRM 16.5.1/16.5.2).** Installs a Preponed-region
1287    /// snapshot ring of the given `depth` (`state::snapshot::SnapshotRing`'s
1288    /// own doc comment), sourced by calling `source` once per time step.
1289    /// Replaces any previously configured ring. Never called at all for a
1290    /// design with no assertion/clocking-block/`$past` site -- [`Self::
1291    /// push_preponed_snapshot`] stays a no-op (no allocation, `source`
1292    /// never invoked) until this is.
1293    pub fn configure_preponed_ring(
1294        &mut self,
1295        depth: usize,
1296        source: impl Fn() -> ArenaSnapshot + 'static,
1297    ) {
1298        *self.preponed.lock().unwrap() = Some(PreponedRing {
1299            ring: SnapshotRing::new(depth),
1300            source: Box::new(source),
1301        });
1302    }
1303
1304    /// Pushes this time step's Preponed snapshot (16.5.1) by invoking the
1305    /// closure [`Self::configure_preponed_ring`] installed -- a no-op when
1306    /// no ring is configured at all (see [`Self::preponed`]'s own doc
1307    /// comment). Called exactly once for every genuinely new nonempty time
1308    /// slot the wheel advances into -- never for the engine's initial
1309    /// `Region::Active` entry at time 0, which neither driver API precedes
1310    /// with a push (`Engine::new` sets `current_region = Active` directly).
1311    /// `run_until`'s outer loop calls this right after `advance_time_to`,
1312    /// in the `Some(t)` arm of its own `next_event_tick()` match (sva T1
1313    /// §1.4: "right where it currently detects a new nonempty time slot");
1314    /// the single-stepping API folds the identical call into
1315    /// [`Self::step_region`]'s `Postponed -> Active` transition, on the same
1316    /// `next_event_tick() == Some` branch (see that method's own doc
1317    /// comment) -- the two call sites are kept in lock-step so a caller
1318    /// driving a given `Engine` through either API (never both interleaved,
1319    /// the same assumption [`Self::current_region`]'s own doc comment
1320    /// already makes for the single-stepper) ends up with the same Preponed
1321    /// ring for the same simulated history. See
1322    /// `run_until_and_step_region_push_the_same_preponed_ring` for the
1323    /// regression proof.
1324    fn push_preponed_snapshot(&self) {
1325        let mut guard = self.preponed.lock().unwrap();
1326        if let Some(p) = guard.as_mut() {
1327            let snap = (p.source)();
1328            p.ring.push(snap);
1329        }
1330    }
1331
1332    /// Test-only read access into the configured Preponed ring (see
1333    /// [`Self::configure_preponed_ring`]) -- `f` receives `None` when no
1334    /// ring is configured at all, or when `ticks_back` is out of the
1335    /// configured ring's own retained range (`SnapshotRing::get`'s own doc
1336    /// comment). Takes a closure rather than returning `Option<&ArenaSnapshot>`
1337    /// directly since the `Mutex` guard cannot outlive this call.
1338    #[cfg(test)]
1339    fn with_preponed_entry<R>(
1340        &self,
1341        ticks_back: usize,
1342        f: impl FnOnce(Option<&ArenaSnapshot>) -> R,
1343    ) -> R {
1344        let guard = self.preponed.lock().unwrap();
1345        f(guard.as_ref().and_then(|p| p.ring.get(ticks_back)))
1346    }
1347
1348    /// Test-only accessor for [`Self::region_id_queries_seen`] -- see that
1349    /// field's own doc comment.
1350    #[cfg(test)]
1351    fn region_id_queries_seen(&self) -> &[(ProcessId, u32)] {
1352        &self.region_id_queries_seen
1353    }
1354
1355    /// **NEW (Task 21, ledger SC-007).** The maximum [`DeltaGuard::count`]
1356    /// this `Engine` has observed at the settled end of any one timestep's
1357    /// inner Active/Inactive/NBA convergence loop, across its whole run so
1358    /// far — a high-water mark, not the current timestep's own count (which
1359    /// is always 0 immediately after `run_until` returns, since `guard.
1360    /// reset()` runs every timestep). Existence-proof surface for SC-007's
1361    /// "region-stratified delta-cycle convergence" acceptance: a caller can
1362    /// assert this stayed low for a design that should settle in a handful
1363    /// of passes, catching an accidental unbounded-oscillation regression
1364    /// well before it would ever hit `DEFAULT_MAX_DELTA_ITERATIONS`.
1365    pub fn max_delta_iterations_in_a_timestep(&self) -> u32 {
1366        self.max_delta_iterations_seen.load(Ordering::SeqCst)
1367    }
1368
1369    /// Testability getter (DT-007's "selected by default when no other
1370    /// controller is configured", made externally observable): see
1371    /// `DeterminismController::kind`.
1372    pub fn controller_kind(&self) -> &'static str {
1373        self.controller.kind()
1374    }
1375
1376    /// **NEW (A2-part2 Task 5, ledger CG-162):** exposes the region
1377    /// [`Self::step_region`] is about to process next -- needed so the ABI's
1378    /// `sim_eval_region(requested_region, ..)` (`vj-runtime::
1379    /// abi_entry`) can single-step exactly up to (but never past)
1380    /// `requested_region`, and, for `Region::Nba` specifically, stop there
1381    /// WITHOUT calling `step_region`/[`Self::advance_nba_region`] at all:
1382    /// RHS evaluation for any pending NBA writes already happened during
1383    /// the prior Active/Inactive dispatch passes (`nba_enqueue` only ever
1384    /// stages into `NbaLog`, never touches the arena -- see
1385    /// `EngineOps::stage_nba`), so observing "we have arrived at the Nba
1386    /// boundary" is genuinely a read-only query, and
1387    /// [`Self::advance_nba_region`] stays the one, separate, explicit call
1388    /// that applies the staged log (CG-162's "never collapsed into one
1389    /// call").
1390    pub fn current_region(&self) -> Region {
1391        self.current_region
1392    }
1393
1394    /// Drains `region`'s queue into a canonically-sorted ready-set (Task 2's
1395    /// `sort_ready_set`, applied here since `form_ready_set`'s own contract
1396    /// is purely "collect what's ready").
1397    fn drain_and_sort(&self, region: Region) -> Vec<PendingActivation> {
1398        let mut ready = {
1399            let mut queues = self.queues.lock().unwrap();
1400            form_ready_set(&mut queues, region)
1401                .expect("Active/Inactive/Reactive/Postponed are never Region::Nba")
1402        };
1403        self.reset_dequeued(&ready);
1404        sort_ready_set(&mut ready);
1405        ready
1406    }
1407
1408    /// SC-056: the dequeue point. Every activation just popped off a region
1409    /// queue clears its process's queued flag here, before any `resume()`, so
1410    /// a change landing between this dequeue and the process's next
1411    /// sensitivity registration re-queues it instead of being swallowed.
1412    fn reset_dequeued(&self, popped: &[PendingActivation]) {
1413        for act in popped {
1414            self.gate.reset_on_dequeue(act.proc);
1415        }
1416    }
1417
1418    /// Partitions `ready` via the installed `DeterminismController`, then
1419    /// dispatches every resulting `WorkItem` through `ResumeDriver`, using
1420    /// one `EngineOps` shared for the whole pass (constructed via a
1421    /// temporary shared reborrow of `self`, dropped before any per-item
1422    /// `&mut self` post-processing — see this module's doc comment).
1423    /// Returns whether anything was actually dispatched.
1424    ///
1425    /// **Task 9 (A2-part2, CG-102):** before running the per-item dispatch
1426    /// body below, `ready` is grouped by each activation's own STORED
1427    /// `region_id` (`ProcessTable::region_id`, via `partition_of`) -- a
1428    /// real, if v1-degenerate, partition-aware dispatch pass, not a
1429    /// placeholder: with `partition_count` fixed at `1` (v1's one real
1430    /// call site, ARCHITECTURE §4 item 1), every activation maps to
1431    /// `PartitionId(0)`, so `groups` always has exactly one entry holding
1432    /// the whole `ready` set in its original order, and the loop below runs
1433    /// the (otherwise unchanged) per-partition dispatch body exactly once
1434    /// -- bit-for-bit the same observable behavior as before this task.
1435    fn dispatch_ready_set(&mut self, region: Region, ready: Vec<PendingActivation>) -> bool {
1436        if ready.is_empty() {
1437            return false;
1438        }
1439        let mut groups: std::collections::BTreeMap<u32, Vec<PendingActivation>> =
1440            std::collections::BTreeMap::new();
1441        for act in ready {
1442            let region_id = self.table.region_id(act.proc);
1443            #[cfg(test)]
1444            self.region_id_queries_seen.push((act.proc, region_id));
1445            let partition = partition_of(region_id, 1);
1446            groups.entry(partition.0).or_default().push(act);
1447        }
1448        for (_, partition_ready) in groups {
1449            self.dispatch_one_partition(region, partition_ready);
1450        }
1451        true
1452    }
1453
1454    /// The per-partition dispatch body [`Self::dispatch_ready_set`]'s new
1455    /// grouping loop calls once per distinct `PartitionId` among `ready`'s
1456    /// activations (always exactly once, for v1's real `partition_count ==
1457    /// 1`) -- unchanged from what `dispatch_ready_set` itself did, in full,
1458    /// before this task.
1459    fn dispatch_one_partition(&mut self, region: Region, ready: Vec<PendingActivation>) {
1460        let work = self.controller.partition(&ready, region, self.now);
1461        // Each activation's suspend is applied as soon as it returns, before
1462        // the next one runs: a process that has just started waiting (`@(u)`)
1463        // is woken by a write a later process makes in the same pass, as in
1464        // any sequential order of the two (LRM 1800-2023 §4.7) -- a wait
1465        // installed only after the whole pass missed it.
1466        for item in &work {
1467            let proc = item.activation.proc;
1468            // An activation queued before a `disable` redirected the process
1469            // (its timer, an event wake) is stale: the wait it ended is gone.
1470            if self
1471                .activation_floor
1472                .get(&proc)
1473                .is_some_and(|&floor| item.activation.id < floor)
1474            {
1475                continue;
1476            }
1477            // A wake for a process that already finished is stale (LRM 9.4.2:
1478            // a finished thread is no longer waiting). It arises inside one
1479            // dispatched batch: the gate is cleared at dequeue (SC-056), so
1480            // an earlier member's write re-matches a later member's
1481            // still-installed wait (`fork @(a[j]) ... join`, a frame
1482            // variable in the event expression); that member then runs on
1483            // its first activation, completes and is reclaimed, and its
1484            // second activation finds no process.
1485            if matches!(item.activation.arg, ResumeArg::EventFired { .. })
1486                && self.table.get(proc).is_none()
1487            {
1488                continue;
1489            }
1490            if matches!(item.activation.arg, ResumeArg::EventFired { .. }) {
1491                if self
1492                    .event_floor
1493                    .get(&proc)
1494                    .is_some_and(|&floor| item.activation.id < floor)
1495                {
1496                    continue;
1497                }
1498                self.event_floor
1499                    .insert(proc, self.activation_seq.load(Ordering::SeqCst));
1500                // A stale wake queued earlier in this pass still holds the
1501                // process's dedup flag; clear it now (the process stops
1502                // waiting here) so the first write to its next wait queues a
1503                // wake instead of being deduplicated against the dropped one.
1504                self.gate.reset_on_dequeue(proc);
1505            }
1506            // Processes resumed via `ResumeArg::EventFired`/`Disabled` -- the
1507            // only two variants that terminate a `Suspend::WaitEvents`
1508            // dynamic wait -- lose their dynamic sensitivity entries
1509            // (`sensitivity.rs`: entries "are gone from the table the
1510            // instant the owning process resumes"). Removed BEFORE the
1511            // process runs: a process is not waiting while it executes, so
1512            // its own blocking writes must not wake it again through the
1513            // wait it was resumed from (an `always @*` that reads a variable
1514            // it also writes would otherwise re-trigger itself forever;
1515            // LRM 1800-2023 §4.7, §9.4.2). Its new `Suspend` is installed by
1516            // `apply_suspend` below, after this removal.
1517            if matches!(
1518                item.activation.arg,
1519                ResumeArg::EventFired { .. } | ResumeArg::Disabled
1520            ) {
1521                self.sensitivity.remove_dynamic(proc);
1522                // Task 3 (ledger SC-052): belt-and-suspenders -- a process
1523                // that resumes for any reason while a `Repeat` term's count is
1524                // only partially satisfied (e.g. it was `Disabled` mid-wait)
1525                // must not leave a stale partial count behind for a future,
1526                // unrelated dynamic wait reusing the same `term_index`.
1527                self.repeats.clear(proc);
1528            }
1529            // Classes plan Task 13 (LRM 9.7): a suspended process does not
1530            // run. The activation that would have run it is parked and
1531            // replayed by `p.resume()`; a running one is no longer waiting.
1532            if let Some(desc) = self.table.get_mut(proc) {
1533                if desc.suspended {
1534                    desc.parked = Some(item.activation.arg);
1535                    continue;
1536                }
1537                desc.waiting = false;
1538                desc.wait_set = None;
1539            }
1540            // 38.36.1 `cbStartOfThread`/`cbEnterThread`: a static process is
1541            // created when it first runs (a fork branch was announced when
1542            // its `fork` executed); any later activation is a resume.
1543            if super::thread_hook_installed()
1544                && let Some(desc) = self.table.get(proc)
1545            {
1546                let event = if matches!(item.activation.arg, ResumeArg::Start) {
1547                    desc.fork_parent
1548                        .is_none()
1549                        .then_some(super::ThreadEvent::Start {
1550                            pid: proc,
1551                            parent: None,
1552                            spawn: None,
1553                        })
1554                } else {
1555                    Some(super::ThreadEvent::Enter { pid: proc })
1556                };
1557                if let Some(event) = event {
1558                    self.fire_thread_event_now(event);
1559                }
1560            }
1561            let suspend = {
1562                let ops = EngineOps {
1563                    engine: &*self,
1564                    now: self.now,
1565                    region,
1566                    reconv_iter: 0,
1567                };
1568                let handle = SimHandle(std::ptr::null_mut());
1569                // A process terminated by `disable fork;` (SC-125,
1570                // `terminate_fork_descendants`) may still have activations in
1571                // flight -- a pending `#delay` timer or a signal-change wake.
1572                // A killed process never runs again (LRM 9.6.3), so they are
1573                // dropped here. Every OTHER unknown id is still a bookkeeping
1574                // bug and panics below.
1575                if self.table.get(proc).is_none() && self.killed.contains(&proc) {
1576                    continue;
1577                }
1578                if matches!(item.activation.arg, ResumeArg::Start) {
1579                    self.driver.start(&self.table, proc, handle, &ops)
1580                } else {
1581                    self.driver
1582                        .resume_for(&self.table, proc, item.activation.arg, handle, &ops)
1583                }
1584                .expect(
1585                    "every dispatched ProcessId came from this Engine's own \
1586                     register_process/handle_fork, so UnknownProcess/AlreadyStarted \
1587                     here would mean a bug in Engine's own bookkeeping",
1588                )
1589                // `ops` (and the shared reborrow of `self` it holds) goes out
1590                // of scope here, before the mutable calls below.
1591            };
1592            self.apply_suspend(proc, suspend);
1593        }
1594    }
1595
1596    /// The NBA log a nonblocking assignment staged by `source` belongs to: the
1597    /// Re-NBA log for a Reactive-region process, the NBA log otherwise.
1598    /// Whether a nonblocking write `source` stages now belongs to the Re-NBA
1599    /// region: `source` is a Reactive-region process, or the running kernel
1600    /// is in a clocking-block synchronous drive (LRM 14.16: drives update in
1601    /// the Re-NBA region).
1602    fn stages_reactive(&self, source: ProcessId) -> bool {
1603        self.has_reactive.load(Ordering::Relaxed)
1604            && (self.table.is_reactive(source) || super::kernel_adapter::reactive_drive())
1605    }
1606
1607    /// An `edge` of `cell` just happened: stages the edge-skewed clocking
1608    /// drives (LRM 1800-2023 14.4) waiting on it, `after` ticks later when they
1609    /// carry a post-edge delay.
1610    fn fire_edge_nbas(&self, cell: StateCellId, edge: EdgeKind) {
1611        if edge == EdgeKind::None {
1612            return;
1613        }
1614        let fired: Vec<EdgeNba> = {
1615            let mut list = self.edge_nbas.lock().unwrap();
1616            let (hit, keep): (Vec<_>, Vec<_>) = std::mem::take(&mut *list)
1617                .into_iter()
1618                .partition(|w| w.cell == cell && (w.edge == edge || w.edge == EdgeKind::BothEdges));
1619            *list = keep;
1620            if list.is_empty() {
1621                self.has_edge_nbas.store(false, Ordering::Relaxed);
1622            }
1623            hit
1624        };
1625        for w in fired {
1626            if w.after == 0 {
1627                w.nba.stage_into(self);
1628            } else {
1629                self.delayed_nbas
1630                    .lock()
1631                    .unwrap()
1632                    .entry(self.now.ticks + w.after)
1633                    .or_default()
1634                    .push(w.nba);
1635            }
1636        }
1637    }
1638
1639    fn nba_for(&self, source: ProcessId) -> &Mutex<NbaLog> {
1640        if self.stages_reactive(source) {
1641            &self.re_nba
1642        } else {
1643            &self.nba
1644        }
1645    }
1646
1647    /// The Re-NBA region commit pass (LRM 4.5): applies the nonblocking
1648    /// assignments Reactive-region processes staged. Returns whether anything
1649    /// changed.
1650    fn commit_re_nba_once(&self) -> bool {
1651        let mut nba = self.re_nba.lock().unwrap();
1652        if nba.is_empty() {
1653            return false;
1654        }
1655        let mut arena = self.arena.lock().unwrap();
1656        let mut sink = EngineNotifySink {
1657            engine: self,
1658            at: self.now,
1659            reconv_iter: 0,
1660            delta: None,
1661        };
1662        crate::sched::commit_nba(&mut nba, &mut **arena, self.now, &mut sink) > 0
1663    }
1664
1665    /// LRM 4.5/4.6: once the Reactive region set (Re-Active, Re-Inactive,
1666    /// Re-NBA) is entered, iteration over the design regions does not resume
1667    /// until all of it is empty. Drains Re-Active to empty, then one
1668    /// Re-Inactive batch (back to Re-Active), then Re-NBA, repeating.
1669    fn drain_reactive_set(&mut self) {
1670        loop {
1671            loop {
1672                let ready = self.drain_and_sort(Region::Reactive);
1673                if ready.is_empty() {
1674                    break;
1675                }
1676                self.dispatch_ready_set(Region::Reactive, ready);
1677            }
1678            self.drain_region_callbacks(Region::Reactive);
1679            self.drain_pending_spawns();
1680            let re_inactive = {
1681                let mut queues = self.queues.lock().unwrap();
1682                queues.drain_re_inactive()
1683            };
1684            if !re_inactive.is_empty() {
1685                self.reset_dequeued(&re_inactive);
1686                self.dispatch_ready_set(Region::Reactive, re_inactive);
1687            } else {
1688                self.maybe_commit_re_nba();
1689                self.apply_class_event_wakes();
1690                let done = {
1691                    let queues = self.queues.lock().unwrap();
1692                    queues.reactive_is_empty() && queues.re_inactive_is_empty()
1693                };
1694                if done {
1695                    break;
1696                }
1697            }
1698            self.guard.tick(self.now).expect(
1699                "SC-080/DT-050: delta-loop livelock guard exceeded -- hard error, \
1700                 never warn-and-truncate",
1701            );
1702        }
1703    }
1704
1705    /// Runs the Re-NBA region when Re-Active and Re-Inactive are empty
1706    /// (LRM 4.5).
1707    fn maybe_commit_re_nba(&self) {
1708        if !self.has_reactive.load(Ordering::Relaxed) {
1709            return;
1710        }
1711        let drained = {
1712            let queues = self.queues.lock().unwrap();
1713            queues.reactive_is_empty() && queues.re_inactive_is_empty()
1714        };
1715        if drained {
1716            self.commit_re_nba_once();
1717        }
1718    }
1719
1720    /// The SC-039 NBA commit pass for the current timestep. Returns whether
1721    /// anything actually changed.
1722    fn commit_nba_once(&self) -> bool {
1723        let mut nba = self.nba.lock().unwrap();
1724        let mut arena = self.arena.lock().unwrap();
1725        let mut sink = EngineNotifySink {
1726            engine: self,
1727            at: self.now,
1728            reconv_iter: 0,
1729            delta: None,
1730        };
1731        crate::sched::commit_nba(&mut nba, &mut **arena, self.now, &mut sink) > 0
1732    }
1733
1734    /// **NEW (A2-part2 Task 5, ledger CG-162):** the LRM's NBA
1735    /// update-APPLICATION half, extracted verbatim from `step_region`'s own
1736    /// `Region::Nba` arm (a pure refactor -- `step_region`'s own observable
1737    /// behavior, for every existing caller including debugger/VPI
1738    /// single-stepping, is unchanged bit-for-bit) so it can also be called
1739    /// directly as its own, separately-named operation. The ABI's
1740    /// `sim_commit_nba` entry point (`vj-runtime::abi_entry`) needs
1741    /// exactly this, decoupled from `sim_eval_region(Nba)` (see
1742    /// [`Self::current_region`]'s own doc comment for that half) --
1743    /// ARCHITECTURE §5's "`sim_commit_nba` kept distinct from
1744    /// `eval_region(Nba)` to mirror the LRM's RHS-eval vs update-apply
1745    /// split," never collapsed into one call.
1746    pub fn advance_nba_region(&mut self) -> StepResult {
1747        let changed = self.commit_nba_once();
1748        self.apply_class_event_wakes();
1749        let mut settled = {
1750            let queues = self.queues.lock().unwrap();
1751            queues.active_and_inactive_both_empty()
1752        };
1753        // sva T2 (§1.4): Active/Inactive/Nba have genuinely settled this
1754        // round -- drain Observed (the automaton-engine step hook) before
1755        // committing to Reactive, and re-check: a matured action could hand
1756        // new work to Active/Inactive, in which case the delta loop must
1757        // re-run rather than fall through. See
1758        // `Self::drain_observed_and_check_settled`'s own doc comment.
1759        if !changed && settled {
1760            settled = self.drain_observed_and_check_settled();
1761        }
1762        if changed || !settled {
1763            self.guard.tick(self.now).expect(
1764                "SC-080/DT-050: delta-loop livelock guard exceeded -- hard error, \
1765                 never warn-and-truncate",
1766            );
1767            self.current_region = Region::Active;
1768        } else {
1769            self.guard.reset();
1770            self.forks_this_step = 0;
1771            self.current_region = Region::Reactive;
1772        }
1773        StepResult::Advanced
1774    }
1775
1776    /// Advances the wheel to `tick`, dispatching every popped `TimerEntry`
1777    /// per its own `TimerAction` (SC-069): a `Resume` entry pushes onto its
1778    /// target region's queue exactly as before this task; a `TriggerEvent`
1779    /// entry is fanned out through [`EngineNotifySink::notify_change`] --
1780    /// the same path an ordinary committed write already uses -- with
1781    /// `EdgeKind::None` (the sentinel `SensitivityTable::matches` already
1782    /// treats as "any change"/a trigger pulse, `sensitivity.rs`'s own doc
1783    /// comment). Then advances `self.now`.
1784    ///
1785    /// Triggers are collected and fired only after every `Resume` entry has
1786    /// already been pushed and `self.now` updated: `notify_change` reads
1787    /// `self.now` (via the `EngineNotifySink` it constructs) for the
1788    /// `PendingActivation`s it enqueues, so a trigger fired at the new tick
1789    /// must see that tick as `self.now`, not the pre-advance value.
1790    /// The earliest tick with anything scheduled: a wheel entry or a
1791    /// delayed nonblocking write.
1792    /// Installs the embedder's scheduling hooks (`super::host`).
1793    pub fn set_host(&mut self, host: Box<dyn super::host::Host>) {
1794        self.host = Some(host);
1795        self.host_slot = None;
1796    }
1797
1798    /// Runs `f` on the installed host, if any, with this engine's ops (also
1799    /// installed as the current ops, so code the host calls can write).
1800    fn with_host<R>(
1801        &mut self,
1802        region: Region,
1803        f: impl FnOnce(&mut dyn super::host::Host, &dyn SchedulerOps) -> R,
1804    ) -> Option<R> {
1805        let mut host = self.host.take()?;
1806        let r = {
1807            let ops = EngineOps {
1808                engine: &*self,
1809                now: self.now,
1810                region,
1811                reconv_iter: 0,
1812            };
1813            crate::sched::with_scheduler_ops(&ops, || f(&mut *host, &ops))
1814        };
1815        self.host = Some(host);
1816        Some(r)
1817    }
1818
1819    /// Reports a thread event to the installed thread hook with this
1820    /// engine's ops (`super::thread_hooks`).
1821    fn fire_thread_event_now(&self, event: super::ThreadEvent) {
1822        let ops = EngineOps {
1823            engine: self,
1824            now: self.now,
1825            region: Region::Active,
1826            reconv_iter: 0,
1827        };
1828        super::fire_thread_event(&ops, event);
1829    }
1830
1831    /// The host's slot-begin hook, once per time slot.
1832    fn host_slot_begin(&mut self) {
1833        if self.host.is_some() && self.host_slot != Some(self.now.ticks) {
1834            self.host_slot = Some(self.now.ticks);
1835            self.with_host(Region::Active, |h, ops| h.slot_begin(ops));
1836        }
1837    }
1838
1839    fn next_event_tick(&self) -> Option<SimTick> {
1840        let delayed = self.delayed_nbas.lock().unwrap().keys().next().copied();
1841        let host = self.host.as_ref().and_then(|h| h.next_wakeup());
1842        [self.wheel.next_tick(), delayed, host]
1843            .into_iter()
1844            .flatten()
1845            .min()
1846    }
1847
1848    fn advance_time_to(&mut self, tick: SimTick) {
1849        let due = self.wheel.advance_to(tick);
1850        let mut triggers: Vec<StateCellId> = Vec::new();
1851        {
1852            let mut queues = self.queues.lock().unwrap();
1853            for entry in due {
1854                match entry.action {
1855                    TimerAction::Resume(act, region) => {
1856                        queues
1857                            .push(region, act)
1858                            .expect("TimeWheel::arm_delay only ever arms Region::Active entries");
1859                    }
1860                    TimerAction::TriggerEvent(cell) => triggers.push(cell),
1861                }
1862            }
1863        }
1864        self.now = SchedTime { ticks: tick };
1865        // Delayed nonblocking writes due now join this step's NBA log, ahead
1866        // of any the step's own processes stage (they were scheduled first).
1867        let due_nbas: Vec<DelayedNba> = {
1868            let mut delayed = self.delayed_nbas.lock().unwrap();
1869            let later = delayed.split_off(&(tick + 1));
1870            std::mem::replace(&mut *delayed, later)
1871                .into_values()
1872                .flatten()
1873                .collect()
1874        };
1875        if !due_nbas.is_empty() {
1876            for d in due_nbas {
1877                d.stage_into(self);
1878            }
1879        }
1880        for cell in triggers {
1881            let mut sink = EngineNotifySink {
1882                engine: self,
1883                at: self.now,
1884                reconv_iter: 0,
1885                delta: None,
1886            };
1887            sink.notify_change(
1888                cell,
1889                EdgeKind::None,
1890                self.now.to_abi_display(0),
1891                Region::Active,
1892            );
1893        }
1894    }
1895
1896    /// **NEW (Task 4, ledger SC-090/SC-123).** Pushes `cb` onto
1897    /// [`Self::region_callbacks`] to fire once `region` is next drained
1898    /// (via [`Self::drain_region_callbacks`]) with `self.now >= at`. Not
1899    /// `#[cfg(test)]`-gated: `EngineOps::register_region_callback` (the
1900    /// `SchedulerOps` trait method's one production implementation) is a
1901    /// thin forward to this, and this module's own test setup calls it
1902    /// directly for callbacks registered before `run_until` starts (where
1903    /// no `SchedulerOps`/dispatched-process context exists yet).
1904    ///
1905    /// **Review fix (Task 4 r1):** exhaustive `match region`, mirroring
1906    /// [`RegionQueues::push`]'s own established `Result` convention:
1907    /// `Reactive`/`Postponed` are the only two regions
1908    /// [`Self::drain_region_callbacks`] ever drains (see that method's own
1909    /// doc comment), so only those two push and return `Ok(())`;
1910    /// `Active`/`Inactive`/`Nba` return
1911    /// `Err(RegionCallbackRegionError::NotDrained(region))` instead of
1912    /// silently accepting a registration that would sit in
1913    /// `region_callbacks` forever and never fire. **sva T1/T2:**
1914    /// `Preponed` was added to the rejecting side (T1); `Observed` was
1915    /// added to the ACCEPTING side (T2) once `drain_region_callbacks` grew
1916    /// a real caller for it -- see the match's own per-arm comments below.
1917    pub fn push_region_callback(
1918        &self,
1919        at: SchedTime,
1920        region: Region,
1921        cb: Box<dyn RegionCallback>,
1922    ) -> Result<(), RegionCallbackRegionError> {
1923        match region {
1924            // sva T2 (§1.4, review fix, blocking finding 2), Observed's
1925            // half: `Observed` joins the ACCEPTING side, unlike `Preponed`
1926            // below -- this is the literal mechanism by which the
1927            // Observed-region automaton-engine step (a later task) gets
1928            // invoked: its engine step registers itself as one more region
1929            // callback here, fired by `drain_region_callbacks(Region::
1930            // Observed)` (see `Self::drain_observed_and_check_settled`).
1931            Region::Reactive | Region::Postponed | Region::Observed => {
1932                self.region_callbacks.lock().unwrap().push((at, region, cb));
1933                Ok(())
1934            }
1935            // sva T1 (§1.4, review fix, blocking finding 2): `Preponed`
1936            // joins the REJECTING side, never the accepting one -- the
1937            // Preponed snapshot push is inline code in `run_until`'s own
1938            // loop / `step_region`'s `Postponed` arm (see
1939            // `Engine::push_preponed_snapshot`), never a registered
1940            // callback, so nothing may register one for it.
1941            Region::Active | Region::Inactive | Region::Nba | Region::Preponed => {
1942                Err(RegionCallbackRegionError::NotDrained(region))
1943            }
1944        }
1945    }
1946
1947    /// **NEW (Task 4, ledger SC-090/SC-123/SC-020's scheduler-owned half).**
1948    /// Removes and fires every registered callback for `region` whose
1949    /// `fire_at <= self.now`, in registration order (never resorted — see
1950    /// [`Self::region_callbacks`]'s own doc comment). Called from
1951    /// `run_until`/`step_region` immediately after each
1952    /// `dispatch_ready_set(Region::Reactive, ..)`/`dispatch_ready_set(
1953    /// Region::Postponed, ..)` call — i.e. strictly after that region's own
1954    /// process dispatch, and (for `Postponed`) strictly after the whole
1955    /// Active/Inactive/NBA delta loop has already converged this timestep.
1956    ///
1957    /// **sva T2:** also called for `Region::Observed`, but not from a
1958    /// `dispatch_ready_set` call site like Reactive/Postponed -- `Observed`
1959    /// has no `RegionQueues` queue (§1.4), so it is drained instead from
1960    /// [`Self::drain_observed_and_check_settled`], itself called only once
1961    /// Active/Inactive/Nba have genuinely settled (see that method's own
1962    /// doc comment).
1963    ///
1964    /// This is SC-090's own literal acceptance ("fires exactly once per
1965    /// settled time step") and the pinning `region_callback`'s own module
1966    /// doc describes for `1step` (SC-123): a callback registered for
1967    /// `Region::Postponed` fires strictly after every other
1968    /// `Region::Postponed` callback registered before it, in the same
1969    /// drain pass, never in a separate/later timestep.
1970    fn drain_region_callbacks(&self, region: Region) {
1971        let due: Vec<Box<dyn RegionCallback>> = {
1972            let mut callbacks = self.region_callbacks.lock().unwrap();
1973            let mut due = Vec::new();
1974            let mut i = 0;
1975            while i < callbacks.len() {
1976                if callbacks[i].1 == region && callbacks[i].0 <= self.now {
1977                    due.push(callbacks.remove(i).2);
1978                } else {
1979                    i += 1;
1980                }
1981            }
1982            due
1983        };
1984        if due.is_empty() {
1985            return;
1986        }
1987        let ctx = EngineSchedulerCtx {
1988            engine: self,
1989            now: self.now,
1990        };
1991        // A callback may run compiled code (a `$strobe`/`$monitor` argument
1992        // thunk) that calls `$time` etc. -- make this Engine's ops reachable
1993        // to those runtime entry points exactly as during a kernel dispatch.
1994        let ops = EngineOps {
1995            engine: self,
1996            now: self.now,
1997            region,
1998            reconv_iter: 0,
1999        };
2000        for mut cb in due {
2001            super::kernel_adapter::with_scheduler_ops(&ops, || cb.invoke(&ctx));
2002        }
2003    }
2004
2005    /// Whether the Reactive region, which has just run, left work for the rest
2006    /// of this time step (LRM 4.5: after the Reactive region the simulator
2007    /// goes back to the Active region if anything is scheduled there): a
2008    /// process it woke in Active or Inactive, a nonblocking assignment it
2009    /// staged (the Re-NBA region, collapsed into the single NBA region), a
2010    /// process or callback it left in Reactive for its next round.
2011    fn reactive_left_work(&self) -> bool {
2012        {
2013            let queues = self.queues.lock().unwrap();
2014            if !queues.active_and_inactive_both_empty()
2015                || !queues.reactive_is_empty()
2016                || !queues.re_inactive_is_empty()
2017            {
2018                return true;
2019            }
2020        }
2021        if !self.nba.lock().unwrap().is_empty() {
2022            return true;
2023        }
2024        if self.has_reactive.load(Ordering::Relaxed) && !self.re_nba.lock().unwrap().is_empty() {
2025            return true;
2026        }
2027        // A procedural concurrent assertion a Reactive process reached
2028        // matures in the next Observed region (16.14.6), which the delta loop
2029        // reaches again only if sent round.
2030        if !crate::sched::proc_assert::is_empty() {
2031            return true;
2032        }
2033        self.region_callbacks
2034            .lock()
2035            .unwrap()
2036            .iter()
2037            .any(|(at, region, _)| *region == Region::Reactive && *at <= self.now)
2038    }
2039
2040    /// **NEW (sva T2, §1.4).** Drains any registered `Region::Observed`
2041    /// callback -- the automaton-engine step hook a later task wires in;
2042    /// none is registered yet this task, so this is a plumbing-only no-op
2043    /// in every design today, proved by
2044    /// `step_region_never_observes_current_region_eq_observed` and the
2045    /// zero-SVA-cost guard the T1 ring already established -- and reports
2046    /// whether `Active`/`Inactive` are (still) both empty afterward.
2047    ///
2048    /// Called from both `run_until`'s inlined delta loop and
2049    /// [`Self::advance_nba_region`] (the single-stepper's `Region::Nba`
2050    /// arm) at the identical point: only once NBA committed no further
2051    /// change AND Active/Inactive are already empty -- i.e. only at the
2052    /// genuine idle point the LRM's own Observed region is pinned to
2053    /// (§1.4: "fires inside the existing inner loop every time Active/
2054    /// Inactive/Nba all empty out"), never speculatively on every delta
2055    /// iteration. A matured report an Observed callback schedules into
2056    /// `Region::Reactive` (via the generalized `sched::maturation`, through
2057    /// `SchedulerOps::register_region_callback`) is consequently already
2058    /// sitting in `region_callbacks` by the time this same timestep's own
2059    /// Reactive dispatch runs -- see
2060    /// `observed_region_maturation_fires_before_reactive_same_timestep`
2061    /// and `observed_engine_step_result_visible_by_next_reactive_step`.
2062    ///
2063    /// If an Observed callback's own effect (not implemented by any task
2064    /// yet) handed new work to Active/Inactive, the caller must re-enter
2065    /// the delta loop rather than proceed to Reactive -- the reference
2066    /// algorithm's own "re-fires on each outer re-iteration, not just
2067    /// once" structure (Clause 4.5) -- which is exactly why this returns
2068    /// the re-checked `bool` rather than deciding the transition itself.
2069    fn drain_observed_and_check_settled(&self) -> bool {
2070        self.drain_region_callbacks(Region::Observed);
2071        let queues = self.queues.lock().unwrap();
2072        queues.active_and_inactive_both_empty()
2073    }
2074
2075    /// Applies one dispatched process's returned `Suspend`, exhaustive over
2076    /// all 7 variants (see this module's doc comment, deviation 3, for
2077    /// `WaitEvents`/`ForkJoin`'s scope this task; `WaitFork`/`WaitOrder` are
2078    /// B-sched Task 9's addition, ledger SC-125; `WaitForGvt` is abi Task 8's,
2079    /// AB-085).
2080    ///
2081    /// **Task 8 correction (AB-085):** the `abi`-track plan this arm was
2082    /// written against claimed `Suspend` had no exhaustive-match consumer in
2083    /// this file at the time `WaitForGvt` was added; that was wrong -- this
2084    /// very function already matched all 4 pre-existing variants with no
2085    /// wildcard arm, so adding a 5th variant is a real, necessary edit here,
2086    /// not an optional one. The fix is minimal and additive: `WaitForGvt`
2087    /// delegates to the exact same code the `Delay(ticks)` arm below already
2088    /// runs, with `ticks = 0` -- i.e. it shares that variant's dispatch path
2089    /// verbatim rather than introducing any new execution mechanism, which
2090    /// is AB-085's literal acceptance criterion. Real GVT-barrier semantics
2091    /// (gating resumption on every parallel lane reaching the same logical
2092    /// time, not just the next delta step) are the determinism/parallel-
2093    /// engine track's job and are not implemented here.
2094    fn apply_suspend(&mut self, proc: ProcessId, suspend: Suspend) {
2095        // Task 9 (B-sched wave, SC-125): drains every `disable fork;`
2096        // request `EngineOps::disable_fork` queued mid-`resume()` (that
2097        // trait method only ever gets `&self`, so it cannot mutate
2098        // `self.table` itself). `apply_suspend` always runs with `&mut
2099        // self` immediately after the `resume()` call(s) that might have
2100        // queued a request -- see `Self::drain_pending_disable_fork`'s own
2101        // doc comment for why this placement is sufficient.
2102        self.drain_pending_disable_fork();
2103        self.drain_pending_disable_named();
2104        self.drain_pending_disable_scope();
2105        self.drain_pending_process_requests();
2106        // A process that `process::kill`ed itself (or whose ancestor it
2107        // killed) is already gone from the table, with its parent notified
2108        // and its exit recorded; whatever it returned is moot.
2109        if self.killed.contains(&proc) && self.table.get(proc).is_none() {
2110            return;
2111        }
2112        if self.any_tracked.load(Ordering::SeqCst)
2113            && !matches!(suspend, Suspend::Done)
2114            && let Some(desc) = self.table.get_mut(proc)
2115        {
2116            desc.waiting = true;
2117            desc.wait_set = match &suspend {
2118                Suspend::WaitEvents(set) => Some(set.clone()),
2119                _ => None,
2120            };
2121        }
2122        match suspend {
2123            Suspend::WaitForGvt => self.apply_suspend(proc, Suspend::Delay(0)),
2124            Suspend::ByRequest => self.apply_by_request(proc),
2125            Suspend::Done => {
2126                // Review fix: report this completion to ResumeDriver::
2127                // on_child_done when `proc` is itself a fork child
2128                // (`resume.rs`'s own doc comment: on_child_done "reports one
2129                // fork child's completion to its parent, returning the
2130                // parent's own next resume() argument exactly when the join
2131                // condition is satisfied"). `fork_parent` is read from
2132                // `proc`'s own table entry (set once, by handle_fork, and
2133                // never for a non-child process), never a linear scan.
2134                if let Some(parent) = self.table.get(proc).and_then(|d| d.fork_parent) {
2135                    // Task 9 (SC-125): `on_child_done` now returns
2136                    // `Result<Option<ResumeArg>, ResumeError>` --
2137                    // `Err(WaitOrderViolated(..))` means a `wait_order(...)`-
2138                    // suspended parent observed an out-of-order completion.
2139                    // There is no real-SV reporting path for this yet this
2140                    // wave (no track's codegen emits `Suspend::WaitOrder`
2141                    // this wave -- this task's own e2e test is gated on a
2142                    // currently-unscheduled future wave), so the only
2143                    // correct scheduler-side behavior available today is
2144                    // exactly what a satisfied-`None` result already does:
2145                    // never resume the parent for this wait. Never a panic
2146                    // that would take down an otherwise-unrelated run over a
2147                    // still-undeliverable SV-level diagnostic.
2148                    if let Ok(Some(arg)) = self.driver.on_child_done(&mut self.table, proc) {
2149                        let act = PendingActivation {
2150                            id: self.activation_seq.fetch_add(1, Ordering::SeqCst),
2151                            proc: parent,
2152                            arg,
2153                            reconv_iter: 0,
2154                            logical_order: (self.now, Region::Active, parent as u64),
2155                        };
2156                        self.queues
2157                            .lock()
2158                            .unwrap()
2159                            .push(Region::Active, act)
2160                            .expect("Region::Active is never rejected by RegionQueues::push");
2161                    }
2162                }
2163                self.note_exit(proc, ProcessState::Finished);
2164            }
2165            Suspend::Delay(ticks) => {
2166                let act = PendingActivation {
2167                    id: self.activation_seq.fetch_add(1, Ordering::SeqCst),
2168                    proc,
2169                    arg: ResumeArg::TimeElapsed,
2170                    reconv_iter: 0,
2171                    logical_order: (
2172                        SchedTime {
2173                            ticks: self.now.ticks + ticks,
2174                        },
2175                        Region::Active,
2176                        proc as u64,
2177                    ),
2178                };
2179                // The one entry point a Suspend::Delay normalizes through
2180                // (wheel.rs's own doc comment).
2181                let dest = self.wheel.arm_delay(self.now.ticks, ticks, act);
2182                if dest == Region::Inactive && self.table.is_reactive(proc) {
2183                    // LRM 4.4.2.7, 4.5: `#0` taken from the Reactive region
2184                    // resumes in Re-Inactive, once Reactive has drained.
2185                    let act = PendingActivation {
2186                        logical_order: (act.logical_order.0, Region::Reactive, act.logical_order.2),
2187                        ..act
2188                    };
2189                    self.queues.lock().unwrap().push_re_inactive(act);
2190                } else if dest == Region::Inactive {
2191                    // Review fix (mirrors subscribe.rs::register_wait's
2192                    // identical delay_ticks==0 case): `act` was built above
2193                    // with logical_order.1 == Region::Active (the common
2194                    // case, where arm_delay arms the wheel instead). When
2195                    // arm_delay instead reports Region::Inactive
2196                    // (delay_ticks==0), that tag must agree with the queue
2197                    // the activation actually lands in -- order.rs's
2198                    // canonical_key_of/sort_ready_set read logical_order.1
2199                    // directly to build the canonical key (SC-010/DT-005/
2200                    // DT-010) -- so re-tag it to Region::Inactive before
2201                    // pushing.
2202                    let act = PendingActivation {
2203                        logical_order: (act.logical_order.0, Region::Inactive, act.logical_order.2),
2204                        ..act
2205                    };
2206                    self.queues
2207                        .lock()
2208                        .unwrap()
2209                        .push(Region::Inactive, act)
2210                        .expect("Region::Inactive is never rejected by RegionQueues::push");
2211                }
2212            }
2213            Suspend::WaitEvents(set) => {
2214                self.sensitivity.install_dynamic(&set, proc);
2215            }
2216            Suspend::ForkJoin(kind, spawns) => {
2217                // Each child is built by the PARENT (`CompiledProcess::
2218                // mint_fork_child`): it alone knows its compiled branch
2219                // kernels and its instance's `SimHandle`. The parent's own
2220                // `resume` has already returned, so its mutex is free here.
2221                // A parent with no branch kernels (hand-written test
2222                // processes) keeps the inert `UnmintedForkChild` placeholder.
2223                let mut minted: Vec<Box<dyn CompiledProcess>> = {
2224                    let parent = self.table.get(proc).map(|d| d.compiled.lock().unwrap());
2225                    spawns
2226                        .iter()
2227                        .map(|spec| {
2228                            parent
2229                                .as_ref()
2230                                .and_then(|p| p.mint_fork_child(spec))
2231                                .unwrap_or_else(|| Box::new(UnmintedForkChild))
2232                        })
2233                        .collect()
2234                };
2235                minted.reverse();
2236                let mut mint_child = |_spec: &SpawnSpec| -> Box<dyn CompiledProcess> {
2237                    minted
2238                        .pop()
2239                        .expect("handle_fork mints exactly one child per SpawnSpec")
2240                };
2241                let children =
2242                    self.driver
2243                        .handle_fork(&mut self.table, proc, kind, &spawns, &mut mint_child);
2244                self.forks_this_step = self.forks_this_step.saturating_add(children.len() as u32);
2245                if self.forks_this_step > MAX_FORKS_PER_STEP && self.sync_livelock.is_none() {
2246                    self.sync_livelock = Some(format!(
2247                        "more than {MAX_FORKS_PER_STEP} fork branches spawned at {:?} without \
2248                         time advancing (a zero-delay fork loop; hard error per SC-080/DT-050 \
2249                         policy)",
2250                        self.now
2251                    ));
2252                }
2253                // Tags each newly minted child with its own `SpawnSpec::
2254                // disable_name` (the fork's own label and/or this branch's
2255                // named outermost block, LRM 9.6.2) -- `EngineOps::
2256                // disable_named`'s `collect_disable_named` is the one reader.
2257                for (&child, spec) in children.iter().zip(spawns.iter()) {
2258                    if !spec.disable_name.is_empty()
2259                        && let Some(child_desc) = self.table.get_mut(child)
2260                    {
2261                        child_desc
2262                            .disable_names
2263                            .extend(spec.disable_name.iter().cloned());
2264                    }
2265                }
2266                // Task 14 (LRM 18.14.2): each child's generator is seeded
2267                // with the next value of the forking process's, one draw per
2268                // child in source order, before any child can run.
2269                for &child in &children {
2270                    super::process_rng::preseed(child, super::process_rng::draw_from_process(proc));
2271                }
2272                // 38.36.1 `cbStartOfThread`: each branch is a new thread,
2273                // created here, before it (or the continuing parent) runs.
2274                if super::thread_hook_installed() {
2275                    for (&child, spec) in children.iter().zip(&spawns) {
2276                        self.fire_thread_event_now(super::ThreadEvent::Start {
2277                            pid: child,
2278                            parent: Some(proc),
2279                            spawn: Some(spec.clone()),
2280                        });
2281                    }
2282                }
2283                {
2284                    let mut queues = self.queues.lock().unwrap();
2285                    for &child in &children {
2286                        let act = PendingActivation {
2287                            id: self.activation_seq.fetch_add(1, Ordering::SeqCst),
2288                            proc: child,
2289                            arg: ResumeArg::Start,
2290                            reconv_iter: 0,
2291                            logical_order: (self.now, Region::Active, child as u64),
2292                        };
2293                        queues
2294                            .push(Region::Active, act)
2295                            .expect("Region::Active is never rejected by RegionQueues::push");
2296                    }
2297                }
2298                // Task 7 (ledger SC-115): `JoinKind::None`'s "the parent
2299                // proceeds without waiting" -- resolved here, per this
2300                // module's own doc comment (deviation 3), *without* a new
2301                // cross-track `ResumeArg` variant meaning "just continue"
2302                // (`ResumeArg` is A2-process/codegen-owned,
2303                // `docs/CONTRACTS.md`'s ownership table): the scheduler
2304                // itself immediately re-invokes the parent's own next
2305                // segment, synchronously, in this same `apply_suspend`
2306                // call, right after registering (and enqueueing the Start
2307                // activation for) every spawned child above -- "proceeds
2308                // without waiting" because there genuinely is nothing to
2309                // wait *for*, never delivered as a resume-with-a-reason.
2310                //
2311                // **Deviation from the plan's literal `self.driver.start(
2312                // ..)` text:** that cannot be `ResumeDriver::start` --
2313                // `proc` already went through `Start` once to reach this
2314                // very `Suspend::ForkJoin` return in the first place, so a
2315                // second `start()` call would be rejected outright by its
2316                // own once-only `AlreadyStarted` CAS guard (`resume.rs`).
2317                // This calls `resume_for` instead, which is the driver's
2318                // own "continue an already-started process" entry point --
2319                // the same one `dispatch_one_partition`'s own ordinary loop
2320                // uses for every non-`Start` resume. It is reused here with
2321                // `ResumeArg::TimeElapsed` -- the least-surprising "no
2322                // special payload" choice among the 5 variants that already
2323                // exist, matching how an ordinary `Suspend::Delay`
2324                // continuation already carries no information beyond "keep
2325                // going". The resulting `Suspend` is fed back into this
2326                // same function recursively, exactly as `dispatch_one_
2327                // partition`'s own loop would have done for it.
2328                // LRM 9.3.2: a fork with no branch statements has nothing to
2329                // wait for under ANY join keyword (`fork join` is a no-op);
2330                // without this the parent parked forever on a join no child
2331                // could ever satisfy.
2332                if kind == JoinKind::None || spawns.is_empty() {
2333                    self.resume_synchronously(proc, ResumeArg::TimeElapsed);
2334                }
2335            }
2336            // --- Task 9 (B-sched wave, ledger SC-125) ---
2337            Suspend::WaitFork => {
2338                self.apply_wait_fork(proc);
2339            }
2340            Suspend::WaitOrder(order) => {
2341                self.apply_wait_order(proc, order);
2342            }
2343        }
2344    }
2345
2346    /// **NEW (Task 9, B-sched wave).** Synchronously re-invokes `proc`'s own
2347    /// next segment with `arg` and immediately applies the resulting
2348    /// `Suspend`, in this same call -- the "the caller does not suspend, it
2349    /// just keeps going" pattern `Suspend::ForkJoin`'s own `JoinKind::None`
2350    /// continuation established (this module's doc comment, deviation 3).
2351    /// Factored out of that arm so `Suspend::WaitFork`/`Suspend::WaitOrder`'s
2352    /// own "already satisfied, nothing left to actually wait for" cases
2353    /// (`apply_wait_fork`/`apply_wait_order` below) can reuse it verbatim
2354    /// instead of re-deriving the same `resume_for`/`apply_suspend`
2355    /// recursion.
2356    fn resume_synchronously(&mut self, proc: ProcessId, arg: ResumeArg) {
2357        // Trampoline: every caller is in tail position of `apply_suspend`, so
2358        // a request made while a drain is running runs right after the
2359        // current one returns, with the same effect and bounded stack.
2360        if let Some(queue) = self.sync_queue.as_mut() {
2361            queue.push_back((proc, arg));
2362            return;
2363        }
2364        self.sync_queue = Some(std::collections::VecDeque::new());
2365        self.resume_synchronously_once(proc, arg);
2366        let mut drained = 0u32;
2367        while self.sync_livelock.is_none()
2368            && let Some((p, a)) = self.sync_queue.as_mut().and_then(|q| q.pop_front())
2369        {
2370            drained += 1;
2371            if drained > MAX_SYNC_RESUMES {
2372                self.sync_livelock = Some(format!(
2373                    "a process continued without ever blocking {MAX_SYNC_RESUMES} times at \
2374                     {:?} (a zero-delay `fork ... join_none` loop never lets time advance; \
2375                     hard error per SC-080/DT-050 policy)",
2376                    self.now
2377                ));
2378                break;
2379            }
2380            self.resume_synchronously_once(p, a);
2381        }
2382        self.sync_queue = None;
2383    }
2384
2385    fn resume_synchronously_once(&mut self, proc: ProcessId, arg: ResumeArg) {
2386        if let Some(desc) = self.table.get_mut(proc) {
2387            desc.waiting = false;
2388        }
2389        let suspend = {
2390            let ops = EngineOps {
2391                engine: &*self,
2392                now: self.now,
2393                region: Region::Active,
2394                reconv_iter: 0,
2395            };
2396            let handle = SimHandle(std::ptr::null_mut());
2397            self.driver
2398                .resume_for(&self.table, proc, arg, handle, &ops)
2399                .expect(
2400                    "proc is a known, already-started ProcessId in this Engine's own \
2401                     table (it just returned a Suspend to reach this call) -- \
2402                     UnknownProcess/AlreadyStarted here would mean a bug in Engine's \
2403                     own bookkeeping",
2404                )
2405        };
2406        self.apply_suspend(proc, suspend);
2407    }
2408
2409    /// **NEW (Task 9, ledger SC-125).** `Suspend::WaitFork`'s scheduler-side
2410    /// installation: counts how many of `proc`'s own `fork_children` are
2411    /// still live (not yet `Suspend::Done` + reclaimed) right now. If none
2412    /// are (the common "every child already finished before `wait fork;`
2413    /// itself ran" case, or a degenerate `wait fork;` with no live
2414    /// children at all), `proc` resumes immediately and synchronously --
2415    /// mirroring `Suspend::ForkJoin`'s own `JoinKind::None` "nothing to
2416    /// actually wait for" continuation just above -- rather than
2417    /// round-tripping through the scheduler loop for an already-satisfied
2418    /// wait. Otherwise, installs the live count onto `proc`'s own
2419    /// descriptor for `ResumeDriver::on_child_done` to decrement as each
2420    /// child later completes.
2421    fn apply_wait_fork(&mut self, proc: ProcessId) {
2422        let children = self
2423            .table
2424            .get(proc)
2425            .map(|d| d.fork_children.clone())
2426            .unwrap_or_default();
2427        let live = children
2428            .iter()
2429            .filter(|&&child| self.table.get(child).is_some())
2430            .count() as u32;
2431        if live == 0 {
2432            self.resume_synchronously(proc, ResumeArg::AllForkChildrenDone);
2433        } else if let Some(desc) = self.table.get_mut(proc) {
2434            desc.wait_fork_remaining = Some(live);
2435        }
2436    }
2437
2438    /// **NEW (Task 9, ledger SC-125).** `Suspend::WaitOrder(order)`'s
2439    /// scheduler-side installation. An empty `order` (a degenerate
2440    /// `wait_order()`) is vacuously satisfied, so `proc` resumes
2441    /// immediately and synchronously (mirroring `apply_wait_fork`'s
2442    /// identical "nothing left to wait for" case); otherwise installs
2443    /// `proc`'s own ordered-completion expectation for `ResumeDriver::
2444    /// on_child_done` to advance/validate as each declared child completes.
2445    fn apply_wait_order(&mut self, proc: ProcessId, order: Vec<ProcessId>) {
2446        if order.is_empty() {
2447            self.resume_synchronously(proc, ResumeArg::WaitOrderSatisfied);
2448            return;
2449        }
2450        if let Some(desc) = self.table.get_mut(proc) {
2451            desc.wait_order = Some((order, 0));
2452            desc.wait_order_violated = false;
2453        }
2454    }
2455
2456    /// **NEW (Task 9, ledger SC-125).** Drains every `ProcessId`
2457    /// `EngineOps::disable_fork` queued (see that method's own doc comment
2458    /// for why the actual mutation cannot happen synchronously inside that
2459    /// `&self` call) and, for each, recursively terminates its live
2460    /// descendants via [`Self::terminate_fork_descendants`]. Called at the
2461    /// top of [`Self::apply_suspend`], which always runs with `&mut self`
2462    /// immediately after the `resume()` call(s) that might have queued a
2463    /// request -- so by the time any caller's own dispatch step (a
2464    /// `run_until`/`step_region` call, or a direct `apply_suspend` call in
2465    /// a test) returns, every requested descendant is already gone.
2466    fn drain_pending_disable_fork(&mut self) {
2467        let requested: Vec<ProcessId> =
2468            std::mem::take(&mut *self.pending_disable_fork.lock().unwrap());
2469        for proc in requested {
2470            self.terminate_fork_descendants(proc);
2471        }
2472        let blocks: Vec<ProcessId> =
2473            std::mem::take(&mut *self.pending_disable_block.lock().unwrap());
2474        for proc in blocks {
2475            self.disable_enclosing_fork(proc);
2476        }
2477    }
2478
2479    /// `disable f;` run by `proc`, a branch of fork `f`: `proc`'s own
2480    /// descendants and every sibling branch (with theirs) end now; `proc`
2481    /// returns `Done` right after the call, and that completion alone is
2482    /// what the parent's join still waits for.
2483    fn disable_enclosing_fork(&mut self, proc: ProcessId) {
2484        self.terminate_fork_descendants(proc);
2485        let Some(parent) = self.table.get(proc).and_then(|d| d.fork_parent) else {
2486            return;
2487        };
2488        // Siblings are the children of THIS fork only (same `fork_group`):
2489        // `disable f` names one fork block, never the parent's other forks.
2490        let group = self.table.get(proc).map_or(0, |d| d.fork_group);
2491        let siblings: Vec<ProcessId> = self
2492            .table
2493            .get(parent)
2494            .map(|d| d.fork_children.clone())
2495            .unwrap_or_default()
2496            .into_iter()
2497            .filter(|&s| self.table.get(s).is_some_and(|d| d.fork_group == group))
2498            .collect();
2499        for s in siblings {
2500            if s != proc {
2501                self.terminate_fork_descendants(s);
2502                self.drop_waits(s);
2503                self.table.remove(s);
2504                self.killed.insert(s);
2505                self.note_exit(s, ProcessState::Killed);
2506            }
2507        }
2508        if let Some(parent_desc) = self.table.get_mut(parent)
2509            && parent_desc.fork_gen == group
2510            && parent_desc.fork_remaining > 0
2511        {
2512            parent_desc.fork_remaining = 1;
2513        }
2514    }
2515
2516    /// **NEW (Task 9, ledger SC-125).** `disable fork;`'s actual recursive
2517    /// termination: removes every live entry in `proc`'s own
2518    /// `fork_children`, and, for each, its own descendants first
2519    /// (post-order) -- never `proc` itself. A child already reclaimed
2520    /// (e.g. it had already completed normally before `disable fork;` ran)
2521    /// is silently skipped, not an error: `disable fork;`'s own LRM
2522    /// semantics only ever terminate what is still live.
2523    fn terminate_fork_descendants(&mut self, proc: ProcessId) {
2524        let children = self
2525            .table
2526            .get(proc)
2527            .map(|d| d.fork_children.clone())
2528            .unwrap_or_default();
2529        for child in children {
2530            if self.table.get(child).is_some() {
2531                self.terminate_fork_descendants(child);
2532                self.drop_waits(child);
2533                self.table.remove(child);
2534                self.killed.insert(child);
2535                self.note_exit(child, ProcessState::Killed);
2536            }
2537        }
2538    }
2539
2540    /// `EngineOps::disable_named`'s deferred half: for each queued
2541    /// `(requester, targets)`, force-kills every target's subtree except
2542    /// `requester` itself (see [`Self::kill_subtree_except`] for why
2543    /// `requester` is always excluded: its own end, when it is one of the
2544    /// targets or descends from one, already happened through the abort
2545    /// flag the synchronous half of `disable_named` told it to set, the
2546    /// same mechanism `process::kill()` uses on itself -- killing its table
2547    /// entry a second time here, out from under its own still-returning
2548    /// `resume()` call, is exactly the hazard that mechanism exists to
2549    /// avoid).
2550    fn drain_pending_disable_named(&mut self) {
2551        let requests: Vec<(ProcessId, Vec<ProcessId>)> =
2552            std::mem::take(&mut *self.pending_disable_named.lock().unwrap());
2553        for (requester, targets) in requests {
2554            for target in targets {
2555                self.kill_subtree_except(target, requester);
2556            }
2557        }
2558    }
2559
2560    /// `EngineOps::disable_named`'s other deferred half (LRM 9.6.2): for each
2561    /// queued `(requester, label)`, every other process of the requester's
2562    /// instance that is suspended inside the named block or task activation
2563    /// `label` (`CompiledProcess::disable_exit`) ends that activity and
2564    /// resumes after it ([`Self::redirect_process`]). The requester's own
2565    /// jump out of `label` is part of its compiled control flow.
2566    fn drain_pending_disable_scope(&mut self) {
2567        let requests: Vec<(ProcessId, String)> =
2568            std::mem::take(&mut *self.pending_disable_scope.lock().unwrap());
2569        for (requester, label) in requests {
2570            let root = self.fork_root(requester);
2571            let instance = self
2572                .table
2573                .get(root)
2574                .and_then(|d| d.compiled.lock().unwrap().disable_instance());
2575            let Some(instance) = instance else { continue };
2576            if label == EXIT_PROGRAM_LABEL {
2577                // Every program `initial` thread of the caller's program
2578                // instance ends, fork descendants included.
2579                for t in 0..self.table.count() as ProcessId {
2580                    let Some(d) = self.table.get(t) else { continue };
2581                    if d.program_thread
2582                        && d.fork_parent.is_none()
2583                        && (t == root
2584                            || d.compiled.lock().unwrap().disable_instance() == Some(instance))
2585                    {
2586                        self.kill_subtree_except(t, requester);
2587                    }
2588                }
2589                continue;
2590            }
2591            // `disable u1.blk;` (LRM 9.6.2, an instance path): the compiled
2592            // kernels of the descendant instance carry the scope under
2593            // `<requester instance>/<path>` and `/<absolute path>` (run.rs).
2594            if label.contains('.') {
2595                let keys = [format!("{instance}/{label}"), format!("/{label}")];
2596                for target in 0..self.table.count() as ProcessId {
2597                    if target == requester || self.ended.contains_key(&target) {
2598                        continue;
2599                    }
2600                    let exit = self.table.get(target).and_then(|d| {
2601                        let compiled = d.compiled.lock().unwrap();
2602                        keys.iter().find_map(|k| compiled.disable_exit(k))
2603                    });
2604                    if let Some(pc) = exit {
2605                        self.redirect_process(target, pc);
2606                    }
2607                }
2608                // ... and the fork branches of the block's process left
2609                // running (the process may be past the block, or finished):
2610                // activities enabled within the block end with it.
2611                let leaf = label.rsplit('.').next().unwrap_or(&label);
2612                for root in 0..self.table.count() as ProcessId {
2613                    if root == requester
2614                        || self.table.get(root).is_none_or(|d| d.fork_parent.is_some())
2615                    {
2616                        continue;
2617                    }
2618                    let owns = self.table.get(root).is_some_and(|d| {
2619                        let compiled = d.compiled.lock().unwrap();
2620                        keys.iter().any(|k| compiled.owns_disable_scope(k))
2621                    });
2622                    if owns {
2623                        let mut branches = Vec::new();
2624                        self.collect_disable_named(root, leaf, &mut branches);
2625                        for t in branches {
2626                            self.kill_subtree_except(t, requester);
2627                        }
2628                    }
2629                }
2630                continue;
2631            }
2632            // A fork branch of ANOTHER process of the instance named `label`
2633            // ends too. Done here, not in the synchronous half: the requester's
2634            // compiled process is locked while it runs.
2635            let mut branches = Vec::new();
2636            self.collect_disable_named_in_peers(root, &label, &mut branches);
2637            for t in branches {
2638                self.kill_subtree_except(t, requester);
2639            }
2640            for target in 0..self.table.count() as ProcessId {
2641                if target == requester || self.ended.contains_key(&target) {
2642                    continue;
2643                }
2644                let exit = self.table.get(target).and_then(|d| {
2645                    let compiled = d.compiled.lock().unwrap();
2646                    (compiled.disable_instance() == Some(instance))
2647                        .then(|| compiled.disable_exit(&label))
2648                        .flatten()
2649                });
2650                if let Some(pc) = exit {
2651                    self.redirect_process(target, pc);
2652                }
2653            }
2654        }
2655    }
2656
2657    /// Ends what `target` is waiting for and makes it run again at `pc` in
2658    /// this time step (a `disable` of the block or task it is inside, LRM
2659    /// 9.6.2): its timer, event wait, `wait`, semaphore or mailbox wait and the
2660    /// processes it joins are dropped; activations queued for it before this
2661    /// point are stale ([`Self::activation_floor`]).
2662    fn redirect_process(&mut self, target: ProcessId, pc: u32) {
2663        self.drop_waits(target);
2664        let was_joined = self
2665            .table
2666            .get(target)
2667            .map(|d| d.compiled.lock().unwrap().redirect_resume(pc))
2668            .unwrap_or(false);
2669        let children = self
2670            .table
2671            .get(target)
2672            .map(|d| d.fork_children.clone())
2673            .unwrap_or_default();
2674        if was_joined {
2675            // LRM 9.6.2: the activities enabled within the block end too.
2676            for child in children {
2677                if self.table.get(child).is_some() {
2678                    self.kill_subtree_except(child, target);
2679                }
2680            }
2681        }
2682        if let Some(desc) = self.table.get_mut(target) {
2683            desc.waiting = false;
2684            desc.wait_set = None;
2685            desc.awaiting = None;
2686            desc.parked = None;
2687            desc.desensitized = false;
2688            if was_joined {
2689                desc.fork_remaining = 0;
2690                desc.wait_fork_remaining = None;
2691                desc.wait_order = None;
2692            }
2693        }
2694        let id = self.activation_seq.fetch_add(1, Ordering::SeqCst);
2695        self.activation_floor.insert(target, id);
2696        let act = PendingActivation {
2697            id,
2698            proc: target,
2699            arg: ResumeArg::Disabled,
2700            reconv_iter: 0,
2701            logical_order: (self.now, Region::Active, target as u64),
2702        };
2703        self.queues
2704            .lock()
2705            .unwrap()
2706            .push(Region::Active, act)
2707            .expect("Region::Active is never rejected by RegionQueues::push");
2708    }
2709
2710    /// `node` and every live descendant of it, post-order, EXCEPT `exclude`
2711    /// itself (`exclude`'s own table entry is left untouched -- it ends
2712    /// through its own CFG/abort-flag path, if at all; this call still
2713    /// recurses INTO `exclude`'s own children, since whatever `exclude`
2714    /// itself forked has no such self-ending mechanism and must be force-
2715    /// killed like any other descendant). Mirrors [`Self::kill_process`]
2716    /// exactly (including its "already reclaimed is silently skipped, not
2717    /// an error" posture) with that one exclusion added.
2718    fn kill_subtree_except(&mut self, node: ProcessId, exclude: ProcessId) {
2719        let children = self
2720            .table
2721            .get(node)
2722            .map(|d| d.fork_children.clone())
2723            .unwrap_or_default();
2724        for child in children {
2725            if self.table.get(child).is_some() {
2726                self.kill_subtree_except(child, exclude);
2727            }
2728        }
2729        if node != exclude && self.table.get(node).is_some() && !self.ended.contains_key(&node) {
2730            self.terminate_victim(node);
2731        }
2732    }
2733
2734    /// The top-level ancestor of `node` (follows `fork_parent` to its end) --
2735    /// [`EngineOps::disable_named`]'s search scope, so a `disable <label>;`
2736    /// run by one process never reaches a same-named branch spawned by a
2737    /// wholly unrelated top-level process of the same instance.
2738    fn fork_root(&self, node: ProcessId) -> ProcessId {
2739        let mut cur = node;
2740        while let Some(parent) = self.table.get(cur).and_then(|d| d.fork_parent) {
2741            cur = parent;
2742        }
2743        cur
2744    }
2745
2746    /// [`Self::collect_disable_named`] over the fork trees of the other
2747    /// top-level processes of `root`'s instance (the processes that share its
2748    /// [`CompiledProcess::disable_instance`]), for a `disable <name>;` of a
2749    /// fork branch's block that lives in another process (LRM 9.6.2).
2750    fn collect_disable_named_in_peers(
2751        &self,
2752        root: ProcessId,
2753        label: &str,
2754        out: &mut Vec<ProcessId>,
2755    ) {
2756        let instance = self
2757            .table
2758            .get(root)
2759            .and_then(|d| d.compiled.lock().unwrap().disable_instance());
2760        let Some(instance) = instance else { return };
2761        for other in 0..self.table.count() as ProcessId {
2762            if other == root || self.ended.contains_key(&other) {
2763                continue;
2764            }
2765            let Some(d) = self.table.get(other) else {
2766                continue;
2767            };
2768            if d.fork_parent.is_some()
2769                || d.compiled.lock().unwrap().disable_instance() != Some(instance)
2770            {
2771                continue;
2772            }
2773            self.collect_disable_named(other, label, out);
2774        }
2775    }
2776
2777    /// Every live process in `node`'s own fork subtree (`node` included)
2778    /// whose [`super::ProcessDescriptor::disable_names`] contains `label`,
2779    /// appended to `out` in no particular order -- `EngineOps::
2780    /// disable_named`'s match set. Recurses into every match too: a label
2781    /// reused by several concurrently live occurrences (a loop or a
2782    /// reentrant task forking the same named branch repeatedly) is not
2783    /// assumed unique, matching every one of them, never just the first.
2784    fn collect_disable_named(&self, node: ProcessId, label: &str, out: &mut Vec<ProcessId>) {
2785        let Some(desc) = self.table.get(node) else {
2786            return;
2787        };
2788        if desc.disable_names.iter().any(|n| n == label) {
2789            out.push(node);
2790        }
2791        for &child in &desc.fork_children {
2792            self.collect_disable_named(child, label, out);
2793        }
2794    }
2795
2796    // --- Classes plan Task 13 (LRM 9.7): the `process` class ---
2797
2798    /// Pushes an Active-region activation for `proc` (the shape the fork
2799    /// completion path above uses).
2800    fn push_active(&mut self, proc: ProcessId, arg: ResumeArg) {
2801        let act = PendingActivation {
2802            id: self.activation_seq.fetch_add(1, Ordering::SeqCst),
2803            proc,
2804            arg,
2805            reconv_iter: 0,
2806            logical_order: (self.now, Region::Active, proc as u64),
2807        };
2808        self.queues
2809            .lock()
2810            .unwrap()
2811            .push(Region::Active, act)
2812            .expect("Region::Active is never rejected by RegionQueues::push");
2813    }
2814
2815    /// Whether `node` is `ancestor` itself or one of its fork descendants
2816    /// (the `fork_parent` chain, which reclaimed ancestors break -- a
2817    /// descendant of a reclaimed process is not found, see `kill_process`).
2818    fn is_in_fork_subtree(&self, node: ProcessId, ancestor: ProcessId) -> bool {
2819        let mut cur = Some(node);
2820        while let Some(p) = cur {
2821            if p == ancestor {
2822                return true;
2823            }
2824            cur = self.table.get(p).and_then(|d| d.fork_parent);
2825        }
2826        false
2827    }
2828
2829    /// `process::status()`'s answer before any queued request is overlaid.
2830    fn base_process_state(&self, target: ProcessId) -> ProcessState {
2831        if let Some(&state) = self.ended.get(&target) {
2832            return state;
2833        }
2834        match self.table.get(target) {
2835            None if self.killed.contains(&target) => ProcessState::Killed,
2836            None => ProcessState::Finished,
2837            Some(d) if d.suspended => ProcessState::Suspended,
2838            Some(d) if d.waiting || d.awaiting.is_some() || d.sync_blocked => ProcessState::Waiting,
2839            Some(_) => ProcessState::Running,
2840        }
2841    }
2842
2843    /// `process::status()` (LRM 9.7): the base state with every request the
2844    /// calling process has queued but the engine has not yet applied laid
2845    /// over it, in call order -- so `p.kill(); p.status()` reads `KILLED`
2846    /// in the same segment.
2847    fn effective_process_state(&self, target: ProcessId) -> ProcessState {
2848        let mut state = self.base_process_state(target);
2849        for &(_, req) in self.pending_process_requests.lock().unwrap().iter() {
2850            match req {
2851                ProcessRequest::Kill(root) => {
2852                    if !matches!(state, ProcessState::Finished | ProcessState::Killed)
2853                        && self.is_in_fork_subtree(target, root)
2854                    {
2855                        state = ProcessState::Killed;
2856                    }
2857                }
2858                ProcessRequest::Suspend(t) => {
2859                    if t == target && matches!(state, ProcessState::Running | ProcessState::Waiting)
2860                    {
2861                        state = ProcessState::Suspended;
2862                    }
2863                }
2864                ProcessRequest::Resume(t) => {
2865                    if t == target && state == ProcessState::Suspended {
2866                        state = ProcessState::Waiting;
2867                    }
2868                }
2869                // Blocking in / waking from a synchronisation object is not
2870                // something the caller can observe through `status()` before
2871                // the engine applies it.
2872                ProcessRequest::Await(_)
2873                | ProcessRequest::SyncBlock
2874                | ProcessRequest::SyncWake(_) => {}
2875            }
2876        }
2877        state
2878    }
2879
2880    /// Applies every queued `process` request, in call order. Called at the
2881    /// top of [`Self::apply_suspend`].
2882    fn drain_pending_process_requests(&mut self) {
2883        let requests: Vec<(ProcessId, ProcessRequest)> =
2884            std::mem::take(&mut *self.pending_process_requests.lock().unwrap());
2885        for (requester, req) in requests {
2886            match req {
2887                ProcessRequest::Kill(target) => self.kill_process(target),
2888                ProcessRequest::Suspend(target) => self.suspend_process(target),
2889                ProcessRequest::Resume(target) => self.resume_process(target),
2890                ProcessRequest::Await(target) => self.register_await(requester, target),
2891                ProcessRequest::SyncBlock => self.apply_sync_block(requester),
2892                ProcessRequest::SyncWake(target) => self.apply_sync_wake(target),
2893            }
2894        }
2895    }
2896
2897    /// `p.kill()` (LRM 9.7.3): `target` and every live fork descendant end
2898    /// before this returns -- descendants first (post-order), `target` last.
2899    /// A `target` that already ended is skipped but its live descendants are
2900    /// still terminated (the LRM requires it). A descendant of a *reclaimed*
2901    /// process is not reachable (its `fork_parent` link died with the
2902    /// table entry); that gap is documented in the classes plan.
2903    fn kill_process(&mut self, target: ProcessId) {
2904        let children = self
2905            .table
2906            .get(target)
2907            .map(|d| d.fork_children.clone())
2908            .unwrap_or_default();
2909        for child in children {
2910            if self.table.get(child).is_some() {
2911                self.kill_process(child);
2912            }
2913        }
2914        if self.table.get(target).is_some() && !self.ended.contains_key(&target) {
2915            self.terminate_victim(target);
2916        }
2917    }
2918
2919    /// Ends one live process: drops its waits, records `KILLED`, reports it
2920    /// to its fork parent's join exactly like a normal completion
2921    /// (`ResumeDriver::on_child_done`, which also reclaims the table slot),
2922    /// and wakes its awaiters. Activations already queued for it are
2923    /// dropped at dispatch (`killed`).
2924    fn terminate_victim(&mut self, victim: ProcessId) {
2925        self.drop_waits(victim);
2926        let parent = self.table.get(victim).and_then(|d| d.fork_parent);
2927        // `Err(WaitOrderViolated)` has no reporting path (see the `Done`
2928        // arm of `apply_suspend`); the slot is reclaimed either way.
2929        let join = self.driver.on_child_done(&mut self.table, victim);
2930        if let (Some(parent), Ok(Some(arg))) = (parent, join) {
2931            self.push_active(parent, arg);
2932        }
2933        self.killed.insert(victim);
2934        self.note_exit(victim, ProcessState::Killed);
2935    }
2936
2937    /// Drops everything a process about to end is parked on. EVERY path that
2938    /// ends a live process (`process::kill`, `disable fork`, `disable` of an
2939    /// enclosing fork) goes through here before the table slot is reclaimed: a
2940    /// victim left in a semaphore or mailbox queue would be handed the next key
2941    /// or message and swallow it (the timeout idiom `fork s.get(1); #5;
2942    /// join_any disable fork;` would lose the following `put`).
2943    fn drop_waits(&mut self, victim: ProcessId) {
2944        self.sensitivity.remove_dynamic(victim);
2945        self.repeats.clear(victim);
2946        // A process killed while blocked in `semaphore.get` leaves the queue,
2947        // which can let the waiters behind it through.
2948        let mut released = Vec::new();
2949        crate::sched::class_event::cancel(victim);
2950        semaphore::cancel(victim, &mut |req| released.push(req));
2951        for req in released {
2952            if let ProcessRequest::SyncWake(target) = req {
2953                self.apply_sync_wake(target);
2954            }
2955        }
2956        // Likewise in a mailbox call: a blocked put/get/peek leaves its queue
2957        // and gives back what it held (a woken waiter's undelivered message).
2958        mailbox::cancel(victim);
2959    }
2960
2961    /// Records how a tracked process ended and wakes whoever is blocked in
2962    /// `await()` on it.
2963    fn note_exit(&mut self, proc: ProcessId, state: ProcessState) {
2964        if let Some(roots) = self.program_roots.as_mut() {
2965            roots.remove(&proc);
2966        }
2967        // 38.36.1 `cbEndOfThread`: the thread is deleted.
2968        if super::thread_hook_installed() {
2969            self.fire_thread_event_now(super::ThreadEvent::End {
2970                pid: proc,
2971                killed: state == ProcessState::Killed,
2972            });
2973        }
2974        if self.tracked.lock().unwrap().contains(&proc) {
2975            self.ended.entry(proc).or_insert(state);
2976        }
2977        for awaiter in self.awaiters.remove(&proc).unwrap_or_default() {
2978            if let Some(desc) = self.table.get_mut(awaiter)
2979                && desc.awaiting == Some(proc)
2980            {
2981                desc.awaiting = None;
2982                self.push_active(awaiter, ResumeArg::TimeElapsed);
2983            }
2984        }
2985        // `wait (p.status() == process::FINISHED)` sleeps on object state: a
2986        // process ending (finished or killed) is a status change no signal
2987        // reports (LRM 1800-2023 9.7). Only a tracked process has a status
2988        // handle to wait on: a statically sensitive process that finishes each
2989        // wake (a class covergroup's watcher, which object state wakes) is not
2990        // an ending, and notifying for it would wake itself forever.
2991        if crate::sched::object_state_watched() && self.tracked.lock().unwrap().contains(&proc) {
2992            let mut sink = EngineNotifySink {
2993                engine: self,
2994                at: self.now,
2995                reconv_iter: 0,
2996                delta: None,
2997            };
2998            crate::sched::notify_blocking_change(
2999                crate::sched::OBJECT_STATE_CELL,
3000                EdgeKind::None,
3001                self.now,
3002                &mut sink,
3003            );
3004        }
3005    }
3006
3007    /// `p.suspend()` (LRM 9.7.4): no effect on a process that already ended
3008    /// or is already suspended. A process blocked in an `@(...)` event
3009    /// control is desensitised: its dynamic sensitivity is removed, so an
3010    /// event that fires while it is suspended is missed, not replayed. A
3011    /// `wait(expr)` waiter keeps its sensitivity (its resumed kernel
3012    /// re-checks the condition, so a parked wake is harmless), and a delay
3013    /// or other wait keeps its timer/join, whose firing is parked and
3014    /// replayed by [`Self::resume_process`].
3015    fn suspend_process(&mut self, target: ProcessId) {
3016        if self.ended.contains_key(&target) {
3017            return;
3018        }
3019        let Some(desc) = self.table.get_mut(target) else {
3020            return;
3021        };
3022        if desc.suspended {
3023            return;
3024        }
3025        desc.suspended = true;
3026        let desensitize = desc.waiting
3027            && desc
3028                .wait_set
3029                .as_ref()
3030                .is_some_and(|set| !set.rechecks_on_wake);
3031        if desensitize {
3032            desc.desensitized = true;
3033            self.sensitivity.remove_dynamic(target);
3034        }
3035    }
3036
3037    /// `p.resume()` (LRM 9.7.5): a no-op unless `target` is suspended;
3038    /// otherwise it runs again. An activation parked while it was suspended
3039    /// is replayed (it was already runnable, or is a re-checking wait or a
3040    /// delay that expired); a desensitised `@(...)` waiter that nothing woke
3041    /// is resensitised to the same event expression.
3042    fn resume_process(&mut self, target: ProcessId) {
3043        let Some(desc) = self.table.get_mut(target) else {
3044            return;
3045        };
3046        if !desc.suspended {
3047            return;
3048        }
3049        desc.suspended = false;
3050        let was_desensitized = std::mem::take(&mut desc.desensitized);
3051        if let Some(arg) = desc.parked.take() {
3052            self.push_active(target, arg);
3053        } else if was_desensitized && let Some(set) = desc.wait_set.clone() {
3054            self.sensitivity.install_dynamic(&set, target);
3055        }
3056    }
3057
3058    /// `p.await()` (LRM 9.7): blocks `requester` until `target` ends. A
3059    /// `target` that already ended (or is `requester` itself, which the
3060    /// runtime shim reports as an error) blocks nothing.
3061    fn register_await(&mut self, requester: ProcessId, target: ProcessId) {
3062        if requester == target
3063            || self.ended.contains_key(&target)
3064            || self.table.get(target).is_none()
3065        {
3066            return;
3067        }
3068        if let Some(desc) = self.table.get_mut(requester) {
3069            desc.awaiting = Some(target);
3070            self.awaiters.entry(target).or_default().push(requester);
3071        }
3072    }
3073
3074    /// [`ProcessRequest::SyncBlock`]: the requester's `get` queued it on a
3075    /// semaphore. [`Self::apply_by_request`] then leaves it yielded.
3076    fn apply_sync_block(&mut self, requester: ProcessId) {
3077        if let Some(desc) = self.table.get_mut(requester) {
3078            desc.sync_blocked = true;
3079        }
3080    }
3081
3082    /// Applies the wakes an NBA/Re-NBA commit pass raised for the waiters of a
3083    /// class event a `->> e` triggered (LRM 15.5.1; `sched::class_event`): the
3084    /// pass has no process to carry the requests, so it parks them.
3085    fn apply_class_event_wakes(&mut self) {
3086        for req in super::class_event::take_deferred_wakes() {
3087            if let ProcessRequest::SyncWake(target) = req {
3088                self.apply_sync_wake(target);
3089            }
3090        }
3091    }
3092
3093    /// [`ProcessRequest::SyncWake`]: `target`'s `get` was granted its keys, so
3094    /// it runs again (parked instead if `p.suspend()` suspended it meanwhile,
3095    /// like every other activation). A target that no longer exists was killed
3096    /// after being granted; its keys are gone with it. A live target that is
3097    /// not blocked would be a block/wake ordering bug -- `semaphore` emits
3098    /// `SyncBlock` before any `SyncWake` of the same process, and the queue
3099    /// preserves that order -- so it is reported loudly instead of resuming a
3100    /// running process a second time.
3101    fn apply_sync_wake(&mut self, target: ProcessId) {
3102        let Some(desc) = self.table.get_mut(target) else {
3103            return;
3104        };
3105        if !desc.sync_blocked && super::class_event::is_dual(target) {
3106            // A class event woke a process in a mixed `@(class_event or
3107            // signal ...)` wait (LRM 1800-2023 9.4.2.1): it also waits on
3108            // signals, so the signal side is dropped first (and an activation
3109            // a signal already queued is stale: `event_floor`), and the
3110            // process resumes as if an event fired (term `-1`: no signal term).
3111            self.sensitivity.remove_dynamic(target);
3112            self.repeats.clear(target);
3113            let floor = self.activation_seq.load(Ordering::SeqCst);
3114            self.event_floor.insert(target, floor);
3115            self.push_active(
3116                target,
3117                ResumeArg::EventFired {
3118                    term_index: usize::MAX,
3119                },
3120            );
3121            return;
3122        }
3123        assert!(
3124            desc.sync_blocked,
3125            "internal: semaphore woke process {target}, which is not blocked in a get()"
3126        );
3127        desc.sync_blocked = false;
3128        self.push_active(target, ResumeArg::TimeElapsed);
3129    }
3130
3131    /// [`Suspend::ByRequest`]: the process yielded right after queueing a
3132    /// request, which [`Self::drain_pending_process_requests`] has just
3133    /// applied. It keeps going at once unless the request left it
3134    /// self-suspended (the activation is parked for `resume()`) or blocked
3135    /// in `await()` (`note_exit` wakes it).
3136    fn apply_by_request(&mut self, proc: ProcessId) {
3137        let Some(desc) = self.table.get_mut(proc) else {
3138            return;
3139        };
3140        if desc.suspended {
3141            desc.parked = Some(ResumeArg::TimeElapsed);
3142        } else if desc.awaiting.is_none() && !desc.sync_blocked {
3143            self.resume_synchronously(proc, ResumeArg::TimeElapsed);
3144        }
3145    }
3146
3147    // --- #[cfg(test)]-only accessors, added for exactly the tests below ---
3148
3149    #[cfg(test)]
3150    fn test_dispatch_ops(&self, now: SchedTime, region: Region, reconv_iter: u32) -> EngineOps<'_> {
3151        EngineOps {
3152            engine: self,
3153            now,
3154            region,
3155            reconv_iter,
3156        }
3157    }
3158
3159    #[cfg(test)]
3160    fn test_read_cell(&self, cell: StateCellId) -> FourStateValue {
3161        self.arena.lock().unwrap().read(cell)
3162    }
3163
3164    #[cfg(test)]
3165    fn test_nba_len(&self) -> usize {
3166        self.nba.lock().unwrap().len() + self.re_nba.lock().unwrap().len()
3167    }
3168
3169    #[cfg(test)]
3170    fn test_drain_active(&mut self) -> Vec<PendingActivation> {
3171        let popped = self.queues.get_mut().unwrap().drain_active();
3172        self.reset_dequeued(&popped);
3173        popped
3174    }
3175
3176    #[cfg(test)]
3177    fn test_drain_inactive(&mut self) -> Vec<PendingActivation> {
3178        let popped = self.queues.get_mut().unwrap().drain_inactive();
3179        self.reset_dequeued(&popped);
3180        popped
3181    }
3182
3183    #[cfg(test)]
3184    fn test_sensitivity_entry_count(&self) -> usize {
3185        self.sensitivity.entry_count()
3186    }
3187
3188    /// **Task 7 (B-sched wave, ledger SC-062, SC-115).** Whether `id` is
3189    /// still a live entry in this `Engine`'s own `ProcessTable` -- the
3190    /// literal accessor Task 7's own plan text names, used to prove a
3191    /// completed fork child's slot really was reclaimed (`SC-062`) and,
3192    /// conversely, that a `JoinKind::None` child that has NOT yet been
3193    /// dispatched is still live (`SC-115`).
3194    #[cfg(test)]
3195    fn test_process_exists(&self, id: ProcessId) -> bool {
3196        self.table.get(id).is_some()
3197    }
3198}
3199
3200impl Engine {
3201    /// [`SchedulerControl::register_process`]'s body, with the process's home
3202    /// region: `reactive` processes (LRM 17.7.3: a checker's procedures and
3203    /// continuous assignments) start and wake in the Reactive region, every
3204    /// other process in Active. See [`Self::register_reactive_process`].
3205    fn register_process_in(
3206        &mut self,
3207        proc: Box<dyn CompiledProcess>,
3208        kind: ProcessKind,
3209        sensitivity: Option<StaticSensitivity>,
3210        region_id: u32,
3211        reactive: bool,
3212        procedure: bool,
3213    ) -> ProcessId {
3214        let id = self.table.register(proc);
3215        // Task 14 (LRM 18.14.1): a static process is seeded from its
3216        // containing instance's initialization RNG, before it can ever draw.
3217        match self.registration_scope {
3218            Some(scope) if self.registration_static_initializer => {
3219                super::process_rng::alias_init(id, scope);
3220            }
3221            Some(scope) => {
3222                super::process_rng::preseed(id, super::process_rng::draw_from_init(scope));
3223            }
3224            // An id a finished initializer process left behind must not keep
3225            // its alias.
3226            None => super::process_rng::clear_alias(id),
3227        }
3228        if reactive {
3229            self.table.set_reactive(id);
3230            self.has_reactive.store(true, Ordering::Relaxed);
3231        }
3232        let home = if reactive {
3233            Region::Reactive
3234        } else {
3235            Region::Active
3236        };
3237        // LRM 9.2.2.2: an `always_comb` / `always_latch` "is automatically
3238        // triggered once at time zero, after all initial and always
3239        // procedures have been started". Its start activation therefore
3240        // goes to Inactive, which the time step drains only once Active has
3241        // quiesced (every other process has run to its first suspension),
3242        // so a sibling `always @(ev)` is already waiting for an event the
3243        // comb block triggers on that first run (ivtest br_gh508a).
3244        let start_region = match kind {
3245            ProcessKind::AlwaysComb | ProcessKind::AlwaysLatch if procedure => Region::Inactive,
3246            _ => home,
3247        };
3248        // Task 9 (A2-part2, CG-102): stores `region_id` for
3249        // `dispatch_ready_set` to look up later via `ProcessTable::
3250        // region_id` -- see `set_region_id`'s own doc comment for why this
3251        // is a separate call rather than a wider `register` signature.
3252        self.table.set_region_id(id, region_id);
3253        // The crate's one and only production call site of `install_static`
3254        // (SC-045) — called at most once per registration, never a second
3255        // time for the same `id` anywhere in this body.
3256        if let Some(sens) = sensitivity.as_ref() {
3257            self.sensitivity.install_static(sens, id);
3258        }
3259        match kind {
3260            ProcessKind::Final => {
3261                self.finals.push(FinalProcess {
3262                    proc: id,
3263                    registration_order: id as u64,
3264                });
3265            }
3266            ProcessKind::Initial
3267            | ProcessKind::Always
3268            | ProcessKind::AlwaysComb
3269            | ProcessKind::AlwaysLatch
3270            | ProcessKind::AlwaysFF => {
3271                let act = PendingActivation {
3272                    id: self.activation_seq.fetch_add(1, Ordering::SeqCst),
3273                    proc: id,
3274                    arg: ResumeArg::Start,
3275                    reconv_iter: 0,
3276                    logical_order: (self.now, home, id as u64),
3277                };
3278                if start_region != home {
3279                    // The pending start already is the process's first run: a
3280                    // change landing before it must not queue a second one.
3281                    self.gate.mark_queued_if_not_already(id);
3282                }
3283                self.queues.lock().unwrap().push(start_region, act).expect(
3284                    "Region::Active and Region::Reactive are never rejected by RegionQueues::push",
3285                );
3286            }
3287        }
3288        id
3289    }
3290
3291    /// Registers the processes [`SchedulerOps::spawn_detached`] queued, each a
3292    /// one-shot Reactive-region process (16.11) that starts in the iteration
3293    /// that queued it.
3294    fn drain_pending_spawns(&mut self) {
3295        let spawned = std::mem::take(&mut *self.pending_spawns.lock().unwrap());
3296        for proc in spawned {
3297            self.register_process_in(proc, ProcessKind::Initial, None, 0, true, false);
3298        }
3299    }
3300
3301    /// Registers a process that runs in the Reactive region (LRM 17.7.3): a
3302    /// checker's procedures and continuous assignments "are scheduled in the
3303    /// Reactive region, similarly to programs". Its start activation and
3304    /// every wake-up (a static or dynamic sensitivity match) are queued in
3305    /// Reactive, which the time step drains after Active, Inactive, NBA and
3306    /// the Observed region have settled and re-enters the delta loop from when
3307    /// it leaves work behind (see `run_until`). Otherwise identical to
3308    /// [`SchedulerControl::register_process`].
3309    /// Marks `id` (just registered) as a program block's `initial` thread
3310    /// (LRM 24.3: "the simulation ends implicitly when all the program
3311    /// threads end"). Its fork descendants inherit the mark.
3312    pub fn mark_program_initial(&mut self, id: ProcessId) {
3313        if let Some(d) = self.table.get_mut(id) {
3314            d.program_thread = true;
3315        }
3316        self.program_roots
3317            .get_or_insert_with(Default::default)
3318            .insert(id);
3319    }
3320
3321    /// Whether the design has program `initial` threads and every one of them,
3322    /// with every fork descendant, has ended (LRM 24.3: implicit `$finish`).
3323    fn program_threads_all_ended(&self) -> bool {
3324        let Some(roots) = self.program_roots.as_ref() else {
3325            return false;
3326        };
3327        roots.is_empty() && !self.table.any_live_program_child()
3328    }
3329
3330    pub fn register_reactive_process(
3331        &mut self,
3332        proc: Box<dyn CompiledProcess>,
3333        kind: ProcessKind,
3334        sensitivity: Option<StaticSensitivity>,
3335        region_id: u32,
3336    ) -> ProcessId {
3337        self.register_process_in(proc, kind, sensitivity, region_id, true, false)
3338    }
3339
3340    /// [`SchedulerControl::register_process`] for a user-written procedure
3341    /// (`initial`/`always*`): an `always_comb`/`always_latch` starts after every
3342    /// other process of the time-zero Active region has started, LRM 1800-2023
3343    /// 9.2.2.2 ("automatically triggered once at time zero, after all initial
3344    /// and always procedures have been started"). A continuous assignment
3345    /// (also an `AlwaysComb` kernel) keeps `register_process`: it starts in
3346    /// registration order like any Active process.
3347    pub fn register_procedure(
3348        &mut self,
3349        proc: Box<dyn CompiledProcess>,
3350        kind: ProcessKind,
3351        sensitivity: Option<StaticSensitivity>,
3352        region_id: u32,
3353    ) -> ProcessId {
3354        self.register_process_in(proc, kind, sensitivity, region_id, false, true)
3355    }
3356
3357    /// Narrows `proc`'s `always_comb`/`always_latch` sensitivity on `cell` to
3358    /// the constant bit windows it reads (LRM 9.2.2.2.1, longest static
3359    /// prefix); see [`SensitivityTable::narrow_static`].
3360    pub fn narrow_static_sensitivity(
3361        &mut self,
3362        proc: ProcessId,
3363        cell: StateCellId,
3364        windows: &[(u32, u32)],
3365    ) {
3366        self.sensitivity.narrow_static(proc, cell, windows);
3367    }
3368}
3369
3370impl SchedulerControl for Engine {
3371    /// See this module's doc comment, deviation 2, for the additional
3372    /// `kind`-based branching beyond the plan's literal two statements.
3373    fn register_process(
3374        &mut self,
3375        proc: Box<dyn CompiledProcess>,
3376        kind: ProcessKind,
3377        sensitivity: Option<StaticSensitivity>,
3378        region_id: u32,
3379    ) -> ProcessId {
3380        self.register_process_in(proc, kind, sensitivity, region_id, false, false)
3381    }
3382
3383    /// scheduler.md §1's pseudocode, concretely: per timestep, an inner
3384    /// Active/Inactive/NBA delta-convergence loop (guarded by `DeltaGuard`,
3385    /// SC-079/SC-080), then Reactive/Postponed drained once each,
3386    /// `finish_requested` checked once per timestep (SC-075 — see this
3387    /// module's doc comment, deviation 4), then the wheel is asked for the
3388    /// next tick.
3389    fn run_until(&mut self, deadline: Option<SchedTime>) -> RunResult {
3390        loop {
3391            // The host's start of this time slot (`super::host`).
3392            self.host_slot_begin();
3393            loop {
3394                // LRM 1800-2023 §4.5: the Active region runs until it is
3395                // EMPTY -- including everything woken while it runs (a
3396                // continuous assignment reacting to `a = 10;`) -- before
3397                // the Inactive region (`#0`) moves in; each Inactive batch
3398                // then hands back to Active the same way.
3399                loop {
3400                    let active_ready = self.drain_and_sort(Region::Active);
3401                    if active_ready.is_empty() {
3402                        let inactive_ready = self.drain_and_sort(Region::Inactive);
3403                        if inactive_ready.is_empty() {
3404                            break;
3405                        }
3406                        self.dispatch_ready_set(Region::Inactive, inactive_ready);
3407                    } else {
3408                        self.dispatch_ready_set(Region::Active, active_ready);
3409                    }
3410                    self.with_host(Region::Active, |h, ops| h.activity(ops));
3411                    if let Some(msg) = self.sync_livelock.take() {
3412                        return RunResult::Livelock(msg);
3413                    }
3414                    if let Err(e) = self.guard.tick(self.now) {
3415                        return RunResult::Livelock(e.to_string());
3416                    }
3417                }
3418                self.with_host(Region::Active, |h, ops| h.nba_synch(ops));
3419                let nba_changed = self.commit_nba_once();
3420                self.apply_class_event_wakes();
3421                self.with_host(Region::Active, |h, ops| h.activity(ops));
3422                let mut settled = {
3423                    let queues = self.queues.lock().unwrap();
3424                    queues.active_and_inactive_both_empty()
3425                };
3426                // sva T2 (§1.4): the identical Observed-drain-and-recheck
3427                // `advance_nba_region` (the single-stepper's own Nba arm)
3428                // applies, inlined here for `run_until`'s own delta loop --
3429                // see `Self::drain_observed_and_check_settled`'s doc
3430                // comment for why this is a re-check, not a one-shot drain.
3431                if !nba_changed && settled {
3432                    settled = self.drain_observed_and_check_settled();
3433                    // The host's read-write synch point: a write there runs
3434                    // the loop again.
3435                    if settled
3436                        && self
3437                            .with_host(Region::Active, |h, ops| h.rw_synch(ops))
3438                            .unwrap_or(false)
3439                    {
3440                        settled = false;
3441                    }
3442                    if settled {
3443                        break;
3444                    }
3445                }
3446                if let Err(e) = self.guard.tick(self.now) {
3447                    return RunResult::Livelock(e.to_string());
3448                }
3449            }
3450            // Task 21/SC-007: record this timestep's settled iteration count
3451            // into the running high-water mark before the guard resets it.
3452            self.max_delta_iterations_seen
3453                .fetch_max(self.guard.count(), Ordering::SeqCst);
3454            // LRM 4.5: the Reactive region (checker processes, assertion
3455            // action blocks) runs after Active/Inactive/NBA/Observed have
3456            // settled, and whatever it leaves behind -- a design process it
3457            // woke, a nonblocking assignment it staged (Re-NBA, collapsed
3458            // into the one NBA region), more Reactive work -- sends the time
3459            // step round the delta loop again. The delta guard keeps counting
3460            // across these rounds: a livelock through Reactive is as fatal as
3461            // one inside Active.
3462            self.drain_reactive_set();
3463            if self.reactive_left_work() {
3464                if let Err(e) = self.guard.tick(self.now) {
3465                    return RunResult::Livelock(e.to_string());
3466                }
3467                continue;
3468            }
3469            self.guard.reset();
3470            self.forks_this_step = 0;
3471            let postponed_ready = self.drain_and_sort(Region::Postponed);
3472            self.dispatch_ready_set(Region::Postponed, postponed_ready);
3473            self.drain_region_callbacks(Region::Postponed);
3474            self.with_host(Region::Postponed, |h, ops| h.slot_end(ops));
3475            if let Some(hook) = self.timestep_end_hook.as_mut() {
3476                hook(self.now);
3477            }
3478
3479            if self.program_threads_all_ended() {
3480                self.finish_requested.lock().unwrap().get_or_insert(0);
3481            }
3482            if let Some(code) = *self.finish_requested.lock().unwrap() {
3483                return RunResult::Finished { code };
3484            }
3485            if let Some(d) = deadline
3486                && self.now >= d
3487            {
3488                return RunResult::Deadline;
3489            }
3490            match self.next_event_tick() {
3491                Some(t) => {
3492                    self.advance_time_to(t);
3493                    // sva T1 (§1.4, review fix r2, blocking finding 1): the
3494                    // Preponed snapshot push fires here -- right where
3495                    // `next_event_tick()` has just detected a genuinely new
3496                    // nonempty time slot and `advance_time_to` actually
3497                    // moved `self.now` -- mirroring `step_region`'s own
3498                    // Postponed->Active transition (`Self::step_region`'s
3499                    // `Region::Postponed` arm, `Some(t)` branch) exactly, so
3500                    // both driver APIs push the ring the same number of
3501                    // times for the same simulated history. An earlier
3502                    // draft pushed unconditionally at the top of this outer
3503                    // loop, which also fired on the very first iteration --
3504                    // before `Region::Active` had ever been dispatched at
3505                    // time 0 -- with no counterpart in `step_region` (`Engine
3506                    // ::new` enters `Region::Active` directly, no Preponed
3507                    // push precedes it there either), so the two APIs
3508                    // produced differently-sized rings for the identical
3509                    // history. See
3510                    // `run_until_and_step_region_push_the_same_preponed_ring`
3511                    // below for the regression proof.
3512                    self.push_preponed_snapshot();
3513                }
3514                None => return RunResult::Completed,
3515            }
3516        }
3517    }
3518
3519    /// Single-steps through the 5-region cycle, one call per region (see
3520    /// this module's doc comment, deviation 3, for scope): `Active` ->
3521    /// `Inactive` -> `Nba` -> (back to `Active` if not yet settled, else)
3522    /// `Reactive` -> `Postponed` -> (wheel advance, back to `Active`, or
3523    /// `Blocked` if the wheel is empty too). A debugger-mock harness driving
3524    /// this repeatedly observes only committed state between calls: every
3525    /// write reaching STATE goes through `commit_blocking_write`/`commit_nba`
3526    /// synchronously, inside this call, never buffered across calls.
3527    fn step_region(&mut self) -> StepResult {
3528        match self.current_region {
3529            Region::Active | Region::Inactive => {
3530                let region = self.current_region;
3531                let ready = self.drain_and_sort(region);
3532                self.dispatch_ready_set(region, ready);
3533                self.current_region = if region == Region::Active {
3534                    Region::Inactive
3535                } else {
3536                    Region::Nba
3537                };
3538                StepResult::Advanced
3539            }
3540            Region::Nba => self.advance_nba_region(),
3541            Region::Reactive => {
3542                self.drain_reactive_set();
3543                // `run_until`'s loop, one region at a time: work the Reactive
3544                // region left behind sends the time step back to Active.
3545                if self.reactive_left_work() {
3546                    self.guard.tick(self.now).expect(
3547                        "SC-080/DT-050: delta-loop livelock guard exceeded -- hard error, \
3548                         never warn-and-truncate",
3549                    );
3550                    self.current_region = Region::Active;
3551                } else {
3552                    self.current_region = Region::Postponed;
3553                }
3554                StepResult::Advanced
3555            }
3556            Region::Postponed => {
3557                let ready = self.drain_and_sort(Region::Postponed);
3558                self.dispatch_ready_set(Region::Postponed, ready);
3559                self.drain_region_callbacks(Region::Postponed);
3560                match self.next_event_tick() {
3561                    Some(t) => {
3562                        self.advance_time_to(t);
3563                        // sva T1 (§1.4, review fix r1, blocking finding 1):
3564                        // the snapshot push is folded into this
3565                        // Postponed->Active transition, but ONLY on the
3566                        // branch that actually starts a new time step
3567                        // (`advance_time_to` just ran, `self.now` genuinely
3568                        // moved) -- the single-stepper's own counterpart of
3569                        // `run_until`'s outer-loop-top push, which likewise
3570                        // only ever runs again after `next_event_tick()`
3571                        // returned `Some` and `advance_time_to` was called
3572                        // (`run_until` returns `RunResult::Completed`
3573                        // instead of looping again on `None`, so it never
3574                        // re-pushes for a quiescent wheel either). Moving
3575                        // this call out of the `None` arm below is the fix
3576                        // itself -- see that arm's own doc comment.
3577                        self.push_preponed_snapshot();
3578                        self.current_region = Region::Active;
3579                        StepResult::Advanced
3580                    }
3581                    None => {
3582                        // sva T1 (§1.4, review fix r1, blocking finding 1):
3583                        // **no** snapshot push here. `next_event_tick() ==
3584                        // None` means the wheel is empty and nothing is
3585                        // scheduled -- `self.now` does not advance and no
3586                        // new time slot begins (LRM 16.5.1's sampled value
3587                        // is defined per "time slot"; a repeated
3588                        // `step_region()` call on an already-quiescent/
3589                        // completed simulation re-enters this exact arm
3590                        // without any new time slot ever starting). An
3591                        // earlier draft pushed here unconditionally, so a
3592                        // debugger-style caller single-stepping a
3593                        // completed design re-pushed the SAME unchanged
3594                        // snapshot on every call, silently inflating a
3595                        // bounded ring with duplicate entries and evicting
3596                        // genuinely distinct older ticks that a future
3597                        // `$past` consumer needs intact. See
3598                        // `step_region_blocked_does_not_repush_the_
3599                        // preponed_ring` below for the regression proof.
3600                        self.current_region = Region::Active;
3601                        StepResult::Blocked
3602                    }
3603                }
3604            }
3605            // sva T1 (§1.4, review fix, blocking finding 2): `self.
3606            // current_region` is never set to `Region::Preponed` anywhere
3607            // in this file -- the Preponed snapshot push is the
3608            // unconditional inline side effect above (and `run_until`'s own
3609            // outer-loop-top push for the full-run driver), never a state
3610            // the single-stepper transitions into. **Deviation from the
3611            // plan's literal text:** the plan's own prose claims
3612            // `step_region`'s match "keeps its original 5 arms and compiles
3613            // unchanged" -- not literally possible once `Region` gains a
3614            // 6th variant with no wildcard arm (`cargo build` confirms:
3615            // E0004 non-exhaustive patterns without this arm). This
3616            // defensive `unreachable!` arm is the minimal fix that keeps
3617            // the plan's INTENDED behavior (no real transition into this
3618            // region) while satisfying the compiler; the dedicated
3619            // regression test below proves it is in fact never reached.
3620            //
3621            // sva T2: `Region::Observed` joins this same defensive arm for
3622            // the identical reason -- per §1.4, "that re-check is exactly
3623            // the decision point already inside step_region's existing
3624            // Region::Nba => self.advance_nba_region() arm", so `Observed`
3625            // is drained from inside `advance_nba_region` (folded into the
3626            // Nba->Reactive transition, see
3627            // `Self::drain_observed_and_check_settled`), never a state
3628            // `current_region` itself transitions into either. The plan's
3629            // own claim that this task "gains no new arm at all" means no
3630            // new TRANSITION logic, not that the match's pattern list is
3631            // unchanged -- the same E0004 constraint T1 already hit applies
3632            // again for the 7th variant, so `Observed` is folded into this
3633            // identical defensive arm rather than a second one.
3634            Region::Preponed | Region::Observed => unreachable!(
3635                "step_region: current_region must never be Region::Preponed or \
3636                 Region::Observed -- the Preponed snapshot push is an inline side effect \
3637                 folded into the Postponed->Active transition, and the Observed engine-step \
3638                 drain is folded into the Nba->Reactive transition; neither is ever an \
3639                 observable single-stepper state"
3640            ),
3641        }
3642    }
3643
3644    fn run_finals(&mut self) {
3645        // `self.finals` is taken out (and put back afterward) so `ops`'s
3646        // shared reborrow of `self` below doesn't conflict with the `&mut
3647        // Vec<FinalProcess>` `finals::run_finals` needs.
3648        let mut finals = std::mem::take(&mut self.finals);
3649        let handle = SimHandle(std::ptr::null_mut());
3650        {
3651            let ops = EngineOps {
3652                engine: &*self,
3653                now: self.now,
3654                region: Region::Active,
3655                reconv_iter: 0,
3656            };
3657            // LRM 9.2.3: `$finish` from within a final procedure ends the
3658            // simulation immediately, so no later final runs. The request
3659            // that ended the simulation is set aside while they run.
3660            let ended_by = self.finish_requested.lock().unwrap().take();
3661            finals.sort_by_key(|f| f.registration_order);
3662            for f in finals.chunks_mut(1) {
3663                crate::sched::run_finals(f, &self.table, handle, &ops, &self.driver);
3664                if self.finish_requested.lock().unwrap().is_some() {
3665                    break;
3666                }
3667            }
3668            let mut slot = self.finish_requested.lock().unwrap();
3669            if slot.is_none() {
3670                *slot = ended_by;
3671            }
3672        }
3673        self.finals = finals;
3674    }
3675
3676    fn now(&self) -> SchedTime {
3677        self.now
3678    }
3679
3680    fn set_determinism_controller(&mut self, ctrl: Box<dyn DeterminismController>) {
3681        self.controller = ctrl;
3682    }
3683}
3684
3685/// The literal SC-116 deliverable: pairs each of the 9 ABI entry points
3686/// (`sim_abi_version` excluded — versioning is `abi`-owned) with the
3687/// `Engine`/`SchedulerControl` method or internal pass it calls.
3688/// Documentation-as-data (a table, not prose) so it's `assert_eq!`-testable
3689/// and can't silently go stale.
3690///
3691/// **`SimHandle` concurrent-call threading contract (workspace-abi.md's own
3692/// flagged open question):** one `Engine` instance is never called
3693/// concurrently for two different ABI entry points on the same `SimHandle`
3694/// — the engine itself serializes ABI entry (a `Mutex<Engine>` at the
3695/// `vj-runtime` loader boundary, outside this crate); concurrency inside
3696/// a run happens only *below* the ABI boundary, inside
3697/// `DeterminismController::partition`/`drain_and_resolve`.
3698pub fn abi_entry_point_map() -> &'static [(&'static str, &'static str)] {
3699    &[
3700        (
3701            vj_abi::entry::SIM_INIT_SYMBOL,
3702            "Engine::new (+ SchedulerControl::register_process for each elaborated process)",
3703        ),
3704        (
3705            vj_abi::entry::SIM_SETTLE_SYMBOL,
3706            "Engine::run_until(Some(now)) -- settle all zero-delay activity at the current time",
3707        ),
3708        (vj_abi::entry::SIM_EVAL_REGION_SYMBOL, "Engine::step_region"),
3709        (
3710            vj_abi::entry::SIM_COMMIT_NBA_SYMBOL,
3711            "Engine::<internal NBA commit pass -- commit_nba, Task 7/13>",
3712        ),
3713        (
3714            vj_abi::entry::SIM_SAVE_SYMBOL,
3715            "Engine::<checkpoint -- DeterminismController-owned, not materialized this wave>",
3716        ),
3717        (
3718            vj_abi::entry::SIM_RESTORE_SYMBOL,
3719            "Engine::<restore -- DeterminismController-owned, not materialized this wave>",
3720        ),
3721        (
3722            vj_abi::entry::SIM_STEP_TIME_SYMBOL,
3723            "Engine::run_until(Some(deadline)) -- advance exactly one timestep",
3724        ),
3725        (vj_abi::entry::SIM_FINAL_SYMBOL, "Engine::run_finals"),
3726        (
3727            vj_abi::entry::SIM_PROCESS_COUNT_SYMBOL,
3728            "Engine::<ProcessTable length accessor>",
3729        ),
3730    ]
3731}
3732
3733#[cfg(test)]
3734mod tests {
3735    use super::*;
3736    use crate::sched::{ActivationId, EventTerm, EventWaitSet, TimerEntry};
3737    use std::collections::HashMap;
3738    use std::sync::{Arc, Mutex as StdMutex};
3739
3740    // --- shared test doubles (independently declared -- see this module's
3741    // doc comment: neither `notify.rs`'s/`ops.rs`'s `FakeArena` nor Task 8's
3742    // `MockSchedulerOps` is reachable from this module) ---
3743
3744    struct TestArena(HashMap<StateCellId, FourStateValue>);
3745
3746    impl TestArena {
3747        fn new() -> Self {
3748            TestArena(HashMap::new())
3749        }
3750
3751        fn seeded(cell: StateCellId, value: FourStateValue) -> Self {
3752            let mut m = HashMap::new();
3753            m.insert(cell, value);
3754            TestArena(m)
3755        }
3756    }
3757
3758    impl SignalAccess for TestArena {
3759        fn read(&self, cell: StateCellId) -> FourStateValue {
3760            self.0
3761                .get(&cell)
3762                .cloned()
3763                .unwrap_or(FourStateValue::Narrow(0))
3764        }
3765
3766        fn write(&mut self, cell: StateCellId, value: FourStateValue) -> bool {
3767            let changed = match self.0.get(&cell) {
3768                Some(FourStateValue::Narrow(existing)) => {
3769                    if let FourStateValue::Narrow(new) = &value {
3770                        *existing != *new
3771                    } else {
3772                        true
3773                    }
3774                }
3775                Some(FourStateValue::Wide { .. }) => true,
3776                None => true,
3777            };
3778            self.0.insert(cell, value);
3779            changed
3780        }
3781
3782        fn subscribe(
3783            &mut self,
3784            _cell: StateCellId,
3785            _role: crate::state::ListenerRole,
3786            _sink: Box<dyn ValueChangeSink + Send>,
3787        ) {
3788            // Not exercised by this task's tests.
3789        }
3790    }
3791
3792    #[derive(Default)]
3793    struct TestIoSink {
3794        calls: StdMutex<Vec<(SchedTime, Region, u64)>>,
3795    }
3796
3797    impl IoSink for TestIoSink {
3798        fn enqueue(&self, at: SchedTime, region: Region, seq: u64, cb: DeferredIoCallback) {
3799            drop(cb);
3800            self.calls.lock().unwrap().push((at, region, seq));
3801        }
3802    }
3803
3804    fn narrow(v: u128) -> FourStateValue {
3805        FourStateValue::Narrow(v)
3806    }
3807
3808    fn new_engine() -> Engine {
3809        Engine::new(Box::new(TestArena::new()), Box::new(TestIoSink::default()))
3810    }
3811
3812    // --- register_process / run_until / run_finals process fixtures ---
3813
3814    /// Logs `(ProcessId, ResumeArg)` for every `resume()` call, always
3815    /// returning `Suspend::Done` (never re-arms). `id_for_log` is filled in
3816    /// (via the shared cell) only *after* `register_process` returns the
3817    /// real id -- `resume()` is never called before registration completes,
3818    /// so it is always `Some` by the time any log entry is pushed.
3819    struct LoggingProcess {
3820        id_for_log: Arc<StdMutex<Option<ProcessId>>>,
3821        log: Arc<StdMutex<Vec<(ProcessId, &'static str)>>>,
3822    }
3823
3824    impl CompiledProcess for LoggingProcess {
3825        fn resume(
3826            &mut self,
3827            _state: SimHandle,
3828            arg: ResumeArg,
3829            _ops: &dyn SchedulerOps,
3830        ) -> Suspend {
3831            let tag = match arg {
3832                ResumeArg::Start => "Start",
3833                ResumeArg::TimeElapsed => "TimeElapsed",
3834                ResumeArg::EventFired { .. } => "EventFired",
3835                ResumeArg::ForkChildDone { .. } => "ForkChildDone",
3836                ResumeArg::Disabled => "Disabled",
3837                ResumeArg::AllForkChildrenDone => "AllForkChildrenDone",
3838                ResumeArg::WaitOrderSatisfied => "WaitOrderSatisfied",
3839            };
3840            let id = self
3841                .id_for_log
3842                .lock()
3843                .unwrap()
3844                .expect("id_for_log must be set before resume() is ever called");
3845            self.log.lock().unwrap().push((id, tag));
3846            Suspend::Done
3847        }
3848    }
3849
3850    fn logging_process(
3851        log: &Arc<StdMutex<Vec<(ProcessId, &'static str)>>>,
3852    ) -> (Box<LoggingProcess>, Arc<StdMutex<Option<ProcessId>>>) {
3853        let id_cell = Arc::new(StdMutex::new(None));
3854        (
3855            Box::new(LoggingProcess {
3856                id_for_log: id_cell.clone(),
3857                log: log.clone(),
3858            }),
3859            id_cell,
3860        )
3861    }
3862
3863    // --- SC-083 ---
3864
3865    #[test]
3866    fn sequential_controller_is_selected_when_none_is_ever_set() {
3867        let mut engine = new_engine();
3868        let log = Arc::new(StdMutex::new(Vec::new()));
3869        let (proc, id_cell) = logging_process(&log);
3870        let id = engine.register_process(proc, ProcessKind::Initial, None, 0);
3871        *id_cell.lock().unwrap() = Some(id);
3872
3873        let result = engine.run_until(Some(SchedTime { ticks: 100 }));
3874        assert!(matches!(result, RunResult::Completed));
3875        assert_eq!(engine.controller_kind(), "sequential");
3876    }
3877
3878    /// Mutation-test kill: `run_until`'s deadline check must fire on
3879    /// `self.now >= deadline`, not `self.now > deadline` -- the two agree
3880    /// for every test elsewhere in this file that names a deadline strictly
3881    /// later than where the run would otherwise settle, so only an exact
3882    /// `now == deadline` boundary distinguishes them. A process that settles
3883    /// immediately with no delay leaves `now` at its initial `SchedTime{
3884    /// ticks: 0}`; a deadline of exactly `0` must therefore report
3885    /// `RunResult::Deadline` on the very first pass, not silently continue to
3886    /// `RunResult::Completed`.
3887    #[test]
3888    fn run_until_reports_deadline_when_now_exactly_equals_it() {
3889        let mut engine = new_engine();
3890        engine.register_process(Box::new(UnmintedForkChild), ProcessKind::Initial, None, 0);
3891
3892        let result = engine.run_until(Some(SchedTime { ticks: 0 }));
3893        assert!(
3894            matches!(result, RunResult::Deadline),
3895            "now has already reached the deadline exactly (both 0) -- must report \
3896             RunResult::Deadline, not RunResult::Completed; got {result:?}"
3897        );
3898    }
3899
3900    #[test]
3901    fn set_determinism_controller_overrides_the_default() {
3902        struct FakeController;
3903        impl DeterminismController for FakeController {
3904            fn partition(
3905                &self,
3906                _ready: &[PendingActivation],
3907                _region: Region,
3908                _now: SchedTime,
3909            ) -> Vec<crate::determinism::WorkItem> {
3910                vec![]
3911            }
3912            fn on_read(&self, _act: ActivationId, _cell: StateCellId, _version: u64) {}
3913            fn on_write(
3914                &self,
3915                _act: ActivationId,
3916                _cell: StateCellId,
3917                _prior: FourStateValue,
3918                _version: u64,
3919            ) {
3920            }
3921            fn gvt(&self) -> SchedTime {
3922                SchedTime { ticks: 0 }
3923            }
3924            fn drain_and_resolve(
3925                &self,
3926                _work: Vec<crate::determinism::WorkItem>,
3927            ) -> crate::determinism::ResolvedBatch {
3928                crate::determinism::ResolvedBatch::default()
3929            }
3930            fn kind(&self) -> &'static str {
3931                "fake-tagged"
3932            }
3933        }
3934
3935        let mut engine = new_engine();
3936        assert_eq!(engine.controller_kind(), "sequential");
3937        engine.set_determinism_controller(Box::new(FakeController));
3938        assert_eq!(engine.controller_kind(), "fake-tagged");
3939    }
3940
3941    /// SC-083 — a debugger-mock harness single-steps through regions via
3942    /// `step_region()`, observing only committed state between steps: `p1`
3943    /// (Initial) blocking-writes `cell_a`; `p2` (AlwaysComb, statically
3944    /// sensitive to `cell_a`) is woken by that write and blocking-writes
3945    /// `cell_b`. Every read via the engine's arena accessor, at every point
3946    /// during the multi-call sequence, shows either the pre-write default or
3947    /// the fully-committed written value -- never anything else, since
3948    /// `commit_blocking_write` only ever applies synchronously.
3949    #[test]
3950    fn step_region_single_steps_exposing_only_committed_state() {
3951        struct P1 {
3952            cell_a: StateCellId,
3953        }
3954        impl CompiledProcess for P1 {
3955            fn resume(
3956                &mut self,
3957                _state: SimHandle,
3958                arg: ResumeArg,
3959                ops: &dyn SchedulerOps,
3960            ) -> Suspend {
3961                if matches!(arg, ResumeArg::Start) {
3962                    ops.commit_blocking_write(self.cell_a, narrow(1));
3963                }
3964                Suspend::Done
3965            }
3966        }
3967        struct P2 {
3968            cell_b: StateCellId,
3969        }
3970        impl CompiledProcess for P2 {
3971            fn resume(
3972                &mut self,
3973                _state: SimHandle,
3974                arg: ResumeArg,
3975                ops: &dyn SchedulerOps,
3976            ) -> Suspend {
3977                if matches!(arg, ResumeArg::EventFired { .. }) {
3978                    ops.commit_blocking_write(self.cell_b, narrow(1));
3979                }
3980                Suspend::Done
3981            }
3982        }
3983
3984        let cell_a = StateCellId(1);
3985        let cell_b = StateCellId(2);
3986        let mut engine = new_engine();
3987        engine.register_process(Box::new(P1 { cell_a }), ProcessKind::Initial, None, 0);
3988        engine.register_process(
3989            Box::new(P2 { cell_b }),
3990            ProcessKind::AlwaysComb,
3991            Some(StaticSensitivity {
3992                edges: vec![(cell_a, None)],
3993            }),
3994            0,
3995        );
3996
3997        // Before anything runs: both cells read the pre-write default.
3998        assert!(matches!(
3999            engine.test_read_cell(cell_a),
4000            FourStateValue::Narrow(0)
4001        ));
4002        assert!(matches!(
4003            engine.test_read_cell(cell_b),
4004            FourStateValue::Narrow(0)
4005        ));
4006
4007        let mut saw_a_committed = false;
4008        let mut saw_b_committed = false;
4009        for _ in 0..16 {
4010            engine.step_region();
4011            let a = engine.test_read_cell(cell_a);
4012            let b = engine.test_read_cell(cell_b);
4013            // At every single point, each cell is exactly its pre-write
4014            // default or its one fully-committed value -- never a third,
4015            // "partially applied" value.
4016            assert!(matches!(
4017                a,
4018                FourStateValue::Narrow(0) | FourStateValue::Narrow(1)
4019            ));
4020            assert!(matches!(
4021                b,
4022                FourStateValue::Narrow(0) | FourStateValue::Narrow(1)
4023            ));
4024            if matches!(a, FourStateValue::Narrow(1)) {
4025                saw_a_committed = true;
4026            }
4027            if matches!(b, FourStateValue::Narrow(1)) {
4028                // cell_b only ever becomes 1 after cell_a already did --
4029                // p2's write is causally downstream of p1's.
4030                assert!(saw_a_committed, "cell_b committed before cell_a");
4031                saw_b_committed = true;
4032            }
4033        }
4034        assert!(
4035            saw_a_committed,
4036            "p1's write was never observed as committed"
4037        );
4038        assert!(
4039            saw_b_committed,
4040            "p2's woken write was never observed as committed"
4041        );
4042    }
4043
4044    /// sva SV-D (LRM 17.7.3, 4.5): a Reactive process (a checker procedure) runs only after
4045    /// the Active/Inactive/NBA queues have emptied, however many Active processes the
4046    /// time step's earlier regions chain through, and the work it leaves behind (here an Active
4047    /// process woken by its write) is run in the same time step -- the Reactive-to-Active
4048    /// feedback of the delta loop -- not deferred to the next `run_until` call.
4049    #[test]
4050    fn reactive_process_runs_after_active_settles_and_feeds_back_to_active() {
4051        struct Tracer {
4052            name: &'static str,
4053            log: Arc<StdMutex<Vec<String>>>,
4054            write: Option<(StateCellId, u128)>,
4055        }
4056        impl CompiledProcess for Tracer {
4057            fn resume(
4058                &mut self,
4059                _state: SimHandle,
4060                arg: ResumeArg,
4061                ops: &dyn SchedulerOps,
4062            ) -> Suspend {
4063                let tag = match arg {
4064                    ResumeArg::Start => "start",
4065                    ResumeArg::EventFired { .. } => "event",
4066                    _ => "other",
4067                };
4068                self.log
4069                    .lock()
4070                    .unwrap()
4071                    .push(format!("{}:{tag}", self.name));
4072                if let Some((cell, v)) = self.write {
4073                    ops.commit_blocking_write(cell, narrow(v));
4074                }
4075                Suspend::Done
4076            }
4077        }
4078        let (a, b, c) = (StateCellId(1), StateCellId(2), StateCellId(3));
4079        let log = Arc::new(StdMutex::new(Vec::new()));
4080        let tracer = |name, write| {
4081            Box::new(Tracer {
4082                name,
4083                log: log.clone(),
4084                write,
4085            })
4086        };
4087        let sens = |cell| {
4088            Some(StaticSensitivity {
4089                edges: vec![(cell, None)],
4090            })
4091        };
4092        let mut engine = new_engine();
4093        // Registered Reactive-process-first: the id order must not matter.
4094        // `r` (Reactive) is woken by `b`, which the Active `b_w` writes after `a`
4095        // changed; it writes `c`, which wakes the Active `c_w`.
4096        engine.register_reactive_process(
4097            tracer("r", Some((c, 1))),
4098            ProcessKind::AlwaysComb,
4099            sens(b),
4100            0,
4101        );
4102        engine.register_process(tracer("w", Some((a, 1))), ProcessKind::Initial, None, 0);
4103        engine.register_process(
4104            tracer("b_w", Some((b, 1))),
4105            ProcessKind::AlwaysComb,
4106            sens(a),
4107            0,
4108        );
4109        engine.register_process(tracer("c_w", None), ProcessKind::AlwaysComb, sens(c), 0);
4110
4111        let result = engine.run_until(None);
4112        assert!(matches!(result, RunResult::Completed));
4113        let log = log.lock().unwrap().clone();
4114        let pos = |s: &str| {
4115            log.iter()
4116                .position(|e| e == s)
4117                .unwrap_or_else(|| panic!("{s} never ran: {log:?}"))
4118        };
4119        // The Reactive process's start activation is not run in Active: both Active
4120        // chains (w -> b_w, and c_w's own start) complete before any `r` entry.
4121        let first_r = log.iter().position(|e| e.starts_with("r:")).unwrap();
4122        assert!(
4123            pos("w:start") < first_r && pos("b_w:event") < first_r && pos("c_w:start") < first_r,
4124            "a Reactive process ran before the Active region settled: {log:?}"
4125        );
4126        // Feedback: the Active process woken by `r`'s write ran in the same time step.
4127        assert!(
4128            pos("c_w:event") > pos("r:event"),
4129            "the Active wake-up of a Reactive write was not run: {log:?}"
4130        );
4131    }
4132
4133    /// Mutation-test kill: `step_region`'s `Active`/`Inactive` arm advances
4134    /// `current_region` to `Inactive` from `Active` and to `Nba` from
4135    /// `Inactive` -- a mutant that swapped those two targets passes every
4136    /// other test in this file, since none of them observe `current_region`
4137    /// directly (they only observe committed cell state or dispatch logs,
4138    /// which happen to still converge to the same eventual outcome for a
4139    /// design with no `Inactive`-region-specific behavior). This test reads
4140    /// `Engine::current_region` directly -- a private field, reachable here
4141    /// since this test module is a descendant of `control`.
4142    #[test]
4143    fn step_region_advances_active_to_inactive_to_nba_in_order() {
4144        let mut engine = new_engine();
4145        assert_eq!(
4146            engine.current_region,
4147            Region::Active,
4148            "a fresh Engine starts in Active"
4149        );
4150
4151        engine.step_region();
4152        assert_eq!(
4153            engine.current_region,
4154            Region::Inactive,
4155            "Active must step to Inactive next, never Nba"
4156        );
4157
4158        engine.step_region();
4159        assert_eq!(
4160            engine.current_region,
4161            Region::Nba,
4162            "Inactive must step to Nba next, never back to Inactive"
4163        );
4164    }
4165
4166    /// sva T1 (§1.4, review fix, blocking finding 2): drives the
4167    /// single-stepper through many full 5-region cycles (well past the
4168    /// `register_wait`-style edge cases) and asserts `current_region` is
4169    /// never observed to be `Region::Preponed` at any point -- the
4170    /// behavioral half of `step_region`'s own defensive `unreachable!`
4171    /// arm: if that arm were ever actually reached, this test would panic
4172    /// (via the `unreachable!` itself) rather than silently pass.
4173    #[test]
4174    fn step_region_never_observes_current_region_eq_preponed() {
4175        let mut engine = new_engine();
4176        engine.register_process(Box::new(UnmintedForkChild), ProcessKind::Initial, None, 0);
4177
4178        for _ in 0..30 {
4179            engine.step_region();
4180            assert_ne!(
4181                engine.current_region(),
4182                Region::Preponed,
4183                "the single-stepper must never transition into Region::Preponed -- the \
4184                 Preponed snapshot push is an inline side effect, never an observable region"
4185            );
4186        }
4187    }
4188
4189    /// sva T2 (§1.4, review fix, blocking finding 2): the identical
4190    /// behavioral proof as `step_region_never_observes_current_region_eq_
4191    /// preponed`, now for `Region::Observed` -- if `step_region`'s
4192    /// defensive `unreachable!` arm (now covering both variants) were ever
4193    /// actually reached for `Observed`, this test would panic rather than
4194    /// silently pass.
4195    #[test]
4196    fn step_region_never_observes_current_region_eq_observed() {
4197        let mut engine = new_engine();
4198        engine.register_process(Box::new(UnmintedForkChild), ProcessKind::Initial, None, 0);
4199
4200        for _ in 0..30 {
4201            engine.step_region();
4202            assert_ne!(
4203                engine.current_region(),
4204                Region::Observed,
4205                "the single-stepper must never transition into Region::Observed -- the \
4206                 Observed engine-step drain is folded into advance_nba_region, never an \
4207                 observable region"
4208            );
4209        }
4210    }
4211
4212    /// sva T1 r1 (review fix, blocking finding 1): `step_region`'s
4213    /// `Region::Postponed` arm must push the configured Preponed ring
4214    /// exactly once per time step that genuinely BEGINS -- never again
4215    /// merely because a caller keeps single-stepping an already-quiescent
4216    /// simulation. A one-shot process (`Suspend::Delay(1)` on its first
4217    /// `Start` resume, then `Suspend::Done` on its second) arms the wheel
4218    /// for exactly one real tick transition and then goes permanently
4219    /// quiet, so `next_event_tick()` returns `Some` exactly once across the
4220    /// whole run and `None` (`StepResult::Blocked`) on every Postponed arm
4221    /// after that -- precisely the "ordinary debugger-driver" shape the
4222    /// finding's own live repro used (30 `step_region()` calls on a
4223    /// completed/quiescent design hit `StepResult::Blocked` repeatedly).
4224    /// This proves both halves of the fix: the push DOES fire on the one
4225    /// real `Some(t)` transition, and it does NOT fire again on any of the
4226    /// many subsequent `None`/`Blocked` cycles -- a regression to the
4227    /// unconditional-push bug (pushing in both match arms) would fail the
4228    /// second assertion by inflating `push_count` past `1`.
4229    #[test]
4230    fn step_region_blocked_does_not_repush_the_preponed_ring() {
4231        use crate::state::{StateEngine, TemplateRegistry};
4232        use std::cell::Cell;
4233        use std::rc::Rc;
4234
4235        struct OnceDelayed {
4236            started: bool,
4237        }
4238        impl CompiledProcess for OnceDelayed {
4239            fn resume(
4240                &mut self,
4241                _state: SimHandle,
4242                arg: ResumeArg,
4243                _ops: &dyn SchedulerOps,
4244            ) -> Suspend {
4245                match (self.started, arg) {
4246                    (false, ResumeArg::Start) => {
4247                        self.started = true;
4248                        Suspend::Delay(1)
4249                    }
4250                    _ => Suspend::Done,
4251                }
4252            }
4253        }
4254
4255        let mut engine = new_engine();
4256        let push_count = Rc::new(Cell::new(0u32));
4257        {
4258            let push_count = push_count.clone();
4259            // A fresh StateEngine purely to mint real `ArenaSnapshot`
4260            // values -- this test counts invocations of the source
4261            // closure, never the snapshotted content itself (already
4262            // covered by `sva_preponed_sampling_matches_lrm_16_5_1` above).
4263            let state_engine = StateEngine::new(TemplateRegistry::new());
4264            engine.configure_preponed_ring(0, move || {
4265                push_count.set(push_count.get() + 1);
4266                state_engine.snapshot(None)
4267            });
4268        }
4269        engine.register_process(
4270            Box::new(OnceDelayed { started: false }),
4271            ProcessKind::Initial,
4272            None,
4273            0,
4274        );
4275
4276        let mut blocked_seen = 0;
4277        for _ in 0..30 {
4278            if engine.step_region() == StepResult::Blocked {
4279                blocked_seen += 1;
4280            }
4281        }
4282        assert!(
4283            blocked_seen > 0,
4284            "this 30-call sequence must actually reach StepResult::Blocked at least once \
4285             (it does, repeatedly, once the one armed tick has fired and gone quiet), or \
4286             the regression assertion below would be vacuous"
4287        );
4288        assert_eq!(
4289            push_count.get(),
4290            1,
4291            "exactly one real time-step transition occurred (the single Delay(1)) -- the \
4292             ring must have been pushed exactly once, never once per StepResult::Blocked \
4293             call (that was the bug: a quiescent/completed single-stepper re-pushed the \
4294             same unchanged snapshot on every call, silently inflating a bounded ring and \
4295             evicting genuinely distinct older ticks a future $past consumer needs intact)"
4296        );
4297    }
4298
4299    /// sva T1 r2 (review fix, blocking finding 1): `run_until` and
4300    /// `step_region` are two separate driver entry points for the exact
4301    /// same `Engine`/`push_preponed_snapshot` mechanism (`Self::
4302    /// push_preponed_snapshot`'s own doc comment), so the identical
4303    /// simulated history must push the Preponed ring the same number of
4304    /// times regardless of which one drives it. This drives the SAME
4305    /// one-shot process (`Suspend::Delay(1)` then `Suspend::Done` -- the
4306    /// exact repro the finding's own live check used) through two freshly
4307    /// constructed engines, one via `run_until(None)` and one via 30
4308    /// `step_region()` calls (mirroring `step_region_blocked_does_not_
4309    /// repush_the_preponed_ring` immediately above), and asserts both
4310    /// counted exactly one push. Before the fix, `run_until`'s outer loop
4311    /// pushed unconditionally at the top -- including on its very first
4312    /// iteration, before `Region::Active` had ever been dispatched at time
4313    /// 0 -- so this process's `run_until` push count was 2 while its
4314    /// `step_region` push count was 1 for the identical history; this test
4315    /// fails on that regression by catching the mismatch directly, not by
4316    /// hard-coding either side's count in isolation.
4317    #[test]
4318    fn run_until_and_step_region_push_the_same_preponed_ring() {
4319        use crate::state::{StateEngine, TemplateRegistry};
4320        use std::cell::Cell;
4321        use std::rc::Rc;
4322
4323        struct OnceDelayed {
4324            started: bool,
4325        }
4326        impl CompiledProcess for OnceDelayed {
4327            fn resume(
4328                &mut self,
4329                _state: SimHandle,
4330                arg: ResumeArg,
4331                _ops: &dyn SchedulerOps,
4332            ) -> Suspend {
4333                match (self.started, arg) {
4334                    (false, ResumeArg::Start) => {
4335                        self.started = true;
4336                        Suspend::Delay(1)
4337                    }
4338                    _ => Suspend::Done,
4339                }
4340            }
4341        }
4342
4343        fn count_pushes_via(driver: impl FnOnce(&mut Engine)) -> u32 {
4344            let mut engine = new_engine();
4345            let push_count = Rc::new(Cell::new(0u32));
4346            {
4347                let push_count = push_count.clone();
4348                let state_engine = StateEngine::new(TemplateRegistry::new());
4349                engine.configure_preponed_ring(0, move || {
4350                    push_count.set(push_count.get() + 1);
4351                    state_engine.snapshot(None)
4352                });
4353            }
4354            engine.register_process(
4355                Box::new(OnceDelayed { started: false }),
4356                ProcessKind::Initial,
4357                None,
4358                0,
4359            );
4360            driver(&mut engine);
4361            push_count.get()
4362        }
4363
4364        let run_until_pushes = count_pushes_via(|engine| {
4365            let result = engine.run_until(None);
4366            assert!(matches!(result, RunResult::Completed));
4367        });
4368        let step_region_pushes = count_pushes_via(|engine| {
4369            for _ in 0..30 {
4370                engine.step_region();
4371            }
4372        });
4373
4374        assert_eq!(
4375            run_until_pushes, step_region_pushes,
4376            "run_until and step_region must push the configured Preponed ring the same \
4377             number of times for the identical simulated history (one real Delay(1) \
4378             transition) -- a mismatch here means the two driver APIs disagree about what \
4379             'once per time step' means, which is exactly the asymmetry this regression \
4380             test exists to catch"
4381        );
4382        assert_eq!(
4383            run_until_pushes, 1,
4384            "exactly one real time-step transition occurred (the single Delay(1)); neither \
4385             driver may push at the engine's initial Region::Active entry at time 0 (no \
4386             Preponed push precedes it for either API -- Engine::new sets current_region = \
4387             Active directly)"
4388        );
4389    }
4390
4391    /// sva T1 end-to-end (§1.3/§1.4): a signal written in `Region::Active`
4392    /// is read back through a configured Preponed ring at the START of the
4393    /// following time steps -- the "stub consumer" this task's own plan
4394    /// describes (no `$past`/assertion lowering exists yet to read the ring
4395    /// from compiled SV source; `Engine::with_preponed_entry` is this
4396    /// task's own test-only substitute). Driven via [`Engine::step_region`]
4397    /// (never `run_until`'s deadline-based API) specifically so each
4398    /// Postponed->Active transition -- and so each ring push -- is counted
4399    /// exactly once (5 `step_region` calls == one full Active/Inactive/
4400    /// Nba/Reactive/Postponed cycle, the same convention
4401    /// `suspend_delay_logical_order_uses_absolute_time_across_chained_delays`
4402    /// above already establishes).
4403    ///
4404    /// Verified by hand against LRM 16.5.1: a depth-1 ring's `get(0)`
4405    /// ("current") always holds the value as of the end of the time step
4406    /// that just finished; `get(1)` holds the value as of the end of the
4407    /// time step before that. `cell` is written to 10 during time step 0's
4408    /// `Active` region and to 20 during time step 1's -- after time step 0
4409    /// finishes (one 5-step cycle), `get(0)` must read 10 and `get(1)` must
4410    /// be unretained (`None`, nothing pushed before entry 0 yet); after
4411    /// time step 1 finishes (a second 5-step cycle), `get(0)` must read 20
4412    /// and `get(1)` must read 10.
4413    #[test]
4414    fn sva_preponed_sampling_matches_lrm_16_5_1() {
4415        use crate::state::{
4416            SlotClass, SlotLocation, StateCellId as RealStateCellId, StateEngine, TemplateRegistry,
4417        };
4418        use std::cell::RefCell;
4419        use std::rc::Rc;
4420
4421        // A real, StateEngine-backed signal, mirroring `state::snapshot`'s
4422        // own test fixtures -- `Engine`'s own `arena: Box<dyn SignalAccess>`
4423        // stays a `TestArena` double (sched's established, STATE-decoupled
4424        // test convention), while the Preponed ring's `source` closure
4425        // reads the REAL StateEngine `MirroringArena::write` below also
4426        // writes into, proving the ring genuinely snapshots live arena
4427        // state, not the test double. `Rc<RefCell<_>>`, not `Arc<Mutex<_>>`:
4428        // `StateEngine` holds `Rc` internals of its own (`state::instance`'s
4429        // own doc comment) and is neither `Send` nor `Sync` -- this is
4430        // single-threaded test code, so there is no reason to reach for a
4431        // thread-safe wrapper clippy's `arc_with_non_send_sync` correctly
4432        // flags as misleading here.
4433        let state_engine = Rc::new(RefCell::new(StateEngine::new(TemplateRegistry::new())));
4434        let real_cell: RealStateCellId = {
4435            let mut se = state_engine.borrow_mut();
4436            // SAFETY: local, single-owned engine; no other live borrow of
4437            // the inline plane at this point.
4438            let word_offset = unsafe { se.arena.alloc_inline_word() };
4439            se.alloc_cell(SlotLocation {
4440                class: SlotClass::Inline,
4441                word_offset,
4442                bit_width: 8,
4443                elem_word_count: 1,
4444                row_stride_words: 0,
4445                num_rows: 0,
4446            })
4447        };
4448
4449        // `Engine`'s own test-double cell (what the scripted process
4450        // actually addresses through `SchedulerOps`) maps 1:1 onto
4451        // `real_cell` by sharing the same numeric id -- `MirroringArena`
4452        // mirrors every write into `state_engine` too, so both views stay
4453        // in lockstep without `sched` needing a concrete STATE dependency.
4454        let cell = StateCellId(real_cell.0);
4455
4456        struct MirroringArena {
4457            inner: TestArena,
4458            mirror_to: Rc<RefCell<StateEngine>>,
4459        }
4460        impl SignalAccess for MirroringArena {
4461            fn read(&self, cell: StateCellId) -> FourStateValue {
4462                self.inner.read(cell)
4463            }
4464            fn write(&mut self, cell: StateCellId, value: FourStateValue) -> bool {
4465                self.mirror_to.borrow_mut().commit_write(
4466                    RealStateCellId(cell.0),
4467                    value.clone(),
4468                    &mut (),
4469                );
4470                self.inner.write(cell, value)
4471            }
4472            fn subscribe(
4473                &mut self,
4474                cell: StateCellId,
4475                role: crate::state::ListenerRole,
4476                sink: Box<dyn ValueChangeSink + Send>,
4477            ) {
4478                self.inner.subscribe(cell, role, sink);
4479            }
4480        }
4481
4482        /// Writes 10 at `Start` (time step 0), 20 at the first
4483        /// `TimeElapsed` (time step 1), then goes quiet (`step == 2`'s
4484        /// `TimeElapsed` writes nothing and stays done) -- `Suspend::
4485        /// Delay(1)` after each write is what makes the wheel advance
4486        /// exactly one tick per time step.
4487        struct SequencedWriter {
4488            cell: StateCellId,
4489            step: u32,
4490        }
4491        impl CompiledProcess for SequencedWriter {
4492            fn resume(
4493                &mut self,
4494                _state: SimHandle,
4495                arg: ResumeArg,
4496                ops: &dyn SchedulerOps,
4497            ) -> Suspend {
4498                match (self.step, arg) {
4499                    (0, ResumeArg::Start) => {
4500                        ops.commit_blocking_write(self.cell, narrow(10));
4501                        self.step = 1;
4502                        Suspend::Delay(1)
4503                    }
4504                    (1, ResumeArg::TimeElapsed) => {
4505                        ops.commit_blocking_write(self.cell, narrow(20));
4506                        self.step = 2;
4507                        Suspend::Delay(1)
4508                    }
4509                    _ => Suspend::Done,
4510                }
4511            }
4512        }
4513
4514        let mut engine = Engine::new(
4515            Box::new(MirroringArena {
4516                inner: TestArena::new(),
4517                mirror_to: state_engine.clone(),
4518            }),
4519            Box::new(TestIoSink::default()),
4520        );
4521        {
4522            let src = state_engine.clone();
4523            engine.configure_preponed_ring(1, move || src.borrow().snapshot(None));
4524        }
4525        engine.register_process(
4526            Box::new(SequencedWriter { cell, step: 0 }),
4527            ProcessKind::Initial,
4528            None,
4529            0,
4530        );
4531
4532        // `FourStateValue` carries no `PartialEq` (reference/introspection
4533        // shape only, `state::mod.rs`'s own doc comment on it) -- this
4534        // narrows down to the one `u128` lane this test's own single
4535        // `SlotClass::Inline` cell ever uses, mirroring `state::snapshot`'s
4536        // own `read_narrow` test helper.
4537        let read_through_ring = |engine: &Engine, ticks_back: usize| -> Option<u128> {
4538            engine.with_preponed_entry(ticks_back, |snap| {
4539                snap.map(|snap| {
4540                    let mut se = state_engine.borrow_mut();
4541                    se.restore(snap)
4542                        .expect("same StateEngine the ring sampled from, same layout");
4543                    match se.read(real_cell) {
4544                        FourStateValue::Narrow(w) => w,
4545                        FourStateValue::Wide { .. } => panic!("expected Narrow"),
4546                    }
4547                })
4548            })
4549        };
4550
4551        // One full 5-region cycle completes time step 0 (Start dispatched
4552        // during Active, writing 10 and arming Delay(1); the Postponed step
4553        // then advances the wheel to tick 1 and pushes the Preponed
4554        // snapshot for the start of tick 1).
4555        for _ in 0..5 {
4556            engine.step_region();
4557        }
4558        assert_eq!(
4559            read_through_ring(&engine, 0),
4560            Some(10u128),
4561            "16.5.1: after time step 0 finishes, the ring's current entry must read the \
4562             value committed during time step 0's own Active region (10)"
4563        );
4564        assert_eq!(
4565            read_through_ring(&engine, 1),
4566            None,
4567            "only one push has happened so far -- ticks_back=1 must not be retained yet"
4568        );
4569
4570        // A second 5-region cycle completes time step 1 (the resumed
4571        // TimeElapsed writes 20 and arms another Delay(1); Postponed pushes
4572        // the Preponed snapshot for the start of tick 2).
4573        for _ in 0..5 {
4574            engine.step_region();
4575        }
4576        assert_eq!(
4577            read_through_ring(&engine, 0),
4578            Some(20u128),
4579            "16.5.1: after time step 1 finishes, the ring's current entry must read the \
4580             value committed during time step 1's own Active region (20)"
4581        );
4582        assert_eq!(
4583            read_through_ring(&engine, 1),
4584            Some(10u128),
4585            "16.5.1: ticks_back=1 must still read time step 0's own committed value (10)"
4586        );
4587    }
4588
4589    #[test]
4590    fn register_process_returns_the_ids_run_until_and_run_finals_operate_on() {
4591        let log: Arc<StdMutex<Vec<(ProcessId, &'static str)>>> =
4592            Arc::new(StdMutex::new(Vec::new()));
4593        let mut engine = new_engine();
4594
4595        let mut regular_ids = Vec::new();
4596        for _ in 0..3 {
4597            let (proc, id_cell) = logging_process(&log);
4598            let id = engine.register_process(proc, ProcessKind::Initial, None, 0);
4599            *id_cell.lock().unwrap() = Some(id);
4600            regular_ids.push(id);
4601        }
4602
4603        let mut final_ids = Vec::new();
4604        for _ in 0..3 {
4605            let (proc, id_cell) = logging_process(&log);
4606            let id = engine.register_process(proc, ProcessKind::Final, None, 0);
4607            *id_cell.lock().unwrap() = Some(id);
4608            final_ids.push(id);
4609        }
4610
4611        let result = engine.run_until(None);
4612        assert!(matches!(result, RunResult::Completed));
4613        engine.run_finals();
4614
4615        let entries = log.lock().unwrap().clone();
4616        // Every regular process ran (via run_until's initial dispatch) with
4617        // exactly one Start; no final-kind process was dispatched by
4618        // run_until (Final processes are never queued onto Active).
4619        for &id in &regular_ids {
4620            let starts: Vec<_> = entries
4621                .iter()
4622                .filter(|(p, tag)| *p == id && *tag == "Start")
4623                .collect();
4624            assert_eq!(
4625                starts.len(),
4626                1,
4627                "process {id} must have gotten exactly one Start"
4628            );
4629        }
4630        // The finals ran (via run_finals), in registration order.
4631        let final_start_order: Vec<ProcessId> = entries
4632            .iter()
4633            .filter(|(p, _)| final_ids.contains(p))
4634            .map(|(p, _)| *p)
4635            .collect();
4636        assert_eq!(
4637            final_start_order, final_ids,
4638            "finals must run in registration order"
4639        );
4640        for &id in &final_ids {
4641            let starts: Vec<_> = entries
4642                .iter()
4643                .filter(|(p, tag)| *p == id && *tag == "Start")
4644                .collect();
4645            assert_eq!(
4646                starts.len(),
4647                1,
4648                "final process {id} must have gotten exactly one Start"
4649            );
4650        }
4651    }
4652
4653    /// Classes plan Task 14 (LRM 18.14.1): a process registered while
4654    /// `registration_scope` names an instance is seeded from that instance's
4655    /// initialization RNG, one draw per process in registration order; two
4656    /// instances have independent streams; and a process registered with no
4657    /// scope (infrastructure) does not advance any instance's RNG.
4658    #[test]
4659    fn registered_processes_are_seeded_from_their_instances_init_rng() {
4660        use crate::sched::process_rng::{self, with_process_state};
4661        let nop = || -> Box<dyn CompiledProcess> { script(|_, _| Suspend::Done) };
4662        process_rng::reset();
4663        let mut engine = new_engine();
4664        engine.set_registration_scope(Some(0));
4665        let a = engine.register_process(nop(), ProcessKind::Initial, None, 0);
4666        engine.set_registration_scope(None);
4667        let infra = engine.register_process(nop(), ProcessKind::Initial, None, 0);
4668        engine.set_registration_scope(Some(0));
4669        let b = engine.register_process(nop(), ProcessKind::Initial, None, 0);
4670        engine.set_registration_scope(Some(1));
4671        let c = engine.register_process(nop(), ProcessKind::Initial, None, 0);
4672        engine.set_registration_scope(None);
4673        let seen: Vec<u64> = [a, infra, b, c]
4674            .iter()
4675            .map(|&p| with_process_state(Some(p), |s| *s))
4676            .collect();
4677
4678        process_rng::reset();
4679        let (e0, e1, e2) = (
4680            process_rng::draw_from_init(0),
4681            process_rng::draw_from_init(0),
4682            process_rng::draw_from_init(1),
4683        );
4684        assert_eq!(seen[0], e0, "first process of instance 0");
4685        assert_eq!(
4686            seen[2], e1,
4687            "second process of instance 0 (the infra one skipped)"
4688        );
4689        assert_eq!(seen[3], e2, "first process of instance 1");
4690        assert_eq!(seen[1], process_rng::DEFAULT_SEED, "unscoped: never seeded");
4691        assert_ne!(seen[0], seen[2]);
4692    }
4693
4694    /// Classes plan Task 14 (LRM 18.14.2): `fork` children are seeded when
4695    /// they are created, one draw each in source order from the parent's own
4696    /// generator (so the parent advances by exactly one step per child), no
4697    /// matter when they later run.
4698    #[test]
4699    fn fork_children_are_seeded_from_the_forking_process_in_source_order() {
4700        use crate::sched::process_rng::{self, with_process_state};
4701        process_rng::reset();
4702        let mut engine = new_engine();
4703        let spawn = || SpawnSpec {
4704            kernel: 0,
4705            entry_pc: 0,
4706            parent: 0,
4707            disable_name: Vec::new(),
4708        };
4709        let parent = engine.register_process(
4710            script(move |arg, _| match arg {
4711                ResumeArg::Start => Suspend::ForkJoin(JoinKind::None, vec![spawn(), spawn()]),
4712                _ => Suspend::Done,
4713            }),
4714            ProcessKind::Initial,
4715            None,
4716            0,
4717        );
4718        assert_eq!(parent, 0);
4719        // The parent's stream, before the fork.
4720        with_process_state(Some(parent), |s| *s = 99);
4721        let ready = engine.drain_and_sort(Region::Active);
4722        engine.dispatch_ready_set(Region::Active, ready);
4723        let seen: Vec<u64> = [1, 2]
4724            .iter()
4725            .map(|&c| with_process_state(Some(c), |s| *s))
4726            .collect();
4727        let parent_after = with_process_state(Some(parent), |s| *s);
4728
4729        let mut p = 99u64;
4730        let want = [
4731            process_rng::splitmix64_next(&mut p),
4732            process_rng::splitmix64_next(&mut p),
4733        ];
4734        assert_eq!(seen, want, "children seeded by successive parent draws");
4735        assert_eq!(parent_after, p, "one parent step per child, no more");
4736        assert_ne!(seen[0], seen[1]);
4737    }
4738
4739    /// SC-045's "installation happens exactly once, at registration" proof:
4740    /// no `commit_nba`/`commit_blocking_write`/`notify_change` call anywhere
4741    /// in this test, so downstream dedup cannot mask a double-install.
4742    #[test]
4743    fn register_process_installs_static_sensitivity_exactly_once() {
4744        let a = StateCellId(1);
4745        let b = StateCellId(2);
4746        let c = StateCellId(3);
4747        let mut engine = new_engine();
4748        engine.register_process(
4749            Box::new(UnmintedForkChild),
4750            ProcessKind::AlwaysComb,
4751            Some(StaticSensitivity {
4752                edges: vec![(a, None), (b, None), (c, None)],
4753            }),
4754            0,
4755        );
4756        assert_eq!(engine.test_sensitivity_entry_count(), 3);
4757    }
4758
4759    #[test]
4760    fn abi_entry_point_map_names_all_nine_entry_points_exactly_once() {
4761        let map = abi_entry_point_map();
4762        let names: std::collections::HashSet<&str> = map.iter().map(|(name, _)| *name).collect();
4763        assert_eq!(names.len(), map.len(), "no duplicate entry-point names");
4764        let expected: std::collections::HashSet<&str> = [
4765            "sim_init",
4766            "sim_settle",
4767            "sim_eval_region",
4768            "sim_commit_nba",
4769            "sim_save",
4770            "sim_restore",
4771            "sim_step_time",
4772            "sim_final",
4773            "sim_process_count",
4774        ]
4775        .into_iter()
4776        .collect();
4777        assert_eq!(names, expected);
4778    }
4779
4780    // --- EngineOps / EngineNotifySink wiring against Engine's own state ---
4781
4782    #[test]
4783    fn engine_ops_commit_blocking_write_writes_through_to_the_engines_own_arena() {
4784        let cell = StateCellId(7);
4785        let engine = Engine::new(
4786            Box::new(TestArena::seeded(cell, narrow(0))),
4787            Box::new(TestIoSink::default()),
4788        );
4789        let ops = engine.test_dispatch_ops(SchedTime { ticks: 0 }, Region::Active, 0);
4790        ops.commit_blocking_write(cell, narrow(1));
4791        assert!(matches!(
4792            engine.test_read_cell(cell),
4793            FourStateValue::Narrow(1)
4794        ));
4795    }
4796
4797    #[test]
4798    fn engine_ops_commit_blocking_write_wakes_a_statically_sensitive_process_via_the_real_queues() {
4799        let cell = StateCellId(9);
4800        let mut engine = Engine::new(
4801            Box::new(TestArena::seeded(cell, narrow(0))),
4802            Box::new(TestIoSink::default()),
4803        );
4804        let p1 =
4805            engine.register_process(Box::new(UnmintedForkChild), ProcessKind::Initial, None, 0);
4806        let p2 = engine.register_process(
4807            Box::new(UnmintedForkChild),
4808            ProcessKind::AlwaysFF,
4809            Some(StaticSensitivity {
4810                edges: vec![(cell, Some(EdgeKind::Posedge))],
4811            }),
4812            0,
4813        );
4814        // Both p1/p2 auto-queued a Start activation at registration; drain
4815        // that away so the assertion below only counts the wakeup this test
4816        // itself triggers.
4817        let _ = engine.test_drain_active();
4818
4819        {
4820            let ops = engine.test_dispatch_ops(SchedTime { ticks: 0 }, Region::Active, 0);
4821            // 0 -> 1 on bit 0 is a Posedge (notify.rs's infer_edge table).
4822            ops.commit_blocking_write(cell, narrow(1));
4823        }
4824
4825        let queued = engine.test_drain_active();
4826        assert_eq!(queued.len(), 1, "exactly one new activation queued");
4827        assert_eq!(
4828            queued[0].proc, p2,
4829            "the statically-sensitive process p2 was woken"
4830        );
4831        let _ = p1;
4832    }
4833
4834    /// SC-055 through the real `Engine`: a process statically sensitive to
4835    /// two cells that both change in the same pass is queued exactly once.
4836    /// Without the gate `notify_change` pushes one activation per matching
4837    /// change (2 here).
4838    #[test]
4839    fn engine_queues_a_process_once_when_two_of_its_cells_change_in_one_pass() {
4840        let a = StateCellId(20);
4841        let b = StateCellId(21);
4842        let mut engine = new_engine();
4843        let p = engine.register_process(
4844            Box::new(UnmintedForkChild),
4845            ProcessKind::AlwaysComb,
4846            Some(StaticSensitivity {
4847                edges: vec![(a, None), (b, None)],
4848            }),
4849            0,
4850        );
4851        let _ = engine.test_drain_active();
4852
4853        {
4854            let ops = engine.test_dispatch_ops(SchedTime { ticks: 0 }, Region::Active, 0);
4855            ops.commit_blocking_write(a, narrow(1));
4856            ops.commit_blocking_write(b, narrow(1));
4857        }
4858
4859        let queued = engine.test_drain_active();
4860        assert_eq!(
4861            queued.iter().map(|q| q.proc).collect::<Vec<_>>(),
4862            vec![p],
4863            "two changes in one pass must queue the process once, not twice"
4864        );
4865    }
4866
4867    /// SC-056 through the real `Engine`: once the queued activation has been
4868    /// popped (the dequeue point resets the flag), a new change queues the
4869    /// process again. A gate that never reset would swallow the second wakeup.
4870    #[test]
4871    fn engine_requeues_a_process_changed_again_after_its_activation_was_dequeued() {
4872        let a = StateCellId(22);
4873        let mut engine = new_engine();
4874        let p = engine.register_process(
4875            Box::new(UnmintedForkChild),
4876            ProcessKind::AlwaysComb,
4877            Some(StaticSensitivity {
4878                edges: vec![(a, None)],
4879            }),
4880            0,
4881        );
4882        let _ = engine.test_drain_active();
4883
4884        {
4885            let ops = engine.test_dispatch_ops(SchedTime { ticks: 0 }, Region::Active, 0);
4886            ops.commit_blocking_write(a, narrow(1));
4887        }
4888        assert_eq!(
4889            engine.test_drain_active().len(),
4890            1,
4891            "first change queues the process"
4892        );
4893
4894        {
4895            let ops = engine.test_dispatch_ops(SchedTime { ticks: 0 }, Region::Active, 1);
4896            ops.commit_blocking_write(a, narrow(0));
4897        }
4898        let queued = engine.test_drain_active();
4899        assert_eq!(
4900            queued.iter().map(|q| q.proc).collect::<Vec<_>>(),
4901            vec![p],
4902            "a change after the dequeue must queue the process again"
4903        );
4904    }
4905
4906    #[test]
4907    fn engine_ops_stage_nba_appends_to_the_engines_own_nba_log() {
4908        let engine = new_engine();
4909        assert_eq!(engine.test_nba_len(), 0);
4910        let ops = engine.test_dispatch_ops(SchedTime { ticks: 0 }, Region::Active, 0);
4911        ops.stage_nba(StateCellId(1), narrow(1), 0);
4912        assert_eq!(engine.test_nba_len(), 1);
4913    }
4914
4915    #[test]
4916    fn engine_ops_defer_io_forwards_with_a_seq_counter_that_persists_across_passes() {
4917        // `io_sink: Box<dyn IoSink>` is not downcastable, so this test uses a
4918        // shared-state sink to observe the recorded seqs from outside.
4919        struct SharedIoSink(Arc<StdMutex<Vec<u64>>>);
4920        impl IoSink for SharedIoSink {
4921            fn enqueue(&self, _at: SchedTime, _region: Region, seq: u64, cb: DeferredIoCallback) {
4922                drop(cb);
4923                self.0.lock().unwrap().push(seq);
4924            }
4925        }
4926        let seqs = Arc::new(StdMutex::new(Vec::new()));
4927        let engine = Engine::new(
4928            Box::new(TestArena::new()),
4929            Box::new(SharedIoSink(seqs.clone())),
4930        );
4931        let ops1 = engine.test_dispatch_ops(SchedTime { ticks: 0 }, Region::Active, 0);
4932        ops1.defer_io(SchedTime { ticks: 0 }, Region::Active, Box::new(|_| {}));
4933        let ops2 = engine.test_dispatch_ops(SchedTime { ticks: 5 }, Region::Inactive, 0);
4934        ops2.defer_io(SchedTime { ticks: 5 }, Region::Inactive, Box::new(|_| {}));
4935
4936        assert_eq!(
4937            *seqs.lock().unwrap(),
4938            vec![0, 1],
4939            "seq must strictly increase across two independently-constructed EngineOps"
4940        );
4941    }
4942
4943    // --- review fixes (r1) ---
4944
4945    /// Review fix — `apply_suspend`'s `Suspend::Delay` arm used to always
4946    /// build `act` with `logical_order.1 == Region::Active`, even when
4947    /// `wheel.arm_delay` reports `Region::Inactive` (a zero-delay `#0`
4948    /// reactivation) and the activation is pushed straight into
4949    /// `Region::Inactive` instead. Mirrors `subscribe.rs::register_wait`'s
4950    /// own `delay_zero_lands_directly_in_inactive_not_the_wheel` test for
4951    /// the identical hazard, one file over.
4952    #[test]
4953    fn apply_suspend_delay_zero_lands_directly_in_inactive_with_the_correct_region_tag() {
4954        struct DelayZeroProcess;
4955        impl CompiledProcess for DelayZeroProcess {
4956            fn resume(
4957                &mut self,
4958                _state: SimHandle,
4959                arg: ResumeArg,
4960                _ops: &dyn SchedulerOps,
4961            ) -> Suspend {
4962                match arg {
4963                    ResumeArg::Start => Suspend::Delay(0),
4964                    _ => Suspend::Done,
4965                }
4966            }
4967        }
4968
4969        let mut engine = new_engine();
4970        let proc =
4971            engine.register_process(Box::new(DelayZeroProcess), ProcessKind::Initial, None, 0);
4972
4973        // Single-steps exactly the Active region: dispatches the process's
4974        // Start, which returns Suspend::Delay(0) -- apply_suspend's
4975        // wheel.arm_delay call reports Region::Inactive (delay_ticks == 0,
4976        // per wheel.rs's own doc comment) without ever touching the wheel.
4977        engine.step_region();
4978
4979        let inactive = engine.test_drain_inactive();
4980        assert_eq!(
4981            inactive.len(),
4982            1,
4983            "the zero-delay reactivation must land directly in Region::Inactive"
4984        );
4985        assert_eq!(inactive[0].proc, proc);
4986        assert_eq!(
4987            inactive[0].logical_order.1,
4988            Region::Inactive,
4989            "an activation landed in the Inactive queue must carry Region::Inactive as its \
4990             own logical_order.1 tag, agreeing with the queue it actually lives in -- \
4991             order.rs's canonical_key_of/sort_ready_set read logical_order.1 directly to \
4992             build the canonical key that SC-010/DT-005/DT-010 depend on"
4993        );
4994    }
4995
4996    /// Whole-track review fix (AB-085): the only prior test for
4997    /// `Suspend::WaitForGvt` (`wait_for_gvt_shares_the_suspend_dispatch_path`
4998    /// in `sched::mod`) never called `apply_suspend` at all -- it matched a
4999    /// test-local, hand-written `label` function against the `Suspend` enum,
5000    /// which only proves that function's own match arms are exhaustive, not
5001    /// that `apply_suspend`'s `Suspend::WaitForGvt => self.apply_suspend(proc,
5002    /// Suspend::Delay(0))` arm actually delegates anywhere. This test drives
5003    /// a real process through the real `Engine`/`apply_suspend` with
5004    /// `Suspend::WaitForGvt` and asserts the resulting scheduler state is
5005    /// identical (region, queue length, logical_order tag) to what
5006    /// `apply_suspend_delay_zero_lands_directly_in_inactive_with_the_correct_
5007    /// region_tag` (immediately above) observes for `Suspend::Delay(0)` --
5008    /// proving the delegation for real. Before this test existed, changing
5009    /// the `WaitForGvt` arm to `Suspend::Delay(1)`, to a silent no-op, or to
5010    /// removing the delegation entirely (stranding the process) would still
5011    /// have passed the full test suite.
5012    #[test]
5013    fn apply_suspend_wait_for_gvt_delegates_to_the_same_path_as_delay_zero() {
5014        struct WaitForGvtProcess;
5015        impl CompiledProcess for WaitForGvtProcess {
5016            fn resume(
5017                &mut self,
5018                _state: SimHandle,
5019                arg: ResumeArg,
5020                _ops: &dyn SchedulerOps,
5021            ) -> Suspend {
5022                match arg {
5023                    ResumeArg::Start => Suspend::WaitForGvt,
5024                    _ => Suspend::Done,
5025                }
5026            }
5027        }
5028
5029        let mut engine = new_engine();
5030        let proc =
5031            engine.register_process(Box::new(WaitForGvtProcess), ProcessKind::Initial, None, 0);
5032
5033        // Single-steps exactly the Active region: dispatches the process's
5034        // Start, which returns Suspend::WaitForGvt. If apply_suspend's
5035        // `Suspend::WaitForGvt` arm really delegates to
5036        // `self.apply_suspend(proc, Suspend::Delay(0))`, the observable
5037        // outcome must be pixel-for-pixel identical to the Delay(0) test
5038        // above: one reactivation, landed directly in Region::Inactive
5039        // (never touching the wheel), tagged with Region::Inactive.
5040        engine.step_region();
5041
5042        let inactive = engine.test_drain_inactive();
5043        assert_eq!(
5044            inactive.len(),
5045            1,
5046            "WaitForGvt must delegate to Delay(0)'s dispatch, landing its reactivation \
5047             directly in Region::Inactive exactly like Delay(0) does -- a broken or removed \
5048             delegation would leave this queue empty (process stranded) or route it \
5049             elsewhere"
5050        );
5051        assert_eq!(inactive[0].proc, proc);
5052        assert_eq!(
5053            inactive[0].logical_order.1,
5054            Region::Inactive,
5055            "the delegated activation must carry the same Region::Inactive logical_order \
5056             tag Delay(0) produces, agreeing with the queue it actually lives in -- a \
5057             WaitForGvt arm changed to Delay(1) or to any wheel-touching path would instead \
5058             land here with Region::Active, or not land here at all this step"
5059        );
5060        assert_eq!(
5061            inactive[0].logical_order.0,
5062            SchedTime { ticks: 0 },
5063            "delegating to Delay(0) from now == 0 must produce the same absolute fire tick \
5064             Delay(0) produces (0 + 0 == 0), not some other tick a differently-parameterized \
5065             delegation (e.g. Delay(1)) would produce"
5066        );
5067    }
5068
5069    /// Mutation-test kill: `apply_suspend`'s `Suspend::Delay` arm must build
5070    /// the reactivation's `logical_order.0` from `self.now.ticks + ticks`
5071    /// (the real absolute fire tick), never from `ticks` alone. The bug is
5072    /// invisible at `now == 0` (where the two computations coincide, exactly
5073    /// the case every other `Delay` test in this file exercises), so this
5074    /// test chains two delays -- `Delay(3)` fired from `now == 0`, then
5075    /// `Delay(4)` fired from the resulting `now == 3` -- and inspects the
5076    /// *second* reactivation's own `logical_order.0` after the wheel fires
5077    /// it: it must be `SchedTime{ticks: 7}` (3+4), never `SchedTime{ticks:
5078    /// 4}`. The wheel's own real fire time is unaffected by this bug (it
5079    /// computes `now + delay_ticks` internally, independent of the
5080    /// activation's `logical_order` field), so `engine.now()` alone cannot
5081    /// catch it -- only inspecting the queued activation's own field can.
5082    #[test]
5083    fn suspend_delay_logical_order_uses_absolute_time_across_chained_delays() {
5084        struct TwoStageDelay;
5085        impl CompiledProcess for TwoStageDelay {
5086            fn resume(
5087                &mut self,
5088                _state: SimHandle,
5089                arg: ResumeArg,
5090                _ops: &dyn SchedulerOps,
5091            ) -> Suspend {
5092                match arg {
5093                    ResumeArg::Start => Suspend::Delay(3),
5094                    ResumeArg::TimeElapsed => Suspend::Delay(4),
5095                    _ => Suspend::Done,
5096                }
5097            }
5098        }
5099
5100        let mut engine = new_engine();
5101        engine.register_process(Box::new(TwoStageDelay), ProcessKind::Initial, None, 0);
5102
5103        // Exactly 10 step_region() calls: 5 regions (Active/Inactive/Nba/
5104        // Reactive/Postponed) x 2 timesteps -- the first timestep dispatches
5105        // Start (arming Delay(3)) and advances the wheel to tick 3 without
5106        // dispatching the resulting reactivation; the second timestep
5107        // dispatches that TimeElapsed (arming Delay(4)) and advances the
5108        // wheel to tick 7, again landing the reactivation in Region::Active
5109        // without dispatching it.
5110        for _ in 0..10 {
5111            engine.step_region();
5112        }
5113
5114        assert_eq!(
5115            engine.now(),
5116            SchedTime { ticks: 7 },
5117            "the wheel's own real fire time (unaffected by this bug) must be 0+3, then 3+4 = 7"
5118        );
5119
5120        let active = engine.test_drain_active();
5121        assert_eq!(
5122            active.len(),
5123            1,
5124            "the second delay's reactivation must be sitting in Region::Active, undispatched"
5125        );
5126        assert_eq!(
5127            active[0].logical_order.0,
5128            SchedTime { ticks: 7 },
5129            "logical_order.0 must be the absolute fire tick (now=3 + delay=4 = 7), not \
5130             the raw delay amount (4) alone"
5131        );
5132    }
5133
5134    /// Review fix — `apply_suspend`'s `Suspend::WaitEvents` arm installed a
5135    /// dynamic sensitivity entry on suspend but nothing in `Engine`'s
5136    /// dispatch path ever called `SensitivityTable::remove_dynamic` when the
5137    /// waiting process later resumed, leaking the entry forever and risking
5138    /// a phantom wakeup of an already-resumed process on a later write to
5139    /// the same cell.
5140    #[test]
5141    fn wait_events_dynamic_sensitivity_entry_is_removed_when_the_process_resumes() {
5142        struct WaiterProcess {
5143            cell: StateCellId,
5144        }
5145        impl CompiledProcess for WaiterProcess {
5146            fn resume(
5147                &mut self,
5148                _state: SimHandle,
5149                arg: ResumeArg,
5150                _ops: &dyn SchedulerOps,
5151            ) -> Suspend {
5152                match arg {
5153                    ResumeArg::Start => Suspend::WaitEvents(EventWaitSet {
5154                        terms: vec![EventTerm::Edge {
5155                            cell: self.cell,
5156                            edge: EdgeKind::Posedge,
5157                            iff: None,
5158                        }],
5159                        ..Default::default()
5160                    }),
5161                    _ => Suspend::Done,
5162                }
5163            }
5164        }
5165        struct WriterProcess {
5166            cell: StateCellId,
5167        }
5168        impl CompiledProcess for WriterProcess {
5169            fn resume(
5170                &mut self,
5171                _state: SimHandle,
5172                arg: ResumeArg,
5173                ops: &dyn SchedulerOps,
5174            ) -> Suspend {
5175                match arg {
5176                    // Delay past t=0 so this write lands in its own,
5177                    // later region pass -- one full pass after
5178                    // WaiterProcess's own Start has already been applied
5179                    // (apply_suspend, and therefore install_dynamic, only
5180                    // runs once every item dispatched *in the same pass*
5181                    // has itself returned its Suspend), so the dynamic
5182                    // entry genuinely exists by the time this write fires.
5183                    ResumeArg::Start => Suspend::Delay(1),
5184                    ResumeArg::TimeElapsed => {
5185                        ops.commit_blocking_write(self.cell, narrow(1));
5186                        Suspend::Done
5187                    }
5188                    other => panic!("WriterProcess must only see Start/TimeElapsed, got {other:?}"),
5189                }
5190            }
5191        }
5192
5193        let cell = StateCellId(42);
5194        let mut engine = Engine::new(
5195            Box::new(TestArena::seeded(cell, narrow(0))),
5196            Box::new(TestIoSink::default()),
5197        );
5198        engine.register_process(
5199            Box::new(WaiterProcess { cell }),
5200            ProcessKind::Initial,
5201            None,
5202            0,
5203        );
5204        engine.register_process(
5205            Box::new(WriterProcess { cell }),
5206            ProcessKind::Initial,
5207            None,
5208            0,
5209        );
5210
5211        let result = engine.run_until(None);
5212        assert!(matches!(result, RunResult::Completed));
5213
5214        assert_eq!(
5215            engine.test_sensitivity_entry_count(),
5216            0,
5217            "the dynamic entry must be removed once the waiting process resumes via \
5218             EventFired -- sensitivity.rs's own doc comment: dynamic entries \"are gone \
5219             from the table the instant the owning process resumes\" -- an earlier draft \
5220             never called SensitivityTable::remove_dynamic anywhere in Engine's dispatch \
5221             path, leaking this row forever"
5222        );
5223    }
5224
5225    /// Mutation-test kill: `dispatch_ready_set`'s dynamic-wait-resumed
5226    /// removal set names two `ResumeArg` variants, `EventFired` and
5227    /// `Disabled` -- but no other test in this file ever drives a
5228    /// `ResumeArg::Disabled` activation through `dispatch_ready_set` (no
5229    /// production path in this crate constructs one yet), so a mutant that
5230    /// narrowed the removal check to `EventFired` alone would pass every
5231    /// other test. This test dispatches `Start` normally (installing one
5232    /// dynamic sensitivity entry via `Suspend::WaitEvents`), then hand-builds
5233    /// a `ResumeArg::Disabled` activation for the same process and dispatches
5234    /// it directly -- `dispatch_ready_set`/`drain_and_sort` are private
5235    /// methods on `Engine`, reachable here since this test module is a
5236    /// descendant of `control`.
5237    #[test]
5238    fn dispatch_ready_set_removes_dynamic_sensitivity_entry_for_disabled_resume_too() {
5239        struct WaiterProcess {
5240            cell: StateCellId,
5241        }
5242        impl CompiledProcess for WaiterProcess {
5243            fn resume(
5244                &mut self,
5245                _state: SimHandle,
5246                arg: ResumeArg,
5247                _ops: &dyn SchedulerOps,
5248            ) -> Suspend {
5249                match arg {
5250                    ResumeArg::Start => Suspend::WaitEvents(EventWaitSet {
5251                        terms: vec![EventTerm::Edge {
5252                            cell: self.cell,
5253                            edge: EdgeKind::Posedge,
5254                            iff: None,
5255                        }],
5256                        ..Default::default()
5257                    }),
5258                    _ => Suspend::Done,
5259                }
5260            }
5261        }
5262
5263        let cell = StateCellId(99);
5264        let mut engine = new_engine();
5265        let proc = engine.register_process(
5266            Box::new(WaiterProcess { cell }),
5267            ProcessKind::Initial,
5268            None,
5269            0,
5270        );
5271
5272        let ready = engine.drain_and_sort(Region::Active);
5273        engine.dispatch_ready_set(Region::Active, ready);
5274        assert_eq!(
5275            engine.test_sensitivity_entry_count(),
5276            1,
5277            "Suspend::WaitEvents must install exactly one dynamic sensitivity entry"
5278        );
5279
5280        let disabled_act = PendingActivation {
5281            id: 999,
5282            proc,
5283            arg: ResumeArg::Disabled,
5284            reconv_iter: 0,
5285            logical_order: (engine.now(), Region::Active, 0),
5286        };
5287        engine.dispatch_ready_set(Region::Active, vec![disabled_act]);
5288
5289        assert_eq!(
5290            engine.test_sensitivity_entry_count(),
5291            0,
5292            "a ResumeArg::Disabled resume must also remove its process's dynamic \
5293             sensitivity entry, exactly like EventFired does -- a mutant narrowing the \
5294             removal check to EventFired alone would leave this row (and every future \
5295             Disabled-terminated wait's row) leaked forever"
5296        );
5297    }
5298
5299    /// Review fix — `apply_suspend`'s `Suspend::ForkJoin` arm spawned
5300    /// children via `ResumeDriver::handle_fork` but nothing in `Engine`
5301    /// ever called `ResumeDriver::on_child_done` when a spawned child later
5302    /// completed, so the parent was never reactivated: fork/join was
5303    /// completely nonfunctional (any forking process silently hung forever
5304    /// while `run_until` still reported normal completion). Drives a real
5305    /// `JoinKind::All` fork with one child through `Engine::run_until` and
5306    /// asserts the parent actually resumes via `ResumeArg::ForkChildDone`.
5307    #[test]
5308    fn fork_join_all_actually_resumes_the_parent_when_its_one_child_completes() {
5309        struct ParentProcess {
5310            log: Arc<StdMutex<Vec<&'static str>>>,
5311        }
5312        impl CompiledProcess for ParentProcess {
5313            fn resume(
5314                &mut self,
5315                _state: SimHandle,
5316                arg: ResumeArg,
5317                _ops: &dyn SchedulerOps,
5318            ) -> Suspend {
5319                match arg {
5320                    ResumeArg::Start => {
5321                        self.log.lock().unwrap().push("forked");
5322                        Suspend::ForkJoin(
5323                            JoinKind::All,
5324                            vec![SpawnSpec {
5325                                kernel: 0,
5326                                entry_pc: 0,
5327                                parent: 0,
5328                                disable_name: Vec::new(),
5329                            }],
5330                        )
5331                    }
5332                    ResumeArg::ForkChildDone { .. } => {
5333                        self.log.lock().unwrap().push("joined");
5334                        Suspend::Done
5335                    }
5336                    other => panic!(
5337                        "ParentProcess must only ever be resumed with Start or \
5338                         ForkChildDone, got {other:?}"
5339                    ),
5340                }
5341            }
5342        }
5343
5344        let log = Arc::new(StdMutex::new(Vec::new()));
5345        let mut engine = new_engine();
5346        engine.register_process(
5347            Box::new(ParentProcess { log: log.clone() }),
5348            ProcessKind::Initial,
5349            None,
5350            0,
5351        );
5352
5353        let result = engine.run_until(None);
5354        assert!(matches!(result, RunResult::Completed));
5355
5356        assert_eq!(
5357            log.lock().unwrap().as_slice(),
5358            &["forked", "joined"],
5359            "the parent must actually be reactivated via ResumeArg::ForkChildDone once its \
5360             one child (an UnmintedForkChild, immediately Done) completes -- an earlier \
5361             draft never wired ResumeDriver::on_child_done into Engine at all, so the \
5362             parent would silently hang forever (never reach \"joined\") despite run_until \
5363             still reporting RunResult::Completed"
5364        );
5365    }
5366
5367    // --- Task 7 (B-sched wave, ledger SC-062, SC-115) ---
5368
5369    /// SC-062's own literal acceptance, proven through the real `Engine`
5370    /// dispatch loop (not just `ResumeDriver` in isolation, which
5371    /// `resume.rs`'s own `join_all_resumes_parent_only_after_every_child_
5372    /// reports_done`/`on_child_done_reclaims_the_childs_slot_when_the_join_
5373    /// is_satisfied` already cover): a `JoinAll` fork of 3 children (each an
5374    /// `UnmintedForkChild`, immediately `Done` on its own first dispatch --
5375    /// this wave's own documented interim placeholder, see this module's
5376    /// doc comment deviation 3) completing one after another *within the
5377    /// same Active-region dispatch pass*. Asserts the parent resumes (logs
5378    /// "joined") only once, after all 3, and that every one of the 3
5379    /// forked children's own `ProcessId`s is unreachable via
5380    /// `Engine::test_process_exists` by the time it does. Fails if slot
5381    /// reclaim is never wired (every child id would still report `true`),
5382    /// or if it reclaims the wrong process (e.g. the parent).
5383    #[test]
5384    fn sc062_joinall_child_returning_done_satisfies_the_parents_join_and_reclaims_its_slot() {
5385        struct ParentProcess {
5386            log: Arc<StdMutex<Vec<&'static str>>>,
5387        }
5388        impl CompiledProcess for ParentProcess {
5389            fn resume(
5390                &mut self,
5391                _state: SimHandle,
5392                arg: ResumeArg,
5393                _ops: &dyn SchedulerOps,
5394            ) -> Suspend {
5395                match arg {
5396                    ResumeArg::Start => {
5397                        self.log.lock().unwrap().push("forked");
5398                        Suspend::ForkJoin(
5399                            JoinKind::All,
5400                            vec![
5401                                SpawnSpec {
5402                                    kernel: 0,
5403                                    entry_pc: 0,
5404                                    parent: 0,
5405                                    disable_name: Vec::new(),
5406                                },
5407                                SpawnSpec {
5408                                    kernel: 1,
5409                                    entry_pc: 0,
5410                                    parent: 0,
5411                                    disable_name: Vec::new(),
5412                                },
5413                                SpawnSpec {
5414                                    kernel: 2,
5415                                    entry_pc: 0,
5416                                    parent: 0,
5417                                    disable_name: Vec::new(),
5418                                },
5419                            ],
5420                        )
5421                    }
5422                    ResumeArg::ForkChildDone { .. } => {
5423                        self.log.lock().unwrap().push("joined");
5424                        Suspend::Done
5425                    }
5426                    other => panic!(
5427                        "ParentProcess must only ever be resumed with Start or \
5428                         ForkChildDone, got {other:?}"
5429                    ),
5430                }
5431            }
5432        }
5433
5434        let log = Arc::new(StdMutex::new(Vec::new()));
5435        let mut engine = new_engine();
5436        let parent = engine.register_process(
5437            Box::new(ParentProcess { log: log.clone() }),
5438            ProcessKind::Initial,
5439            None,
5440            0,
5441        );
5442        assert_eq!(parent, 0, "test assumes a fresh engine's first id is 0");
5443
5444        let result = engine.run_until(None);
5445        assert!(matches!(result, RunResult::Completed));
5446        assert_eq!(
5447            log.lock().unwrap().as_slice(),
5448            &["forked", "joined"],
5449            "the parent must resume via ForkChildDone exactly once, only after all 3 \
5450             children have completed"
5451        );
5452
5453        for child in (parent + 1)..=(parent + 3) {
5454            assert!(
5455                !engine.test_process_exists(child),
5456                "fork child {child} must be unreachable via the process table once its \
5457                 Suspend::Done has been processed and the JoinAll it belongs to has \
5458                 been satisfied -- SC-062's own literal 'the child's slot is reclaimed'"
5459            );
5460        }
5461    }
5462
5463    /// SC-115's own literal acceptance ("a process can be spawned via fork
5464    /// after sim_init has run, with its own scheduled entry in the
5465    /// ready-set machinery") combined with this task's own `JoinKind::None`
5466    /// resolution (this module's doc comment, deviation 3): the parent's
5467    /// own next segment must run *synchronously*, inside the very same
5468    /// `apply_suspend` call that registered the fork's children -- no
5469    /// second `dispatch_ready_set`/`run_until` pass needed -- while the
5470    /// spawned child is left genuinely live (not dispatched, not
5471    /// reclaimed) at that exact point, proving `JoinKind::None` neither
5472    /// blocks on nor otherwise touches its child.
5473    #[test]
5474    fn sc115_join_none_children_run_detached_while_the_parent_proceeds_without_waiting() {
5475        struct ParentProcess {
5476            log: Arc<StdMutex<Vec<&'static str>>>,
5477        }
5478        impl CompiledProcess for ParentProcess {
5479            fn resume(
5480                &mut self,
5481                _state: SimHandle,
5482                arg: ResumeArg,
5483                _ops: &dyn SchedulerOps,
5484            ) -> Suspend {
5485                match arg {
5486                    ResumeArg::Start => {
5487                        self.log.lock().unwrap().push("forked");
5488                        Suspend::ForkJoin(
5489                            JoinKind::None,
5490                            vec![SpawnSpec {
5491                                kernel: 0,
5492                                entry_pc: 0,
5493                                parent: 0,
5494                                disable_name: Vec::new(),
5495                            }],
5496                        )
5497                    }
5498                    ResumeArg::TimeElapsed => {
5499                        self.log.lock().unwrap().push("proceeded");
5500                        Suspend::Done
5501                    }
5502                    other => panic!(
5503                        "ParentProcess must only ever be resumed with Start or \
5504                         TimeElapsed (JoinKind::None's own chosen continuation arg, \
5505                         see this module's doc comment), got {other:?}"
5506                    ),
5507                }
5508            }
5509        }
5510
5511        let log = Arc::new(StdMutex::new(Vec::new()));
5512        let mut engine = new_engine();
5513        let parent = engine.register_process(
5514            Box::new(ParentProcess { log: log.clone() }),
5515            ProcessKind::Initial,
5516            None,
5517            0,
5518        );
5519        assert_eq!(parent, 0, "test assumes a fresh engine's first id is 0");
5520        let child = parent + 1;
5521
5522        // `register_process` already queued the parent's own Start
5523        // activation onto Region::Active. Dispatching exactly that one
5524        // ready-set (a single `dispatch_ready_set` call) must be enough to
5525        // see BOTH "forked" and "proceeded" in the log -- proving
5526        // JoinKind::None's continuation happened inside this same call,
5527        // never requiring a second dispatch pass the way waiting for a
5528        // real ForkChildDone would.
5529        let ready = engine.drain_and_sort(Region::Active);
5530        engine.dispatch_ready_set(Region::Active, ready);
5531
5532        assert_eq!(
5533            log.lock().unwrap().as_slice(),
5534            &["forked", "proceeded"],
5535            "JoinKind::None must resume the parent's own next segment synchronously, \
5536             within the very same apply_suspend call that registered its child -- \
5537             never leaving it suspended waiting for a ForkChildDone that will never \
5538             come (the pre-Task-7 bug this row closes)"
5539        );
5540        assert!(
5541            engine.test_process_exists(child),
5542            "the spawned child must still be a live, registered process at this exact \
5543             point -- it was only just enqueued (Region::Active, ResumeArg::Start) by \
5544             this same dispatch_ready_set call, never itself dispatched by it, so \
5545             JoinKind::None resolving the parent must not have touched the child at \
5546             all (neither reclaiming it nor otherwise depending on its completion)"
5547        );
5548    }
5549
5550    /// R-run Task 17 review fix (r1) -- blocking finding: `apply_suspend`'s
5551    /// `Suspend::ForkJoin` arm's `mint_child` closure ignores its own
5552    /// `SpawnSpec` argument entirely and unconditionally builds an
5553    /// `UnmintedForkChild` (this module's own doc comment deviation 3;
5554    /// `vj-codegen`'s R-run Task 17 now produces real, distinct
5555    /// `SpawnSpec::kernel` indices per branch, but nothing in `Engine`
5556    /// consumes that field yet -- Task 21 remains chartered to fix that).
5557    /// No prior test pinned this AS a tested property -- every existing
5558    /// `Suspend::ForkJoin` test above happens to use small/repeating
5559    /// `kernel` values (0, 1, 2) that a partial, buggy future minter could
5560    /// special-case and still pass. This test spawns children with
5561    /// deliberately large, mutually distinct `kernel` indices (a shape no
5562    /// real per-program kernel table plausibly has entries for yet) and
5563    /// proves every single one still completes via a bare `Suspend::Done`
5564    /// on its own very first `Start` dispatch -- the literal, observable
5565    /// signature of `UnmintedForkChild` (a real compiled branch kernel
5566    /// could legitimately suspend, loop, or otherwise not complete in one
5567    /// dispatch). Fails loudly the instant a future minter starts
5568    /// consulting `SpawnSpec::kernel` for any subset of values without this
5569    /// test being updated alongside it.
5570    #[test]
5571    fn apply_suspend_fork_join_still_mints_unmintedforkchild_regardless_of_spawnspec_kernel() {
5572        struct ParentProcess;
5573        impl CompiledProcess for ParentProcess {
5574            fn resume(
5575                &mut self,
5576                _state: SimHandle,
5577                arg: ResumeArg,
5578                _ops: &dyn SchedulerOps,
5579            ) -> Suspend {
5580                match arg {
5581                    ResumeArg::Start => Suspend::ForkJoin(
5582                        JoinKind::All,
5583                        vec![
5584                            SpawnSpec {
5585                                kernel: 424_242,
5586                                entry_pc: 0,
5587                                parent: 0,
5588                                disable_name: Vec::new(),
5589                            },
5590                            SpawnSpec {
5591                                kernel: 7,
5592                                entry_pc: 3,
5593                                parent: 0,
5594                                disable_name: Vec::new(),
5595                            },
5596                            SpawnSpec {
5597                                kernel: u32::MAX,
5598                                entry_pc: 0,
5599                                parent: 0,
5600                                disable_name: Vec::new(),
5601                            },
5602                        ],
5603                    ),
5604                    // Every child completes in one dispatch (proven below),
5605                    // so the join is satisfied by the time this fires; the
5606                    // real assertions are about the children, not this arm.
5607                    ResumeArg::ForkChildDone { .. } => Suspend::Done,
5608                    other => panic!(
5609                        "ParentProcess must only ever see Start or ForkChildDone, got {other:?}"
5610                    ),
5611                }
5612            }
5613        }
5614
5615        let mut engine = new_engine();
5616        let parent =
5617            engine.register_process(Box::new(ParentProcess), ProcessKind::Initial, None, 0);
5618        assert_eq!(parent, 0, "test assumes a fresh engine's first id is 0");
5619
5620        // Dispatch the parent's own Start -- this is the call that runs
5621        // apply_suspend's Suspend::ForkJoin arm and mints the 3 children.
5622        let ready = engine.drain_and_sort(Region::Active);
5623        engine.dispatch_ready_set(Region::Active, ready);
5624
5625        let children = [parent + 1, parent + 2, parent + 3];
5626        for &child in &children {
5627            assert!(
5628                engine.test_process_exists(child),
5629                "child {child} must exist right after being minted, before its own first \
5630                 dispatch"
5631            );
5632        }
5633
5634        // Dispatch every child's own queued Start activation in one pass.
5635        let ready = engine.drain_and_sort(Region::Active);
5636        assert_eq!(
5637            ready.len(),
5638            3,
5639            "exactly the 3 freshly-minted children's Start activations must be ready, got {}",
5640            ready.len()
5641        );
5642        engine.dispatch_ready_set(Region::Active, ready);
5643
5644        for &child in &children {
5645            assert!(
5646                !engine.test_process_exists(child),
5647                "child {child} (SpawnSpec::kernel ignored by today's mint_child) must have \
5648                 completed via a bare Suspend::Done on its own very first Start dispatch and \
5649                 been reclaimed -- exactly UnmintedForkChild's own documented behavior, \
5650                 regardless of how large or distinct its own SpawnSpec::kernel index was. A \
5651                 real per-branch compiled kernel is not guaranteed to complete in one dispatch \
5652                 at all, so this failing is the signal that mint_child has started consulting \
5653                 SpawnSpec::kernel for at least one of these values -- update this test (and, \
5654                 per Task 21's own charter, the CLI-level fork/join gate) alongside that change."
5655            );
5656        }
5657    }
5658
5659    // --- Task 9 (A2-part2, CG-102): region_id-consuming partition-aware
5660    // dispatch ---------------------------------------------------------
5661
5662    /// `partition_of` genuinely reads `region_id`, not a hardcoded `0` --
5663    /// real modulo arithmetic against `partition_count`.
5664    #[test]
5665    fn partition_of_is_a_real_modulo_not_a_hardcoded_zero() {
5666        assert_eq!(partition_of(5, 4), PartitionId(1));
5667        assert_eq!(partition_of(6, 4), PartitionId(2));
5668        assert_eq!(partition_of(0, 4), PartitionId(0));
5669    }
5670
5671    /// v1's one real call site always passes `partition_count = 1`
5672    /// (ARCHITECTURE §4 item 1) -- pinned here as a property of the
5673    /// function itself, for several distinct `region_id` values, not an
5674    /// assumption left implicit at the call site.
5675    #[test]
5676    fn partition_of_is_the_documented_v1_degenerate_single_partition_value() {
5677        for region_id in [0, 3, 9] {
5678            assert_eq!(
5679                partition_of(region_id, 1),
5680                PartitionId(0),
5681                "region_id={region_id}: v1's real partition_count=1 call site must always \
5682                 yield PartitionId(0), for any region_id"
5683            );
5684        }
5685    }
5686
5687    /// `partition_of` never divides by zero -- `partition_count == 0`
5688    /// degrades to the same single-partition behavior as `1`, rather than
5689    /// panicking.
5690    #[test]
5691    fn partition_of_with_zero_partition_count_does_not_panic() {
5692        assert_eq!(partition_of(7, 0), PartitionId(0));
5693    }
5694
5695    /// CG-102's scheduler-side literal acceptance ("consumed by the
5696    /// scheduler for partition-aware dispatch"): register 3 processes with
5697    /// distinct `region_id`s (0, 3, 9) via the real `SchedulerControl::
5698    /// register_process` API, dispatch their initial-registration
5699    /// activations, and confirm the spy hook saw every one of their
5700    /// `ProcessId`s queried against exactly the `region_id` each was
5701    /// registered with -- proving the field flows from registration
5702    /// through to dispatch rather than being stored-but-never-read (the
5703    /// literal gap the plan's own "Verified live" review finding named),
5704    /// and would fail if `dispatch_ready_set` were refactored to default
5705    /// every lookup to `0` regardless of what was registered.
5706    #[test]
5707    fn dispatch_ready_set_looks_up_partition_of_using_each_readied_processs_own_stored_region_id() {
5708        let mut engine = new_engine();
5709        let p0 =
5710            engine.register_process(Box::new(UnmintedForkChild), ProcessKind::Initial, None, 0);
5711        let p3 =
5712            engine.register_process(Box::new(UnmintedForkChild), ProcessKind::Initial, None, 3);
5713        let p9 =
5714            engine.register_process(Box::new(UnmintedForkChild), ProcessKind::Initial, None, 9);
5715
5716        // Each `register_process` above already queued its own Start
5717        // activation into Region::Active -- drive exactly one dispatch pass
5718        // over all 3 so `dispatch_ready_set` genuinely runs its grouping
5719        // loop once, over all 3 activations together.
5720        let ready = engine.drain_and_sort(Region::Active);
5721        assert_eq!(
5722            ready.len(),
5723            3,
5724            "all 3 registrations' Start activations must be pending"
5725        );
5726        engine.dispatch_ready_set(Region::Active, ready);
5727
5728        let mut seen = engine.region_id_queries_seen().to_vec();
5729        seen.sort_unstable();
5730        let mut want = vec![(p0, 0u32), (p3, 3u32), (p9, 9u32)];
5731        want.sort_unstable();
5732        assert_eq!(
5733            seen, want,
5734            "dispatch_ready_set must query ProcessTable::region_id for every readied \
5735             process, and every value returned must exactly match what register_process \
5736             stored for it"
5737        );
5738    }
5739
5740    // --- Task 3 (B-sched, ledger SC-004, SC-005, SC-052, SC-053):
5741    // EventTerm::Repeat engine semantics + ##N/clocking-block runtime
5742    // resolution ---
5743
5744    /// SC-052's own literal acceptance, verified against the production
5745    /// `Engine` dispatch loop (not a hand-simulated one): a
5746    /// `repeat(3)@(posedge clk)`-shaped mock process must resume only after
5747    /// the 3rd matching posedge, never the 1st or 2nd, and exactly once
5748    /// after that (not once per remaining posedge -- which would mean a
5749    /// `RepeatOutcome::StillWaiting` firing wrongly fell through to
5750    /// enqueue).
5751    #[test]
5752    fn engine_resumes_a_repeat_3_process_only_on_the_third_matching_edge_not_the_first() {
5753        struct Repeat3Waiter {
5754            cell: StateCellId,
5755            resume_log: Arc<StdMutex<Vec<ResumeArg>>>,
5756        }
5757        impl CompiledProcess for Repeat3Waiter {
5758            fn resume(
5759                &mut self,
5760                _state: SimHandle,
5761                arg: ResumeArg,
5762                _ops: &dyn SchedulerOps,
5763            ) -> Suspend {
5764                self.resume_log.lock().unwrap().push(arg);
5765                match arg {
5766                    ResumeArg::Start => Suspend::WaitEvents(EventWaitSet {
5767                        terms: vec![EventTerm::Repeat {
5768                            count: 3,
5769                            inner: Box::new(EventTerm::Edge {
5770                                cell: self.cell,
5771                                edge: EdgeKind::Posedge,
5772                                iff: None,
5773                            }),
5774                        }],
5775                        ..Default::default()
5776                    }),
5777                    _ => Suspend::Done,
5778                }
5779            }
5780        }
5781
5782        let cell = StateCellId(200);
5783        let mut engine = Engine::new(
5784            Box::new(TestArena::seeded(cell, narrow(0))),
5785            Box::new(TestIoSink::default()),
5786        );
5787        let log: Arc<StdMutex<Vec<ResumeArg>>> = Arc::new(StdMutex::new(Vec::new()));
5788        engine.register_process(
5789            Box::new(Repeat3Waiter {
5790                cell,
5791                resume_log: log.clone(),
5792            }),
5793            ProcessKind::Initial,
5794            None,
5795            0,
5796        );
5797
5798        // Dispatch Start: installs the Repeat{count:3, inner: Edge} dynamic
5799        // wait.
5800        let ready = engine.drain_and_sort(Region::Active);
5801        engine.dispatch_ready_set(Region::Active, ready);
5802        assert_eq!(
5803            log.lock().unwrap().len(),
5804            1,
5805            "only Start's own resume() call must have happened so far"
5806        );
5807
5808        let write = |engine: &Engine, value: FourStateValue| {
5809            let ops = engine.test_dispatch_ops(engine.now(), Region::Active, 0);
5810            ops.commit_blocking_write(cell, value);
5811        };
5812
5813        // Posedge 1 (0 -> 1): StillWaiting -- must NOT enqueue/resume.
5814        write(&engine, narrow(1));
5815        let ready = engine.drain_and_sort(Region::Active);
5816        engine.dispatch_ready_set(Region::Active, ready);
5817        assert_eq!(
5818            log.lock().unwrap().len(),
5819            1,
5820            "the 1st matching posedge must not resume a repeat(3) waiter"
5821        );
5822
5823        // Negedge (1 -> 0): does not match a Posedge-qualified dynamic entry
5824        // at all -- irrelevant filler, mirroring an SV clock's real toggle
5825        // pattern.
5826        write(&engine, narrow(0));
5827
5828        // Posedge 2 (0 -> 1): StillWaiting again -- must still NOT resume.
5829        write(&engine, narrow(1));
5830        let ready = engine.drain_and_sort(Region::Active);
5831        engine.dispatch_ready_set(Region::Active, ready);
5832        assert_eq!(
5833            log.lock().unwrap().len(),
5834            1,
5835            "the 2nd matching posedge must not resume a repeat(3) waiter either"
5836        );
5837
5838        write(&engine, narrow(0));
5839
5840        // Posedge 3 (0 -> 1): Satisfied -- must resume exactly now.
5841        write(&engine, narrow(1));
5842        let ready = engine.drain_and_sort(Region::Active);
5843        engine.dispatch_ready_set(Region::Active, ready);
5844        {
5845            let log = log.lock().unwrap();
5846            assert_eq!(
5847                log.len(),
5848                2,
5849                "the 3rd matching posedge must resume the waiter exactly once"
5850            );
5851            assert!(
5852                matches!(log[1], ResumeArg::EventFired { .. }),
5853                "the resuming call's ResumeArg must be EventFired, got {:?}",
5854                log[1]
5855            );
5856        }
5857        assert_eq!(
5858            engine.test_sensitivity_entry_count(),
5859            0,
5860            "the dynamic entry (and its RepeatCounters row) must be gone once satisfied"
5861        );
5862
5863        // Two more posedges after the process has already resumed and
5864        // returned Suspend::Done (no re-arm): must produce no further
5865        // resume() calls at all -- proves a StillWaiting firing never falls
5866        // through to enqueue, and that a satisfied repeat's row is not
5867        // somehow still alive to double-fire.
5868        write(&engine, narrow(0));
5869        write(&engine, narrow(1));
5870        write(&engine, narrow(0));
5871        write(&engine, narrow(1));
5872        let ready = engine.drain_and_sort(Region::Active);
5873        engine.dispatch_ready_set(Region::Active, ready);
5874        assert_eq!(
5875            log.lock().unwrap().len(),
5876            2,
5877            "no resume() call must happen for any posedge after the repeat(3) waiter \
5878             has already resumed and completed -- exactly once total after the 3rd \
5879             posedge, never once per remaining posedge"
5880        );
5881    }
5882
5883    /// SC-053's own differentiating acceptance clause: "the same compiled
5884    /// `##N` delay produces different absolute tick timing when its
5885    /// associated clock's period changes dynamically at runtime" -- proven
5886    /// here at the scheduler level by driving a deliberately IRREGULAR
5887    /// (non-periodic) clock through a `repeat(2)@(posedge clk)`-shaped mock
5888    /// process and asserting it resumes at the clock's real 2nd posedge tick
5889    /// (12), never a precomputed `3 + 2*period`-style absolute tick a wrong
5890    /// "optimize by caching a tick offset" implementation would produce.
5891    #[test]
5892    fn sc053_repeat_based_wait_re_reads_the_clocks_live_period_not_a_cached_tick_offset() {
5893        #[derive(Clone, Copy, Debug, PartialEq, Eq)]
5894        enum LogTag {
5895            Start,
5896            EventFired,
5897        }
5898
5899        struct RepeatWaiter {
5900            cell: StateCellId,
5901            log: Arc<StdMutex<Vec<(LogTag, SchedTime)>>>,
5902        }
5903        impl CompiledProcess for RepeatWaiter {
5904            fn resume(
5905                &mut self,
5906                _state: SimHandle,
5907                arg: ResumeArg,
5908                ops: &dyn SchedulerOps,
5909            ) -> Suspend {
5910                match arg {
5911                    ResumeArg::Start => {
5912                        self.log.lock().unwrap().push((LogTag::Start, ops.now()));
5913                        Suspend::WaitEvents(EventWaitSet {
5914                            terms: vec![EventTerm::Repeat {
5915                                count: 2,
5916                                inner: Box::new(EventTerm::Edge {
5917                                    cell: self.cell,
5918                                    edge: EdgeKind::Posedge,
5919                                    iff: None,
5920                                }),
5921                            }],
5922                            ..Default::default()
5923                        })
5924                    }
5925                    ResumeArg::EventFired { .. } => {
5926                        self.log
5927                            .lock()
5928                            .unwrap()
5929                            .push((LogTag::EventFired, ops.now()));
5930                        Suspend::Done
5931                    }
5932                    other => panic!("RepeatWaiter must only see Start/EventFired, got {other:?}"),
5933                }
5934            }
5935        }
5936
5937        // Irregular clock: a real signal (not a "##N" literal) whose edges
5938        // land at ticks 3 (posedge), 5 (negedge, irrelevant filler), and 12
5939        // (posedge) -- deliberately never landing on a fixed period, so any
5940        // implementation that tried to precompute
5941        // `first_posedge_tick + (count - 1) * observed_period` would land on
5942        // a wrong tick (3 + 1*2 = 5, which is a negedge, not even a 2nd
5943        // posedge) instead of the real 2nd posedge at 12.
5944        struct IrregularClockWriter {
5945            cell: StateCellId,
5946            step: u32,
5947        }
5948        impl CompiledProcess for IrregularClockWriter {
5949            fn resume(
5950                &mut self,
5951                _state: SimHandle,
5952                arg: ResumeArg,
5953                ops: &dyn SchedulerOps,
5954            ) -> Suspend {
5955                match (self.step, arg) {
5956                    (0, ResumeArg::Start) => {
5957                        self.step = 1;
5958                        Suspend::Delay(3) // fires at tick 3
5959                    }
5960                    (1, ResumeArg::TimeElapsed) => {
5961                        ops.commit_blocking_write(self.cell, narrow(1)); // posedge @ 3
5962                        self.step = 2;
5963                        Suspend::Delay(2) // fires at tick 5
5964                    }
5965                    (2, ResumeArg::TimeElapsed) => {
5966                        ops.commit_blocking_write(self.cell, narrow(0)); // negedge @ 5
5967                        self.step = 3;
5968                        Suspend::Delay(7) // fires at tick 12
5969                    }
5970                    (3, ResumeArg::TimeElapsed) => {
5971                        ops.commit_blocking_write(self.cell, narrow(1)); // posedge @ 12
5972                        self.step = 4;
5973                        Suspend::Done
5974                    }
5975                    (step, other) => {
5976                        panic!("IrregularClockWriter unexpected (step={step}, arg={other:?})")
5977                    }
5978                }
5979            }
5980        }
5981
5982        let cell = StateCellId(201);
5983        let mut engine = Engine::new(
5984            Box::new(TestArena::seeded(cell, narrow(0))),
5985            Box::new(TestIoSink::default()),
5986        );
5987        let log: Arc<StdMutex<Vec<(LogTag, SchedTime)>>> = Arc::new(StdMutex::new(Vec::new()));
5988        engine.register_process(
5989            Box::new(RepeatWaiter {
5990                cell,
5991                log: log.clone(),
5992            }),
5993            ProcessKind::Initial,
5994            None,
5995            0,
5996        );
5997        engine.register_process(
5998            Box::new(IrregularClockWriter { cell, step: 0 }),
5999            ProcessKind::Initial,
6000            None,
6001            0,
6002        );
6003
6004        let result = engine.run_until(None);
6005        assert!(matches!(result, RunResult::Completed));
6006
6007        let log = log.lock().unwrap();
6008        assert_eq!(
6009            *log,
6010            vec![
6011                (LogTag::Start, SchedTime { ticks: 0 }),
6012                (LogTag::EventFired, SchedTime { ticks: 12 }),
6013            ],
6014            "the repeat(2) waiter must resume exactly at tick 12 (the clock's real 2nd \
6015             posedge), never a precomputed offset from a cached period"
6016        );
6017    }
6018
6019    // --- Task 4 (B-sched, ledger SC-090, SC-123, SC-020 scheduler-owned
6020    // half): Postponed/Reactive region-boundary placement: region
6021    // callbacks + `1step` ---
6022
6023    /// SC-090's own literal acceptance: a `$strobe`/`$monitor`-shaped
6024    /// `Region::Postponed` callback sampling a cell written twice within
6025    /// one timestep's delta loop must read only the FINAL, post-delta-
6026    /// convergence value, and must fire exactly once for that timestep --
6027    /// never once per intermediate write (which would mean it ran
6028    /// interleaved with the delta loop, not strictly after it converged).
6029    #[test]
6030    fn postponed_callback_fires_exactly_once_and_only_after_delta_convergence() {
6031        struct TwiceWritingProcess {
6032            cell: StateCellId,
6033        }
6034        impl CompiledProcess for TwiceWritingProcess {
6035            fn resume(
6036                &mut self,
6037                _state: SimHandle,
6038                arg: ResumeArg,
6039                ops: &dyn SchedulerOps,
6040            ) -> Suspend {
6041                match arg {
6042                    ResumeArg::Start => {
6043                        ops.commit_blocking_write(self.cell, narrow(0));
6044                        Suspend::Delay(0)
6045                    }
6046                    ResumeArg::TimeElapsed => {
6047                        ops.commit_blocking_write(self.cell, narrow(1));
6048                        Suspend::Done
6049                    }
6050                    other => panic!("TwiceWritingProcess unexpected arg {other:?}"),
6051                }
6052            }
6053        }
6054
6055        struct ReadCellCallback {
6056            cell: StateCellId,
6057            log: Arc<StdMutex<Vec<FourStateValue>>>,
6058        }
6059        impl RegionCallback for ReadCellCallback {
6060            fn invoke(&mut self, ctx: &dyn SchedulerCtx) {
6061                self.log.lock().unwrap().push(ctx.read(self.cell));
6062            }
6063        }
6064
6065        let cell = StateCellId(300);
6066        let mut engine = Engine::new(
6067            Box::new(TestArena::seeded(cell, narrow(9))),
6068            Box::new(TestIoSink::default()),
6069        );
6070        let seen: Arc<StdMutex<Vec<FourStateValue>>> = Arc::new(StdMutex::new(Vec::new()));
6071        {
6072            let ops = engine.test_dispatch_ops(engine.now(), Region::Active, 0);
6073            ops.register_region_callback(
6074                Region::Postponed,
6075                engine.now(),
6076                Box::new(ReadCellCallback {
6077                    cell,
6078                    log: seen.clone(),
6079                }),
6080            );
6081        }
6082        engine.register_process(
6083            Box::new(TwiceWritingProcess { cell }),
6084            ProcessKind::Initial,
6085            None,
6086            0,
6087        );
6088
6089        let result = engine.run_until(None);
6090        assert!(matches!(result, RunResult::Completed));
6091
6092        let recorded = seen.lock().unwrap();
6093        assert_eq!(
6094            recorded.len(),
6095            1,
6096            "the Postponed callback must fire exactly once for the one timestep this \
6097             run has, not once per intermediate write inside the delta loop"
6098        );
6099        assert!(
6100            matches!(recorded[0], FourStateValue::Narrow(1)),
6101            "must read the FINAL, post-delta-convergence value (1), never the \
6102             intermediate write (0) or the pre-run seed (9); got {:?}",
6103            recorded[0]
6104        );
6105    }
6106
6107    /// Region ordering plus registration-order-per-region: a `Region::
6108    /// Reactive` callback and a `Region::Postponed` callback registered for
6109    /// the same `now` must both fire exactly once, Reactive strictly before
6110    /// Postponed (the same region ordering `run_until` already enforces for
6111    /// ordinary process dispatch), each observing the same final settled
6112    /// value -- proving the two regions' callback drains don't share state
6113    /// that lets one suppress or corrupt the other.
6114    #[test]
6115    fn reactive_and_postponed_callbacks_at_the_same_time_do_not_interfere() {
6116        struct OneShotWriter {
6117            cell: StateCellId,
6118        }
6119        impl CompiledProcess for OneShotWriter {
6120            fn resume(
6121                &mut self,
6122                _state: SimHandle,
6123                _arg: ResumeArg,
6124                ops: &dyn SchedulerOps,
6125            ) -> Suspend {
6126                ops.commit_blocking_write(self.cell, narrow(7));
6127                Suspend::Done
6128            }
6129        }
6130
6131        struct TaggedReadCallback {
6132            tag: &'static str,
6133            cell: StateCellId,
6134            log: Arc<StdMutex<Vec<(&'static str, FourStateValue)>>>,
6135        }
6136        impl RegionCallback for TaggedReadCallback {
6137            fn invoke(&mut self, ctx: &dyn SchedulerCtx) {
6138                self.log
6139                    .lock()
6140                    .unwrap()
6141                    .push((self.tag, ctx.read(self.cell)));
6142            }
6143        }
6144
6145        let cell = StateCellId(301);
6146        let mut engine = Engine::new(
6147            Box::new(TestArena::seeded(cell, narrow(0))),
6148            Box::new(TestIoSink::default()),
6149        );
6150        let log: Arc<StdMutex<Vec<(&'static str, FourStateValue)>>> =
6151            Arc::new(StdMutex::new(Vec::new()));
6152        {
6153            let ops = engine.test_dispatch_ops(engine.now(), Region::Active, 0);
6154            ops.register_region_callback(
6155                Region::Reactive,
6156                engine.now(),
6157                Box::new(TaggedReadCallback {
6158                    tag: "Reactive",
6159                    cell,
6160                    log: log.clone(),
6161                }),
6162            );
6163            ops.register_region_callback(
6164                Region::Postponed,
6165                engine.now(),
6166                Box::new(TaggedReadCallback {
6167                    tag: "Postponed",
6168                    cell,
6169                    log: log.clone(),
6170                }),
6171            );
6172        }
6173        engine.register_process(
6174            Box::new(OneShotWriter { cell }),
6175            ProcessKind::Initial,
6176            None,
6177            0,
6178        );
6179
6180        let result = engine.run_until(None);
6181        assert!(matches!(result, RunResult::Completed));
6182
6183        let recorded = log.lock().unwrap();
6184        assert_eq!(
6185            recorded.len(),
6186            2,
6187            "both callbacks must fire exactly once each"
6188        );
6189        assert_eq!(
6190            recorded[0].0, "Reactive",
6191            "Reactive's own callback must fire before Postponed's"
6192        );
6193        assert_eq!(recorded[1].0, "Postponed");
6194        assert!(matches!(recorded[0].1, FourStateValue::Narrow(7)));
6195        assert!(matches!(recorded[1].1, FourStateValue::Narrow(7)));
6196    }
6197
6198    /// sva T2 (§1.4): `Region::Observed` is drained (via
6199    /// `drain_observed_and_check_settled`) strictly before `Region::
6200    /// Reactive`'s own dispatch/callback drain, within the SAME timestep --
6201    /// the reference algorithm's own stratified-region ordering (Clause
6202    /// 4.5), not merely "eventually, on some later timestep". Registers one
6203    /// tagged callback at each region (no process at all is needed: with
6204    /// nothing registered, Active/Inactive/Nba settle on `run_until`'s very
6205    /// first delta-loop pass, which is exactly the "genuinely idle" point
6206    /// `drain_observed_and_check_settled` is pinned to).
6207    #[test]
6208    fn observed_region_maturation_fires_before_reactive_same_timestep() {
6209        struct TagCallback {
6210            tag: &'static str,
6211            log: Arc<StdMutex<Vec<&'static str>>>,
6212        }
6213        impl RegionCallback for TagCallback {
6214            fn invoke(&mut self, _ctx: &dyn SchedulerCtx) {
6215                self.log.lock().unwrap().push(self.tag);
6216            }
6217        }
6218
6219        let mut engine = new_engine();
6220        let log: Arc<StdMutex<Vec<&'static str>>> = Arc::new(StdMutex::new(Vec::new()));
6221        {
6222            let ops = engine.test_dispatch_ops(engine.now(), Region::Active, 0);
6223            ops.register_region_callback(
6224                Region::Reactive,
6225                engine.now(),
6226                Box::new(TagCallback {
6227                    tag: "Reactive",
6228                    log: log.clone(),
6229                }),
6230            );
6231            ops.register_region_callback(
6232                Region::Observed,
6233                engine.now(),
6234                Box::new(TagCallback {
6235                    tag: "Observed",
6236                    log: log.clone(),
6237                }),
6238            );
6239        }
6240
6241        let result = engine.run_until(None);
6242        assert!(matches!(result, RunResult::Completed));
6243
6244        let recorded = log.lock().unwrap();
6245        assert_eq!(
6246            *recorded,
6247            vec!["Observed", "Reactive"],
6248            "Observed must mature before Reactive within the same settled timestep"
6249        );
6250    }
6251
6252    /// sva T2 (§1.4, review fix, blocking finding 2): a matured report an
6253    /// Observed callback schedules into `Region::Reactive` mid-drain (via
6254    /// `SchedulerOps::register_region_callback`, exactly as `sched::
6255    /// maturation::report` will for a real SVA pass/fail action, §1.7)
6256    /// must already be sitting in `region_callbacks` by the time the
6257    /// single-stepping debugger's OWN NEXT `step_region()` call reaches
6258    /// `Region::Reactive` -- not delayed to a later timestep, and not
6259    /// fired early from inside the Observed drain itself.
6260    #[test]
6261    fn observed_engine_step_result_visible_by_next_reactive_step() {
6262        struct TagCallback {
6263            tag: &'static str,
6264            log: Arc<StdMutex<Vec<&'static str>>>,
6265        }
6266        impl RegionCallback for TagCallback {
6267            fn invoke(&mut self, _ctx: &dyn SchedulerCtx) {
6268                self.log.lock().unwrap().push(self.tag);
6269            }
6270        }
6271
6272        struct ObservedReportsIntoReactive {
6273            log: Arc<StdMutex<Vec<&'static str>>>,
6274        }
6275        impl RegionCallback for ObservedReportsIntoReactive {
6276            fn invoke(&mut self, _ctx: &dyn SchedulerCtx) {
6277                // Stands in for a matured SVA thread's `sv_rt_sva_report_
6278                // pass` (§1.7): the Observed-region engine step schedules a
6279                // Reactive action from inside its own callback, exactly as
6280                // `sched::maturation::report` does for every report kind.
6281                let log = self.log.clone();
6282                crate::sched::with_current_ops(move |ops| {
6283                    ops.register_region_callback(
6284                        Region::Reactive,
6285                        ops.now(),
6286                        Box::new(TagCallback {
6287                            tag: "matured",
6288                            log: log.clone(),
6289                        }),
6290                    );
6291                });
6292            }
6293        }
6294
6295        let mut engine = new_engine();
6296        let log: Arc<StdMutex<Vec<&'static str>>> = Arc::new(StdMutex::new(Vec::new()));
6297        {
6298            let ops = engine.test_dispatch_ops(engine.now(), Region::Active, 0);
6299            ops.register_region_callback(
6300                Region::Observed,
6301                engine.now(),
6302                Box::new(ObservedReportsIntoReactive { log: log.clone() }),
6303            );
6304        }
6305
6306        assert_eq!(engine.current_region(), Region::Active);
6307        engine.step_region(); // Active -> Inactive
6308        assert_eq!(engine.current_region(), Region::Inactive);
6309        engine.step_region(); // Inactive -> Nba
6310        assert_eq!(engine.current_region(), Region::Nba);
6311        engine.step_region(); // Nba settles, drains Observed (schedules
6312        // "matured" into Reactive), -> Reactive.
6313        assert_eq!(engine.current_region(), Region::Reactive);
6314        assert!(
6315            log.lock().unwrap().is_empty(),
6316            "the matured report must not fire before Reactive's own step runs"
6317        );
6318
6319        engine.step_region(); // Reactive drains region callbacks -> Postponed.
6320        assert_eq!(engine.current_region(), Region::Postponed);
6321        assert_eq!(
6322            *log.lock().unwrap(),
6323            vec!["matured"],
6324            "the report registered mid-Observed-drain must already be visible to the very \
6325             next Reactive step"
6326        );
6327    }
6328
6329    /// SC-123's pinned `1step` placement: a process suspended via `1step`
6330    /// timing registers itself as a `Region::Postponed` callback at the
6331    /// current `now` (this test's own mock process does so directly via
6332    /// `ops.register_region_callback`, standing in for the codegen
6333    /// lowering no track emits yet this wave -- see `region_callback`'s
6334    /// own module doc comment). Its continuation must run strictly after
6335    /// every ORDINARY `Region::Postponed` callback registered before it,
6336    /// never interleaved before them -- the exact placement a wrongly-
6337    /// modeled `Suspend::Delay(0)` (which lands in `Region::Inactive`,
6338    /// strictly *before* Postponed even runs) could never produce.
6339    #[test]
6340    fn sc123_1step_continuation_runs_after_every_ordinary_postponed_callback_at_the_same_tick() {
6341        struct TagCallback {
6342            tag: &'static str,
6343            log: Arc<StdMutex<Vec<&'static str>>>,
6344        }
6345        impl RegionCallback for TagCallback {
6346            fn invoke(&mut self, _ctx: &dyn SchedulerCtx) {
6347                self.log.lock().unwrap().push(self.tag);
6348            }
6349        }
6350
6351        struct OneStepProcess {
6352            log: Arc<StdMutex<Vec<&'static str>>>,
6353        }
6354        impl CompiledProcess for OneStepProcess {
6355            fn resume(
6356                &mut self,
6357                _state: SimHandle,
6358                arg: ResumeArg,
6359                ops: &dyn SchedulerOps,
6360            ) -> Suspend {
6361                match arg {
6362                    ResumeArg::Start => Suspend::Delay(0),
6363                    ResumeArg::TimeElapsed => {
6364                        // Pinned placement (region_callback's own module
6365                        // doc): register as a Postponed callback at the
6366                        // current tick, rather than resuming again via a
6367                        // second Suspend::Delay(0) (which would land back
6368                        // in Inactive, the wrong side of Postponed).
6369                        ops.register_region_callback(
6370                            Region::Postponed,
6371                            ops.now(),
6372                            Box::new(TagCallback {
6373                                tag: "1step",
6374                                log: self.log.clone(),
6375                            }),
6376                        );
6377                        Suspend::Done
6378                    }
6379                    other => panic!("OneStepProcess unexpected arg {other:?}"),
6380                }
6381            }
6382        }
6383
6384        let mut engine = new_engine();
6385        let log: Arc<StdMutex<Vec<&'static str>>> = Arc::new(StdMutex::new(Vec::new()));
6386        {
6387            let ops = engine.test_dispatch_ops(engine.now(), Region::Active, 0);
6388            ops.register_region_callback(
6389                Region::Postponed,
6390                engine.now(),
6391                Box::new(TagCallback {
6392                    tag: "A",
6393                    log: log.clone(),
6394                }),
6395            );
6396            ops.register_region_callback(
6397                Region::Postponed,
6398                engine.now(),
6399                Box::new(TagCallback {
6400                    tag: "B",
6401                    log: log.clone(),
6402                }),
6403            );
6404        }
6405        engine.register_process(
6406            Box::new(OneStepProcess { log: log.clone() }),
6407            ProcessKind::Initial,
6408            None,
6409            0,
6410        );
6411
6412        let result = engine.run_until(None);
6413        assert!(matches!(result, RunResult::Completed));
6414
6415        let recorded = log.lock().unwrap();
6416        assert_eq!(
6417            *recorded,
6418            vec!["A", "B", "1step"],
6419            "the 1step continuation must fire strictly after every ordinary \
6420             Postponed callback registered before it, never interleaved before them"
6421        );
6422    }
6423
6424    /// Review fix (Task 4 r1): `Engine::push_region_callback` must reject
6425    /// every region `Engine::drain_region_callbacks` never drains
6426    /// (`Active`/`Inactive`/`Nba`) with a typed
6427    /// `Err(RegionCallbackRegionError::NotDrained(region))`, never silently
6428    /// accept it into `region_callbacks` where it would sit forever and
6429    /// never fire (that was the bug: no error, no panic, no log). Only
6430    /// `Reactive`/`Postponed` -- the two regions that method's own doc
6431    /// comment names -- may `Ok(())`.
6432    #[test]
6433    fn push_region_callback_rejects_every_region_drain_region_callbacks_never_drains() {
6434        struct NeverInvoked;
6435        impl RegionCallback for NeverInvoked {
6436            fn invoke(&mut self, _ctx: &dyn SchedulerCtx) {
6437                panic!("must never be invoked -- the registration itself must be rejected");
6438            }
6439        }
6440
6441        let engine = new_engine();
6442
6443        for &region in &[Region::Active, Region::Inactive, Region::Nba] {
6444            let result = engine.push_region_callback(engine.now(), region, Box::new(NeverInvoked));
6445            assert_eq!(
6446                result,
6447                Err(RegionCallbackRegionError::NotDrained(region)),
6448                "{region:?} is never drained by drain_region_callbacks and must be rejected, \
6449                 not silently accepted"
6450            );
6451        }
6452
6453        // `NeverInvoked` registrations accepted just above are still sitting
6454        // in `engine.region_callbacks`, undrained (this test never calls
6455        // `run_until`/`drain_region_callbacks`) -- dropping `engine` here
6456        // drops them without invoking, so `NeverInvoked::invoke`'s panic
6457        // never fires. That drain-ordering/exactly-once behavior is already
6458        // covered end-to-end by `postponed_callback_fires_exactly_once_and_
6459        // only_after_delta_convergence` and
6460        // `reactive_and_postponed_callbacks_at_the_same_time_do_not_
6461        // interfere`; this test's own scope is `push_region_callback`'s
6462        // accept/reject decision alone.
6463        for &region in &[Region::Reactive, Region::Postponed] {
6464            let result = engine.push_region_callback(engine.now(), region, Box::new(NeverInvoked));
6465            assert_eq!(
6466                result,
6467                Ok(()),
6468                "{region:?} IS drained by drain_region_callbacks and must be accepted"
6469            );
6470        }
6471    }
6472
6473    /// sva T2 (§1.4, review fix, blocking finding 2), Observed's half:
6474    /// `Region::Observed` joins `Reactive`/`Postponed` on the ACCEPTING
6475    /// side -- `drain_observed_and_check_settled` genuinely drains it now,
6476    /// so a registration for it must no longer be rejected.
6477    #[test]
6478    fn push_region_callback_accepts_observed() {
6479        struct NeverInvoked;
6480        impl RegionCallback for NeverInvoked {
6481            fn invoke(&mut self, _ctx: &dyn SchedulerCtx) {
6482                panic!("must never be invoked -- this test only checks the accept decision");
6483            }
6484        }
6485
6486        let engine = new_engine();
6487        let result =
6488            engine.push_region_callback(engine.now(), Region::Observed, Box::new(NeverInvoked));
6489        assert_eq!(
6490            result,
6491            Ok(()),
6492            "Region::Observed IS now drained (by drain_observed_and_check_settled) and must \
6493             be accepted"
6494        );
6495    }
6496
6497    /// sva T2 (§1.4, review fix, blocking finding 2): adding `Observed` to
6498    /// the accepting side must not loosen `Preponed`'s own rejection --
6499    /// `Preponed` stays a pure inline snapshot push, never a registered
6500    /// callback, even after `Observed` becomes real (the two Task 1/Task 2
6501    /// additions could easily have been conflated into one "any SVA
6502    /// region" acceptance by a careless edit; this proves they weren't).
6503    #[test]
6504    fn push_region_callback_still_rejects_preponed_after_observed_added() {
6505        struct NeverInvoked;
6506        impl RegionCallback for NeverInvoked {
6507            fn invoke(&mut self, _ctx: &dyn SchedulerCtx) {
6508                panic!("must never be invoked -- the registration itself must be rejected");
6509            }
6510        }
6511
6512        let engine = new_engine();
6513        let result =
6514            engine.push_region_callback(engine.now(), Region::Preponed, Box::new(NeverInvoked));
6515        assert_eq!(
6516            result,
6517            Err(RegionCallbackRegionError::NotDrained(Region::Preponed)),
6518            "Region::Preponed must still be rejected -- it is a pure inline snapshot push, \
6519             never a registered callback, regardless of Observed's own addition"
6520        );
6521    }
6522
6523    /// Review fix (Task 4 r1): the one production trait boundary,
6524    /// `EngineOps::register_region_callback` (`SchedulerOps`'s impl), must
6525    /// convert `RegionCallbackRegionError` into an immediate, loud panic --
6526    /// never a silent, forever-unfired registration -- because the trait
6527    /// method itself returns `()` and cannot propagate a `Result` (its
6528    /// signature is a cross-track contract many `#[cfg(test)]` mocks outside
6529    /// this crate also implement).
6530    #[test]
6531    #[should_panic(expected = "region must be Reactive, Postponed, or Observed")]
6532    fn register_region_callback_panics_loudly_for_an_undrained_region() {
6533        struct NeverInvoked;
6534        impl RegionCallback for NeverInvoked {
6535            fn invoke(&mut self, _ctx: &dyn SchedulerCtx) {
6536                panic!("must never be invoked -- the registration itself must be rejected");
6537            }
6538        }
6539
6540        let engine = new_engine();
6541        let ops = engine.test_dispatch_ops(engine.now(), Region::Active, 0);
6542        ops.register_region_callback(Region::Active, engine.now(), Box::new(NeverInvoked));
6543    }
6544
6545    // --- Task 5 (B-sched, ledger SC-065, SC-066, SC-047, SC-048, SC-068):
6546    // EventTerm/EventWaitSet shape + static/dynamic sensitivity closure ---
6547
6548    /// SC-068's own literal acceptance, verified against the production
6549    /// `Engine` dispatch loop (not a hand-simulated one): `@(posedge clk iff
6550    /// en)` must not call `resume()` at all on a posedge where `en` reads
6551    /// false at wakeup-consideration time, and must resume it (with
6552    /// `ResumeArg::EventFired`) on the very next posedge where `en` reads
6553    /// true. `PredicateFn` is settled (`docs/CONTRACTS.md` D8) as a bare
6554    /// `extern "C" fn(SimHandle) -> bool`, produced per-callsite by codegen's
6555    /// `compile_iff_guard` -- it carries no `StateCellId` parameter of its
6556    /// own, so this mock predicate stands in for a compiled guard by reading
6557    /// a thread-local flag the test flips directly, exactly mirroring how a
6558    /// real compiled guard would read `en` through the (here, unused-by-the-
6559    /// mock) `SimHandle`. Fails if `EngineNotifySink::notify_change` ever
6560    /// enqueues/resumes without first consulting a `Some(iff)` row's
6561    /// predicate, or evaluates it backwards (blocking on true, allowing on
6562    /// false).
6563    #[test]
6564    fn engine_never_calls_resume_when_iff_is_false_but_does_on_the_next_true_edge() {
6565        thread_local! {
6566            static EN: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
6567        }
6568        extern "C" fn iff_reads_en(_state: SimHandle) -> bool {
6569            EN.with(|c| c.get())
6570        }
6571
6572        struct IffWaiter {
6573            cell: StateCellId,
6574            resume_log: Arc<StdMutex<Vec<ResumeArg>>>,
6575        }
6576        impl CompiledProcess for IffWaiter {
6577            fn resume(
6578                &mut self,
6579                _state: SimHandle,
6580                arg: ResumeArg,
6581                _ops: &dyn SchedulerOps,
6582            ) -> Suspend {
6583                self.resume_log.lock().unwrap().push(arg);
6584                match arg {
6585                    ResumeArg::Start => Suspend::WaitEvents(EventWaitSet {
6586                        terms: vec![EventTerm::Edge {
6587                            cell: self.cell,
6588                            edge: EdgeKind::Posedge,
6589                            iff: Some(crate::process::PredicateFn(iff_reads_en)),
6590                        }],
6591                        ..Default::default()
6592                    }),
6593                    _ => Suspend::Done,
6594                }
6595            }
6596        }
6597
6598        let cell = StateCellId(210);
6599        let mut engine = Engine::new(
6600            Box::new(TestArena::seeded(cell, narrow(0))),
6601            Box::new(TestIoSink::default()),
6602        );
6603        let log: Arc<StdMutex<Vec<ResumeArg>>> = Arc::new(StdMutex::new(Vec::new()));
6604        engine.register_process(
6605            Box::new(IffWaiter {
6606                cell,
6607                resume_log: log.clone(),
6608            }),
6609            ProcessKind::Initial,
6610            None,
6611            0,
6612        );
6613
6614        // Dispatch Start: installs the iff-guarded Edge dynamic wait.
6615        let ready = engine.drain_and_sort(Region::Active);
6616        engine.dispatch_ready_set(Region::Active, ready);
6617        assert_eq!(
6618            log.lock().unwrap().len(),
6619            1,
6620            "only Start's own resume() call must have happened so far"
6621        );
6622
6623        let write = |engine: &Engine, value: FourStateValue| {
6624            let ops = engine.test_dispatch_ops(engine.now(), Region::Active, 0);
6625            ops.commit_blocking_write(cell, value);
6626        };
6627
6628        // en=0: a real posedge occurs, but the predicate reads false --
6629        // must NOT resume.
6630        EN.with(|c| c.set(false));
6631        write(&engine, narrow(1));
6632        let ready = engine.drain_and_sort(Region::Active);
6633        engine.dispatch_ready_set(Region::Active, ready);
6634        assert_eq!(
6635            log.lock().unwrap().len(),
6636            1,
6637            "a posedge with iff=false must not resume() the waiter at all"
6638        );
6639
6640        write(&engine, narrow(0));
6641
6642        // en=1: the next real posedge, predicate now reads true -- must
6643        // resume exactly once, with ResumeArg::EventFired.
6644        EN.with(|c| c.set(true));
6645        write(&engine, narrow(1));
6646        let ready = engine.drain_and_sort(Region::Active);
6647        engine.dispatch_ready_set(Region::Active, ready);
6648        {
6649            let log = log.lock().unwrap();
6650            assert_eq!(
6651                log.len(),
6652                2,
6653                "a posedge with iff=true must resume() the waiter exactly once"
6654            );
6655            assert!(
6656                matches!(log[1], ResumeArg::EventFired { .. }),
6657                "the resuming call's ResumeArg must be EventFired, got {:?}",
6658                log[1]
6659            );
6660        }
6661        assert_eq!(
6662            engine.test_sensitivity_entry_count(),
6663            0,
6664            "the dynamic entry must be gone once the waiter has resumed"
6665        );
6666    }
6667
6668    // --- Task 6 (B-sched, ledger SC-049, SC-050, SC-051, SC-054, SC-069):
6669    // SignalEventControl family construction + named-event trigger + e2e ---
6670
6671    /// SC-069's own literal acceptance, verified against the production
6672    /// `Engine`/`advance_time_to` dispatch path (not a hand-simulated one):
6673    /// a delayed named-event trigger (`->>#5 my_event;`'s scheduler-side
6674    /// shape -- `TimeWheel::arm_trigger` + `Engine::advance_time_to`'s
6675    /// `TimerAction::TriggerEvent` arm) must, once it fires, enqueue a
6676    /// process that is `@(my_event)`-waiting via `EventTerm::
6677    /// NamedEventTrigger` -- and, distinctly, must NOT create or resume any
6678    /// activation for "the triggering process" itself (there is deliberately
6679    /// no second, triggering process registered at all in this test, so any
6680    /// activation showing up in the post-fire ready set other than the
6681    /// waiter's own would prove a wrong implementation that conflated
6682    /// "trigger this event" with "resume whoever armed it" -- the exact
6683    /// regression this test's own plan text names). This is the real,
6684    /// production-loop version of `sched::wheel::tests::
6685    /// resume_and_trigger_entries_at_the_same_tick_both_fire_correctly`'s
6686    /// wheel-only proof, closing the fan-out half that lives in this module,
6687    /// not in `wheel.rs`.
6688    #[test]
6689    fn sc069_a_delayed_trigger_wakes_a_named_event_waiter_not_a_resumed_process() {
6690        struct NamedEventWaiter {
6691            ev: StateCellId,
6692            resume_log: Arc<StdMutex<Vec<ResumeArg>>>,
6693        }
6694        impl CompiledProcess for NamedEventWaiter {
6695            fn resume(
6696                &mut self,
6697                _state: SimHandle,
6698                arg: ResumeArg,
6699                _ops: &dyn SchedulerOps,
6700            ) -> Suspend {
6701                self.resume_log.lock().unwrap().push(arg);
6702                match arg {
6703                    ResumeArg::Start => Suspend::WaitEvents(EventWaitSet {
6704                        terms: vec![EventTerm::NamedEventTrigger(self.ev)],
6705                        ..Default::default()
6706                    }),
6707                    _ => Suspend::Done,
6708                }
6709            }
6710        }
6711
6712        let ev = StateCellId(400);
6713        let mut engine = Engine::new(Box::new(TestArena::new()), Box::new(TestIoSink::default()));
6714        let log: Arc<StdMutex<Vec<ResumeArg>>> = Arc::new(StdMutex::new(Vec::new()));
6715        let waiter = engine.register_process(
6716            Box::new(NamedEventWaiter {
6717                ev,
6718                resume_log: log.clone(),
6719            }),
6720            ProcessKind::Initial,
6721            None,
6722            0,
6723        );
6724
6725        // Dispatch Start: installs the NamedEventTrigger dynamic wait.
6726        let ready = engine.drain_and_sort(Region::Active);
6727        engine.dispatch_ready_set(Region::Active, ready);
6728        assert_eq!(
6729            log.lock().unwrap().len(),
6730            1,
6731            "only Start's own resume() call must have happened so far"
6732        );
6733
6734        // Arm the delayed trigger directly on the wheel (this task's own
6735        // production entry point, `TimeWheel::arm_trigger`) and advance time
6736        // to it through the real `Engine::advance_time_to` -- never a
6737        // hand-simulated fan-out.
6738        engine.wheel.arm_trigger(10, ev);
6739        engine.advance_time_to(10);
6740
6741        let ready = engine.drain_and_sort(Region::Active);
6742        assert_eq!(
6743            ready.len(),
6744            1,
6745            "the fired trigger must enqueue exactly one activation -- the \
6746             waiter's -- never zero (dropped) and never more than one \
6747             (e.g. also resuming some non-existent 'triggering process')"
6748        );
6749        assert_eq!(
6750            ready[0].proc, waiter,
6751            "the one enqueued activation must be the NamedEventTrigger \
6752             waiter's own process id"
6753        );
6754        engine.dispatch_ready_set(Region::Active, ready);
6755        {
6756            let log = log.lock().unwrap();
6757            assert_eq!(
6758                log.len(),
6759                2,
6760                "the waiter must resume exactly once after the trigger fires"
6761            );
6762            assert!(
6763                matches!(log[1], ResumeArg::EventFired { .. }),
6764                "the resuming call's ResumeArg must be EventFired, got {:?}",
6765                log[1]
6766            );
6767        }
6768        assert_eq!(
6769            engine.test_sensitivity_entry_count(),
6770            0,
6771            "the dynamic NamedEventTrigger entry must be gone once the \
6772             waiter has resumed"
6773        );
6774    }
6775
6776    /// Companion to the wheel-only
6777    /// `resume_and_trigger_entries_at_the_same_tick_both_fire_correctly`:
6778    /// proves the SAME same-tick-FIFO guarantee holds through the full
6779    /// `Engine::advance_time_to` fan-out, not just at the raw
6780    /// `TimeWheel::advance_to` level -- a `Resume` entry (an ordinary
6781    /// `#delay`-suspended process) and a `TriggerEvent` entry armed at the
6782    /// same tick must both take effect: the delayed process resumes, and the
6783    /// named-event waiter is woken, from one `advance_time_to` call.
6784    #[test]
6785    fn engine_advance_time_to_fires_a_resume_and_a_trigger_armed_at_the_same_tick() {
6786        struct DoneOnResume {
6787            resume_log: Arc<StdMutex<Vec<ResumeArg>>>,
6788        }
6789        impl CompiledProcess for DoneOnResume {
6790            fn resume(
6791                &mut self,
6792                _state: SimHandle,
6793                arg: ResumeArg,
6794                _ops: &dyn SchedulerOps,
6795            ) -> Suspend {
6796                self.resume_log.lock().unwrap().push(arg);
6797                Suspend::Done
6798            }
6799        }
6800        struct NamedEventWaiter {
6801            ev: StateCellId,
6802            resume_log: Arc<StdMutex<Vec<ResumeArg>>>,
6803        }
6804        impl CompiledProcess for NamedEventWaiter {
6805            fn resume(
6806                &mut self,
6807                _state: SimHandle,
6808                arg: ResumeArg,
6809                _ops: &dyn SchedulerOps,
6810            ) -> Suspend {
6811                self.resume_log.lock().unwrap().push(arg);
6812                match arg {
6813                    ResumeArg::Start => Suspend::WaitEvents(EventWaitSet {
6814                        terms: vec![EventTerm::NamedEventTrigger(self.ev)],
6815                        ..Default::default()
6816                    }),
6817                    _ => Suspend::Done,
6818                }
6819            }
6820        }
6821
6822        let ev = StateCellId(401);
6823        let mut engine = Engine::new(Box::new(TestArena::new()), Box::new(TestIoSink::default()));
6824        let resume_log: Arc<StdMutex<Vec<ResumeArg>>> = Arc::new(StdMutex::new(Vec::new()));
6825        let waiter_log: Arc<StdMutex<Vec<ResumeArg>>> = Arc::new(StdMutex::new(Vec::new()));
6826
6827        let resumed_proc = engine.register_process(
6828            Box::new(DoneOnResume {
6829                resume_log: resume_log.clone(),
6830            }),
6831            ProcessKind::Initial,
6832            None,
6833            0,
6834        );
6835        let waiter = engine.register_process(
6836            Box::new(NamedEventWaiter {
6837                ev,
6838                resume_log: waiter_log.clone(),
6839            }),
6840            ProcessKind::Initial,
6841            None,
6842            0,
6843        );
6844
6845        // Dispatch both Start activations: `resumed_proc` returns
6846        // Suspend::Done (its own Start resume already happened, nothing more
6847        // to arm for it directly -- this test arms its own `Resume` entry on
6848        // the wheel below, standing in for a `Suspend::Delay`-suspended
6849        // continuation), `waiter` installs its NamedEventTrigger wait.
6850        let ready = engine.drain_and_sort(Region::Active);
6851        engine.dispatch_ready_set(Region::Active, ready);
6852        resume_log.lock().unwrap().clear();
6853
6854        let act = PendingActivation {
6855            id: 9_999,
6856            proc: resumed_proc,
6857            arg: ResumeArg::TimeElapsed,
6858            reconv_iter: 0,
6859            logical_order: (SchedTime { ticks: 0 }, Region::Active, 0),
6860        };
6861        engine.wheel.arm(
6862            10,
6863            TimerEntry {
6864                action: TimerAction::Resume(act, Region::Active),
6865            },
6866        );
6867        engine.wheel.arm_trigger(10, ev);
6868        engine.advance_time_to(10);
6869
6870        let ready = engine.drain_and_sort(Region::Active);
6871        let mut procs: Vec<ProcessId> = ready.iter().map(|a| a.proc).collect();
6872        procs.sort_unstable();
6873        let mut want = vec![resumed_proc, waiter];
6874        want.sort_unstable();
6875        assert_eq!(
6876            procs, want,
6877            "both the Resume entry's own process and the TriggerEvent's \
6878             waiter must be enqueued from a single advance_time_to call \
6879             covering the same tick"
6880        );
6881    }
6882
6883    // --- Task 9 (B-sched wave, ledger SC-125) ---
6884
6885    /// A `resume()` that always returns `Suspend::Done` and, if ever called
6886    /// a second time, panics -- standing in for "a process that must never
6887    /// be resumed again," the sanctioned negative-proof shape for
6888    /// `disable fork;`'s own "terminates without ever resuming its
6889    /// descendants" acceptance.
6890    struct NeverResumedTwice(std::sync::atomic::AtomicUsize);
6891    impl NeverResumedTwice {
6892        fn new() -> Self {
6893            NeverResumedTwice(std::sync::atomic::AtomicUsize::new(0))
6894        }
6895    }
6896    impl CompiledProcess for NeverResumedTwice {
6897        fn resume(&mut self, _s: SimHandle, _arg: ResumeArg, _ops: &dyn SchedulerOps) -> Suspend {
6898            let prior = self.0.fetch_add(1, Ordering::SeqCst);
6899            assert_eq!(
6900                prior, 0,
6901                "a fork descendant terminated by disable_fork must never be resumed \
6902                 at all, let alone more than once"
6903            );
6904            Suspend::Done
6905        }
6906    }
6907
6908    fn spawn_from(parent: ProcessId) -> SpawnSpec {
6909        SpawnSpec {
6910            kernel: 0,
6911            entry_pc: 0,
6912            parent,
6913            disable_name: Vec::new(),
6914        }
6915    }
6916
6917    /// SC-125's own literal `disable fork;` acceptance: a parent with 2 live
6918    /// children, one of which (`child_a`) itself has 1 live grandchild (a
6919    /// nested fork) -- the parent's own mock calls `ops.disable_fork(self)`
6920    /// mid-`resume()` and, in that same call, returns `Suspend::Done` (never
6921    /// a suspending variant, proving the call itself does not suspend the
6922    /// caller). Drives the parent's own `Start` through the real `Engine`/
6923    /// `SchedulerControl` dispatch loop (`run_until`) -- the fork tree
6924    /// itself is built directly via `ResumeDriver::handle_fork` before that
6925    /// (this module's own private fields are reachable from this same-file
6926    /// `tests` submodule), the same "construct the tree directly, dispatch
6927    /// only the one process under test" pattern `resume.rs`'s own nested-
6928    /// fork tests already use, needed here because `Suspend::ForkJoin`'s
6929    /// real dispatch path (`apply_suspend`'s own `mint_child`) always mints
6930    /// an immediately-`Done` `UnmintedForkChild` (Task 7's documented
6931    /// interim placeholder), which could never stay live long enough to
6932    /// itself fork a grandchild.
6933    #[test]
6934    fn sc125_disable_fork_terminates_every_live_descendant_without_suspending_the_caller() {
6935        struct DisablingParent {
6936            id_cell: Arc<StdMutex<Option<ProcessId>>>,
6937            resume_count: Arc<std::sync::atomic::AtomicUsize>,
6938        }
6939        impl CompiledProcess for DisablingParent {
6940            fn resume(&mut self, _s: SimHandle, arg: ResumeArg, ops: &dyn SchedulerOps) -> Suspend {
6941                self.resume_count.fetch_add(1, Ordering::SeqCst);
6942                match arg {
6943                    ResumeArg::Start => {
6944                        let id = self
6945                            .id_cell
6946                            .lock()
6947                            .unwrap()
6948                            .expect("id_cell set right after register_process returns");
6949                        ops.disable_fork(id);
6950                        Suspend::Done
6951                    }
6952                    other => panic!("DisablingParent must only ever see Start, got {other:?}"),
6953                }
6954            }
6955        }
6956
6957        let mut engine = new_engine();
6958        let id_cell = Arc::new(StdMutex::new(None));
6959        let resume_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
6960        let parent = engine.register_process(
6961            Box::new(DisablingParent {
6962                id_cell: id_cell.clone(),
6963                resume_count: resume_count.clone(),
6964            }),
6965            ProcessKind::Initial,
6966            None,
6967            0,
6968        );
6969        *id_cell.lock().unwrap() = Some(parent);
6970
6971        // Build the fork tree directly (see this test's own doc comment):
6972        // parent -> [child_a, child_b]; child_a -> [grandchild].
6973        let mut mint =
6974            |_: &SpawnSpec| -> Box<dyn CompiledProcess> { Box::new(NeverResumedTwice::new()) };
6975        let children = engine.driver.handle_fork(
6976            &mut engine.table,
6977            parent,
6978            JoinKind::None,
6979            &[spawn_from(parent), spawn_from(parent)],
6980            &mut mint,
6981        );
6982        assert_eq!(children.len(), 2);
6983        let (child_a, child_b) = (children[0], children[1]);
6984        let grandchildren = engine.driver.handle_fork(
6985            &mut engine.table,
6986            child_a,
6987            JoinKind::None,
6988            &[spawn_from(child_a)],
6989            &mut mint,
6990        );
6991        assert_eq!(grandchildren.len(), 1);
6992        let grandchild = grandchildren[0];
6993
6994        assert!(engine.test_process_exists(child_a));
6995        assert!(engine.test_process_exists(child_b));
6996        assert!(engine.test_process_exists(grandchild));
6997
6998        // `register_process` already queued `parent`'s own Start activation
6999        // -- `child_a`/`child_b`/`grandchild` were registered directly via
7000        // `handle_fork` above, never given a Start activation of their own,
7001        // so this `run_until` dispatches exactly one process: `parent`.
7002        let result = engine.run_until(None);
7003        assert!(matches!(result, RunResult::Completed));
7004
7005        assert_eq!(
7006            resume_count.load(Ordering::SeqCst),
7007            1,
7008            "the parent's own single resume() call must never be re-entered a second \
7009             time by disable_fork itself"
7010        );
7011        assert!(
7012            !engine.test_process_exists(child_a),
7013            "disable_fork must terminate every live child"
7014        );
7015        assert!(
7016            !engine.test_process_exists(child_b),
7017            "disable_fork must terminate every live child"
7018        );
7019        assert!(
7020            !engine.test_process_exists(grandchild),
7021            "disable_fork must terminate a live grandchild too -- one level deep is \
7022             not enough"
7023        );
7024    }
7025
7026    /// Proves `Suspend::WaitFork`'s own scheduler-side *installation*
7027    /// (`Engine::apply_wait_fork`) — distinct from `ResumeDriver::
7028    /// on_child_done`'s own decrement logic, which
7029    /// `sched::resume::tests::sc125_wait_fork_resumes_only_once_every_live_
7030    /// child_completes_not_on_the_first` already covers directly. Builds 2
7031    /// live children via `handle_fork` (bypassing `Suspend::ForkJoin`'s own
7032    /// hardcoded `UnmintedForkChild` mint, per the disable_fork test's own
7033    /// doc comment, so the children stay live across this test's own
7034    /// `apply_suspend` calls), suspends the parent directly via
7035    /// `apply_suspend(parent, Suspend::WaitFork)`, and drives both children
7036    /// to completion via `apply_suspend(child, Suspend::Done)`. Fails if
7037    /// `apply_wait_fork` ever miscounts the live-child set (e.g. counting
7038    /// `fork_children.len()` instead of filtering for still-live entries).
7039    #[test]
7040    fn sc125_apply_suspend_wait_fork_installs_live_count_and_resumes_after_the_last_child() {
7041        struct WaitForkParent {
7042            log: Arc<StdMutex<Vec<&'static str>>>,
7043        }
7044        impl CompiledProcess for WaitForkParent {
7045            fn resume(
7046                &mut self,
7047                _s: SimHandle,
7048                arg: ResumeArg,
7049                _ops: &dyn SchedulerOps,
7050            ) -> Suspend {
7051                match arg {
7052                    ResumeArg::AllForkChildrenDone => {
7053                        self.log.lock().unwrap().push("resumed");
7054                        Suspend::Done
7055                    }
7056                    other => panic!("WaitForkParent: unexpected {other:?}"),
7057                }
7058            }
7059        }
7060
7061        let log = Arc::new(StdMutex::new(Vec::new()));
7062        let mut engine = new_engine();
7063        // Registered directly via `ProcessTable::register` (not
7064        // `register_process`, which would also queue an unrelated `Start`
7065        // activation this test never dispatches -- this test drives `proc`
7066        // entirely through direct `apply_suspend` calls, standing in for a
7067        // real process already mid-way through its own `fork ... join_none;
7068        // ...; wait fork;` sequence).
7069        let parent = engine
7070            .table
7071            .register(Box::new(WaitForkParent { log: log.clone() }));
7072        let mut mint =
7073            |_: &SpawnSpec| -> Box<dyn CompiledProcess> { Box::new(NeverResumedTwice::new()) };
7074        let children = engine.driver.handle_fork(
7075            &mut engine.table,
7076            parent,
7077            JoinKind::None,
7078            &[spawn_from(parent), spawn_from(parent)],
7079            &mut mint,
7080        );
7081        assert_eq!(children.len(), 2);
7082
7083        engine.apply_suspend(parent, Suspend::WaitFork);
7084        assert_eq!(
7085            engine.table.get(parent).unwrap().wait_fork_remaining,
7086            Some(2),
7087            "apply_wait_fork must install the true live-child count"
7088        );
7089
7090        engine.apply_suspend(children[0], Suspend::Done);
7091        assert!(!engine.test_process_exists(children[0]));
7092        assert!(
7093            log.lock().unwrap().is_empty(),
7094            "must not resume the parent after only 1 of 2 live children"
7095        );
7096        assert_eq!(
7097            engine.table.get(parent).unwrap().wait_fork_remaining,
7098            Some(1)
7099        );
7100
7101        engine.apply_suspend(children[1], Suspend::Done);
7102        assert!(!engine.test_process_exists(children[1]));
7103        assert!(
7104            engine
7105                .table
7106                .get(parent)
7107                .unwrap()
7108                .wait_fork_remaining
7109                .is_none(),
7110            "wait_fork_remaining must be cleared the instant the count reaches 0, \
7111             immediately from apply_suspend itself, before the parent's own \
7112             AllForkChildrenDone activation is ever dispatched below"
7113        );
7114        // The last completion's `Suspend::Done` arm only *enqueues*
7115        // `parent`'s `AllForkChildrenDone` activation onto `Region::Active`
7116        // (the same real queue `ForkChildDone` notifications use) -- it does
7117        // not call `resume()` synchronously, so an actual dispatch pass is
7118        // needed to observe it, exactly like `fork_join_all_actually_
7119        // resumes_the_parent_when_its_one_child_completes` above needs
7120        // `run_until` for the identical reason.
7121        let ready = engine.drain_and_sort(Region::Active);
7122        engine.dispatch_ready_set(Region::Active, ready);
7123        assert_eq!(
7124            log.lock().unwrap().as_slice(),
7125            &["resumed"],
7126            "the parent must resume exactly once, only after the 2nd (last) child"
7127        );
7128    }
7129
7130    /// **Mutation-testing addition (B-sched, SC-125).** `apply_wait_fork`'s
7131    /// own doc comment names "the common 'every child already finished
7132    /// before `wait fork;` itself ran' case" -- this proves it directly, for
7133    /// a *mixed* population (one child already done and reclaimed, one still
7134    /// live) rather than the "0 live" or "all live" cases the other two
7135    /// tests here already cover. `live` must be computed by filtering
7136    /// `fork_children` down to those still present in `table` -- counting
7137    /// every spawned child regardless of liveness (e.g. `children.len()`)
7138    /// would install a `wait_fork_remaining` one too high here, and the
7139    /// parent would never resume: its one remaining live child's own
7140    /// completion would decrement to 1, not 0, and `on_child_done` would
7141    /// never report `AllForkChildrenDone`.
7142    #[test]
7143    fn sc125_apply_wait_fork_counts_only_still_live_children_not_every_spawned_child() {
7144        struct WaitForkParent {
7145            log: Arc<StdMutex<Vec<&'static str>>>,
7146        }
7147        impl CompiledProcess for WaitForkParent {
7148            fn resume(
7149                &mut self,
7150                _s: SimHandle,
7151                arg: ResumeArg,
7152                _ops: &dyn SchedulerOps,
7153            ) -> Suspend {
7154                match arg {
7155                    ResumeArg::AllForkChildrenDone => {
7156                        self.log.lock().unwrap().push("resumed");
7157                        Suspend::Done
7158                    }
7159                    other => panic!("WaitForkParent: unexpected {other:?}"),
7160                }
7161            }
7162        }
7163
7164        let log = Arc::new(StdMutex::new(Vec::new()));
7165        let mut engine = new_engine();
7166        let parent = engine
7167            .table
7168            .register(Box::new(WaitForkParent { log: log.clone() }));
7169        let mut mint =
7170            |_: &SpawnSpec| -> Box<dyn CompiledProcess> { Box::new(NeverResumedTwice::new()) };
7171        let children = engine.driver.handle_fork(
7172            &mut engine.table,
7173            parent,
7174            JoinKind::None,
7175            &[spawn_from(parent), spawn_from(parent)],
7176            &mut mint,
7177        );
7178        assert_eq!(children.len(), 2);
7179
7180        // `children[0]` finishes -- and is reclaimed -- *before* `wait
7181        // fork;` itself ever runs. `parent`'s own `fork_children` still
7182        // lists both ids (nothing prunes that list on completion), but only
7183        // `children[1]` is still a live table entry.
7184        engine.apply_suspend(children[0], Suspend::Done);
7185        assert!(!engine.test_process_exists(children[0]));
7186        assert!(engine.test_process_exists(children[1]));
7187
7188        engine.apply_suspend(parent, Suspend::WaitFork);
7189        assert_eq!(
7190            engine.table.get(parent).unwrap().wait_fork_remaining,
7191            Some(1),
7192            "apply_wait_fork must count only the one still-live child, not both \
7193             spawned children"
7194        );
7195
7196        engine.apply_suspend(children[1], Suspend::Done);
7197        assert!(!engine.test_process_exists(children[1]));
7198        assert!(
7199            engine
7200                .table
7201                .get(parent)
7202                .unwrap()
7203                .wait_fork_remaining
7204                .is_none(),
7205            "wait_fork_remaining must reach 0 (and clear) the instant this one live \
7206             child's completion is reported -- not require a second, nonexistent \
7207             completion"
7208        );
7209        let ready = engine.drain_and_sort(Region::Active);
7210        engine.dispatch_ready_set(Region::Active, ready);
7211        assert_eq!(
7212            log.lock().unwrap().as_slice(),
7213            &["resumed"],
7214            "the parent must actually resume once its one live child completes, \
7215             even though one of its two originally-spawned children finished \
7216             earlier"
7217        );
7218    }
7219
7220    /// Proves `Suspend::WaitOrder(order)`'s own scheduler-side
7221    /// *installation* (`Engine::apply_wait_order`) through a real
7222    /// `apply_suspend` call, complementing `sched::resume::tests`'s own
7223    /// direct `on_child_done` proofs for the ordering/violation logic
7224    /// itself.
7225    #[test]
7226    fn sc125_apply_suspend_wait_order_resumes_only_after_the_exact_declared_order() {
7227        struct WaitOrderParent {
7228            log: Arc<StdMutex<Vec<&'static str>>>,
7229        }
7230        impl CompiledProcess for WaitOrderParent {
7231            fn resume(
7232                &mut self,
7233                _s: SimHandle,
7234                arg: ResumeArg,
7235                _ops: &dyn SchedulerOps,
7236            ) -> Suspend {
7237                match arg {
7238                    ResumeArg::WaitOrderSatisfied => {
7239                        self.log.lock().unwrap().push("resumed");
7240                        Suspend::Done
7241                    }
7242                    other => panic!("WaitOrderParent: unexpected {other:?}"),
7243                }
7244            }
7245        }
7246
7247        let log = Arc::new(StdMutex::new(Vec::new()));
7248        let mut engine = new_engine();
7249        // See the identical `ProcessTable::register` note on
7250        // `sc125_apply_suspend_wait_fork_installs_live_count_and_resumes_
7251        // after_the_last_child` above.
7252        let parent = engine
7253            .table
7254            .register(Box::new(WaitOrderParent { log: log.clone() }));
7255        let mut mint =
7256            |_: &SpawnSpec| -> Box<dyn CompiledProcess> { Box::new(NeverResumedTwice::new()) };
7257        let children = engine.driver.handle_fork(
7258            &mut engine.table,
7259            parent,
7260            JoinKind::None,
7261            &[spawn_from(parent), spawn_from(parent), spawn_from(parent)],
7262            &mut mint,
7263        );
7264        assert_eq!(children.len(), 3);
7265        let order = children.clone();
7266
7267        engine.apply_suspend(parent, Suspend::WaitOrder(order));
7268        assert!(engine.table.get(parent).unwrap().wait_order.is_some());
7269
7270        engine.apply_suspend(children[0], Suspend::Done);
7271        engine.apply_suspend(children[1], Suspend::Done);
7272        assert!(
7273            log.lock().unwrap().is_empty(),
7274            "must not resume before the 3rd, in-order completion"
7275        );
7276
7277        engine.apply_suspend(children[2], Suspend::Done);
7278        // The 3rd completion's `Suspend::Done` arm only *enqueues* the
7279        // parent's `WaitOrderSatisfied` activation onto `Region::Active` --
7280        // see the identical note on `sc125_apply_suspend_wait_fork_
7281        // installs_live_count_and_resumes_after_the_last_child` above.
7282        let ready = engine.drain_and_sort(Region::Active);
7283        engine.dispatch_ready_set(Region::Active, ready);
7284        assert_eq!(log.lock().unwrap().as_slice(), &["resumed"]);
7285    }
7286
7287    // --- Classes plan Task 13 (LRM 9.7): the `process` class ---
7288    //
7289    // Every test drives the real `Engine` dispatch loop (`run_until`) with
7290    // scripted processes that call the same `SchedulerOps` entry points the
7291    // compiled `process` methods reach (`process_track` / `process_request`
7292    // / `process_state`), so the Kill/Suspend/Resume/Await/status mechanics
7293    // are pinned without any codegen. ProcessIds are the registration order
7294    // (0, 1, 2, ...), asserted on every `register_process` return.
7295
7296    type ProcLog = Arc<StdMutex<Vec<String>>>;
7297
7298    /// A process whose every `resume()` is one call of a closure.
7299    type ScriptFn = Box<dyn FnMut(ResumeArg, &dyn SchedulerOps) -> Suspend + Send>;
7300    struct Script(ScriptFn);
7301    impl CompiledProcess for Script {
7302        fn resume(&mut self, _s: SimHandle, arg: ResumeArg, ops: &dyn SchedulerOps) -> Suspend {
7303            (self.0)(arg, ops)
7304        }
7305    }
7306
7307    fn script(
7308        f: impl FnMut(ResumeArg, &dyn SchedulerOps) -> Suspend + Send + 'static,
7309    ) -> Box<dyn CompiledProcess> {
7310        Box::new(Script(Box::new(f)))
7311    }
7312
7313    fn new_log() -> ProcLog {
7314        Arc::new(StdMutex::new(Vec::new()))
7315    }
7316
7317    fn lines(log: &ProcLog) -> Vec<String> {
7318        log.lock().unwrap().clone()
7319    }
7320
7321    fn state_of(ops: &dyn SchedulerOps, target: ProcessId) -> u8 {
7322        ops.process_state(target) as u8
7323    }
7324
7325    fn register_expecting(engine: &mut Engine, proc: Box<dyn CompiledProcess>, want: u32) {
7326        let id = engine.register_process(proc, ProcessKind::Initial, None, 0);
7327        assert_eq!(id, want, "tests hard-code ProcessIds as registration order");
7328    }
7329
7330    /// LRM 9.7.3: `kill()` terminates the target and ALL its descendants
7331    /// before it returns, and `status()` reads KILLED in the very segment
7332    /// that called `kill()`. The victim's pending timer must never fire.
7333    #[test]
7334    fn process_kill_terminates_target_and_descendants_and_reads_killed() {
7335        let mut engine = new_engine();
7336        let log = new_log();
7337
7338        let l0 = log.clone();
7339        register_expecting(
7340            &mut engine,
7341            script(move |arg, ops| match arg {
7342                ResumeArg::Start => {
7343                    ops.process_track(0);
7344                    Suspend::Delay(100)
7345                }
7346                other => {
7347                    l0.lock().unwrap().push(format!("victim resumed {other:?}"));
7348                    Suspend::Done
7349                }
7350            }),
7351            0,
7352        );
7353        let (l1, mut step) = (log.clone(), 0);
7354        register_expecting(
7355            &mut engine,
7356            script(move |arg, ops| {
7357                step += 1;
7358                match (step, arg) {
7359                    (1, ResumeArg::Start) => Suspend::Delay(10),
7360                    (2, ResumeArg::TimeElapsed) => {
7361                        let before = state_of(ops, 0);
7362                        ops.process_request(1, ProcessRequest::Kill(0));
7363                        let after = state_of(ops, 0);
7364                        l1.lock().unwrap().push(format!(
7365                            "t={} before={before} after={after}",
7366                            ops.now().ticks
7367                        ));
7368                        Suspend::ByRequest
7369                    }
7370                    (3, ResumeArg::TimeElapsed) => {
7371                        l1.lock()
7372                            .unwrap()
7373                            .push(format!("resumed state={}", state_of(ops, 0)));
7374                        Suspend::Done
7375                    }
7376                    other => panic!("killer: unexpected {other:?}"),
7377                }
7378            }),
7379            1,
7380        );
7381
7382        // victim (0) -> child (2) -> grandchild (3), built directly.
7383        let mut mint =
7384            |_: &SpawnSpec| -> Box<dyn CompiledProcess> { Box::new(NeverResumedTwice::new()) };
7385        let kids = engine.driver.handle_fork(
7386            &mut engine.table,
7387            0,
7388            JoinKind::None,
7389            &[spawn_from(0)],
7390            &mut mint,
7391        );
7392        let grand = engine.driver.handle_fork(
7393            &mut engine.table,
7394            kids[0],
7395            JoinKind::None,
7396            &[spawn_from(kids[0])],
7397            &mut mint,
7398        );
7399        assert_eq!((kids[0], grand[0]), (2, 3));
7400
7401        assert!(matches!(engine.run_until(None), RunResult::Completed));
7402        assert_eq!(
7403            lines(&log),
7404            // The victim is WAITING on its delay (2); the overlay turns it
7405            // KILLED (4) at once.
7406            ["t=10 before=2 after=4", "resumed state=4"],
7407            "kill must be visible to status() in the same segment, and the \
7408             victim's Delay(100) must never resume it"
7409        );
7410        for id in [0, 2, 3] {
7411            assert!(!engine.test_process_exists(id), "process {id} must be gone");
7412        }
7413        assert_eq!(engine.effective_process_state(0), ProcessState::Killed);
7414        assert_eq!(
7415            engine.effective_process_state(3),
7416            ProcessState::Killed,
7417            "an untracked descendant killed with its ancestor reads KILLED too"
7418        );
7419    }
7420
7421    /// LRM 9.7.3: `kill()` on a process that already ended still terminates
7422    /// its live descendants, and the target keeps reading FINISHED.
7423    #[test]
7424    fn process_kill_of_a_finished_process_still_kills_its_live_descendants() {
7425        let mut engine = new_engine();
7426        register_expecting(
7427            &mut engine,
7428            script(|_, ops| {
7429                ops.process_track(0);
7430                Suspend::Done
7431            }),
7432            0,
7433        );
7434        let mut step = 0;
7435        register_expecting(
7436            &mut engine,
7437            script(move |_, ops| {
7438                step += 1;
7439                match step {
7440                    1 => Suspend::Delay(10),
7441                    2 => {
7442                        ops.process_request(1, ProcessRequest::Kill(0));
7443                        Suspend::ByRequest
7444                    }
7445                    _ => Suspend::Done,
7446                }
7447            }),
7448            1,
7449        );
7450        let mut mint =
7451            |_: &SpawnSpec| -> Box<dyn CompiledProcess> { Box::new(NeverResumedTwice::new()) };
7452        let kids = engine.driver.handle_fork(
7453            &mut engine.table,
7454            0,
7455            JoinKind::None,
7456            &[spawn_from(0)],
7457            &mut mint,
7458        );
7459        assert!(engine.test_process_exists(kids[0]));
7460        assert!(matches!(engine.run_until(None), RunResult::Completed));
7461        assert!(
7462            !engine.test_process_exists(kids[0]),
7463            "the live descendant of an already-finished process must die"
7464        );
7465        assert_eq!(engine.effective_process_state(0), ProcessState::Finished);
7466    }
7467
7468    /// LRM 9.7.1: a process may kill itself; it ends there (`process_request`
7469    /// reports it so the compiled caller can stop) and reads KILLED.
7470    #[test]
7471    fn process_self_kill_ends_the_caller_and_reports_it() {
7472        let mut engine = new_engine();
7473        let log = new_log();
7474        let l0 = log.clone();
7475        register_expecting(
7476            &mut engine,
7477            script(move |_, ops| {
7478                ops.process_track(0);
7479                let ends_me = ops.process_request(0, ProcessRequest::Kill(0));
7480                l0.lock().unwrap().push(format!("ends_requester={ends_me}"));
7481                Suspend::Done
7482            }),
7483            0,
7484        );
7485        assert!(matches!(engine.run_until(None), RunResult::Completed));
7486        assert_eq!(lines(&log), ["ends_requester=true"]);
7487        assert!(!engine.test_process_exists(0));
7488        assert_eq!(engine.effective_process_state(0), ProcessState::Killed);
7489    }
7490
7491    /// Killing the children a `wait fork` is blocked on releases the parent
7492    /// exactly once, only after the last one is gone.
7493    #[test]
7494    fn process_kill_of_fork_children_releases_the_wait_fork_parent() {
7495        let mut engine = new_engine();
7496        let log = new_log();
7497        let l0 = log.clone();
7498        register_expecting(
7499            &mut engine,
7500            script(move |arg, ops| match arg {
7501                ResumeArg::Start => Suspend::WaitFork,
7502                ResumeArg::AllForkChildrenDone => {
7503                    l0.lock()
7504                        .unwrap()
7505                        .push(format!("joined@{}", ops.now().ticks));
7506                    Suspend::Done
7507                }
7508                other => panic!("parent: unexpected {other:?}"),
7509            }),
7510            0,
7511        );
7512        let mut step = 0;
7513        register_expecting(
7514            &mut engine,
7515            script(move |_, ops| {
7516                step += 1;
7517                match step {
7518                    1 => Suspend::Delay(10),
7519                    2 => {
7520                        ops.process_request(1, ProcessRequest::Kill(2));
7521                        ops.process_request(1, ProcessRequest::Kill(3));
7522                        Suspend::ByRequest
7523                    }
7524                    _ => Suspend::Done,
7525                }
7526            }),
7527            1,
7528        );
7529        let mut mint =
7530            |_: &SpawnSpec| -> Box<dyn CompiledProcess> { Box::new(NeverResumedTwice::new()) };
7531        let kids = engine.driver.handle_fork(
7532            &mut engine.table,
7533            0,
7534            JoinKind::None,
7535            &[spawn_from(0), spawn_from(0)],
7536            &mut mint,
7537        );
7538        assert_eq!(kids, vec![2, 3]);
7539        assert!(matches!(engine.run_until(None), RunResult::Completed));
7540        assert_eq!(lines(&log), ["joined@10"]);
7541    }
7542
7543    /// LRM 9.7: `status()` is RUNNING for the caller, WAITING while the
7544    /// target is blocked, FINISHED once it ended.
7545    #[test]
7546    fn process_status_tracks_running_waiting_finished() {
7547        let mut engine = new_engine();
7548        let log = new_log();
7549        let l0 = log.clone();
7550        let mut step = 0;
7551        register_expecting(
7552            &mut engine,
7553            script(move |_, ops| {
7554                step += 1;
7555                match step {
7556                    1 => {
7557                        ops.process_track(0);
7558                        l0.lock()
7559                            .unwrap()
7560                            .push(format!("self={}", state_of(ops, 0)));
7561                        Suspend::Delay(10)
7562                    }
7563                    _ => Suspend::Done,
7564                }
7565            }),
7566            0,
7567        );
7568        let l1 = log.clone();
7569        let mut step = 0;
7570        register_expecting(
7571            &mut engine,
7572            script(move |_, ops| {
7573                step += 1;
7574                match step {
7575                    1 => Suspend::Delay(5),
7576                    2 => {
7577                        l1.lock().unwrap().push(format!("@5={}", state_of(ops, 0)));
7578                        Suspend::Delay(20)
7579                    }
7580                    _ => {
7581                        l1.lock().unwrap().push(format!("@25={}", state_of(ops, 0)));
7582                        Suspend::Done
7583                    }
7584                }
7585            }),
7586            1,
7587        );
7588        assert!(matches!(engine.run_until(None), RunResult::Completed));
7589        assert_eq!(lines(&log), ["self=1", "@5=2", "@25=0"]);
7590    }
7591
7592    /// LRM 9.7: `await()` blocks until the target ends; a target that
7593    /// already ended, or the caller itself, blocks nothing.
7594    #[test]
7595    fn process_await_blocks_until_the_target_ends() {
7596        let mut engine = new_engine();
7597        let log = new_log();
7598        let mut step = 0;
7599        register_expecting(
7600            &mut engine,
7601            script(move |_, ops| {
7602                step += 1;
7603                if step == 1 {
7604                    ops.process_track(0);
7605                    Suspend::Delay(30)
7606                } else {
7607                    Suspend::Done
7608                }
7609            }),
7610            0,
7611        );
7612        let (l1, mut step) = (log.clone(), 0);
7613        register_expecting(
7614            &mut engine,
7615            script(move |_, ops| {
7616                step += 1;
7617                match step {
7618                    1 => Suspend::Delay(5),
7619                    2 => {
7620                        ops.process_request(1, ProcessRequest::Await(0));
7621                        l1.lock().unwrap().push("awaiting@5".into());
7622                        Suspend::ByRequest
7623                    }
7624                    _ => {
7625                        l1.lock()
7626                            .unwrap()
7627                            .push(format!("awaited@{}", ops.now().ticks));
7628                        Suspend::Done
7629                    }
7630                }
7631            }),
7632            1,
7633        );
7634        let (l2, mut step) = (log.clone(), 0);
7635        register_expecting(
7636            &mut engine,
7637            script(move |_, ops| {
7638                step += 1;
7639                match step {
7640                    1 => Suspend::Delay(50),
7641                    2 => {
7642                        ops.process_request(2, ProcessRequest::Await(0));
7643                        Suspend::ByRequest
7644                    }
7645                    _ => {
7646                        l2.lock().unwrap().push(format!("late@{}", ops.now().ticks));
7647                        Suspend::Done
7648                    }
7649                }
7650            }),
7651            2,
7652        );
7653        let (l3, mut step) = (log.clone(), 0);
7654        register_expecting(
7655            &mut engine,
7656            script(move |_, ops| {
7657                step += 1;
7658                if step == 1 {
7659                    ops.process_request(3, ProcessRequest::Await(3));
7660                    Suspend::ByRequest
7661                } else {
7662                    l3.lock().unwrap().push(format!("self@{}", ops.now().ticks));
7663                    Suspend::Done
7664                }
7665            }),
7666            3,
7667        );
7668        assert!(matches!(engine.run_until(None), RunResult::Completed));
7669        assert_eq!(
7670            lines(&log),
7671            ["self@0", "awaiting@5", "awaited@30", "late@50"],
7672            "await wakes exactly at the target's end; an ended target / self \
7673             never blocks"
7674        );
7675    }
7676
7677    /// LRM 9.7.4 / 9.7.5: a suspended process stops advancing; `resume()`
7678    /// continues it; `resume()` on a process that is not suspended is a
7679    /// no-op.
7680    #[test]
7681    fn process_suspend_resume_periodic_process() {
7682        let mut engine = new_engine();
7683        let log = new_log();
7684        let (l0, mut ticks) = (log.clone(), 0);
7685        register_expecting(
7686            &mut engine,
7687            script(move |arg, ops| match arg {
7688                ResumeArg::Start => {
7689                    ops.process_track(0);
7690                    Suspend::Delay(10)
7691                }
7692                _ => {
7693                    ticks += 1;
7694                    l0.lock().unwrap().push(format!("tick@{}", ops.now().ticks));
7695                    if ticks == 4 {
7696                        Suspend::Done
7697                    } else {
7698                        Suspend::Delay(10)
7699                    }
7700                }
7701            }),
7702            0,
7703        );
7704        let (l1, mut step) = (log.clone(), 0);
7705        register_expecting(
7706            &mut engine,
7707            script(move |_, ops| {
7708                step += 1;
7709                match step {
7710                    1 => Suspend::Delay(5),
7711                    2 => {
7712                        // Not suspended: no effect.
7713                        ops.process_request(1, ProcessRequest::Resume(0));
7714                        Suspend::ByRequest
7715                    }
7716                    3 => Suspend::Delay(10),
7717                    4 => {
7718                        ops.process_request(1, ProcessRequest::Suspend(0));
7719                        l1.lock()
7720                            .unwrap()
7721                            .push(format!("@15 state={}", state_of(ops, 0)));
7722                        Suspend::ByRequest
7723                    }
7724                    5 => Suspend::Delay(30),
7725                    6 => {
7726                        l1.lock()
7727                            .unwrap()
7728                            .push(format!("@45 state={}", state_of(ops, 0)));
7729                        ops.process_request(1, ProcessRequest::Resume(0));
7730                        l1.lock()
7731                            .unwrap()
7732                            .push(format!("resumed state={}", state_of(ops, 0)));
7733                        Suspend::ByRequest
7734                    }
7735                    _ => Suspend::Done,
7736                }
7737            }),
7738            1,
7739        );
7740        assert!(matches!(engine.run_until(None), RunResult::Completed));
7741        assert_eq!(
7742            lines(&log),
7743            [
7744                "tick@10",
7745                "@15 state=3",
7746                "@45 state=3",
7747                "resumed state=2",
7748                "tick@45",
7749                "tick@55",
7750                "tick@65",
7751            ],
7752            "the tick due at 20 is held while suspended and replayed at the \
7753             resume (45); then the 10-unit period continues"
7754        );
7755    }
7756
7757    /// A process may suspend itself (`ByRequest` parks it) and another
7758    /// process resumes it.
7759    #[test]
7760    fn process_self_suspend_is_released_by_another_process() {
7761        let mut engine = new_engine();
7762        let log = new_log();
7763        let (l0, mut step) = (log.clone(), 0);
7764        register_expecting(
7765            &mut engine,
7766            script(move |_, ops| {
7767                step += 1;
7768                if step == 1 {
7769                    ops.process_track(0);
7770                    ops.process_request(0, ProcessRequest::Suspend(0));
7771                    l0.lock().unwrap().push("p0 suspending".into());
7772                    Suspend::ByRequest
7773                } else {
7774                    l0.lock()
7775                        .unwrap()
7776                        .push(format!("p0 resumed@{}", ops.now().ticks));
7777                    Suspend::Done
7778                }
7779            }),
7780            0,
7781        );
7782        let (l1, mut step) = (log.clone(), 0);
7783        register_expecting(
7784            &mut engine,
7785            script(move |_, ops| {
7786                step += 1;
7787                match step {
7788                    1 => Suspend::Delay(20),
7789                    2 => {
7790                        l1.lock()
7791                            .unwrap()
7792                            .push(format!("p0 state={}", state_of(ops, 0)));
7793                        ops.process_request(1, ProcessRequest::Resume(0));
7794                        Suspend::ByRequest
7795                    }
7796                    _ => Suspend::Done,
7797                }
7798            }),
7799            1,
7800        );
7801        assert!(matches!(engine.run_until(None), RunResult::Completed));
7802        assert_eq!(
7803            lines(&log),
7804            ["p0 suspending", "p0 state=3", "p0 resumed@20"]
7805        );
7806    }
7807
7808    fn new_semaphore(keys: i64, template: u32) -> u64 {
7809        let h = crate::objects::alloc(
7810            crate::netlist::ClassTemplateId(template),
7811            semaphore::SEMAPHORE_FIELD_WORDS,
7812        );
7813        assert!(semaphore::init(h, keys));
7814        h
7815    }
7816
7817    /// Classes plan Task 17 (LRM 15.3.3/15.3.4): a `get` that must wait yields
7818    /// (`SyncBlock` + `ByRequest`) and reads WAITING until the `put` that
7819    /// grants it queues `SyncWake`, which resumes it at the putter's time.
7820    #[test]
7821    fn semaphore_get_blocks_until_a_put_grants_it() {
7822        let h = new_semaphore(0, 17_100);
7823        let mut engine = new_engine();
7824        let log = new_log();
7825        let (l0, mut step) = (log.clone(), 0);
7826        register_expecting(
7827            &mut engine,
7828            script(move |_, ops| {
7829                step += 1;
7830                if step == 1 {
7831                    let blocked = semaphore::get(h, 0, 1, &mut |r| {
7832                        ops.process_request(0, r);
7833                    });
7834                    assert!(blocked, "an empty semaphore blocks");
7835                    Suspend::ByRequest
7836                } else {
7837                    l0.lock().unwrap().push(format!("got@{}", ops.now().ticks));
7838                    Suspend::Done
7839                }
7840            }),
7841            0,
7842        );
7843        let (l1, mut step) = (log.clone(), 0);
7844        register_expecting(
7845            &mut engine,
7846            script(move |_, ops| {
7847                step += 1;
7848                match step {
7849                    1 => Suspend::Delay(7),
7850                    _ => {
7851                        l1.lock()
7852                            .unwrap()
7853                            .push(format!("before put: state={}", state_of(ops, 0)));
7854                        assert!(semaphore::put(h, 1, &mut |r| {
7855                            ops.process_request(1, r);
7856                        }));
7857                        Suspend::Done
7858                    }
7859                }
7860            }),
7861            1,
7862        );
7863        assert!(matches!(engine.run_until(None), RunResult::Completed));
7864        assert_eq!(lines(&log), ["before put: state=2", "got@7"]);
7865        assert_eq!(semaphore::keys(h), Some(0));
7866    }
7867
7868    /// A process killed while blocked leaves the queue; a waiter behind it
7869    /// that the freed head was holding back is released at the kill.
7870    #[test]
7871    fn killing_a_blocked_get_releases_the_waiter_behind_it() {
7872        let h = new_semaphore(1, 17_101);
7873        let mut engine = new_engine();
7874        let log = new_log();
7875        let mut step = 0;
7876        register_expecting(
7877            &mut engine,
7878            script(move |_, ops| {
7879                step += 1;
7880                if step == 1 {
7881                    assert!(semaphore::get(h, 0, 2, &mut |r| {
7882                        ops.process_request(0, r);
7883                    }));
7884                    Suspend::ByRequest
7885                } else {
7886                    panic!("the killed waiter must never resume");
7887                }
7888            }),
7889            0,
7890        );
7891        let (l1, mut step) = (log.clone(), 0);
7892        register_expecting(
7893            &mut engine,
7894            script(move |_, ops| {
7895                step += 1;
7896                if step == 1 {
7897                    assert!(
7898                        semaphore::get(h, 1, 1, &mut |r| {
7899                            ops.process_request(1, r);
7900                        }),
7901                        "FIFO: queued behind the get(2) although a key is free"
7902                    );
7903                    Suspend::ByRequest
7904                } else {
7905                    l1.lock()
7906                        .unwrap()
7907                        .push(format!("p1 got@{}", ops.now().ticks));
7908                    Suspend::Done
7909                }
7910            }),
7911            1,
7912        );
7913        let (l2, mut step) = (log.clone(), 0);
7914        register_expecting(
7915            &mut engine,
7916            script(move |_, ops| {
7917                step += 1;
7918                match step {
7919                    1 => Suspend::Delay(5),
7920                    2 => {
7921                        l2.lock()
7922                            .unwrap()
7923                            .push(format!("waiters before kill: {}", semaphore::waiters(h)));
7924                        ops.process_request(2, ProcessRequest::Kill(0));
7925                        Suspend::ByRequest
7926                    }
7927                    _ => {
7928                        l2.lock()
7929                            .unwrap()
7930                            .push(format!("waiters after kill: {}", semaphore::waiters(h)));
7931                        Suspend::Done
7932                    }
7933                }
7934            }),
7935            2,
7936        );
7937        assert!(matches!(engine.run_until(None), RunResult::Completed));
7938        assert_eq!(
7939            lines(&log),
7940            [
7941                "waiters before kill: 2",
7942                // The killer continues at once; the released waiter is
7943                // activated, so it runs after the killer's segment.
7944                "waiters after kill: 0",
7945                "p1 got@5",
7946            ]
7947        );
7948        assert_eq!(semaphore::keys(h), Some(0));
7949    }
7950
7951    /// A process suspended with `p.suspend()` while blocked in `get` is
7952    /// granted its keys but stays parked until `p.resume()`.
7953    #[test]
7954    fn a_granted_get_of_a_suspended_process_waits_for_resume() {
7955        let h = new_semaphore(0, 17_102);
7956        let mut engine = new_engine();
7957        let log = new_log();
7958        let (l0, mut step) = (log.clone(), 0);
7959        register_expecting(
7960            &mut engine,
7961            script(move |_, ops| {
7962                step += 1;
7963                if step == 1 {
7964                    ops.process_track(0);
7965                    assert!(semaphore::get(h, 0, 1, &mut |r| {
7966                        ops.process_request(0, r);
7967                    }));
7968                    Suspend::ByRequest
7969                } else {
7970                    l0.lock().unwrap().push(format!("got@{}", ops.now().ticks));
7971                    Suspend::Done
7972                }
7973            }),
7974            0,
7975        );
7976        let mut step = 0;
7977        register_expecting(
7978            &mut engine,
7979            script(move |_, ops| {
7980                step += 1;
7981                match step {
7982                    1 => Suspend::Delay(1),
7983                    2 => {
7984                        ops.process_request(1, ProcessRequest::Suspend(0));
7985                        Suspend::ByRequest
7986                    }
7987                    3 => Suspend::Delay(1),
7988                    4 => {
7989                        semaphore::put(h, 1, &mut |r| {
7990                            ops.process_request(1, r);
7991                        });
7992                        Suspend::Delay(8)
7993                    }
7994                    5 => {
7995                        ops.process_request(1, ProcessRequest::Resume(0));
7996                        Suspend::ByRequest
7997                    }
7998                    _ => Suspend::Done,
7999                }
8000            }),
8001            1,
8002        );
8003        assert!(matches!(engine.run_until(None), RunResult::Completed));
8004        assert_eq!(lines(&log), ["got@10"]);
8005    }
8006}