Skip to main content

sv_sim_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 `SV_SIM_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 sv_sim_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` (`sv-sim-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    /// (`sv-sim-bin::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 `sv-sim-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/// (`sv-sim-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: sv_sim_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, ..)` (`sv-sim-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 (`sv-sim-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/// `sv-sim-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            sv_sim_abi::entry::SIM_INIT_SYMBOL,
3702            "Engine::new (+ SchedulerControl::register_process for each elaborated process)",
3703        ),
3704        (
3705            sv_sim_abi::entry::SIM_SETTLE_SYMBOL,
3706            "Engine::run_until(Some(now)) -- settle all zero-delay activity at the current time",
3707        ),
3708        (
3709            sv_sim_abi::entry::SIM_EVAL_REGION_SYMBOL,
3710            "Engine::step_region",
3711        ),
3712        (
3713            sv_sim_abi::entry::SIM_COMMIT_NBA_SYMBOL,
3714            "Engine::<internal NBA commit pass -- commit_nba, Task 7/13>",
3715        ),
3716        (
3717            sv_sim_abi::entry::SIM_SAVE_SYMBOL,
3718            "Engine::<checkpoint -- DeterminismController-owned, not materialized this wave>",
3719        ),
3720        (
3721            sv_sim_abi::entry::SIM_RESTORE_SYMBOL,
3722            "Engine::<restore -- DeterminismController-owned, not materialized this wave>",
3723        ),
3724        (
3725            sv_sim_abi::entry::SIM_STEP_TIME_SYMBOL,
3726            "Engine::run_until(Some(deadline)) -- advance exactly one timestep",
3727        ),
3728        (sv_sim_abi::entry::SIM_FINAL_SYMBOL, "Engine::run_finals"),
3729        (
3730            sv_sim_abi::entry::SIM_PROCESS_COUNT_SYMBOL,
3731            "Engine::<ProcessTable length accessor>",
3732        ),
3733    ]
3734}
3735
3736#[cfg(test)]
3737mod tests {
3738    use super::*;
3739    use crate::sched::{ActivationId, EventTerm, EventWaitSet, TimerEntry};
3740    use std::collections::HashMap;
3741    use std::sync::{Arc, Mutex as StdMutex};
3742
3743    // --- shared test doubles (independently declared -- see this module's
3744    // doc comment: neither `notify.rs`'s/`ops.rs`'s `FakeArena` nor Task 8's
3745    // `MockSchedulerOps` is reachable from this module) ---
3746
3747    struct TestArena(HashMap<StateCellId, FourStateValue>);
3748
3749    impl TestArena {
3750        fn new() -> Self {
3751            TestArena(HashMap::new())
3752        }
3753
3754        fn seeded(cell: StateCellId, value: FourStateValue) -> Self {
3755            let mut m = HashMap::new();
3756            m.insert(cell, value);
3757            TestArena(m)
3758        }
3759    }
3760
3761    impl SignalAccess for TestArena {
3762        fn read(&self, cell: StateCellId) -> FourStateValue {
3763            self.0
3764                .get(&cell)
3765                .cloned()
3766                .unwrap_or(FourStateValue::Narrow(0))
3767        }
3768
3769        fn write(&mut self, cell: StateCellId, value: FourStateValue) -> bool {
3770            let changed = match self.0.get(&cell) {
3771                Some(FourStateValue::Narrow(existing)) => {
3772                    if let FourStateValue::Narrow(new) = &value {
3773                        *existing != *new
3774                    } else {
3775                        true
3776                    }
3777                }
3778                Some(FourStateValue::Wide { .. }) => true,
3779                None => true,
3780            };
3781            self.0.insert(cell, value);
3782            changed
3783        }
3784
3785        fn subscribe(
3786            &mut self,
3787            _cell: StateCellId,
3788            _role: crate::state::ListenerRole,
3789            _sink: Box<dyn ValueChangeSink + Send>,
3790        ) {
3791            // Not exercised by this task's tests.
3792        }
3793    }
3794
3795    #[derive(Default)]
3796    struct TestIoSink {
3797        calls: StdMutex<Vec<(SchedTime, Region, u64)>>,
3798    }
3799
3800    impl IoSink for TestIoSink {
3801        fn enqueue(&self, at: SchedTime, region: Region, seq: u64, cb: DeferredIoCallback) {
3802            drop(cb);
3803            self.calls.lock().unwrap().push((at, region, seq));
3804        }
3805    }
3806
3807    fn narrow(v: u128) -> FourStateValue {
3808        FourStateValue::Narrow(v)
3809    }
3810
3811    fn new_engine() -> Engine {
3812        Engine::new(Box::new(TestArena::new()), Box::new(TestIoSink::default()))
3813    }
3814
3815    // --- register_process / run_until / run_finals process fixtures ---
3816
3817    /// Logs `(ProcessId, ResumeArg)` for every `resume()` call, always
3818    /// returning `Suspend::Done` (never re-arms). `id_for_log` is filled in
3819    /// (via the shared cell) only *after* `register_process` returns the
3820    /// real id -- `resume()` is never called before registration completes,
3821    /// so it is always `Some` by the time any log entry is pushed.
3822    struct LoggingProcess {
3823        id_for_log: Arc<StdMutex<Option<ProcessId>>>,
3824        log: Arc<StdMutex<Vec<(ProcessId, &'static str)>>>,
3825    }
3826
3827    impl CompiledProcess for LoggingProcess {
3828        fn resume(
3829            &mut self,
3830            _state: SimHandle,
3831            arg: ResumeArg,
3832            _ops: &dyn SchedulerOps,
3833        ) -> Suspend {
3834            let tag = match arg {
3835                ResumeArg::Start => "Start",
3836                ResumeArg::TimeElapsed => "TimeElapsed",
3837                ResumeArg::EventFired { .. } => "EventFired",
3838                ResumeArg::ForkChildDone { .. } => "ForkChildDone",
3839                ResumeArg::Disabled => "Disabled",
3840                ResumeArg::AllForkChildrenDone => "AllForkChildrenDone",
3841                ResumeArg::WaitOrderSatisfied => "WaitOrderSatisfied",
3842            };
3843            let id = self
3844                .id_for_log
3845                .lock()
3846                .unwrap()
3847                .expect("id_for_log must be set before resume() is ever called");
3848            self.log.lock().unwrap().push((id, tag));
3849            Suspend::Done
3850        }
3851    }
3852
3853    fn logging_process(
3854        log: &Arc<StdMutex<Vec<(ProcessId, &'static str)>>>,
3855    ) -> (Box<LoggingProcess>, Arc<StdMutex<Option<ProcessId>>>) {
3856        let id_cell = Arc::new(StdMutex::new(None));
3857        (
3858            Box::new(LoggingProcess {
3859                id_for_log: id_cell.clone(),
3860                log: log.clone(),
3861            }),
3862            id_cell,
3863        )
3864    }
3865
3866    // --- SC-083 ---
3867
3868    #[test]
3869    fn sequential_controller_is_selected_when_none_is_ever_set() {
3870        let mut engine = new_engine();
3871        let log = Arc::new(StdMutex::new(Vec::new()));
3872        let (proc, id_cell) = logging_process(&log);
3873        let id = engine.register_process(proc, ProcessKind::Initial, None, 0);
3874        *id_cell.lock().unwrap() = Some(id);
3875
3876        let result = engine.run_until(Some(SchedTime { ticks: 100 }));
3877        assert!(matches!(result, RunResult::Completed));
3878        assert_eq!(engine.controller_kind(), "sequential");
3879    }
3880
3881    /// Mutation-test kill: `run_until`'s deadline check must fire on
3882    /// `self.now >= deadline`, not `self.now > deadline` -- the two agree
3883    /// for every test elsewhere in this file that names a deadline strictly
3884    /// later than where the run would otherwise settle, so only an exact
3885    /// `now == deadline` boundary distinguishes them. A process that settles
3886    /// immediately with no delay leaves `now` at its initial `SchedTime{
3887    /// ticks: 0}`; a deadline of exactly `0` must therefore report
3888    /// `RunResult::Deadline` on the very first pass, not silently continue to
3889    /// `RunResult::Completed`.
3890    #[test]
3891    fn run_until_reports_deadline_when_now_exactly_equals_it() {
3892        let mut engine = new_engine();
3893        engine.register_process(Box::new(UnmintedForkChild), ProcessKind::Initial, None, 0);
3894
3895        let result = engine.run_until(Some(SchedTime { ticks: 0 }));
3896        assert!(
3897            matches!(result, RunResult::Deadline),
3898            "now has already reached the deadline exactly (both 0) -- must report \
3899             RunResult::Deadline, not RunResult::Completed; got {result:?}"
3900        );
3901    }
3902
3903    #[test]
3904    fn set_determinism_controller_overrides_the_default() {
3905        struct FakeController;
3906        impl DeterminismController for FakeController {
3907            fn partition(
3908                &self,
3909                _ready: &[PendingActivation],
3910                _region: Region,
3911                _now: SchedTime,
3912            ) -> Vec<crate::determinism::WorkItem> {
3913                vec![]
3914            }
3915            fn on_read(&self, _act: ActivationId, _cell: StateCellId, _version: u64) {}
3916            fn on_write(
3917                &self,
3918                _act: ActivationId,
3919                _cell: StateCellId,
3920                _prior: FourStateValue,
3921                _version: u64,
3922            ) {
3923            }
3924            fn gvt(&self) -> SchedTime {
3925                SchedTime { ticks: 0 }
3926            }
3927            fn drain_and_resolve(
3928                &self,
3929                _work: Vec<crate::determinism::WorkItem>,
3930            ) -> crate::determinism::ResolvedBatch {
3931                crate::determinism::ResolvedBatch::default()
3932            }
3933            fn kind(&self) -> &'static str {
3934                "fake-tagged"
3935            }
3936        }
3937
3938        let mut engine = new_engine();
3939        assert_eq!(engine.controller_kind(), "sequential");
3940        engine.set_determinism_controller(Box::new(FakeController));
3941        assert_eq!(engine.controller_kind(), "fake-tagged");
3942    }
3943
3944    /// SC-083 — a debugger-mock harness single-steps through regions via
3945    /// `step_region()`, observing only committed state between steps: `p1`
3946    /// (Initial) blocking-writes `cell_a`; `p2` (AlwaysComb, statically
3947    /// sensitive to `cell_a`) is woken by that write and blocking-writes
3948    /// `cell_b`. Every read via the engine's arena accessor, at every point
3949    /// during the multi-call sequence, shows either the pre-write default or
3950    /// the fully-committed written value -- never anything else, since
3951    /// `commit_blocking_write` only ever applies synchronously.
3952    #[test]
3953    fn step_region_single_steps_exposing_only_committed_state() {
3954        struct P1 {
3955            cell_a: StateCellId,
3956        }
3957        impl CompiledProcess for P1 {
3958            fn resume(
3959                &mut self,
3960                _state: SimHandle,
3961                arg: ResumeArg,
3962                ops: &dyn SchedulerOps,
3963            ) -> Suspend {
3964                if matches!(arg, ResumeArg::Start) {
3965                    ops.commit_blocking_write(self.cell_a, narrow(1));
3966                }
3967                Suspend::Done
3968            }
3969        }
3970        struct P2 {
3971            cell_b: StateCellId,
3972        }
3973        impl CompiledProcess for P2 {
3974            fn resume(
3975                &mut self,
3976                _state: SimHandle,
3977                arg: ResumeArg,
3978                ops: &dyn SchedulerOps,
3979            ) -> Suspend {
3980                if matches!(arg, ResumeArg::EventFired { .. }) {
3981                    ops.commit_blocking_write(self.cell_b, narrow(1));
3982                }
3983                Suspend::Done
3984            }
3985        }
3986
3987        let cell_a = StateCellId(1);
3988        let cell_b = StateCellId(2);
3989        let mut engine = new_engine();
3990        engine.register_process(Box::new(P1 { cell_a }), ProcessKind::Initial, None, 0);
3991        engine.register_process(
3992            Box::new(P2 { cell_b }),
3993            ProcessKind::AlwaysComb,
3994            Some(StaticSensitivity {
3995                edges: vec![(cell_a, None)],
3996            }),
3997            0,
3998        );
3999
4000        // Before anything runs: both cells read the pre-write default.
4001        assert!(matches!(
4002            engine.test_read_cell(cell_a),
4003            FourStateValue::Narrow(0)
4004        ));
4005        assert!(matches!(
4006            engine.test_read_cell(cell_b),
4007            FourStateValue::Narrow(0)
4008        ));
4009
4010        let mut saw_a_committed = false;
4011        let mut saw_b_committed = false;
4012        for _ in 0..16 {
4013            engine.step_region();
4014            let a = engine.test_read_cell(cell_a);
4015            let b = engine.test_read_cell(cell_b);
4016            // At every single point, each cell is exactly its pre-write
4017            // default or its one fully-committed value -- never a third,
4018            // "partially applied" value.
4019            assert!(matches!(
4020                a,
4021                FourStateValue::Narrow(0) | FourStateValue::Narrow(1)
4022            ));
4023            assert!(matches!(
4024                b,
4025                FourStateValue::Narrow(0) | FourStateValue::Narrow(1)
4026            ));
4027            if matches!(a, FourStateValue::Narrow(1)) {
4028                saw_a_committed = true;
4029            }
4030            if matches!(b, FourStateValue::Narrow(1)) {
4031                // cell_b only ever becomes 1 after cell_a already did --
4032                // p2's write is causally downstream of p1's.
4033                assert!(saw_a_committed, "cell_b committed before cell_a");
4034                saw_b_committed = true;
4035            }
4036        }
4037        assert!(
4038            saw_a_committed,
4039            "p1's write was never observed as committed"
4040        );
4041        assert!(
4042            saw_b_committed,
4043            "p2's woken write was never observed as committed"
4044        );
4045    }
4046
4047    /// sva SV-D (LRM 17.7.3, 4.5): a Reactive process (a checker procedure) runs only after
4048    /// the Active/Inactive/NBA queues have emptied, however many Active processes the
4049    /// time step's earlier regions chain through, and the work it leaves behind (here an Active
4050    /// process woken by its write) is run in the same time step -- the Reactive-to-Active
4051    /// feedback of the delta loop -- not deferred to the next `run_until` call.
4052    #[test]
4053    fn reactive_process_runs_after_active_settles_and_feeds_back_to_active() {
4054        struct Tracer {
4055            name: &'static str,
4056            log: Arc<StdMutex<Vec<String>>>,
4057            write: Option<(StateCellId, u128)>,
4058        }
4059        impl CompiledProcess for Tracer {
4060            fn resume(
4061                &mut self,
4062                _state: SimHandle,
4063                arg: ResumeArg,
4064                ops: &dyn SchedulerOps,
4065            ) -> Suspend {
4066                let tag = match arg {
4067                    ResumeArg::Start => "start",
4068                    ResumeArg::EventFired { .. } => "event",
4069                    _ => "other",
4070                };
4071                self.log
4072                    .lock()
4073                    .unwrap()
4074                    .push(format!("{}:{tag}", self.name));
4075                if let Some((cell, v)) = self.write {
4076                    ops.commit_blocking_write(cell, narrow(v));
4077                }
4078                Suspend::Done
4079            }
4080        }
4081        let (a, b, c) = (StateCellId(1), StateCellId(2), StateCellId(3));
4082        let log = Arc::new(StdMutex::new(Vec::new()));
4083        let tracer = |name, write| {
4084            Box::new(Tracer {
4085                name,
4086                log: log.clone(),
4087                write,
4088            })
4089        };
4090        let sens = |cell| {
4091            Some(StaticSensitivity {
4092                edges: vec![(cell, None)],
4093            })
4094        };
4095        let mut engine = new_engine();
4096        // Registered Reactive-process-first: the id order must not matter.
4097        // `r` (Reactive) is woken by `b`, which the Active `b_w` writes after `a`
4098        // changed; it writes `c`, which wakes the Active `c_w`.
4099        engine.register_reactive_process(
4100            tracer("r", Some((c, 1))),
4101            ProcessKind::AlwaysComb,
4102            sens(b),
4103            0,
4104        );
4105        engine.register_process(tracer("w", Some((a, 1))), ProcessKind::Initial, None, 0);
4106        engine.register_process(
4107            tracer("b_w", Some((b, 1))),
4108            ProcessKind::AlwaysComb,
4109            sens(a),
4110            0,
4111        );
4112        engine.register_process(tracer("c_w", None), ProcessKind::AlwaysComb, sens(c), 0);
4113
4114        let result = engine.run_until(None);
4115        assert!(matches!(result, RunResult::Completed));
4116        let log = log.lock().unwrap().clone();
4117        let pos = |s: &str| {
4118            log.iter()
4119                .position(|e| e == s)
4120                .unwrap_or_else(|| panic!("{s} never ran: {log:?}"))
4121        };
4122        // The Reactive process's start activation is not run in Active: both Active
4123        // chains (w -> b_w, and c_w's own start) complete before any `r` entry.
4124        let first_r = log.iter().position(|e| e.starts_with("r:")).unwrap();
4125        assert!(
4126            pos("w:start") < first_r && pos("b_w:event") < first_r && pos("c_w:start") < first_r,
4127            "a Reactive process ran before the Active region settled: {log:?}"
4128        );
4129        // Feedback: the Active process woken by `r`'s write ran in the same time step.
4130        assert!(
4131            pos("c_w:event") > pos("r:event"),
4132            "the Active wake-up of a Reactive write was not run: {log:?}"
4133        );
4134    }
4135
4136    /// Mutation-test kill: `step_region`'s `Active`/`Inactive` arm advances
4137    /// `current_region` to `Inactive` from `Active` and to `Nba` from
4138    /// `Inactive` -- a mutant that swapped those two targets passes every
4139    /// other test in this file, since none of them observe `current_region`
4140    /// directly (they only observe committed cell state or dispatch logs,
4141    /// which happen to still converge to the same eventual outcome for a
4142    /// design with no `Inactive`-region-specific behavior). This test reads
4143    /// `Engine::current_region` directly -- a private field, reachable here
4144    /// since this test module is a descendant of `control`.
4145    #[test]
4146    fn step_region_advances_active_to_inactive_to_nba_in_order() {
4147        let mut engine = new_engine();
4148        assert_eq!(
4149            engine.current_region,
4150            Region::Active,
4151            "a fresh Engine starts in Active"
4152        );
4153
4154        engine.step_region();
4155        assert_eq!(
4156            engine.current_region,
4157            Region::Inactive,
4158            "Active must step to Inactive next, never Nba"
4159        );
4160
4161        engine.step_region();
4162        assert_eq!(
4163            engine.current_region,
4164            Region::Nba,
4165            "Inactive must step to Nba next, never back to Inactive"
4166        );
4167    }
4168
4169    /// sva T1 (§1.4, review fix, blocking finding 2): drives the
4170    /// single-stepper through many full 5-region cycles (well past the
4171    /// `register_wait`-style edge cases) and asserts `current_region` is
4172    /// never observed to be `Region::Preponed` at any point -- the
4173    /// behavioral half of `step_region`'s own defensive `unreachable!`
4174    /// arm: if that arm were ever actually reached, this test would panic
4175    /// (via the `unreachable!` itself) rather than silently pass.
4176    #[test]
4177    fn step_region_never_observes_current_region_eq_preponed() {
4178        let mut engine = new_engine();
4179        engine.register_process(Box::new(UnmintedForkChild), ProcessKind::Initial, None, 0);
4180
4181        for _ in 0..30 {
4182            engine.step_region();
4183            assert_ne!(
4184                engine.current_region(),
4185                Region::Preponed,
4186                "the single-stepper must never transition into Region::Preponed -- the \
4187                 Preponed snapshot push is an inline side effect, never an observable region"
4188            );
4189        }
4190    }
4191
4192    /// sva T2 (§1.4, review fix, blocking finding 2): the identical
4193    /// behavioral proof as `step_region_never_observes_current_region_eq_
4194    /// preponed`, now for `Region::Observed` -- if `step_region`'s
4195    /// defensive `unreachable!` arm (now covering both variants) were ever
4196    /// actually reached for `Observed`, this test would panic rather than
4197    /// silently pass.
4198    #[test]
4199    fn step_region_never_observes_current_region_eq_observed() {
4200        let mut engine = new_engine();
4201        engine.register_process(Box::new(UnmintedForkChild), ProcessKind::Initial, None, 0);
4202
4203        for _ in 0..30 {
4204            engine.step_region();
4205            assert_ne!(
4206                engine.current_region(),
4207                Region::Observed,
4208                "the single-stepper must never transition into Region::Observed -- the \
4209                 Observed engine-step drain is folded into advance_nba_region, never an \
4210                 observable region"
4211            );
4212        }
4213    }
4214
4215    /// sva T1 r1 (review fix, blocking finding 1): `step_region`'s
4216    /// `Region::Postponed` arm must push the configured Preponed ring
4217    /// exactly once per time step that genuinely BEGINS -- never again
4218    /// merely because a caller keeps single-stepping an already-quiescent
4219    /// simulation. A one-shot process (`Suspend::Delay(1)` on its first
4220    /// `Start` resume, then `Suspend::Done` on its second) arms the wheel
4221    /// for exactly one real tick transition and then goes permanently
4222    /// quiet, so `next_event_tick()` returns `Some` exactly once across the
4223    /// whole run and `None` (`StepResult::Blocked`) on every Postponed arm
4224    /// after that -- precisely the "ordinary debugger-driver" shape the
4225    /// finding's own live repro used (30 `step_region()` calls on a
4226    /// completed/quiescent design hit `StepResult::Blocked` repeatedly).
4227    /// This proves both halves of the fix: the push DOES fire on the one
4228    /// real `Some(t)` transition, and it does NOT fire again on any of the
4229    /// many subsequent `None`/`Blocked` cycles -- a regression to the
4230    /// unconditional-push bug (pushing in both match arms) would fail the
4231    /// second assertion by inflating `push_count` past `1`.
4232    #[test]
4233    fn step_region_blocked_does_not_repush_the_preponed_ring() {
4234        use crate::state::{StateEngine, TemplateRegistry};
4235        use std::cell::Cell;
4236        use std::rc::Rc;
4237
4238        struct OnceDelayed {
4239            started: bool,
4240        }
4241        impl CompiledProcess for OnceDelayed {
4242            fn resume(
4243                &mut self,
4244                _state: SimHandle,
4245                arg: ResumeArg,
4246                _ops: &dyn SchedulerOps,
4247            ) -> Suspend {
4248                match (self.started, arg) {
4249                    (false, ResumeArg::Start) => {
4250                        self.started = true;
4251                        Suspend::Delay(1)
4252                    }
4253                    _ => Suspend::Done,
4254                }
4255            }
4256        }
4257
4258        let mut engine = new_engine();
4259        let push_count = Rc::new(Cell::new(0u32));
4260        {
4261            let push_count = push_count.clone();
4262            // A fresh StateEngine purely to mint real `ArenaSnapshot`
4263            // values -- this test counts invocations of the source
4264            // closure, never the snapshotted content itself (already
4265            // covered by `sva_preponed_sampling_matches_lrm_16_5_1` above).
4266            let state_engine = StateEngine::new(TemplateRegistry::new());
4267            engine.configure_preponed_ring(0, move || {
4268                push_count.set(push_count.get() + 1);
4269                state_engine.snapshot(None)
4270            });
4271        }
4272        engine.register_process(
4273            Box::new(OnceDelayed { started: false }),
4274            ProcessKind::Initial,
4275            None,
4276            0,
4277        );
4278
4279        let mut blocked_seen = 0;
4280        for _ in 0..30 {
4281            if engine.step_region() == StepResult::Blocked {
4282                blocked_seen += 1;
4283            }
4284        }
4285        assert!(
4286            blocked_seen > 0,
4287            "this 30-call sequence must actually reach StepResult::Blocked at least once \
4288             (it does, repeatedly, once the one armed tick has fired and gone quiet), or \
4289             the regression assertion below would be vacuous"
4290        );
4291        assert_eq!(
4292            push_count.get(),
4293            1,
4294            "exactly one real time-step transition occurred (the single Delay(1)) -- the \
4295             ring must have been pushed exactly once, never once per StepResult::Blocked \
4296             call (that was the bug: a quiescent/completed single-stepper re-pushed the \
4297             same unchanged snapshot on every call, silently inflating a bounded ring and \
4298             evicting genuinely distinct older ticks a future $past consumer needs intact)"
4299        );
4300    }
4301
4302    /// sva T1 r2 (review fix, blocking finding 1): `run_until` and
4303    /// `step_region` are two separate driver entry points for the exact
4304    /// same `Engine`/`push_preponed_snapshot` mechanism (`Self::
4305    /// push_preponed_snapshot`'s own doc comment), so the identical
4306    /// simulated history must push the Preponed ring the same number of
4307    /// times regardless of which one drives it. This drives the SAME
4308    /// one-shot process (`Suspend::Delay(1)` then `Suspend::Done` -- the
4309    /// exact repro the finding's own live check used) through two freshly
4310    /// constructed engines, one via `run_until(None)` and one via 30
4311    /// `step_region()` calls (mirroring `step_region_blocked_does_not_
4312    /// repush_the_preponed_ring` immediately above), and asserts both
4313    /// counted exactly one push. Before the fix, `run_until`'s outer loop
4314    /// pushed unconditionally at the top -- including on its very first
4315    /// iteration, before `Region::Active` had ever been dispatched at time
4316    /// 0 -- so this process's `run_until` push count was 2 while its
4317    /// `step_region` push count was 1 for the identical history; this test
4318    /// fails on that regression by catching the mismatch directly, not by
4319    /// hard-coding either side's count in isolation.
4320    #[test]
4321    fn run_until_and_step_region_push_the_same_preponed_ring() {
4322        use crate::state::{StateEngine, TemplateRegistry};
4323        use std::cell::Cell;
4324        use std::rc::Rc;
4325
4326        struct OnceDelayed {
4327            started: bool,
4328        }
4329        impl CompiledProcess for OnceDelayed {
4330            fn resume(
4331                &mut self,
4332                _state: SimHandle,
4333                arg: ResumeArg,
4334                _ops: &dyn SchedulerOps,
4335            ) -> Suspend {
4336                match (self.started, arg) {
4337                    (false, ResumeArg::Start) => {
4338                        self.started = true;
4339                        Suspend::Delay(1)
4340                    }
4341                    _ => Suspend::Done,
4342                }
4343            }
4344        }
4345
4346        fn count_pushes_via(driver: impl FnOnce(&mut Engine)) -> u32 {
4347            let mut engine = new_engine();
4348            let push_count = Rc::new(Cell::new(0u32));
4349            {
4350                let push_count = push_count.clone();
4351                let state_engine = StateEngine::new(TemplateRegistry::new());
4352                engine.configure_preponed_ring(0, move || {
4353                    push_count.set(push_count.get() + 1);
4354                    state_engine.snapshot(None)
4355                });
4356            }
4357            engine.register_process(
4358                Box::new(OnceDelayed { started: false }),
4359                ProcessKind::Initial,
4360                None,
4361                0,
4362            );
4363            driver(&mut engine);
4364            push_count.get()
4365        }
4366
4367        let run_until_pushes = count_pushes_via(|engine| {
4368            let result = engine.run_until(None);
4369            assert!(matches!(result, RunResult::Completed));
4370        });
4371        let step_region_pushes = count_pushes_via(|engine| {
4372            for _ in 0..30 {
4373                engine.step_region();
4374            }
4375        });
4376
4377        assert_eq!(
4378            run_until_pushes, step_region_pushes,
4379            "run_until and step_region must push the configured Preponed ring the same \
4380             number of times for the identical simulated history (one real Delay(1) \
4381             transition) -- a mismatch here means the two driver APIs disagree about what \
4382             'once per time step' means, which is exactly the asymmetry this regression \
4383             test exists to catch"
4384        );
4385        assert_eq!(
4386            run_until_pushes, 1,
4387            "exactly one real time-step transition occurred (the single Delay(1)); neither \
4388             driver may push at the engine's initial Region::Active entry at time 0 (no \
4389             Preponed push precedes it for either API -- Engine::new sets current_region = \
4390             Active directly)"
4391        );
4392    }
4393
4394    /// sva T1 end-to-end (§1.3/§1.4): a signal written in `Region::Active`
4395    /// is read back through a configured Preponed ring at the START of the
4396    /// following time steps -- the "stub consumer" this task's own plan
4397    /// describes (no `$past`/assertion lowering exists yet to read the ring
4398    /// from compiled SV source; `Engine::with_preponed_entry` is this
4399    /// task's own test-only substitute). Driven via [`Engine::step_region`]
4400    /// (never `run_until`'s deadline-based API) specifically so each
4401    /// Postponed->Active transition -- and so each ring push -- is counted
4402    /// exactly once (5 `step_region` calls == one full Active/Inactive/
4403    /// Nba/Reactive/Postponed cycle, the same convention
4404    /// `suspend_delay_logical_order_uses_absolute_time_across_chained_delays`
4405    /// above already establishes).
4406    ///
4407    /// Verified by hand against LRM 16.5.1: a depth-1 ring's `get(0)`
4408    /// ("current") always holds the value as of the end of the time step
4409    /// that just finished; `get(1)` holds the value as of the end of the
4410    /// time step before that. `cell` is written to 10 during time step 0's
4411    /// `Active` region and to 20 during time step 1's -- after time step 0
4412    /// finishes (one 5-step cycle), `get(0)` must read 10 and `get(1)` must
4413    /// be unretained (`None`, nothing pushed before entry 0 yet); after
4414    /// time step 1 finishes (a second 5-step cycle), `get(0)` must read 20
4415    /// and `get(1)` must read 10.
4416    #[test]
4417    fn sva_preponed_sampling_matches_lrm_16_5_1() {
4418        use crate::state::{
4419            SlotClass, SlotLocation, StateCellId as RealStateCellId, StateEngine, TemplateRegistry,
4420        };
4421        use std::cell::RefCell;
4422        use std::rc::Rc;
4423
4424        // A real, StateEngine-backed signal, mirroring `state::snapshot`'s
4425        // own test fixtures -- `Engine`'s own `arena: Box<dyn SignalAccess>`
4426        // stays a `TestArena` double (sched's established, STATE-decoupled
4427        // test convention), while the Preponed ring's `source` closure
4428        // reads the REAL StateEngine `MirroringArena::write` below also
4429        // writes into, proving the ring genuinely snapshots live arena
4430        // state, not the test double. `Rc<RefCell<_>>`, not `Arc<Mutex<_>>`:
4431        // `StateEngine` holds `Rc` internals of its own (`state::instance`'s
4432        // own doc comment) and is neither `Send` nor `Sync` -- this is
4433        // single-threaded test code, so there is no reason to reach for a
4434        // thread-safe wrapper clippy's `arc_with_non_send_sync` correctly
4435        // flags as misleading here.
4436        let state_engine = Rc::new(RefCell::new(StateEngine::new(TemplateRegistry::new())));
4437        let real_cell: RealStateCellId = {
4438            let mut se = state_engine.borrow_mut();
4439            // SAFETY: local, single-owned engine; no other live borrow of
4440            // the inline plane at this point.
4441            let word_offset = unsafe { se.arena.alloc_inline_word() };
4442            se.alloc_cell(SlotLocation {
4443                class: SlotClass::Inline,
4444                word_offset,
4445                bit_width: 8,
4446                elem_word_count: 1,
4447                row_stride_words: 0,
4448                num_rows: 0,
4449            })
4450        };
4451
4452        // `Engine`'s own test-double cell (what the scripted process
4453        // actually addresses through `SchedulerOps`) maps 1:1 onto
4454        // `real_cell` by sharing the same numeric id -- `MirroringArena`
4455        // mirrors every write into `state_engine` too, so both views stay
4456        // in lockstep without `sched` needing a concrete STATE dependency.
4457        let cell = StateCellId(real_cell.0);
4458
4459        struct MirroringArena {
4460            inner: TestArena,
4461            mirror_to: Rc<RefCell<StateEngine>>,
4462        }
4463        impl SignalAccess for MirroringArena {
4464            fn read(&self, cell: StateCellId) -> FourStateValue {
4465                self.inner.read(cell)
4466            }
4467            fn write(&mut self, cell: StateCellId, value: FourStateValue) -> bool {
4468                self.mirror_to.borrow_mut().commit_write(
4469                    RealStateCellId(cell.0),
4470                    value.clone(),
4471                    &mut (),
4472                );
4473                self.inner.write(cell, value)
4474            }
4475            fn subscribe(
4476                &mut self,
4477                cell: StateCellId,
4478                role: crate::state::ListenerRole,
4479                sink: Box<dyn ValueChangeSink + Send>,
4480            ) {
4481                self.inner.subscribe(cell, role, sink);
4482            }
4483        }
4484
4485        /// Writes 10 at `Start` (time step 0), 20 at the first
4486        /// `TimeElapsed` (time step 1), then goes quiet (`step == 2`'s
4487        /// `TimeElapsed` writes nothing and stays done) -- `Suspend::
4488        /// Delay(1)` after each write is what makes the wheel advance
4489        /// exactly one tick per time step.
4490        struct SequencedWriter {
4491            cell: StateCellId,
4492            step: u32,
4493        }
4494        impl CompiledProcess for SequencedWriter {
4495            fn resume(
4496                &mut self,
4497                _state: SimHandle,
4498                arg: ResumeArg,
4499                ops: &dyn SchedulerOps,
4500            ) -> Suspend {
4501                match (self.step, arg) {
4502                    (0, ResumeArg::Start) => {
4503                        ops.commit_blocking_write(self.cell, narrow(10));
4504                        self.step = 1;
4505                        Suspend::Delay(1)
4506                    }
4507                    (1, ResumeArg::TimeElapsed) => {
4508                        ops.commit_blocking_write(self.cell, narrow(20));
4509                        self.step = 2;
4510                        Suspend::Delay(1)
4511                    }
4512                    _ => Suspend::Done,
4513                }
4514            }
4515        }
4516
4517        let mut engine = Engine::new(
4518            Box::new(MirroringArena {
4519                inner: TestArena::new(),
4520                mirror_to: state_engine.clone(),
4521            }),
4522            Box::new(TestIoSink::default()),
4523        );
4524        {
4525            let src = state_engine.clone();
4526            engine.configure_preponed_ring(1, move || src.borrow().snapshot(None));
4527        }
4528        engine.register_process(
4529            Box::new(SequencedWriter { cell, step: 0 }),
4530            ProcessKind::Initial,
4531            None,
4532            0,
4533        );
4534
4535        // `FourStateValue` carries no `PartialEq` (reference/introspection
4536        // shape only, `state::mod.rs`'s own doc comment on it) -- this
4537        // narrows down to the one `u128` lane this test's own single
4538        // `SlotClass::Inline` cell ever uses, mirroring `state::snapshot`'s
4539        // own `read_narrow` test helper.
4540        let read_through_ring = |engine: &Engine, ticks_back: usize| -> Option<u128> {
4541            engine.with_preponed_entry(ticks_back, |snap| {
4542                snap.map(|snap| {
4543                    let mut se = state_engine.borrow_mut();
4544                    se.restore(snap)
4545                        .expect("same StateEngine the ring sampled from, same layout");
4546                    match se.read(real_cell) {
4547                        FourStateValue::Narrow(w) => w,
4548                        FourStateValue::Wide { .. } => panic!("expected Narrow"),
4549                    }
4550                })
4551            })
4552        };
4553
4554        // One full 5-region cycle completes time step 0 (Start dispatched
4555        // during Active, writing 10 and arming Delay(1); the Postponed step
4556        // then advances the wheel to tick 1 and pushes the Preponed
4557        // snapshot for the start of tick 1).
4558        for _ in 0..5 {
4559            engine.step_region();
4560        }
4561        assert_eq!(
4562            read_through_ring(&engine, 0),
4563            Some(10u128),
4564            "16.5.1: after time step 0 finishes, the ring's current entry must read the \
4565             value committed during time step 0's own Active region (10)"
4566        );
4567        assert_eq!(
4568            read_through_ring(&engine, 1),
4569            None,
4570            "only one push has happened so far -- ticks_back=1 must not be retained yet"
4571        );
4572
4573        // A second 5-region cycle completes time step 1 (the resumed
4574        // TimeElapsed writes 20 and arms another Delay(1); Postponed pushes
4575        // the Preponed snapshot for the start of tick 2).
4576        for _ in 0..5 {
4577            engine.step_region();
4578        }
4579        assert_eq!(
4580            read_through_ring(&engine, 0),
4581            Some(20u128),
4582            "16.5.1: after time step 1 finishes, the ring's current entry must read the \
4583             value committed during time step 1's own Active region (20)"
4584        );
4585        assert_eq!(
4586            read_through_ring(&engine, 1),
4587            Some(10u128),
4588            "16.5.1: ticks_back=1 must still read time step 0's own committed value (10)"
4589        );
4590    }
4591
4592    #[test]
4593    fn register_process_returns_the_ids_run_until_and_run_finals_operate_on() {
4594        let log: Arc<StdMutex<Vec<(ProcessId, &'static str)>>> =
4595            Arc::new(StdMutex::new(Vec::new()));
4596        let mut engine = new_engine();
4597
4598        let mut regular_ids = Vec::new();
4599        for _ in 0..3 {
4600            let (proc, id_cell) = logging_process(&log);
4601            let id = engine.register_process(proc, ProcessKind::Initial, None, 0);
4602            *id_cell.lock().unwrap() = Some(id);
4603            regular_ids.push(id);
4604        }
4605
4606        let mut final_ids = Vec::new();
4607        for _ in 0..3 {
4608            let (proc, id_cell) = logging_process(&log);
4609            let id = engine.register_process(proc, ProcessKind::Final, None, 0);
4610            *id_cell.lock().unwrap() = Some(id);
4611            final_ids.push(id);
4612        }
4613
4614        let result = engine.run_until(None);
4615        assert!(matches!(result, RunResult::Completed));
4616        engine.run_finals();
4617
4618        let entries = log.lock().unwrap().clone();
4619        // Every regular process ran (via run_until's initial dispatch) with
4620        // exactly one Start; no final-kind process was dispatched by
4621        // run_until (Final processes are never queued onto Active).
4622        for &id in &regular_ids {
4623            let starts: Vec<_> = entries
4624                .iter()
4625                .filter(|(p, tag)| *p == id && *tag == "Start")
4626                .collect();
4627            assert_eq!(
4628                starts.len(),
4629                1,
4630                "process {id} must have gotten exactly one Start"
4631            );
4632        }
4633        // The finals ran (via run_finals), in registration order.
4634        let final_start_order: Vec<ProcessId> = entries
4635            .iter()
4636            .filter(|(p, _)| final_ids.contains(p))
4637            .map(|(p, _)| *p)
4638            .collect();
4639        assert_eq!(
4640            final_start_order, final_ids,
4641            "finals must run in registration order"
4642        );
4643        for &id in &final_ids {
4644            let starts: Vec<_> = entries
4645                .iter()
4646                .filter(|(p, tag)| *p == id && *tag == "Start")
4647                .collect();
4648            assert_eq!(
4649                starts.len(),
4650                1,
4651                "final process {id} must have gotten exactly one Start"
4652            );
4653        }
4654    }
4655
4656    /// Classes plan Task 14 (LRM 18.14.1): a process registered while
4657    /// `registration_scope` names an instance is seeded from that instance's
4658    /// initialization RNG, one draw per process in registration order; two
4659    /// instances have independent streams; and a process registered with no
4660    /// scope (infrastructure) does not advance any instance's RNG.
4661    #[test]
4662    fn registered_processes_are_seeded_from_their_instances_init_rng() {
4663        use crate::sched::process_rng::{self, with_process_state};
4664        let nop = || -> Box<dyn CompiledProcess> { script(|_, _| Suspend::Done) };
4665        process_rng::reset();
4666        let mut engine = new_engine();
4667        engine.set_registration_scope(Some(0));
4668        let a = engine.register_process(nop(), ProcessKind::Initial, None, 0);
4669        engine.set_registration_scope(None);
4670        let infra = engine.register_process(nop(), ProcessKind::Initial, None, 0);
4671        engine.set_registration_scope(Some(0));
4672        let b = engine.register_process(nop(), ProcessKind::Initial, None, 0);
4673        engine.set_registration_scope(Some(1));
4674        let c = engine.register_process(nop(), ProcessKind::Initial, None, 0);
4675        engine.set_registration_scope(None);
4676        let seen: Vec<u64> = [a, infra, b, c]
4677            .iter()
4678            .map(|&p| with_process_state(Some(p), |s| *s))
4679            .collect();
4680
4681        process_rng::reset();
4682        let (e0, e1, e2) = (
4683            process_rng::draw_from_init(0),
4684            process_rng::draw_from_init(0),
4685            process_rng::draw_from_init(1),
4686        );
4687        assert_eq!(seen[0], e0, "first process of instance 0");
4688        assert_eq!(
4689            seen[2], e1,
4690            "second process of instance 0 (the infra one skipped)"
4691        );
4692        assert_eq!(seen[3], e2, "first process of instance 1");
4693        assert_eq!(seen[1], process_rng::DEFAULT_SEED, "unscoped: never seeded");
4694        assert_ne!(seen[0], seen[2]);
4695    }
4696
4697    /// Classes plan Task 14 (LRM 18.14.2): `fork` children are seeded when
4698    /// they are created, one draw each in source order from the parent's own
4699    /// generator (so the parent advances by exactly one step per child), no
4700    /// matter when they later run.
4701    #[test]
4702    fn fork_children_are_seeded_from_the_forking_process_in_source_order() {
4703        use crate::sched::process_rng::{self, with_process_state};
4704        process_rng::reset();
4705        let mut engine = new_engine();
4706        let spawn = || SpawnSpec {
4707            kernel: 0,
4708            entry_pc: 0,
4709            parent: 0,
4710            disable_name: Vec::new(),
4711        };
4712        let parent = engine.register_process(
4713            script(move |arg, _| match arg {
4714                ResumeArg::Start => Suspend::ForkJoin(JoinKind::None, vec![spawn(), spawn()]),
4715                _ => Suspend::Done,
4716            }),
4717            ProcessKind::Initial,
4718            None,
4719            0,
4720        );
4721        assert_eq!(parent, 0);
4722        // The parent's stream, before the fork.
4723        with_process_state(Some(parent), |s| *s = 99);
4724        let ready = engine.drain_and_sort(Region::Active);
4725        engine.dispatch_ready_set(Region::Active, ready);
4726        let seen: Vec<u64> = [1, 2]
4727            .iter()
4728            .map(|&c| with_process_state(Some(c), |s| *s))
4729            .collect();
4730        let parent_after = with_process_state(Some(parent), |s| *s);
4731
4732        let mut p = 99u64;
4733        let want = [
4734            process_rng::splitmix64_next(&mut p),
4735            process_rng::splitmix64_next(&mut p),
4736        ];
4737        assert_eq!(seen, want, "children seeded by successive parent draws");
4738        assert_eq!(parent_after, p, "one parent step per child, no more");
4739        assert_ne!(seen[0], seen[1]);
4740    }
4741
4742    /// SC-045's "installation happens exactly once, at registration" proof:
4743    /// no `commit_nba`/`commit_blocking_write`/`notify_change` call anywhere
4744    /// in this test, so downstream dedup cannot mask a double-install.
4745    #[test]
4746    fn register_process_installs_static_sensitivity_exactly_once() {
4747        let a = StateCellId(1);
4748        let b = StateCellId(2);
4749        let c = StateCellId(3);
4750        let mut engine = new_engine();
4751        engine.register_process(
4752            Box::new(UnmintedForkChild),
4753            ProcessKind::AlwaysComb,
4754            Some(StaticSensitivity {
4755                edges: vec![(a, None), (b, None), (c, None)],
4756            }),
4757            0,
4758        );
4759        assert_eq!(engine.test_sensitivity_entry_count(), 3);
4760    }
4761
4762    #[test]
4763    fn abi_entry_point_map_names_all_nine_entry_points_exactly_once() {
4764        let map = abi_entry_point_map();
4765        let names: std::collections::HashSet<&str> = map.iter().map(|(name, _)| *name).collect();
4766        assert_eq!(names.len(), map.len(), "no duplicate entry-point names");
4767        let expected: std::collections::HashSet<&str> = [
4768            "sim_init",
4769            "sim_settle",
4770            "sim_eval_region",
4771            "sim_commit_nba",
4772            "sim_save",
4773            "sim_restore",
4774            "sim_step_time",
4775            "sim_final",
4776            "sim_process_count",
4777        ]
4778        .into_iter()
4779        .collect();
4780        assert_eq!(names, expected);
4781    }
4782
4783    // --- EngineOps / EngineNotifySink wiring against Engine's own state ---
4784
4785    #[test]
4786    fn engine_ops_commit_blocking_write_writes_through_to_the_engines_own_arena() {
4787        let cell = StateCellId(7);
4788        let engine = Engine::new(
4789            Box::new(TestArena::seeded(cell, narrow(0))),
4790            Box::new(TestIoSink::default()),
4791        );
4792        let ops = engine.test_dispatch_ops(SchedTime { ticks: 0 }, Region::Active, 0);
4793        ops.commit_blocking_write(cell, narrow(1));
4794        assert!(matches!(
4795            engine.test_read_cell(cell),
4796            FourStateValue::Narrow(1)
4797        ));
4798    }
4799
4800    #[test]
4801    fn engine_ops_commit_blocking_write_wakes_a_statically_sensitive_process_via_the_real_queues() {
4802        let cell = StateCellId(9);
4803        let mut engine = Engine::new(
4804            Box::new(TestArena::seeded(cell, narrow(0))),
4805            Box::new(TestIoSink::default()),
4806        );
4807        let p1 =
4808            engine.register_process(Box::new(UnmintedForkChild), ProcessKind::Initial, None, 0);
4809        let p2 = engine.register_process(
4810            Box::new(UnmintedForkChild),
4811            ProcessKind::AlwaysFF,
4812            Some(StaticSensitivity {
4813                edges: vec![(cell, Some(EdgeKind::Posedge))],
4814            }),
4815            0,
4816        );
4817        // Both p1/p2 auto-queued a Start activation at registration; drain
4818        // that away so the assertion below only counts the wakeup this test
4819        // itself triggers.
4820        let _ = engine.test_drain_active();
4821
4822        {
4823            let ops = engine.test_dispatch_ops(SchedTime { ticks: 0 }, Region::Active, 0);
4824            // 0 -> 1 on bit 0 is a Posedge (notify.rs's infer_edge table).
4825            ops.commit_blocking_write(cell, narrow(1));
4826        }
4827
4828        let queued = engine.test_drain_active();
4829        assert_eq!(queued.len(), 1, "exactly one new activation queued");
4830        assert_eq!(
4831            queued[0].proc, p2,
4832            "the statically-sensitive process p2 was woken"
4833        );
4834        let _ = p1;
4835    }
4836
4837    /// SC-055 through the real `Engine`: a process statically sensitive to
4838    /// two cells that both change in the same pass is queued exactly once.
4839    /// Without the gate `notify_change` pushes one activation per matching
4840    /// change (2 here).
4841    #[test]
4842    fn engine_queues_a_process_once_when_two_of_its_cells_change_in_one_pass() {
4843        let a = StateCellId(20);
4844        let b = StateCellId(21);
4845        let mut engine = new_engine();
4846        let p = engine.register_process(
4847            Box::new(UnmintedForkChild),
4848            ProcessKind::AlwaysComb,
4849            Some(StaticSensitivity {
4850                edges: vec![(a, None), (b, None)],
4851            }),
4852            0,
4853        );
4854        let _ = engine.test_drain_active();
4855
4856        {
4857            let ops = engine.test_dispatch_ops(SchedTime { ticks: 0 }, Region::Active, 0);
4858            ops.commit_blocking_write(a, narrow(1));
4859            ops.commit_blocking_write(b, narrow(1));
4860        }
4861
4862        let queued = engine.test_drain_active();
4863        assert_eq!(
4864            queued.iter().map(|q| q.proc).collect::<Vec<_>>(),
4865            vec![p],
4866            "two changes in one pass must queue the process once, not twice"
4867        );
4868    }
4869
4870    /// SC-056 through the real `Engine`: once the queued activation has been
4871    /// popped (the dequeue point resets the flag), a new change queues the
4872    /// process again. A gate that never reset would swallow the second wakeup.
4873    #[test]
4874    fn engine_requeues_a_process_changed_again_after_its_activation_was_dequeued() {
4875        let a = StateCellId(22);
4876        let mut engine = new_engine();
4877        let p = engine.register_process(
4878            Box::new(UnmintedForkChild),
4879            ProcessKind::AlwaysComb,
4880            Some(StaticSensitivity {
4881                edges: vec![(a, None)],
4882            }),
4883            0,
4884        );
4885        let _ = engine.test_drain_active();
4886
4887        {
4888            let ops = engine.test_dispatch_ops(SchedTime { ticks: 0 }, Region::Active, 0);
4889            ops.commit_blocking_write(a, narrow(1));
4890        }
4891        assert_eq!(
4892            engine.test_drain_active().len(),
4893            1,
4894            "first change queues the process"
4895        );
4896
4897        {
4898            let ops = engine.test_dispatch_ops(SchedTime { ticks: 0 }, Region::Active, 1);
4899            ops.commit_blocking_write(a, narrow(0));
4900        }
4901        let queued = engine.test_drain_active();
4902        assert_eq!(
4903            queued.iter().map(|q| q.proc).collect::<Vec<_>>(),
4904            vec![p],
4905            "a change after the dequeue must queue the process again"
4906        );
4907    }
4908
4909    #[test]
4910    fn engine_ops_stage_nba_appends_to_the_engines_own_nba_log() {
4911        let engine = new_engine();
4912        assert_eq!(engine.test_nba_len(), 0);
4913        let ops = engine.test_dispatch_ops(SchedTime { ticks: 0 }, Region::Active, 0);
4914        ops.stage_nba(StateCellId(1), narrow(1), 0);
4915        assert_eq!(engine.test_nba_len(), 1);
4916    }
4917
4918    #[test]
4919    fn engine_ops_defer_io_forwards_with_a_seq_counter_that_persists_across_passes() {
4920        // `io_sink: Box<dyn IoSink>` is not downcastable, so this test uses a
4921        // shared-state sink to observe the recorded seqs from outside.
4922        struct SharedIoSink(Arc<StdMutex<Vec<u64>>>);
4923        impl IoSink for SharedIoSink {
4924            fn enqueue(&self, _at: SchedTime, _region: Region, seq: u64, cb: DeferredIoCallback) {
4925                drop(cb);
4926                self.0.lock().unwrap().push(seq);
4927            }
4928        }
4929        let seqs = Arc::new(StdMutex::new(Vec::new()));
4930        let engine = Engine::new(
4931            Box::new(TestArena::new()),
4932            Box::new(SharedIoSink(seqs.clone())),
4933        );
4934        let ops1 = engine.test_dispatch_ops(SchedTime { ticks: 0 }, Region::Active, 0);
4935        ops1.defer_io(SchedTime { ticks: 0 }, Region::Active, Box::new(|_| {}));
4936        let ops2 = engine.test_dispatch_ops(SchedTime { ticks: 5 }, Region::Inactive, 0);
4937        ops2.defer_io(SchedTime { ticks: 5 }, Region::Inactive, Box::new(|_| {}));
4938
4939        assert_eq!(
4940            *seqs.lock().unwrap(),
4941            vec![0, 1],
4942            "seq must strictly increase across two independently-constructed EngineOps"
4943        );
4944    }
4945
4946    // --- review fixes (r1) ---
4947
4948    /// Review fix — `apply_suspend`'s `Suspend::Delay` arm used to always
4949    /// build `act` with `logical_order.1 == Region::Active`, even when
4950    /// `wheel.arm_delay` reports `Region::Inactive` (a zero-delay `#0`
4951    /// reactivation) and the activation is pushed straight into
4952    /// `Region::Inactive` instead. Mirrors `subscribe.rs::register_wait`'s
4953    /// own `delay_zero_lands_directly_in_inactive_not_the_wheel` test for
4954    /// the identical hazard, one file over.
4955    #[test]
4956    fn apply_suspend_delay_zero_lands_directly_in_inactive_with_the_correct_region_tag() {
4957        struct DelayZeroProcess;
4958        impl CompiledProcess for DelayZeroProcess {
4959            fn resume(
4960                &mut self,
4961                _state: SimHandle,
4962                arg: ResumeArg,
4963                _ops: &dyn SchedulerOps,
4964            ) -> Suspend {
4965                match arg {
4966                    ResumeArg::Start => Suspend::Delay(0),
4967                    _ => Suspend::Done,
4968                }
4969            }
4970        }
4971
4972        let mut engine = new_engine();
4973        let proc =
4974            engine.register_process(Box::new(DelayZeroProcess), ProcessKind::Initial, None, 0);
4975
4976        // Single-steps exactly the Active region: dispatches the process's
4977        // Start, which returns Suspend::Delay(0) -- apply_suspend's
4978        // wheel.arm_delay call reports Region::Inactive (delay_ticks == 0,
4979        // per wheel.rs's own doc comment) without ever touching the wheel.
4980        engine.step_region();
4981
4982        let inactive = engine.test_drain_inactive();
4983        assert_eq!(
4984            inactive.len(),
4985            1,
4986            "the zero-delay reactivation must land directly in Region::Inactive"
4987        );
4988        assert_eq!(inactive[0].proc, proc);
4989        assert_eq!(
4990            inactive[0].logical_order.1,
4991            Region::Inactive,
4992            "an activation landed in the Inactive queue must carry Region::Inactive as its \
4993             own logical_order.1 tag, agreeing with the queue it actually lives in -- \
4994             order.rs's canonical_key_of/sort_ready_set read logical_order.1 directly to \
4995             build the canonical key that SC-010/DT-005/DT-010 depend on"
4996        );
4997    }
4998
4999    /// Whole-track review fix (AB-085): the only prior test for
5000    /// `Suspend::WaitForGvt` (`wait_for_gvt_shares_the_suspend_dispatch_path`
5001    /// in `sched::mod`) never called `apply_suspend` at all -- it matched a
5002    /// test-local, hand-written `label` function against the `Suspend` enum,
5003    /// which only proves that function's own match arms are exhaustive, not
5004    /// that `apply_suspend`'s `Suspend::WaitForGvt => self.apply_suspend(proc,
5005    /// Suspend::Delay(0))` arm actually delegates anywhere. This test drives
5006    /// a real process through the real `Engine`/`apply_suspend` with
5007    /// `Suspend::WaitForGvt` and asserts the resulting scheduler state is
5008    /// identical (region, queue length, logical_order tag) to what
5009    /// `apply_suspend_delay_zero_lands_directly_in_inactive_with_the_correct_
5010    /// region_tag` (immediately above) observes for `Suspend::Delay(0)` --
5011    /// proving the delegation for real. Before this test existed, changing
5012    /// the `WaitForGvt` arm to `Suspend::Delay(1)`, to a silent no-op, or to
5013    /// removing the delegation entirely (stranding the process) would still
5014    /// have passed the full test suite.
5015    #[test]
5016    fn apply_suspend_wait_for_gvt_delegates_to_the_same_path_as_delay_zero() {
5017        struct WaitForGvtProcess;
5018        impl CompiledProcess for WaitForGvtProcess {
5019            fn resume(
5020                &mut self,
5021                _state: SimHandle,
5022                arg: ResumeArg,
5023                _ops: &dyn SchedulerOps,
5024            ) -> Suspend {
5025                match arg {
5026                    ResumeArg::Start => Suspend::WaitForGvt,
5027                    _ => Suspend::Done,
5028                }
5029            }
5030        }
5031
5032        let mut engine = new_engine();
5033        let proc =
5034            engine.register_process(Box::new(WaitForGvtProcess), ProcessKind::Initial, None, 0);
5035
5036        // Single-steps exactly the Active region: dispatches the process's
5037        // Start, which returns Suspend::WaitForGvt. If apply_suspend's
5038        // `Suspend::WaitForGvt` arm really delegates to
5039        // `self.apply_suspend(proc, Suspend::Delay(0))`, the observable
5040        // outcome must be pixel-for-pixel identical to the Delay(0) test
5041        // above: one reactivation, landed directly in Region::Inactive
5042        // (never touching the wheel), tagged with Region::Inactive.
5043        engine.step_region();
5044
5045        let inactive = engine.test_drain_inactive();
5046        assert_eq!(
5047            inactive.len(),
5048            1,
5049            "WaitForGvt must delegate to Delay(0)'s dispatch, landing its reactivation \
5050             directly in Region::Inactive exactly like Delay(0) does -- a broken or removed \
5051             delegation would leave this queue empty (process stranded) or route it \
5052             elsewhere"
5053        );
5054        assert_eq!(inactive[0].proc, proc);
5055        assert_eq!(
5056            inactive[0].logical_order.1,
5057            Region::Inactive,
5058            "the delegated activation must carry the same Region::Inactive logical_order \
5059             tag Delay(0) produces, agreeing with the queue it actually lives in -- a \
5060             WaitForGvt arm changed to Delay(1) or to any wheel-touching path would instead \
5061             land here with Region::Active, or not land here at all this step"
5062        );
5063        assert_eq!(
5064            inactive[0].logical_order.0,
5065            SchedTime { ticks: 0 },
5066            "delegating to Delay(0) from now == 0 must produce the same absolute fire tick \
5067             Delay(0) produces (0 + 0 == 0), not some other tick a differently-parameterized \
5068             delegation (e.g. Delay(1)) would produce"
5069        );
5070    }
5071
5072    /// Mutation-test kill: `apply_suspend`'s `Suspend::Delay` arm must build
5073    /// the reactivation's `logical_order.0` from `self.now.ticks + ticks`
5074    /// (the real absolute fire tick), never from `ticks` alone. The bug is
5075    /// invisible at `now == 0` (where the two computations coincide, exactly
5076    /// the case every other `Delay` test in this file exercises), so this
5077    /// test chains two delays -- `Delay(3)` fired from `now == 0`, then
5078    /// `Delay(4)` fired from the resulting `now == 3` -- and inspects the
5079    /// *second* reactivation's own `logical_order.0` after the wheel fires
5080    /// it: it must be `SchedTime{ticks: 7}` (3+4), never `SchedTime{ticks:
5081    /// 4}`. The wheel's own real fire time is unaffected by this bug (it
5082    /// computes `now + delay_ticks` internally, independent of the
5083    /// activation's `logical_order` field), so `engine.now()` alone cannot
5084    /// catch it -- only inspecting the queued activation's own field can.
5085    #[test]
5086    fn suspend_delay_logical_order_uses_absolute_time_across_chained_delays() {
5087        struct TwoStageDelay;
5088        impl CompiledProcess for TwoStageDelay {
5089            fn resume(
5090                &mut self,
5091                _state: SimHandle,
5092                arg: ResumeArg,
5093                _ops: &dyn SchedulerOps,
5094            ) -> Suspend {
5095                match arg {
5096                    ResumeArg::Start => Suspend::Delay(3),
5097                    ResumeArg::TimeElapsed => Suspend::Delay(4),
5098                    _ => Suspend::Done,
5099                }
5100            }
5101        }
5102
5103        let mut engine = new_engine();
5104        engine.register_process(Box::new(TwoStageDelay), ProcessKind::Initial, None, 0);
5105
5106        // Exactly 10 step_region() calls: 5 regions (Active/Inactive/Nba/
5107        // Reactive/Postponed) x 2 timesteps -- the first timestep dispatches
5108        // Start (arming Delay(3)) and advances the wheel to tick 3 without
5109        // dispatching the resulting reactivation; the second timestep
5110        // dispatches that TimeElapsed (arming Delay(4)) and advances the
5111        // wheel to tick 7, again landing the reactivation in Region::Active
5112        // without dispatching it.
5113        for _ in 0..10 {
5114            engine.step_region();
5115        }
5116
5117        assert_eq!(
5118            engine.now(),
5119            SchedTime { ticks: 7 },
5120            "the wheel's own real fire time (unaffected by this bug) must be 0+3, then 3+4 = 7"
5121        );
5122
5123        let active = engine.test_drain_active();
5124        assert_eq!(
5125            active.len(),
5126            1,
5127            "the second delay's reactivation must be sitting in Region::Active, undispatched"
5128        );
5129        assert_eq!(
5130            active[0].logical_order.0,
5131            SchedTime { ticks: 7 },
5132            "logical_order.0 must be the absolute fire tick (now=3 + delay=4 = 7), not \
5133             the raw delay amount (4) alone"
5134        );
5135    }
5136
5137    /// Review fix — `apply_suspend`'s `Suspend::WaitEvents` arm installed a
5138    /// dynamic sensitivity entry on suspend but nothing in `Engine`'s
5139    /// dispatch path ever called `SensitivityTable::remove_dynamic` when the
5140    /// waiting process later resumed, leaking the entry forever and risking
5141    /// a phantom wakeup of an already-resumed process on a later write to
5142    /// the same cell.
5143    #[test]
5144    fn wait_events_dynamic_sensitivity_entry_is_removed_when_the_process_resumes() {
5145        struct WaiterProcess {
5146            cell: StateCellId,
5147        }
5148        impl CompiledProcess for WaiterProcess {
5149            fn resume(
5150                &mut self,
5151                _state: SimHandle,
5152                arg: ResumeArg,
5153                _ops: &dyn SchedulerOps,
5154            ) -> Suspend {
5155                match arg {
5156                    ResumeArg::Start => Suspend::WaitEvents(EventWaitSet {
5157                        terms: vec![EventTerm::Edge {
5158                            cell: self.cell,
5159                            edge: EdgeKind::Posedge,
5160                            iff: None,
5161                        }],
5162                        ..Default::default()
5163                    }),
5164                    _ => Suspend::Done,
5165                }
5166            }
5167        }
5168        struct WriterProcess {
5169            cell: StateCellId,
5170        }
5171        impl CompiledProcess for WriterProcess {
5172            fn resume(
5173                &mut self,
5174                _state: SimHandle,
5175                arg: ResumeArg,
5176                ops: &dyn SchedulerOps,
5177            ) -> Suspend {
5178                match arg {
5179                    // Delay past t=0 so this write lands in its own,
5180                    // later region pass -- one full pass after
5181                    // WaiterProcess's own Start has already been applied
5182                    // (apply_suspend, and therefore install_dynamic, only
5183                    // runs once every item dispatched *in the same pass*
5184                    // has itself returned its Suspend), so the dynamic
5185                    // entry genuinely exists by the time this write fires.
5186                    ResumeArg::Start => Suspend::Delay(1),
5187                    ResumeArg::TimeElapsed => {
5188                        ops.commit_blocking_write(self.cell, narrow(1));
5189                        Suspend::Done
5190                    }
5191                    other => panic!("WriterProcess must only see Start/TimeElapsed, got {other:?}"),
5192                }
5193            }
5194        }
5195
5196        let cell = StateCellId(42);
5197        let mut engine = Engine::new(
5198            Box::new(TestArena::seeded(cell, narrow(0))),
5199            Box::new(TestIoSink::default()),
5200        );
5201        engine.register_process(
5202            Box::new(WaiterProcess { cell }),
5203            ProcessKind::Initial,
5204            None,
5205            0,
5206        );
5207        engine.register_process(
5208            Box::new(WriterProcess { cell }),
5209            ProcessKind::Initial,
5210            None,
5211            0,
5212        );
5213
5214        let result = engine.run_until(None);
5215        assert!(matches!(result, RunResult::Completed));
5216
5217        assert_eq!(
5218            engine.test_sensitivity_entry_count(),
5219            0,
5220            "the dynamic entry must be removed once the waiting process resumes via \
5221             EventFired -- sensitivity.rs's own doc comment: dynamic entries \"are gone \
5222             from the table the instant the owning process resumes\" -- an earlier draft \
5223             never called SensitivityTable::remove_dynamic anywhere in Engine's dispatch \
5224             path, leaking this row forever"
5225        );
5226    }
5227
5228    /// Mutation-test kill: `dispatch_ready_set`'s dynamic-wait-resumed
5229    /// removal set names two `ResumeArg` variants, `EventFired` and
5230    /// `Disabled` -- but no other test in this file ever drives a
5231    /// `ResumeArg::Disabled` activation through `dispatch_ready_set` (no
5232    /// production path in this crate constructs one yet), so a mutant that
5233    /// narrowed the removal check to `EventFired` alone would pass every
5234    /// other test. This test dispatches `Start` normally (installing one
5235    /// dynamic sensitivity entry via `Suspend::WaitEvents`), then hand-builds
5236    /// a `ResumeArg::Disabled` activation for the same process and dispatches
5237    /// it directly -- `dispatch_ready_set`/`drain_and_sort` are private
5238    /// methods on `Engine`, reachable here since this test module is a
5239    /// descendant of `control`.
5240    #[test]
5241    fn dispatch_ready_set_removes_dynamic_sensitivity_entry_for_disabled_resume_too() {
5242        struct WaiterProcess {
5243            cell: StateCellId,
5244        }
5245        impl CompiledProcess for WaiterProcess {
5246            fn resume(
5247                &mut self,
5248                _state: SimHandle,
5249                arg: ResumeArg,
5250                _ops: &dyn SchedulerOps,
5251            ) -> Suspend {
5252                match arg {
5253                    ResumeArg::Start => Suspend::WaitEvents(EventWaitSet {
5254                        terms: vec![EventTerm::Edge {
5255                            cell: self.cell,
5256                            edge: EdgeKind::Posedge,
5257                            iff: None,
5258                        }],
5259                        ..Default::default()
5260                    }),
5261                    _ => Suspend::Done,
5262                }
5263            }
5264        }
5265
5266        let cell = StateCellId(99);
5267        let mut engine = new_engine();
5268        let proc = engine.register_process(
5269            Box::new(WaiterProcess { cell }),
5270            ProcessKind::Initial,
5271            None,
5272            0,
5273        );
5274
5275        let ready = engine.drain_and_sort(Region::Active);
5276        engine.dispatch_ready_set(Region::Active, ready);
5277        assert_eq!(
5278            engine.test_sensitivity_entry_count(),
5279            1,
5280            "Suspend::WaitEvents must install exactly one dynamic sensitivity entry"
5281        );
5282
5283        let disabled_act = PendingActivation {
5284            id: 999,
5285            proc,
5286            arg: ResumeArg::Disabled,
5287            reconv_iter: 0,
5288            logical_order: (engine.now(), Region::Active, 0),
5289        };
5290        engine.dispatch_ready_set(Region::Active, vec![disabled_act]);
5291
5292        assert_eq!(
5293            engine.test_sensitivity_entry_count(),
5294            0,
5295            "a ResumeArg::Disabled resume must also remove its process's dynamic \
5296             sensitivity entry, exactly like EventFired does -- a mutant narrowing the \
5297             removal check to EventFired alone would leave this row (and every future \
5298             Disabled-terminated wait's row) leaked forever"
5299        );
5300    }
5301
5302    /// Review fix — `apply_suspend`'s `Suspend::ForkJoin` arm spawned
5303    /// children via `ResumeDriver::handle_fork` but nothing in `Engine`
5304    /// ever called `ResumeDriver::on_child_done` when a spawned child later
5305    /// completed, so the parent was never reactivated: fork/join was
5306    /// completely nonfunctional (any forking process silently hung forever
5307    /// while `run_until` still reported normal completion). Drives a real
5308    /// `JoinKind::All` fork with one child through `Engine::run_until` and
5309    /// asserts the parent actually resumes via `ResumeArg::ForkChildDone`.
5310    #[test]
5311    fn fork_join_all_actually_resumes_the_parent_when_its_one_child_completes() {
5312        struct ParentProcess {
5313            log: Arc<StdMutex<Vec<&'static str>>>,
5314        }
5315        impl CompiledProcess for ParentProcess {
5316            fn resume(
5317                &mut self,
5318                _state: SimHandle,
5319                arg: ResumeArg,
5320                _ops: &dyn SchedulerOps,
5321            ) -> Suspend {
5322                match arg {
5323                    ResumeArg::Start => {
5324                        self.log.lock().unwrap().push("forked");
5325                        Suspend::ForkJoin(
5326                            JoinKind::All,
5327                            vec![SpawnSpec {
5328                                kernel: 0,
5329                                entry_pc: 0,
5330                                parent: 0,
5331                                disable_name: Vec::new(),
5332                            }],
5333                        )
5334                    }
5335                    ResumeArg::ForkChildDone { .. } => {
5336                        self.log.lock().unwrap().push("joined");
5337                        Suspend::Done
5338                    }
5339                    other => panic!(
5340                        "ParentProcess must only ever be resumed with Start or \
5341                         ForkChildDone, got {other:?}"
5342                    ),
5343                }
5344            }
5345        }
5346
5347        let log = Arc::new(StdMutex::new(Vec::new()));
5348        let mut engine = new_engine();
5349        engine.register_process(
5350            Box::new(ParentProcess { log: log.clone() }),
5351            ProcessKind::Initial,
5352            None,
5353            0,
5354        );
5355
5356        let result = engine.run_until(None);
5357        assert!(matches!(result, RunResult::Completed));
5358
5359        assert_eq!(
5360            log.lock().unwrap().as_slice(),
5361            &["forked", "joined"],
5362            "the parent must actually be reactivated via ResumeArg::ForkChildDone once its \
5363             one child (an UnmintedForkChild, immediately Done) completes -- an earlier \
5364             draft never wired ResumeDriver::on_child_done into Engine at all, so the \
5365             parent would silently hang forever (never reach \"joined\") despite run_until \
5366             still reporting RunResult::Completed"
5367        );
5368    }
5369
5370    // --- Task 7 (B-sched wave, ledger SC-062, SC-115) ---
5371
5372    /// SC-062's own literal acceptance, proven through the real `Engine`
5373    /// dispatch loop (not just `ResumeDriver` in isolation, which
5374    /// `resume.rs`'s own `join_all_resumes_parent_only_after_every_child_
5375    /// reports_done`/`on_child_done_reclaims_the_childs_slot_when_the_join_
5376    /// is_satisfied` already cover): a `JoinAll` fork of 3 children (each an
5377    /// `UnmintedForkChild`, immediately `Done` on its own first dispatch --
5378    /// this wave's own documented interim placeholder, see this module's
5379    /// doc comment deviation 3) completing one after another *within the
5380    /// same Active-region dispatch pass*. Asserts the parent resumes (logs
5381    /// "joined") only once, after all 3, and that every one of the 3
5382    /// forked children's own `ProcessId`s is unreachable via
5383    /// `Engine::test_process_exists` by the time it does. Fails if slot
5384    /// reclaim is never wired (every child id would still report `true`),
5385    /// or if it reclaims the wrong process (e.g. the parent).
5386    #[test]
5387    fn sc062_joinall_child_returning_done_satisfies_the_parents_join_and_reclaims_its_slot() {
5388        struct ParentProcess {
5389            log: Arc<StdMutex<Vec<&'static str>>>,
5390        }
5391        impl CompiledProcess for ParentProcess {
5392            fn resume(
5393                &mut self,
5394                _state: SimHandle,
5395                arg: ResumeArg,
5396                _ops: &dyn SchedulerOps,
5397            ) -> Suspend {
5398                match arg {
5399                    ResumeArg::Start => {
5400                        self.log.lock().unwrap().push("forked");
5401                        Suspend::ForkJoin(
5402                            JoinKind::All,
5403                            vec![
5404                                SpawnSpec {
5405                                    kernel: 0,
5406                                    entry_pc: 0,
5407                                    parent: 0,
5408                                    disable_name: Vec::new(),
5409                                },
5410                                SpawnSpec {
5411                                    kernel: 1,
5412                                    entry_pc: 0,
5413                                    parent: 0,
5414                                    disable_name: Vec::new(),
5415                                },
5416                                SpawnSpec {
5417                                    kernel: 2,
5418                                    entry_pc: 0,
5419                                    parent: 0,
5420                                    disable_name: Vec::new(),
5421                                },
5422                            ],
5423                        )
5424                    }
5425                    ResumeArg::ForkChildDone { .. } => {
5426                        self.log.lock().unwrap().push("joined");
5427                        Suspend::Done
5428                    }
5429                    other => panic!(
5430                        "ParentProcess must only ever be resumed with Start or \
5431                         ForkChildDone, got {other:?}"
5432                    ),
5433                }
5434            }
5435        }
5436
5437        let log = Arc::new(StdMutex::new(Vec::new()));
5438        let mut engine = new_engine();
5439        let parent = engine.register_process(
5440            Box::new(ParentProcess { log: log.clone() }),
5441            ProcessKind::Initial,
5442            None,
5443            0,
5444        );
5445        assert_eq!(parent, 0, "test assumes a fresh engine's first id is 0");
5446
5447        let result = engine.run_until(None);
5448        assert!(matches!(result, RunResult::Completed));
5449        assert_eq!(
5450            log.lock().unwrap().as_slice(),
5451            &["forked", "joined"],
5452            "the parent must resume via ForkChildDone exactly once, only after all 3 \
5453             children have completed"
5454        );
5455
5456        for child in (parent + 1)..=(parent + 3) {
5457            assert!(
5458                !engine.test_process_exists(child),
5459                "fork child {child} must be unreachable via the process table once its \
5460                 Suspend::Done has been processed and the JoinAll it belongs to has \
5461                 been satisfied -- SC-062's own literal 'the child's slot is reclaimed'"
5462            );
5463        }
5464    }
5465
5466    /// SC-115's own literal acceptance ("a process can be spawned via fork
5467    /// after sim_init has run, with its own scheduled entry in the
5468    /// ready-set machinery") combined with this task's own `JoinKind::None`
5469    /// resolution (this module's doc comment, deviation 3): the parent's
5470    /// own next segment must run *synchronously*, inside the very same
5471    /// `apply_suspend` call that registered the fork's children -- no
5472    /// second `dispatch_ready_set`/`run_until` pass needed -- while the
5473    /// spawned child is left genuinely live (not dispatched, not
5474    /// reclaimed) at that exact point, proving `JoinKind::None` neither
5475    /// blocks on nor otherwise touches its child.
5476    #[test]
5477    fn sc115_join_none_children_run_detached_while_the_parent_proceeds_without_waiting() {
5478        struct ParentProcess {
5479            log: Arc<StdMutex<Vec<&'static str>>>,
5480        }
5481        impl CompiledProcess for ParentProcess {
5482            fn resume(
5483                &mut self,
5484                _state: SimHandle,
5485                arg: ResumeArg,
5486                _ops: &dyn SchedulerOps,
5487            ) -> Suspend {
5488                match arg {
5489                    ResumeArg::Start => {
5490                        self.log.lock().unwrap().push("forked");
5491                        Suspend::ForkJoin(
5492                            JoinKind::None,
5493                            vec![SpawnSpec {
5494                                kernel: 0,
5495                                entry_pc: 0,
5496                                parent: 0,
5497                                disable_name: Vec::new(),
5498                            }],
5499                        )
5500                    }
5501                    ResumeArg::TimeElapsed => {
5502                        self.log.lock().unwrap().push("proceeded");
5503                        Suspend::Done
5504                    }
5505                    other => panic!(
5506                        "ParentProcess must only ever be resumed with Start or \
5507                         TimeElapsed (JoinKind::None's own chosen continuation arg, \
5508                         see this module's doc comment), got {other:?}"
5509                    ),
5510                }
5511            }
5512        }
5513
5514        let log = Arc::new(StdMutex::new(Vec::new()));
5515        let mut engine = new_engine();
5516        let parent = engine.register_process(
5517            Box::new(ParentProcess { log: log.clone() }),
5518            ProcessKind::Initial,
5519            None,
5520            0,
5521        );
5522        assert_eq!(parent, 0, "test assumes a fresh engine's first id is 0");
5523        let child = parent + 1;
5524
5525        // `register_process` already queued the parent's own Start
5526        // activation onto Region::Active. Dispatching exactly that one
5527        // ready-set (a single `dispatch_ready_set` call) must be enough to
5528        // see BOTH "forked" and "proceeded" in the log -- proving
5529        // JoinKind::None's continuation happened inside this same call,
5530        // never requiring a second dispatch pass the way waiting for a
5531        // real ForkChildDone would.
5532        let ready = engine.drain_and_sort(Region::Active);
5533        engine.dispatch_ready_set(Region::Active, ready);
5534
5535        assert_eq!(
5536            log.lock().unwrap().as_slice(),
5537            &["forked", "proceeded"],
5538            "JoinKind::None must resume the parent's own next segment synchronously, \
5539             within the very same apply_suspend call that registered its child -- \
5540             never leaving it suspended waiting for a ForkChildDone that will never \
5541             come (the pre-Task-7 bug this row closes)"
5542        );
5543        assert!(
5544            engine.test_process_exists(child),
5545            "the spawned child must still be a live, registered process at this exact \
5546             point -- it was only just enqueued (Region::Active, ResumeArg::Start) by \
5547             this same dispatch_ready_set call, never itself dispatched by it, so \
5548             JoinKind::None resolving the parent must not have touched the child at \
5549             all (neither reclaiming it nor otherwise depending on its completion)"
5550        );
5551    }
5552
5553    /// R-run Task 17 review fix (r1) -- blocking finding: `apply_suspend`'s
5554    /// `Suspend::ForkJoin` arm's `mint_child` closure ignores its own
5555    /// `SpawnSpec` argument entirely and unconditionally builds an
5556    /// `UnmintedForkChild` (this module's own doc comment deviation 3;
5557    /// `sv-sim-codegen`'s R-run Task 17 now produces real, distinct
5558    /// `SpawnSpec::kernel` indices per branch, but nothing in `Engine`
5559    /// consumes that field yet -- Task 21 remains chartered to fix that).
5560    /// No prior test pinned this AS a tested property -- every existing
5561    /// `Suspend::ForkJoin` test above happens to use small/repeating
5562    /// `kernel` values (0, 1, 2) that a partial, buggy future minter could
5563    /// special-case and still pass. This test spawns children with
5564    /// deliberately large, mutually distinct `kernel` indices (a shape no
5565    /// real per-program kernel table plausibly has entries for yet) and
5566    /// proves every single one still completes via a bare `Suspend::Done`
5567    /// on its own very first `Start` dispatch -- the literal, observable
5568    /// signature of `UnmintedForkChild` (a real compiled branch kernel
5569    /// could legitimately suspend, loop, or otherwise not complete in one
5570    /// dispatch). Fails loudly the instant a future minter starts
5571    /// consulting `SpawnSpec::kernel` for any subset of values without this
5572    /// test being updated alongside it.
5573    #[test]
5574    fn apply_suspend_fork_join_still_mints_unmintedforkchild_regardless_of_spawnspec_kernel() {
5575        struct ParentProcess;
5576        impl CompiledProcess for ParentProcess {
5577            fn resume(
5578                &mut self,
5579                _state: SimHandle,
5580                arg: ResumeArg,
5581                _ops: &dyn SchedulerOps,
5582            ) -> Suspend {
5583                match arg {
5584                    ResumeArg::Start => Suspend::ForkJoin(
5585                        JoinKind::All,
5586                        vec![
5587                            SpawnSpec {
5588                                kernel: 424_242,
5589                                entry_pc: 0,
5590                                parent: 0,
5591                                disable_name: Vec::new(),
5592                            },
5593                            SpawnSpec {
5594                                kernel: 7,
5595                                entry_pc: 3,
5596                                parent: 0,
5597                                disable_name: Vec::new(),
5598                            },
5599                            SpawnSpec {
5600                                kernel: u32::MAX,
5601                                entry_pc: 0,
5602                                parent: 0,
5603                                disable_name: Vec::new(),
5604                            },
5605                        ],
5606                    ),
5607                    // Every child completes in one dispatch (proven below),
5608                    // so the join is satisfied by the time this fires; the
5609                    // real assertions are about the children, not this arm.
5610                    ResumeArg::ForkChildDone { .. } => Suspend::Done,
5611                    other => panic!(
5612                        "ParentProcess must only ever see Start or ForkChildDone, got {other:?}"
5613                    ),
5614                }
5615            }
5616        }
5617
5618        let mut engine = new_engine();
5619        let parent =
5620            engine.register_process(Box::new(ParentProcess), ProcessKind::Initial, None, 0);
5621        assert_eq!(parent, 0, "test assumes a fresh engine's first id is 0");
5622
5623        // Dispatch the parent's own Start -- this is the call that runs
5624        // apply_suspend's Suspend::ForkJoin arm and mints the 3 children.
5625        let ready = engine.drain_and_sort(Region::Active);
5626        engine.dispatch_ready_set(Region::Active, ready);
5627
5628        let children = [parent + 1, parent + 2, parent + 3];
5629        for &child in &children {
5630            assert!(
5631                engine.test_process_exists(child),
5632                "child {child} must exist right after being minted, before its own first \
5633                 dispatch"
5634            );
5635        }
5636
5637        // Dispatch every child's own queued Start activation in one pass.
5638        let ready = engine.drain_and_sort(Region::Active);
5639        assert_eq!(
5640            ready.len(),
5641            3,
5642            "exactly the 3 freshly-minted children's Start activations must be ready, got {}",
5643            ready.len()
5644        );
5645        engine.dispatch_ready_set(Region::Active, ready);
5646
5647        for &child in &children {
5648            assert!(
5649                !engine.test_process_exists(child),
5650                "child {child} (SpawnSpec::kernel ignored by today's mint_child) must have \
5651                 completed via a bare Suspend::Done on its own very first Start dispatch and \
5652                 been reclaimed -- exactly UnmintedForkChild's own documented behavior, \
5653                 regardless of how large or distinct its own SpawnSpec::kernel index was. A \
5654                 real per-branch compiled kernel is not guaranteed to complete in one dispatch \
5655                 at all, so this failing is the signal that mint_child has started consulting \
5656                 SpawnSpec::kernel for at least one of these values -- update this test (and, \
5657                 per Task 21's own charter, the CLI-level fork/join gate) alongside that change."
5658            );
5659        }
5660    }
5661
5662    // --- Task 9 (A2-part2, CG-102): region_id-consuming partition-aware
5663    // dispatch ---------------------------------------------------------
5664
5665    /// `partition_of` genuinely reads `region_id`, not a hardcoded `0` --
5666    /// real modulo arithmetic against `partition_count`.
5667    #[test]
5668    fn partition_of_is_a_real_modulo_not_a_hardcoded_zero() {
5669        assert_eq!(partition_of(5, 4), PartitionId(1));
5670        assert_eq!(partition_of(6, 4), PartitionId(2));
5671        assert_eq!(partition_of(0, 4), PartitionId(0));
5672    }
5673
5674    /// v1's one real call site always passes `partition_count = 1`
5675    /// (ARCHITECTURE §4 item 1) -- pinned here as a property of the
5676    /// function itself, for several distinct `region_id` values, not an
5677    /// assumption left implicit at the call site.
5678    #[test]
5679    fn partition_of_is_the_documented_v1_degenerate_single_partition_value() {
5680        for region_id in [0, 3, 9] {
5681            assert_eq!(
5682                partition_of(region_id, 1),
5683                PartitionId(0),
5684                "region_id={region_id}: v1's real partition_count=1 call site must always \
5685                 yield PartitionId(0), for any region_id"
5686            );
5687        }
5688    }
5689
5690    /// `partition_of` never divides by zero -- `partition_count == 0`
5691    /// degrades to the same single-partition behavior as `1`, rather than
5692    /// panicking.
5693    #[test]
5694    fn partition_of_with_zero_partition_count_does_not_panic() {
5695        assert_eq!(partition_of(7, 0), PartitionId(0));
5696    }
5697
5698    /// CG-102's scheduler-side literal acceptance ("consumed by the
5699    /// scheduler for partition-aware dispatch"): register 3 processes with
5700    /// distinct `region_id`s (0, 3, 9) via the real `SchedulerControl::
5701    /// register_process` API, dispatch their initial-registration
5702    /// activations, and confirm the spy hook saw every one of their
5703    /// `ProcessId`s queried against exactly the `region_id` each was
5704    /// registered with -- proving the field flows from registration
5705    /// through to dispatch rather than being stored-but-never-read (the
5706    /// literal gap the plan's own "Verified live" review finding named),
5707    /// and would fail if `dispatch_ready_set` were refactored to default
5708    /// every lookup to `0` regardless of what was registered.
5709    #[test]
5710    fn dispatch_ready_set_looks_up_partition_of_using_each_readied_processs_own_stored_region_id() {
5711        let mut engine = new_engine();
5712        let p0 =
5713            engine.register_process(Box::new(UnmintedForkChild), ProcessKind::Initial, None, 0);
5714        let p3 =
5715            engine.register_process(Box::new(UnmintedForkChild), ProcessKind::Initial, None, 3);
5716        let p9 =
5717            engine.register_process(Box::new(UnmintedForkChild), ProcessKind::Initial, None, 9);
5718
5719        // Each `register_process` above already queued its own Start
5720        // activation into Region::Active -- drive exactly one dispatch pass
5721        // over all 3 so `dispatch_ready_set` genuinely runs its grouping
5722        // loop once, over all 3 activations together.
5723        let ready = engine.drain_and_sort(Region::Active);
5724        assert_eq!(
5725            ready.len(),
5726            3,
5727            "all 3 registrations' Start activations must be pending"
5728        );
5729        engine.dispatch_ready_set(Region::Active, ready);
5730
5731        let mut seen = engine.region_id_queries_seen().to_vec();
5732        seen.sort_unstable();
5733        let mut want = vec![(p0, 0u32), (p3, 3u32), (p9, 9u32)];
5734        want.sort_unstable();
5735        assert_eq!(
5736            seen, want,
5737            "dispatch_ready_set must query ProcessTable::region_id for every readied \
5738             process, and every value returned must exactly match what register_process \
5739             stored for it"
5740        );
5741    }
5742
5743    // --- Task 3 (B-sched, ledger SC-004, SC-005, SC-052, SC-053):
5744    // EventTerm::Repeat engine semantics + ##N/clocking-block runtime
5745    // resolution ---
5746
5747    /// SC-052's own literal acceptance, verified against the production
5748    /// `Engine` dispatch loop (not a hand-simulated one): a
5749    /// `repeat(3)@(posedge clk)`-shaped mock process must resume only after
5750    /// the 3rd matching posedge, never the 1st or 2nd, and exactly once
5751    /// after that (not once per remaining posedge -- which would mean a
5752    /// `RepeatOutcome::StillWaiting` firing wrongly fell through to
5753    /// enqueue).
5754    #[test]
5755    fn engine_resumes_a_repeat_3_process_only_on_the_third_matching_edge_not_the_first() {
5756        struct Repeat3Waiter {
5757            cell: StateCellId,
5758            resume_log: Arc<StdMutex<Vec<ResumeArg>>>,
5759        }
5760        impl CompiledProcess for Repeat3Waiter {
5761            fn resume(
5762                &mut self,
5763                _state: SimHandle,
5764                arg: ResumeArg,
5765                _ops: &dyn SchedulerOps,
5766            ) -> Suspend {
5767                self.resume_log.lock().unwrap().push(arg);
5768                match arg {
5769                    ResumeArg::Start => Suspend::WaitEvents(EventWaitSet {
5770                        terms: vec![EventTerm::Repeat {
5771                            count: 3,
5772                            inner: Box::new(EventTerm::Edge {
5773                                cell: self.cell,
5774                                edge: EdgeKind::Posedge,
5775                                iff: None,
5776                            }),
5777                        }],
5778                        ..Default::default()
5779                    }),
5780                    _ => Suspend::Done,
5781                }
5782            }
5783        }
5784
5785        let cell = StateCellId(200);
5786        let mut engine = Engine::new(
5787            Box::new(TestArena::seeded(cell, narrow(0))),
5788            Box::new(TestIoSink::default()),
5789        );
5790        let log: Arc<StdMutex<Vec<ResumeArg>>> = Arc::new(StdMutex::new(Vec::new()));
5791        engine.register_process(
5792            Box::new(Repeat3Waiter {
5793                cell,
5794                resume_log: log.clone(),
5795            }),
5796            ProcessKind::Initial,
5797            None,
5798            0,
5799        );
5800
5801        // Dispatch Start: installs the Repeat{count:3, inner: Edge} dynamic
5802        // wait.
5803        let ready = engine.drain_and_sort(Region::Active);
5804        engine.dispatch_ready_set(Region::Active, ready);
5805        assert_eq!(
5806            log.lock().unwrap().len(),
5807            1,
5808            "only Start's own resume() call must have happened so far"
5809        );
5810
5811        let write = |engine: &Engine, value: FourStateValue| {
5812            let ops = engine.test_dispatch_ops(engine.now(), Region::Active, 0);
5813            ops.commit_blocking_write(cell, value);
5814        };
5815
5816        // Posedge 1 (0 -> 1): StillWaiting -- must NOT enqueue/resume.
5817        write(&engine, narrow(1));
5818        let ready = engine.drain_and_sort(Region::Active);
5819        engine.dispatch_ready_set(Region::Active, ready);
5820        assert_eq!(
5821            log.lock().unwrap().len(),
5822            1,
5823            "the 1st matching posedge must not resume a repeat(3) waiter"
5824        );
5825
5826        // Negedge (1 -> 0): does not match a Posedge-qualified dynamic entry
5827        // at all -- irrelevant filler, mirroring an SV clock's real toggle
5828        // pattern.
5829        write(&engine, narrow(0));
5830
5831        // Posedge 2 (0 -> 1): StillWaiting again -- must still NOT resume.
5832        write(&engine, narrow(1));
5833        let ready = engine.drain_and_sort(Region::Active);
5834        engine.dispatch_ready_set(Region::Active, ready);
5835        assert_eq!(
5836            log.lock().unwrap().len(),
5837            1,
5838            "the 2nd matching posedge must not resume a repeat(3) waiter either"
5839        );
5840
5841        write(&engine, narrow(0));
5842
5843        // Posedge 3 (0 -> 1): Satisfied -- must resume exactly now.
5844        write(&engine, narrow(1));
5845        let ready = engine.drain_and_sort(Region::Active);
5846        engine.dispatch_ready_set(Region::Active, ready);
5847        {
5848            let log = log.lock().unwrap();
5849            assert_eq!(
5850                log.len(),
5851                2,
5852                "the 3rd matching posedge must resume the waiter exactly once"
5853            );
5854            assert!(
5855                matches!(log[1], ResumeArg::EventFired { .. }),
5856                "the resuming call's ResumeArg must be EventFired, got {:?}",
5857                log[1]
5858            );
5859        }
5860        assert_eq!(
5861            engine.test_sensitivity_entry_count(),
5862            0,
5863            "the dynamic entry (and its RepeatCounters row) must be gone once satisfied"
5864        );
5865
5866        // Two more posedges after the process has already resumed and
5867        // returned Suspend::Done (no re-arm): must produce no further
5868        // resume() calls at all -- proves a StillWaiting firing never falls
5869        // through to enqueue, and that a satisfied repeat's row is not
5870        // somehow still alive to double-fire.
5871        write(&engine, narrow(0));
5872        write(&engine, narrow(1));
5873        write(&engine, narrow(0));
5874        write(&engine, narrow(1));
5875        let ready = engine.drain_and_sort(Region::Active);
5876        engine.dispatch_ready_set(Region::Active, ready);
5877        assert_eq!(
5878            log.lock().unwrap().len(),
5879            2,
5880            "no resume() call must happen for any posedge after the repeat(3) waiter \
5881             has already resumed and completed -- exactly once total after the 3rd \
5882             posedge, never once per remaining posedge"
5883        );
5884    }
5885
5886    /// SC-053's own differentiating acceptance clause: "the same compiled
5887    /// `##N` delay produces different absolute tick timing when its
5888    /// associated clock's period changes dynamically at runtime" -- proven
5889    /// here at the scheduler level by driving a deliberately IRREGULAR
5890    /// (non-periodic) clock through a `repeat(2)@(posedge clk)`-shaped mock
5891    /// process and asserting it resumes at the clock's real 2nd posedge tick
5892    /// (12), never a precomputed `3 + 2*period`-style absolute tick a wrong
5893    /// "optimize by caching a tick offset" implementation would produce.
5894    #[test]
5895    fn sc053_repeat_based_wait_re_reads_the_clocks_live_period_not_a_cached_tick_offset() {
5896        #[derive(Clone, Copy, Debug, PartialEq, Eq)]
5897        enum LogTag {
5898            Start,
5899            EventFired,
5900        }
5901
5902        struct RepeatWaiter {
5903            cell: StateCellId,
5904            log: Arc<StdMutex<Vec<(LogTag, SchedTime)>>>,
5905        }
5906        impl CompiledProcess for RepeatWaiter {
5907            fn resume(
5908                &mut self,
5909                _state: SimHandle,
5910                arg: ResumeArg,
5911                ops: &dyn SchedulerOps,
5912            ) -> Suspend {
5913                match arg {
5914                    ResumeArg::Start => {
5915                        self.log.lock().unwrap().push((LogTag::Start, ops.now()));
5916                        Suspend::WaitEvents(EventWaitSet {
5917                            terms: vec![EventTerm::Repeat {
5918                                count: 2,
5919                                inner: Box::new(EventTerm::Edge {
5920                                    cell: self.cell,
5921                                    edge: EdgeKind::Posedge,
5922                                    iff: None,
5923                                }),
5924                            }],
5925                            ..Default::default()
5926                        })
5927                    }
5928                    ResumeArg::EventFired { .. } => {
5929                        self.log
5930                            .lock()
5931                            .unwrap()
5932                            .push((LogTag::EventFired, ops.now()));
5933                        Suspend::Done
5934                    }
5935                    other => panic!("RepeatWaiter must only see Start/EventFired, got {other:?}"),
5936                }
5937            }
5938        }
5939
5940        // Irregular clock: a real signal (not a "##N" literal) whose edges
5941        // land at ticks 3 (posedge), 5 (negedge, irrelevant filler), and 12
5942        // (posedge) -- deliberately never landing on a fixed period, so any
5943        // implementation that tried to precompute
5944        // `first_posedge_tick + (count - 1) * observed_period` would land on
5945        // a wrong tick (3 + 1*2 = 5, which is a negedge, not even a 2nd
5946        // posedge) instead of the real 2nd posedge at 12.
5947        struct IrregularClockWriter {
5948            cell: StateCellId,
5949            step: u32,
5950        }
5951        impl CompiledProcess for IrregularClockWriter {
5952            fn resume(
5953                &mut self,
5954                _state: SimHandle,
5955                arg: ResumeArg,
5956                ops: &dyn SchedulerOps,
5957            ) -> Suspend {
5958                match (self.step, arg) {
5959                    (0, ResumeArg::Start) => {
5960                        self.step = 1;
5961                        Suspend::Delay(3) // fires at tick 3
5962                    }
5963                    (1, ResumeArg::TimeElapsed) => {
5964                        ops.commit_blocking_write(self.cell, narrow(1)); // posedge @ 3
5965                        self.step = 2;
5966                        Suspend::Delay(2) // fires at tick 5
5967                    }
5968                    (2, ResumeArg::TimeElapsed) => {
5969                        ops.commit_blocking_write(self.cell, narrow(0)); // negedge @ 5
5970                        self.step = 3;
5971                        Suspend::Delay(7) // fires at tick 12
5972                    }
5973                    (3, ResumeArg::TimeElapsed) => {
5974                        ops.commit_blocking_write(self.cell, narrow(1)); // posedge @ 12
5975                        self.step = 4;
5976                        Suspend::Done
5977                    }
5978                    (step, other) => {
5979                        panic!("IrregularClockWriter unexpected (step={step}, arg={other:?})")
5980                    }
5981                }
5982            }
5983        }
5984
5985        let cell = StateCellId(201);
5986        let mut engine = Engine::new(
5987            Box::new(TestArena::seeded(cell, narrow(0))),
5988            Box::new(TestIoSink::default()),
5989        );
5990        let log: Arc<StdMutex<Vec<(LogTag, SchedTime)>>> = Arc::new(StdMutex::new(Vec::new()));
5991        engine.register_process(
5992            Box::new(RepeatWaiter {
5993                cell,
5994                log: log.clone(),
5995            }),
5996            ProcessKind::Initial,
5997            None,
5998            0,
5999        );
6000        engine.register_process(
6001            Box::new(IrregularClockWriter { cell, step: 0 }),
6002            ProcessKind::Initial,
6003            None,
6004            0,
6005        );
6006
6007        let result = engine.run_until(None);
6008        assert!(matches!(result, RunResult::Completed));
6009
6010        let log = log.lock().unwrap();
6011        assert_eq!(
6012            *log,
6013            vec![
6014                (LogTag::Start, SchedTime { ticks: 0 }),
6015                (LogTag::EventFired, SchedTime { ticks: 12 }),
6016            ],
6017            "the repeat(2) waiter must resume exactly at tick 12 (the clock's real 2nd \
6018             posedge), never a precomputed offset from a cached period"
6019        );
6020    }
6021
6022    // --- Task 4 (B-sched, ledger SC-090, SC-123, SC-020 scheduler-owned
6023    // half): Postponed/Reactive region-boundary placement: region
6024    // callbacks + `1step` ---
6025
6026    /// SC-090's own literal acceptance: a `$strobe`/`$monitor`-shaped
6027    /// `Region::Postponed` callback sampling a cell written twice within
6028    /// one timestep's delta loop must read only the FINAL, post-delta-
6029    /// convergence value, and must fire exactly once for that timestep --
6030    /// never once per intermediate write (which would mean it ran
6031    /// interleaved with the delta loop, not strictly after it converged).
6032    #[test]
6033    fn postponed_callback_fires_exactly_once_and_only_after_delta_convergence() {
6034        struct TwiceWritingProcess {
6035            cell: StateCellId,
6036        }
6037        impl CompiledProcess for TwiceWritingProcess {
6038            fn resume(
6039                &mut self,
6040                _state: SimHandle,
6041                arg: ResumeArg,
6042                ops: &dyn SchedulerOps,
6043            ) -> Suspend {
6044                match arg {
6045                    ResumeArg::Start => {
6046                        ops.commit_blocking_write(self.cell, narrow(0));
6047                        Suspend::Delay(0)
6048                    }
6049                    ResumeArg::TimeElapsed => {
6050                        ops.commit_blocking_write(self.cell, narrow(1));
6051                        Suspend::Done
6052                    }
6053                    other => panic!("TwiceWritingProcess unexpected arg {other:?}"),
6054                }
6055            }
6056        }
6057
6058        struct ReadCellCallback {
6059            cell: StateCellId,
6060            log: Arc<StdMutex<Vec<FourStateValue>>>,
6061        }
6062        impl RegionCallback for ReadCellCallback {
6063            fn invoke(&mut self, ctx: &dyn SchedulerCtx) {
6064                self.log.lock().unwrap().push(ctx.read(self.cell));
6065            }
6066        }
6067
6068        let cell = StateCellId(300);
6069        let mut engine = Engine::new(
6070            Box::new(TestArena::seeded(cell, narrow(9))),
6071            Box::new(TestIoSink::default()),
6072        );
6073        let seen: Arc<StdMutex<Vec<FourStateValue>>> = Arc::new(StdMutex::new(Vec::new()));
6074        {
6075            let ops = engine.test_dispatch_ops(engine.now(), Region::Active, 0);
6076            ops.register_region_callback(
6077                Region::Postponed,
6078                engine.now(),
6079                Box::new(ReadCellCallback {
6080                    cell,
6081                    log: seen.clone(),
6082                }),
6083            );
6084        }
6085        engine.register_process(
6086            Box::new(TwiceWritingProcess { cell }),
6087            ProcessKind::Initial,
6088            None,
6089            0,
6090        );
6091
6092        let result = engine.run_until(None);
6093        assert!(matches!(result, RunResult::Completed));
6094
6095        let recorded = seen.lock().unwrap();
6096        assert_eq!(
6097            recorded.len(),
6098            1,
6099            "the Postponed callback must fire exactly once for the one timestep this \
6100             run has, not once per intermediate write inside the delta loop"
6101        );
6102        assert!(
6103            matches!(recorded[0], FourStateValue::Narrow(1)),
6104            "must read the FINAL, post-delta-convergence value (1), never the \
6105             intermediate write (0) or the pre-run seed (9); got {:?}",
6106            recorded[0]
6107        );
6108    }
6109
6110    /// Region ordering plus registration-order-per-region: a `Region::
6111    /// Reactive` callback and a `Region::Postponed` callback registered for
6112    /// the same `now` must both fire exactly once, Reactive strictly before
6113    /// Postponed (the same region ordering `run_until` already enforces for
6114    /// ordinary process dispatch), each observing the same final settled
6115    /// value -- proving the two regions' callback drains don't share state
6116    /// that lets one suppress or corrupt the other.
6117    #[test]
6118    fn reactive_and_postponed_callbacks_at_the_same_time_do_not_interfere() {
6119        struct OneShotWriter {
6120            cell: StateCellId,
6121        }
6122        impl CompiledProcess for OneShotWriter {
6123            fn resume(
6124                &mut self,
6125                _state: SimHandle,
6126                _arg: ResumeArg,
6127                ops: &dyn SchedulerOps,
6128            ) -> Suspend {
6129                ops.commit_blocking_write(self.cell, narrow(7));
6130                Suspend::Done
6131            }
6132        }
6133
6134        struct TaggedReadCallback {
6135            tag: &'static str,
6136            cell: StateCellId,
6137            log: Arc<StdMutex<Vec<(&'static str, FourStateValue)>>>,
6138        }
6139        impl RegionCallback for TaggedReadCallback {
6140            fn invoke(&mut self, ctx: &dyn SchedulerCtx) {
6141                self.log
6142                    .lock()
6143                    .unwrap()
6144                    .push((self.tag, ctx.read(self.cell)));
6145            }
6146        }
6147
6148        let cell = StateCellId(301);
6149        let mut engine = Engine::new(
6150            Box::new(TestArena::seeded(cell, narrow(0))),
6151            Box::new(TestIoSink::default()),
6152        );
6153        let log: Arc<StdMutex<Vec<(&'static str, FourStateValue)>>> =
6154            Arc::new(StdMutex::new(Vec::new()));
6155        {
6156            let ops = engine.test_dispatch_ops(engine.now(), Region::Active, 0);
6157            ops.register_region_callback(
6158                Region::Reactive,
6159                engine.now(),
6160                Box::new(TaggedReadCallback {
6161                    tag: "Reactive",
6162                    cell,
6163                    log: log.clone(),
6164                }),
6165            );
6166            ops.register_region_callback(
6167                Region::Postponed,
6168                engine.now(),
6169                Box::new(TaggedReadCallback {
6170                    tag: "Postponed",
6171                    cell,
6172                    log: log.clone(),
6173                }),
6174            );
6175        }
6176        engine.register_process(
6177            Box::new(OneShotWriter { cell }),
6178            ProcessKind::Initial,
6179            None,
6180            0,
6181        );
6182
6183        let result = engine.run_until(None);
6184        assert!(matches!(result, RunResult::Completed));
6185
6186        let recorded = log.lock().unwrap();
6187        assert_eq!(
6188            recorded.len(),
6189            2,
6190            "both callbacks must fire exactly once each"
6191        );
6192        assert_eq!(
6193            recorded[0].0, "Reactive",
6194            "Reactive's own callback must fire before Postponed's"
6195        );
6196        assert_eq!(recorded[1].0, "Postponed");
6197        assert!(matches!(recorded[0].1, FourStateValue::Narrow(7)));
6198        assert!(matches!(recorded[1].1, FourStateValue::Narrow(7)));
6199    }
6200
6201    /// sva T2 (§1.4): `Region::Observed` is drained (via
6202    /// `drain_observed_and_check_settled`) strictly before `Region::
6203    /// Reactive`'s own dispatch/callback drain, within the SAME timestep --
6204    /// the reference algorithm's own stratified-region ordering (Clause
6205    /// 4.5), not merely "eventually, on some later timestep". Registers one
6206    /// tagged callback at each region (no process at all is needed: with
6207    /// nothing registered, Active/Inactive/Nba settle on `run_until`'s very
6208    /// first delta-loop pass, which is exactly the "genuinely idle" point
6209    /// `drain_observed_and_check_settled` is pinned to).
6210    #[test]
6211    fn observed_region_maturation_fires_before_reactive_same_timestep() {
6212        struct TagCallback {
6213            tag: &'static str,
6214            log: Arc<StdMutex<Vec<&'static str>>>,
6215        }
6216        impl RegionCallback for TagCallback {
6217            fn invoke(&mut self, _ctx: &dyn SchedulerCtx) {
6218                self.log.lock().unwrap().push(self.tag);
6219            }
6220        }
6221
6222        let mut engine = new_engine();
6223        let log: Arc<StdMutex<Vec<&'static str>>> = Arc::new(StdMutex::new(Vec::new()));
6224        {
6225            let ops = engine.test_dispatch_ops(engine.now(), Region::Active, 0);
6226            ops.register_region_callback(
6227                Region::Reactive,
6228                engine.now(),
6229                Box::new(TagCallback {
6230                    tag: "Reactive",
6231                    log: log.clone(),
6232                }),
6233            );
6234            ops.register_region_callback(
6235                Region::Observed,
6236                engine.now(),
6237                Box::new(TagCallback {
6238                    tag: "Observed",
6239                    log: log.clone(),
6240                }),
6241            );
6242        }
6243
6244        let result = engine.run_until(None);
6245        assert!(matches!(result, RunResult::Completed));
6246
6247        let recorded = log.lock().unwrap();
6248        assert_eq!(
6249            *recorded,
6250            vec!["Observed", "Reactive"],
6251            "Observed must mature before Reactive within the same settled timestep"
6252        );
6253    }
6254
6255    /// sva T2 (§1.4, review fix, blocking finding 2): a matured report an
6256    /// Observed callback schedules into `Region::Reactive` mid-drain (via
6257    /// `SchedulerOps::register_region_callback`, exactly as `sched::
6258    /// maturation::report` will for a real SVA pass/fail action, §1.7)
6259    /// must already be sitting in `region_callbacks` by the time the
6260    /// single-stepping debugger's OWN NEXT `step_region()` call reaches
6261    /// `Region::Reactive` -- not delayed to a later timestep, and not
6262    /// fired early from inside the Observed drain itself.
6263    #[test]
6264    fn observed_engine_step_result_visible_by_next_reactive_step() {
6265        struct TagCallback {
6266            tag: &'static str,
6267            log: Arc<StdMutex<Vec<&'static str>>>,
6268        }
6269        impl RegionCallback for TagCallback {
6270            fn invoke(&mut self, _ctx: &dyn SchedulerCtx) {
6271                self.log.lock().unwrap().push(self.tag);
6272            }
6273        }
6274
6275        struct ObservedReportsIntoReactive {
6276            log: Arc<StdMutex<Vec<&'static str>>>,
6277        }
6278        impl RegionCallback for ObservedReportsIntoReactive {
6279            fn invoke(&mut self, _ctx: &dyn SchedulerCtx) {
6280                // Stands in for a matured SVA thread's `sv_rt_sva_report_
6281                // pass` (§1.7): the Observed-region engine step schedules a
6282                // Reactive action from inside its own callback, exactly as
6283                // `sched::maturation::report` does for every report kind.
6284                let log = self.log.clone();
6285                crate::sched::with_current_ops(move |ops| {
6286                    ops.register_region_callback(
6287                        Region::Reactive,
6288                        ops.now(),
6289                        Box::new(TagCallback {
6290                            tag: "matured",
6291                            log: log.clone(),
6292                        }),
6293                    );
6294                });
6295            }
6296        }
6297
6298        let mut engine = new_engine();
6299        let log: Arc<StdMutex<Vec<&'static str>>> = Arc::new(StdMutex::new(Vec::new()));
6300        {
6301            let ops = engine.test_dispatch_ops(engine.now(), Region::Active, 0);
6302            ops.register_region_callback(
6303                Region::Observed,
6304                engine.now(),
6305                Box::new(ObservedReportsIntoReactive { log: log.clone() }),
6306            );
6307        }
6308
6309        assert_eq!(engine.current_region(), Region::Active);
6310        engine.step_region(); // Active -> Inactive
6311        assert_eq!(engine.current_region(), Region::Inactive);
6312        engine.step_region(); // Inactive -> Nba
6313        assert_eq!(engine.current_region(), Region::Nba);
6314        engine.step_region(); // Nba settles, drains Observed (schedules
6315        // "matured" into Reactive), -> Reactive.
6316        assert_eq!(engine.current_region(), Region::Reactive);
6317        assert!(
6318            log.lock().unwrap().is_empty(),
6319            "the matured report must not fire before Reactive's own step runs"
6320        );
6321
6322        engine.step_region(); // Reactive drains region callbacks -> Postponed.
6323        assert_eq!(engine.current_region(), Region::Postponed);
6324        assert_eq!(
6325            *log.lock().unwrap(),
6326            vec!["matured"],
6327            "the report registered mid-Observed-drain must already be visible to the very \
6328             next Reactive step"
6329        );
6330    }
6331
6332    /// SC-123's pinned `1step` placement: a process suspended via `1step`
6333    /// timing registers itself as a `Region::Postponed` callback at the
6334    /// current `now` (this test's own mock process does so directly via
6335    /// `ops.register_region_callback`, standing in for the codegen
6336    /// lowering no track emits yet this wave -- see `region_callback`'s
6337    /// own module doc comment). Its continuation must run strictly after
6338    /// every ORDINARY `Region::Postponed` callback registered before it,
6339    /// never interleaved before them -- the exact placement a wrongly-
6340    /// modeled `Suspend::Delay(0)` (which lands in `Region::Inactive`,
6341    /// strictly *before* Postponed even runs) could never produce.
6342    #[test]
6343    fn sc123_1step_continuation_runs_after_every_ordinary_postponed_callback_at_the_same_tick() {
6344        struct TagCallback {
6345            tag: &'static str,
6346            log: Arc<StdMutex<Vec<&'static str>>>,
6347        }
6348        impl RegionCallback for TagCallback {
6349            fn invoke(&mut self, _ctx: &dyn SchedulerCtx) {
6350                self.log.lock().unwrap().push(self.tag);
6351            }
6352        }
6353
6354        struct OneStepProcess {
6355            log: Arc<StdMutex<Vec<&'static str>>>,
6356        }
6357        impl CompiledProcess for OneStepProcess {
6358            fn resume(
6359                &mut self,
6360                _state: SimHandle,
6361                arg: ResumeArg,
6362                ops: &dyn SchedulerOps,
6363            ) -> Suspend {
6364                match arg {
6365                    ResumeArg::Start => Suspend::Delay(0),
6366                    ResumeArg::TimeElapsed => {
6367                        // Pinned placement (region_callback's own module
6368                        // doc): register as a Postponed callback at the
6369                        // current tick, rather than resuming again via a
6370                        // second Suspend::Delay(0) (which would land back
6371                        // in Inactive, the wrong side of Postponed).
6372                        ops.register_region_callback(
6373                            Region::Postponed,
6374                            ops.now(),
6375                            Box::new(TagCallback {
6376                                tag: "1step",
6377                                log: self.log.clone(),
6378                            }),
6379                        );
6380                        Suspend::Done
6381                    }
6382                    other => panic!("OneStepProcess unexpected arg {other:?}"),
6383                }
6384            }
6385        }
6386
6387        let mut engine = new_engine();
6388        let log: Arc<StdMutex<Vec<&'static str>>> = Arc::new(StdMutex::new(Vec::new()));
6389        {
6390            let ops = engine.test_dispatch_ops(engine.now(), Region::Active, 0);
6391            ops.register_region_callback(
6392                Region::Postponed,
6393                engine.now(),
6394                Box::new(TagCallback {
6395                    tag: "A",
6396                    log: log.clone(),
6397                }),
6398            );
6399            ops.register_region_callback(
6400                Region::Postponed,
6401                engine.now(),
6402                Box::new(TagCallback {
6403                    tag: "B",
6404                    log: log.clone(),
6405                }),
6406            );
6407        }
6408        engine.register_process(
6409            Box::new(OneStepProcess { log: log.clone() }),
6410            ProcessKind::Initial,
6411            None,
6412            0,
6413        );
6414
6415        let result = engine.run_until(None);
6416        assert!(matches!(result, RunResult::Completed));
6417
6418        let recorded = log.lock().unwrap();
6419        assert_eq!(
6420            *recorded,
6421            vec!["A", "B", "1step"],
6422            "the 1step continuation must fire strictly after every ordinary \
6423             Postponed callback registered before it, never interleaved before them"
6424        );
6425    }
6426
6427    /// Review fix (Task 4 r1): `Engine::push_region_callback` must reject
6428    /// every region `Engine::drain_region_callbacks` never drains
6429    /// (`Active`/`Inactive`/`Nba`) with a typed
6430    /// `Err(RegionCallbackRegionError::NotDrained(region))`, never silently
6431    /// accept it into `region_callbacks` where it would sit forever and
6432    /// never fire (that was the bug: no error, no panic, no log). Only
6433    /// `Reactive`/`Postponed` -- the two regions that method's own doc
6434    /// comment names -- may `Ok(())`.
6435    #[test]
6436    fn push_region_callback_rejects_every_region_drain_region_callbacks_never_drains() {
6437        struct NeverInvoked;
6438        impl RegionCallback for NeverInvoked {
6439            fn invoke(&mut self, _ctx: &dyn SchedulerCtx) {
6440                panic!("must never be invoked -- the registration itself must be rejected");
6441            }
6442        }
6443
6444        let engine = new_engine();
6445
6446        for &region in &[Region::Active, Region::Inactive, Region::Nba] {
6447            let result = engine.push_region_callback(engine.now(), region, Box::new(NeverInvoked));
6448            assert_eq!(
6449                result,
6450                Err(RegionCallbackRegionError::NotDrained(region)),
6451                "{region:?} is never drained by drain_region_callbacks and must be rejected, \
6452                 not silently accepted"
6453            );
6454        }
6455
6456        // `NeverInvoked` registrations accepted just above are still sitting
6457        // in `engine.region_callbacks`, undrained (this test never calls
6458        // `run_until`/`drain_region_callbacks`) -- dropping `engine` here
6459        // drops them without invoking, so `NeverInvoked::invoke`'s panic
6460        // never fires. That drain-ordering/exactly-once behavior is already
6461        // covered end-to-end by `postponed_callback_fires_exactly_once_and_
6462        // only_after_delta_convergence` and
6463        // `reactive_and_postponed_callbacks_at_the_same_time_do_not_
6464        // interfere`; this test's own scope is `push_region_callback`'s
6465        // accept/reject decision alone.
6466        for &region in &[Region::Reactive, Region::Postponed] {
6467            let result = engine.push_region_callback(engine.now(), region, Box::new(NeverInvoked));
6468            assert_eq!(
6469                result,
6470                Ok(()),
6471                "{region:?} IS drained by drain_region_callbacks and must be accepted"
6472            );
6473        }
6474    }
6475
6476    /// sva T2 (§1.4, review fix, blocking finding 2), Observed's half:
6477    /// `Region::Observed` joins `Reactive`/`Postponed` on the ACCEPTING
6478    /// side -- `drain_observed_and_check_settled` genuinely drains it now,
6479    /// so a registration for it must no longer be rejected.
6480    #[test]
6481    fn push_region_callback_accepts_observed() {
6482        struct NeverInvoked;
6483        impl RegionCallback for NeverInvoked {
6484            fn invoke(&mut self, _ctx: &dyn SchedulerCtx) {
6485                panic!("must never be invoked -- this test only checks the accept decision");
6486            }
6487        }
6488
6489        let engine = new_engine();
6490        let result =
6491            engine.push_region_callback(engine.now(), Region::Observed, Box::new(NeverInvoked));
6492        assert_eq!(
6493            result,
6494            Ok(()),
6495            "Region::Observed IS now drained (by drain_observed_and_check_settled) and must \
6496             be accepted"
6497        );
6498    }
6499
6500    /// sva T2 (§1.4, review fix, blocking finding 2): adding `Observed` to
6501    /// the accepting side must not loosen `Preponed`'s own rejection --
6502    /// `Preponed` stays a pure inline snapshot push, never a registered
6503    /// callback, even after `Observed` becomes real (the two Task 1/Task 2
6504    /// additions could easily have been conflated into one "any SVA
6505    /// region" acceptance by a careless edit; this proves they weren't).
6506    #[test]
6507    fn push_region_callback_still_rejects_preponed_after_observed_added() {
6508        struct NeverInvoked;
6509        impl RegionCallback for NeverInvoked {
6510            fn invoke(&mut self, _ctx: &dyn SchedulerCtx) {
6511                panic!("must never be invoked -- the registration itself must be rejected");
6512            }
6513        }
6514
6515        let engine = new_engine();
6516        let result =
6517            engine.push_region_callback(engine.now(), Region::Preponed, Box::new(NeverInvoked));
6518        assert_eq!(
6519            result,
6520            Err(RegionCallbackRegionError::NotDrained(Region::Preponed)),
6521            "Region::Preponed must still be rejected -- it is a pure inline snapshot push, \
6522             never a registered callback, regardless of Observed's own addition"
6523        );
6524    }
6525
6526    /// Review fix (Task 4 r1): the one production trait boundary,
6527    /// `EngineOps::register_region_callback` (`SchedulerOps`'s impl), must
6528    /// convert `RegionCallbackRegionError` into an immediate, loud panic --
6529    /// never a silent, forever-unfired registration -- because the trait
6530    /// method itself returns `()` and cannot propagate a `Result` (its
6531    /// signature is a cross-track contract many `#[cfg(test)]` mocks outside
6532    /// this crate also implement).
6533    #[test]
6534    #[should_panic(expected = "region must be Reactive, Postponed, or Observed")]
6535    fn register_region_callback_panics_loudly_for_an_undrained_region() {
6536        struct NeverInvoked;
6537        impl RegionCallback for NeverInvoked {
6538            fn invoke(&mut self, _ctx: &dyn SchedulerCtx) {
6539                panic!("must never be invoked -- the registration itself must be rejected");
6540            }
6541        }
6542
6543        let engine = new_engine();
6544        let ops = engine.test_dispatch_ops(engine.now(), Region::Active, 0);
6545        ops.register_region_callback(Region::Active, engine.now(), Box::new(NeverInvoked));
6546    }
6547
6548    // --- Task 5 (B-sched, ledger SC-065, SC-066, SC-047, SC-048, SC-068):
6549    // EventTerm/EventWaitSet shape + static/dynamic sensitivity closure ---
6550
6551    /// SC-068's own literal acceptance, verified against the production
6552    /// `Engine` dispatch loop (not a hand-simulated one): `@(posedge clk iff
6553    /// en)` must not call `resume()` at all on a posedge where `en` reads
6554    /// false at wakeup-consideration time, and must resume it (with
6555    /// `ResumeArg::EventFired`) on the very next posedge where `en` reads
6556    /// true. `PredicateFn` is settled (`docs/CONTRACTS.md` D8) as a bare
6557    /// `extern "C" fn(SimHandle) -> bool`, produced per-callsite by codegen's
6558    /// `compile_iff_guard` -- it carries no `StateCellId` parameter of its
6559    /// own, so this mock predicate stands in for a compiled guard by reading
6560    /// a thread-local flag the test flips directly, exactly mirroring how a
6561    /// real compiled guard would read `en` through the (here, unused-by-the-
6562    /// mock) `SimHandle`. Fails if `EngineNotifySink::notify_change` ever
6563    /// enqueues/resumes without first consulting a `Some(iff)` row's
6564    /// predicate, or evaluates it backwards (blocking on true, allowing on
6565    /// false).
6566    #[test]
6567    fn engine_never_calls_resume_when_iff_is_false_but_does_on_the_next_true_edge() {
6568        thread_local! {
6569            static EN: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
6570        }
6571        extern "C" fn iff_reads_en(_state: SimHandle) -> bool {
6572            EN.with(|c| c.get())
6573        }
6574
6575        struct IffWaiter {
6576            cell: StateCellId,
6577            resume_log: Arc<StdMutex<Vec<ResumeArg>>>,
6578        }
6579        impl CompiledProcess for IffWaiter {
6580            fn resume(
6581                &mut self,
6582                _state: SimHandle,
6583                arg: ResumeArg,
6584                _ops: &dyn SchedulerOps,
6585            ) -> Suspend {
6586                self.resume_log.lock().unwrap().push(arg);
6587                match arg {
6588                    ResumeArg::Start => Suspend::WaitEvents(EventWaitSet {
6589                        terms: vec![EventTerm::Edge {
6590                            cell: self.cell,
6591                            edge: EdgeKind::Posedge,
6592                            iff: Some(crate::process::PredicateFn(iff_reads_en)),
6593                        }],
6594                        ..Default::default()
6595                    }),
6596                    _ => Suspend::Done,
6597                }
6598            }
6599        }
6600
6601        let cell = StateCellId(210);
6602        let mut engine = Engine::new(
6603            Box::new(TestArena::seeded(cell, narrow(0))),
6604            Box::new(TestIoSink::default()),
6605        );
6606        let log: Arc<StdMutex<Vec<ResumeArg>>> = Arc::new(StdMutex::new(Vec::new()));
6607        engine.register_process(
6608            Box::new(IffWaiter {
6609                cell,
6610                resume_log: log.clone(),
6611            }),
6612            ProcessKind::Initial,
6613            None,
6614            0,
6615        );
6616
6617        // Dispatch Start: installs the iff-guarded Edge dynamic wait.
6618        let ready = engine.drain_and_sort(Region::Active);
6619        engine.dispatch_ready_set(Region::Active, ready);
6620        assert_eq!(
6621            log.lock().unwrap().len(),
6622            1,
6623            "only Start's own resume() call must have happened so far"
6624        );
6625
6626        let write = |engine: &Engine, value: FourStateValue| {
6627            let ops = engine.test_dispatch_ops(engine.now(), Region::Active, 0);
6628            ops.commit_blocking_write(cell, value);
6629        };
6630
6631        // en=0: a real posedge occurs, but the predicate reads false --
6632        // must NOT resume.
6633        EN.with(|c| c.set(false));
6634        write(&engine, narrow(1));
6635        let ready = engine.drain_and_sort(Region::Active);
6636        engine.dispatch_ready_set(Region::Active, ready);
6637        assert_eq!(
6638            log.lock().unwrap().len(),
6639            1,
6640            "a posedge with iff=false must not resume() the waiter at all"
6641        );
6642
6643        write(&engine, narrow(0));
6644
6645        // en=1: the next real posedge, predicate now reads true -- must
6646        // resume exactly once, with ResumeArg::EventFired.
6647        EN.with(|c| c.set(true));
6648        write(&engine, narrow(1));
6649        let ready = engine.drain_and_sort(Region::Active);
6650        engine.dispatch_ready_set(Region::Active, ready);
6651        {
6652            let log = log.lock().unwrap();
6653            assert_eq!(
6654                log.len(),
6655                2,
6656                "a posedge with iff=true must resume() the waiter exactly once"
6657            );
6658            assert!(
6659                matches!(log[1], ResumeArg::EventFired { .. }),
6660                "the resuming call's ResumeArg must be EventFired, got {:?}",
6661                log[1]
6662            );
6663        }
6664        assert_eq!(
6665            engine.test_sensitivity_entry_count(),
6666            0,
6667            "the dynamic entry must be gone once the waiter has resumed"
6668        );
6669    }
6670
6671    // --- Task 6 (B-sched, ledger SC-049, SC-050, SC-051, SC-054, SC-069):
6672    // SignalEventControl family construction + named-event trigger + e2e ---
6673
6674    /// SC-069's own literal acceptance, verified against the production
6675    /// `Engine`/`advance_time_to` dispatch path (not a hand-simulated one):
6676    /// a delayed named-event trigger (`->>#5 my_event;`'s scheduler-side
6677    /// shape -- `TimeWheel::arm_trigger` + `Engine::advance_time_to`'s
6678    /// `TimerAction::TriggerEvent` arm) must, once it fires, enqueue a
6679    /// process that is `@(my_event)`-waiting via `EventTerm::
6680    /// NamedEventTrigger` -- and, distinctly, must NOT create or resume any
6681    /// activation for "the triggering process" itself (there is deliberately
6682    /// no second, triggering process registered at all in this test, so any
6683    /// activation showing up in the post-fire ready set other than the
6684    /// waiter's own would prove a wrong implementation that conflated
6685    /// "trigger this event" with "resume whoever armed it" -- the exact
6686    /// regression this test's own plan text names). This is the real,
6687    /// production-loop version of `sched::wheel::tests::
6688    /// resume_and_trigger_entries_at_the_same_tick_both_fire_correctly`'s
6689    /// wheel-only proof, closing the fan-out half that lives in this module,
6690    /// not in `wheel.rs`.
6691    #[test]
6692    fn sc069_a_delayed_trigger_wakes_a_named_event_waiter_not_a_resumed_process() {
6693        struct NamedEventWaiter {
6694            ev: StateCellId,
6695            resume_log: Arc<StdMutex<Vec<ResumeArg>>>,
6696        }
6697        impl CompiledProcess for NamedEventWaiter {
6698            fn resume(
6699                &mut self,
6700                _state: SimHandle,
6701                arg: ResumeArg,
6702                _ops: &dyn SchedulerOps,
6703            ) -> Suspend {
6704                self.resume_log.lock().unwrap().push(arg);
6705                match arg {
6706                    ResumeArg::Start => Suspend::WaitEvents(EventWaitSet {
6707                        terms: vec![EventTerm::NamedEventTrigger(self.ev)],
6708                        ..Default::default()
6709                    }),
6710                    _ => Suspend::Done,
6711                }
6712            }
6713        }
6714
6715        let ev = StateCellId(400);
6716        let mut engine = Engine::new(Box::new(TestArena::new()), Box::new(TestIoSink::default()));
6717        let log: Arc<StdMutex<Vec<ResumeArg>>> = Arc::new(StdMutex::new(Vec::new()));
6718        let waiter = engine.register_process(
6719            Box::new(NamedEventWaiter {
6720                ev,
6721                resume_log: log.clone(),
6722            }),
6723            ProcessKind::Initial,
6724            None,
6725            0,
6726        );
6727
6728        // Dispatch Start: installs the NamedEventTrigger dynamic wait.
6729        let ready = engine.drain_and_sort(Region::Active);
6730        engine.dispatch_ready_set(Region::Active, ready);
6731        assert_eq!(
6732            log.lock().unwrap().len(),
6733            1,
6734            "only Start's own resume() call must have happened so far"
6735        );
6736
6737        // Arm the delayed trigger directly on the wheel (this task's own
6738        // production entry point, `TimeWheel::arm_trigger`) and advance time
6739        // to it through the real `Engine::advance_time_to` -- never a
6740        // hand-simulated fan-out.
6741        engine.wheel.arm_trigger(10, ev);
6742        engine.advance_time_to(10);
6743
6744        let ready = engine.drain_and_sort(Region::Active);
6745        assert_eq!(
6746            ready.len(),
6747            1,
6748            "the fired trigger must enqueue exactly one activation -- the \
6749             waiter's -- never zero (dropped) and never more than one \
6750             (e.g. also resuming some non-existent 'triggering process')"
6751        );
6752        assert_eq!(
6753            ready[0].proc, waiter,
6754            "the one enqueued activation must be the NamedEventTrigger \
6755             waiter's own process id"
6756        );
6757        engine.dispatch_ready_set(Region::Active, ready);
6758        {
6759            let log = log.lock().unwrap();
6760            assert_eq!(
6761                log.len(),
6762                2,
6763                "the waiter must resume exactly once after the trigger fires"
6764            );
6765            assert!(
6766                matches!(log[1], ResumeArg::EventFired { .. }),
6767                "the resuming call's ResumeArg must be EventFired, got {:?}",
6768                log[1]
6769            );
6770        }
6771        assert_eq!(
6772            engine.test_sensitivity_entry_count(),
6773            0,
6774            "the dynamic NamedEventTrigger entry must be gone once the \
6775             waiter has resumed"
6776        );
6777    }
6778
6779    /// Companion to the wheel-only
6780    /// `resume_and_trigger_entries_at_the_same_tick_both_fire_correctly`:
6781    /// proves the SAME same-tick-FIFO guarantee holds through the full
6782    /// `Engine::advance_time_to` fan-out, not just at the raw
6783    /// `TimeWheel::advance_to` level -- a `Resume` entry (an ordinary
6784    /// `#delay`-suspended process) and a `TriggerEvent` entry armed at the
6785    /// same tick must both take effect: the delayed process resumes, and the
6786    /// named-event waiter is woken, from one `advance_time_to` call.
6787    #[test]
6788    fn engine_advance_time_to_fires_a_resume_and_a_trigger_armed_at_the_same_tick() {
6789        struct DoneOnResume {
6790            resume_log: Arc<StdMutex<Vec<ResumeArg>>>,
6791        }
6792        impl CompiledProcess for DoneOnResume {
6793            fn resume(
6794                &mut self,
6795                _state: SimHandle,
6796                arg: ResumeArg,
6797                _ops: &dyn SchedulerOps,
6798            ) -> Suspend {
6799                self.resume_log.lock().unwrap().push(arg);
6800                Suspend::Done
6801            }
6802        }
6803        struct NamedEventWaiter {
6804            ev: StateCellId,
6805            resume_log: Arc<StdMutex<Vec<ResumeArg>>>,
6806        }
6807        impl CompiledProcess for NamedEventWaiter {
6808            fn resume(
6809                &mut self,
6810                _state: SimHandle,
6811                arg: ResumeArg,
6812                _ops: &dyn SchedulerOps,
6813            ) -> Suspend {
6814                self.resume_log.lock().unwrap().push(arg);
6815                match arg {
6816                    ResumeArg::Start => Suspend::WaitEvents(EventWaitSet {
6817                        terms: vec![EventTerm::NamedEventTrigger(self.ev)],
6818                        ..Default::default()
6819                    }),
6820                    _ => Suspend::Done,
6821                }
6822            }
6823        }
6824
6825        let ev = StateCellId(401);
6826        let mut engine = Engine::new(Box::new(TestArena::new()), Box::new(TestIoSink::default()));
6827        let resume_log: Arc<StdMutex<Vec<ResumeArg>>> = Arc::new(StdMutex::new(Vec::new()));
6828        let waiter_log: Arc<StdMutex<Vec<ResumeArg>>> = Arc::new(StdMutex::new(Vec::new()));
6829
6830        let resumed_proc = engine.register_process(
6831            Box::new(DoneOnResume {
6832                resume_log: resume_log.clone(),
6833            }),
6834            ProcessKind::Initial,
6835            None,
6836            0,
6837        );
6838        let waiter = engine.register_process(
6839            Box::new(NamedEventWaiter {
6840                ev,
6841                resume_log: waiter_log.clone(),
6842            }),
6843            ProcessKind::Initial,
6844            None,
6845            0,
6846        );
6847
6848        // Dispatch both Start activations: `resumed_proc` returns
6849        // Suspend::Done (its own Start resume already happened, nothing more
6850        // to arm for it directly -- this test arms its own `Resume` entry on
6851        // the wheel below, standing in for a `Suspend::Delay`-suspended
6852        // continuation), `waiter` installs its NamedEventTrigger wait.
6853        let ready = engine.drain_and_sort(Region::Active);
6854        engine.dispatch_ready_set(Region::Active, ready);
6855        resume_log.lock().unwrap().clear();
6856
6857        let act = PendingActivation {
6858            id: 9_999,
6859            proc: resumed_proc,
6860            arg: ResumeArg::TimeElapsed,
6861            reconv_iter: 0,
6862            logical_order: (SchedTime { ticks: 0 }, Region::Active, 0),
6863        };
6864        engine.wheel.arm(
6865            10,
6866            TimerEntry {
6867                action: TimerAction::Resume(act, Region::Active),
6868            },
6869        );
6870        engine.wheel.arm_trigger(10, ev);
6871        engine.advance_time_to(10);
6872
6873        let ready = engine.drain_and_sort(Region::Active);
6874        let mut procs: Vec<ProcessId> = ready.iter().map(|a| a.proc).collect();
6875        procs.sort_unstable();
6876        let mut want = vec![resumed_proc, waiter];
6877        want.sort_unstable();
6878        assert_eq!(
6879            procs, want,
6880            "both the Resume entry's own process and the TriggerEvent's \
6881             waiter must be enqueued from a single advance_time_to call \
6882             covering the same tick"
6883        );
6884    }
6885
6886    // --- Task 9 (B-sched wave, ledger SC-125) ---
6887
6888    /// A `resume()` that always returns `Suspend::Done` and, if ever called
6889    /// a second time, panics -- standing in for "a process that must never
6890    /// be resumed again," the sanctioned negative-proof shape for
6891    /// `disable fork;`'s own "terminates without ever resuming its
6892    /// descendants" acceptance.
6893    struct NeverResumedTwice(std::sync::atomic::AtomicUsize);
6894    impl NeverResumedTwice {
6895        fn new() -> Self {
6896            NeverResumedTwice(std::sync::atomic::AtomicUsize::new(0))
6897        }
6898    }
6899    impl CompiledProcess for NeverResumedTwice {
6900        fn resume(&mut self, _s: SimHandle, _arg: ResumeArg, _ops: &dyn SchedulerOps) -> Suspend {
6901            let prior = self.0.fetch_add(1, Ordering::SeqCst);
6902            assert_eq!(
6903                prior, 0,
6904                "a fork descendant terminated by disable_fork must never be resumed \
6905                 at all, let alone more than once"
6906            );
6907            Suspend::Done
6908        }
6909    }
6910
6911    fn spawn_from(parent: ProcessId) -> SpawnSpec {
6912        SpawnSpec {
6913            kernel: 0,
6914            entry_pc: 0,
6915            parent,
6916            disable_name: Vec::new(),
6917        }
6918    }
6919
6920    /// SC-125's own literal `disable fork;` acceptance: a parent with 2 live
6921    /// children, one of which (`child_a`) itself has 1 live grandchild (a
6922    /// nested fork) -- the parent's own mock calls `ops.disable_fork(self)`
6923    /// mid-`resume()` and, in that same call, returns `Suspend::Done` (never
6924    /// a suspending variant, proving the call itself does not suspend the
6925    /// caller). Drives the parent's own `Start` through the real `Engine`/
6926    /// `SchedulerControl` dispatch loop (`run_until`) -- the fork tree
6927    /// itself is built directly via `ResumeDriver::handle_fork` before that
6928    /// (this module's own private fields are reachable from this same-file
6929    /// `tests` submodule), the same "construct the tree directly, dispatch
6930    /// only the one process under test" pattern `resume.rs`'s own nested-
6931    /// fork tests already use, needed here because `Suspend::ForkJoin`'s
6932    /// real dispatch path (`apply_suspend`'s own `mint_child`) always mints
6933    /// an immediately-`Done` `UnmintedForkChild` (Task 7's documented
6934    /// interim placeholder), which could never stay live long enough to
6935    /// itself fork a grandchild.
6936    #[test]
6937    fn sc125_disable_fork_terminates_every_live_descendant_without_suspending_the_caller() {
6938        struct DisablingParent {
6939            id_cell: Arc<StdMutex<Option<ProcessId>>>,
6940            resume_count: Arc<std::sync::atomic::AtomicUsize>,
6941        }
6942        impl CompiledProcess for DisablingParent {
6943            fn resume(&mut self, _s: SimHandle, arg: ResumeArg, ops: &dyn SchedulerOps) -> Suspend {
6944                self.resume_count.fetch_add(1, Ordering::SeqCst);
6945                match arg {
6946                    ResumeArg::Start => {
6947                        let id = self
6948                            .id_cell
6949                            .lock()
6950                            .unwrap()
6951                            .expect("id_cell set right after register_process returns");
6952                        ops.disable_fork(id);
6953                        Suspend::Done
6954                    }
6955                    other => panic!("DisablingParent must only ever see Start, got {other:?}"),
6956                }
6957            }
6958        }
6959
6960        let mut engine = new_engine();
6961        let id_cell = Arc::new(StdMutex::new(None));
6962        let resume_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
6963        let parent = engine.register_process(
6964            Box::new(DisablingParent {
6965                id_cell: id_cell.clone(),
6966                resume_count: resume_count.clone(),
6967            }),
6968            ProcessKind::Initial,
6969            None,
6970            0,
6971        );
6972        *id_cell.lock().unwrap() = Some(parent);
6973
6974        // Build the fork tree directly (see this test's own doc comment):
6975        // parent -> [child_a, child_b]; child_a -> [grandchild].
6976        let mut mint =
6977            |_: &SpawnSpec| -> Box<dyn CompiledProcess> { Box::new(NeverResumedTwice::new()) };
6978        let children = engine.driver.handle_fork(
6979            &mut engine.table,
6980            parent,
6981            JoinKind::None,
6982            &[spawn_from(parent), spawn_from(parent)],
6983            &mut mint,
6984        );
6985        assert_eq!(children.len(), 2);
6986        let (child_a, child_b) = (children[0], children[1]);
6987        let grandchildren = engine.driver.handle_fork(
6988            &mut engine.table,
6989            child_a,
6990            JoinKind::None,
6991            &[spawn_from(child_a)],
6992            &mut mint,
6993        );
6994        assert_eq!(grandchildren.len(), 1);
6995        let grandchild = grandchildren[0];
6996
6997        assert!(engine.test_process_exists(child_a));
6998        assert!(engine.test_process_exists(child_b));
6999        assert!(engine.test_process_exists(grandchild));
7000
7001        // `register_process` already queued `parent`'s own Start activation
7002        // -- `child_a`/`child_b`/`grandchild` were registered directly via
7003        // `handle_fork` above, never given a Start activation of their own,
7004        // so this `run_until` dispatches exactly one process: `parent`.
7005        let result = engine.run_until(None);
7006        assert!(matches!(result, RunResult::Completed));
7007
7008        assert_eq!(
7009            resume_count.load(Ordering::SeqCst),
7010            1,
7011            "the parent's own single resume() call must never be re-entered a second \
7012             time by disable_fork itself"
7013        );
7014        assert!(
7015            !engine.test_process_exists(child_a),
7016            "disable_fork must terminate every live child"
7017        );
7018        assert!(
7019            !engine.test_process_exists(child_b),
7020            "disable_fork must terminate every live child"
7021        );
7022        assert!(
7023            !engine.test_process_exists(grandchild),
7024            "disable_fork must terminate a live grandchild too -- one level deep is \
7025             not enough"
7026        );
7027    }
7028
7029    /// Proves `Suspend::WaitFork`'s own scheduler-side *installation*
7030    /// (`Engine::apply_wait_fork`) — distinct from `ResumeDriver::
7031    /// on_child_done`'s own decrement logic, which
7032    /// `sched::resume::tests::sc125_wait_fork_resumes_only_once_every_live_
7033    /// child_completes_not_on_the_first` already covers directly. Builds 2
7034    /// live children via `handle_fork` (bypassing `Suspend::ForkJoin`'s own
7035    /// hardcoded `UnmintedForkChild` mint, per the disable_fork test's own
7036    /// doc comment, so the children stay live across this test's own
7037    /// `apply_suspend` calls), suspends the parent directly via
7038    /// `apply_suspend(parent, Suspend::WaitFork)`, and drives both children
7039    /// to completion via `apply_suspend(child, Suspend::Done)`. Fails if
7040    /// `apply_wait_fork` ever miscounts the live-child set (e.g. counting
7041    /// `fork_children.len()` instead of filtering for still-live entries).
7042    #[test]
7043    fn sc125_apply_suspend_wait_fork_installs_live_count_and_resumes_after_the_last_child() {
7044        struct WaitForkParent {
7045            log: Arc<StdMutex<Vec<&'static str>>>,
7046        }
7047        impl CompiledProcess for WaitForkParent {
7048            fn resume(
7049                &mut self,
7050                _s: SimHandle,
7051                arg: ResumeArg,
7052                _ops: &dyn SchedulerOps,
7053            ) -> Suspend {
7054                match arg {
7055                    ResumeArg::AllForkChildrenDone => {
7056                        self.log.lock().unwrap().push("resumed");
7057                        Suspend::Done
7058                    }
7059                    other => panic!("WaitForkParent: unexpected {other:?}"),
7060                }
7061            }
7062        }
7063
7064        let log = Arc::new(StdMutex::new(Vec::new()));
7065        let mut engine = new_engine();
7066        // Registered directly via `ProcessTable::register` (not
7067        // `register_process`, which would also queue an unrelated `Start`
7068        // activation this test never dispatches -- this test drives `proc`
7069        // entirely through direct `apply_suspend` calls, standing in for a
7070        // real process already mid-way through its own `fork ... join_none;
7071        // ...; wait fork;` sequence).
7072        let parent = engine
7073            .table
7074            .register(Box::new(WaitForkParent { log: log.clone() }));
7075        let mut mint =
7076            |_: &SpawnSpec| -> Box<dyn CompiledProcess> { Box::new(NeverResumedTwice::new()) };
7077        let children = engine.driver.handle_fork(
7078            &mut engine.table,
7079            parent,
7080            JoinKind::None,
7081            &[spawn_from(parent), spawn_from(parent)],
7082            &mut mint,
7083        );
7084        assert_eq!(children.len(), 2);
7085
7086        engine.apply_suspend(parent, Suspend::WaitFork);
7087        assert_eq!(
7088            engine.table.get(parent).unwrap().wait_fork_remaining,
7089            Some(2),
7090            "apply_wait_fork must install the true live-child count"
7091        );
7092
7093        engine.apply_suspend(children[0], Suspend::Done);
7094        assert!(!engine.test_process_exists(children[0]));
7095        assert!(
7096            log.lock().unwrap().is_empty(),
7097            "must not resume the parent after only 1 of 2 live children"
7098        );
7099        assert_eq!(
7100            engine.table.get(parent).unwrap().wait_fork_remaining,
7101            Some(1)
7102        );
7103
7104        engine.apply_suspend(children[1], Suspend::Done);
7105        assert!(!engine.test_process_exists(children[1]));
7106        assert!(
7107            engine
7108                .table
7109                .get(parent)
7110                .unwrap()
7111                .wait_fork_remaining
7112                .is_none(),
7113            "wait_fork_remaining must be cleared the instant the count reaches 0, \
7114             immediately from apply_suspend itself, before the parent's own \
7115             AllForkChildrenDone activation is ever dispatched below"
7116        );
7117        // The last completion's `Suspend::Done` arm only *enqueues*
7118        // `parent`'s `AllForkChildrenDone` activation onto `Region::Active`
7119        // (the same real queue `ForkChildDone` notifications use) -- it does
7120        // not call `resume()` synchronously, so an actual dispatch pass is
7121        // needed to observe it, exactly like `fork_join_all_actually_
7122        // resumes_the_parent_when_its_one_child_completes` above needs
7123        // `run_until` for the identical reason.
7124        let ready = engine.drain_and_sort(Region::Active);
7125        engine.dispatch_ready_set(Region::Active, ready);
7126        assert_eq!(
7127            log.lock().unwrap().as_slice(),
7128            &["resumed"],
7129            "the parent must resume exactly once, only after the 2nd (last) child"
7130        );
7131    }
7132
7133    /// **Mutation-testing addition (B-sched, SC-125).** `apply_wait_fork`'s
7134    /// own doc comment names "the common 'every child already finished
7135    /// before `wait fork;` itself ran' case" -- this proves it directly, for
7136    /// a *mixed* population (one child already done and reclaimed, one still
7137    /// live) rather than the "0 live" or "all live" cases the other two
7138    /// tests here already cover. `live` must be computed by filtering
7139    /// `fork_children` down to those still present in `table` -- counting
7140    /// every spawned child regardless of liveness (e.g. `children.len()`)
7141    /// would install a `wait_fork_remaining` one too high here, and the
7142    /// parent would never resume: its one remaining live child's own
7143    /// completion would decrement to 1, not 0, and `on_child_done` would
7144    /// never report `AllForkChildrenDone`.
7145    #[test]
7146    fn sc125_apply_wait_fork_counts_only_still_live_children_not_every_spawned_child() {
7147        struct WaitForkParent {
7148            log: Arc<StdMutex<Vec<&'static str>>>,
7149        }
7150        impl CompiledProcess for WaitForkParent {
7151            fn resume(
7152                &mut self,
7153                _s: SimHandle,
7154                arg: ResumeArg,
7155                _ops: &dyn SchedulerOps,
7156            ) -> Suspend {
7157                match arg {
7158                    ResumeArg::AllForkChildrenDone => {
7159                        self.log.lock().unwrap().push("resumed");
7160                        Suspend::Done
7161                    }
7162                    other => panic!("WaitForkParent: unexpected {other:?}"),
7163                }
7164            }
7165        }
7166
7167        let log = Arc::new(StdMutex::new(Vec::new()));
7168        let mut engine = new_engine();
7169        let parent = engine
7170            .table
7171            .register(Box::new(WaitForkParent { log: log.clone() }));
7172        let mut mint =
7173            |_: &SpawnSpec| -> Box<dyn CompiledProcess> { Box::new(NeverResumedTwice::new()) };
7174        let children = engine.driver.handle_fork(
7175            &mut engine.table,
7176            parent,
7177            JoinKind::None,
7178            &[spawn_from(parent), spawn_from(parent)],
7179            &mut mint,
7180        );
7181        assert_eq!(children.len(), 2);
7182
7183        // `children[0]` finishes -- and is reclaimed -- *before* `wait
7184        // fork;` itself ever runs. `parent`'s own `fork_children` still
7185        // lists both ids (nothing prunes that list on completion), but only
7186        // `children[1]` is still a live table entry.
7187        engine.apply_suspend(children[0], Suspend::Done);
7188        assert!(!engine.test_process_exists(children[0]));
7189        assert!(engine.test_process_exists(children[1]));
7190
7191        engine.apply_suspend(parent, Suspend::WaitFork);
7192        assert_eq!(
7193            engine.table.get(parent).unwrap().wait_fork_remaining,
7194            Some(1),
7195            "apply_wait_fork must count only the one still-live child, not both \
7196             spawned children"
7197        );
7198
7199        engine.apply_suspend(children[1], Suspend::Done);
7200        assert!(!engine.test_process_exists(children[1]));
7201        assert!(
7202            engine
7203                .table
7204                .get(parent)
7205                .unwrap()
7206                .wait_fork_remaining
7207                .is_none(),
7208            "wait_fork_remaining must reach 0 (and clear) the instant this one live \
7209             child's completion is reported -- not require a second, nonexistent \
7210             completion"
7211        );
7212        let ready = engine.drain_and_sort(Region::Active);
7213        engine.dispatch_ready_set(Region::Active, ready);
7214        assert_eq!(
7215            log.lock().unwrap().as_slice(),
7216            &["resumed"],
7217            "the parent must actually resume once its one live child completes, \
7218             even though one of its two originally-spawned children finished \
7219             earlier"
7220        );
7221    }
7222
7223    /// Proves `Suspend::WaitOrder(order)`'s own scheduler-side
7224    /// *installation* (`Engine::apply_wait_order`) through a real
7225    /// `apply_suspend` call, complementing `sched::resume::tests`'s own
7226    /// direct `on_child_done` proofs for the ordering/violation logic
7227    /// itself.
7228    #[test]
7229    fn sc125_apply_suspend_wait_order_resumes_only_after_the_exact_declared_order() {
7230        struct WaitOrderParent {
7231            log: Arc<StdMutex<Vec<&'static str>>>,
7232        }
7233        impl CompiledProcess for WaitOrderParent {
7234            fn resume(
7235                &mut self,
7236                _s: SimHandle,
7237                arg: ResumeArg,
7238                _ops: &dyn SchedulerOps,
7239            ) -> Suspend {
7240                match arg {
7241                    ResumeArg::WaitOrderSatisfied => {
7242                        self.log.lock().unwrap().push("resumed");
7243                        Suspend::Done
7244                    }
7245                    other => panic!("WaitOrderParent: unexpected {other:?}"),
7246                }
7247            }
7248        }
7249
7250        let log = Arc::new(StdMutex::new(Vec::new()));
7251        let mut engine = new_engine();
7252        // See the identical `ProcessTable::register` note on
7253        // `sc125_apply_suspend_wait_fork_installs_live_count_and_resumes_
7254        // after_the_last_child` above.
7255        let parent = engine
7256            .table
7257            .register(Box::new(WaitOrderParent { log: log.clone() }));
7258        let mut mint =
7259            |_: &SpawnSpec| -> Box<dyn CompiledProcess> { Box::new(NeverResumedTwice::new()) };
7260        let children = engine.driver.handle_fork(
7261            &mut engine.table,
7262            parent,
7263            JoinKind::None,
7264            &[spawn_from(parent), spawn_from(parent), spawn_from(parent)],
7265            &mut mint,
7266        );
7267        assert_eq!(children.len(), 3);
7268        let order = children.clone();
7269
7270        engine.apply_suspend(parent, Suspend::WaitOrder(order));
7271        assert!(engine.table.get(parent).unwrap().wait_order.is_some());
7272
7273        engine.apply_suspend(children[0], Suspend::Done);
7274        engine.apply_suspend(children[1], Suspend::Done);
7275        assert!(
7276            log.lock().unwrap().is_empty(),
7277            "must not resume before the 3rd, in-order completion"
7278        );
7279
7280        engine.apply_suspend(children[2], Suspend::Done);
7281        // The 3rd completion's `Suspend::Done` arm only *enqueues* the
7282        // parent's `WaitOrderSatisfied` activation onto `Region::Active` --
7283        // see the identical note on `sc125_apply_suspend_wait_fork_
7284        // installs_live_count_and_resumes_after_the_last_child` above.
7285        let ready = engine.drain_and_sort(Region::Active);
7286        engine.dispatch_ready_set(Region::Active, ready);
7287        assert_eq!(log.lock().unwrap().as_slice(), &["resumed"]);
7288    }
7289
7290    // --- Classes plan Task 13 (LRM 9.7): the `process` class ---
7291    //
7292    // Every test drives the real `Engine` dispatch loop (`run_until`) with
7293    // scripted processes that call the same `SchedulerOps` entry points the
7294    // compiled `process` methods reach (`process_track` / `process_request`
7295    // / `process_state`), so the Kill/Suspend/Resume/Await/status mechanics
7296    // are pinned without any codegen. ProcessIds are the registration order
7297    // (0, 1, 2, ...), asserted on every `register_process` return.
7298
7299    type ProcLog = Arc<StdMutex<Vec<String>>>;
7300
7301    /// A process whose every `resume()` is one call of a closure.
7302    type ScriptFn = Box<dyn FnMut(ResumeArg, &dyn SchedulerOps) -> Suspend + Send>;
7303    struct Script(ScriptFn);
7304    impl CompiledProcess for Script {
7305        fn resume(&mut self, _s: SimHandle, arg: ResumeArg, ops: &dyn SchedulerOps) -> Suspend {
7306            (self.0)(arg, ops)
7307        }
7308    }
7309
7310    fn script(
7311        f: impl FnMut(ResumeArg, &dyn SchedulerOps) -> Suspend + Send + 'static,
7312    ) -> Box<dyn CompiledProcess> {
7313        Box::new(Script(Box::new(f)))
7314    }
7315
7316    fn new_log() -> ProcLog {
7317        Arc::new(StdMutex::new(Vec::new()))
7318    }
7319
7320    fn lines(log: &ProcLog) -> Vec<String> {
7321        log.lock().unwrap().clone()
7322    }
7323
7324    fn state_of(ops: &dyn SchedulerOps, target: ProcessId) -> u8 {
7325        ops.process_state(target) as u8
7326    }
7327
7328    fn register_expecting(engine: &mut Engine, proc: Box<dyn CompiledProcess>, want: u32) {
7329        let id = engine.register_process(proc, ProcessKind::Initial, None, 0);
7330        assert_eq!(id, want, "tests hard-code ProcessIds as registration order");
7331    }
7332
7333    /// LRM 9.7.3: `kill()` terminates the target and ALL its descendants
7334    /// before it returns, and `status()` reads KILLED in the very segment
7335    /// that called `kill()`. The victim's pending timer must never fire.
7336    #[test]
7337    fn process_kill_terminates_target_and_descendants_and_reads_killed() {
7338        let mut engine = new_engine();
7339        let log = new_log();
7340
7341        let l0 = log.clone();
7342        register_expecting(
7343            &mut engine,
7344            script(move |arg, ops| match arg {
7345                ResumeArg::Start => {
7346                    ops.process_track(0);
7347                    Suspend::Delay(100)
7348                }
7349                other => {
7350                    l0.lock().unwrap().push(format!("victim resumed {other:?}"));
7351                    Suspend::Done
7352                }
7353            }),
7354            0,
7355        );
7356        let (l1, mut step) = (log.clone(), 0);
7357        register_expecting(
7358            &mut engine,
7359            script(move |arg, ops| {
7360                step += 1;
7361                match (step, arg) {
7362                    (1, ResumeArg::Start) => Suspend::Delay(10),
7363                    (2, ResumeArg::TimeElapsed) => {
7364                        let before = state_of(ops, 0);
7365                        ops.process_request(1, ProcessRequest::Kill(0));
7366                        let after = state_of(ops, 0);
7367                        l1.lock().unwrap().push(format!(
7368                            "t={} before={before} after={after}",
7369                            ops.now().ticks
7370                        ));
7371                        Suspend::ByRequest
7372                    }
7373                    (3, ResumeArg::TimeElapsed) => {
7374                        l1.lock()
7375                            .unwrap()
7376                            .push(format!("resumed state={}", state_of(ops, 0)));
7377                        Suspend::Done
7378                    }
7379                    other => panic!("killer: unexpected {other:?}"),
7380                }
7381            }),
7382            1,
7383        );
7384
7385        // victim (0) -> child (2) -> grandchild (3), built directly.
7386        let mut mint =
7387            |_: &SpawnSpec| -> Box<dyn CompiledProcess> { Box::new(NeverResumedTwice::new()) };
7388        let kids = engine.driver.handle_fork(
7389            &mut engine.table,
7390            0,
7391            JoinKind::None,
7392            &[spawn_from(0)],
7393            &mut mint,
7394        );
7395        let grand = engine.driver.handle_fork(
7396            &mut engine.table,
7397            kids[0],
7398            JoinKind::None,
7399            &[spawn_from(kids[0])],
7400            &mut mint,
7401        );
7402        assert_eq!((kids[0], grand[0]), (2, 3));
7403
7404        assert!(matches!(engine.run_until(None), RunResult::Completed));
7405        assert_eq!(
7406            lines(&log),
7407            // The victim is WAITING on its delay (2); the overlay turns it
7408            // KILLED (4) at once.
7409            ["t=10 before=2 after=4", "resumed state=4"],
7410            "kill must be visible to status() in the same segment, and the \
7411             victim's Delay(100) must never resume it"
7412        );
7413        for id in [0, 2, 3] {
7414            assert!(!engine.test_process_exists(id), "process {id} must be gone");
7415        }
7416        assert_eq!(engine.effective_process_state(0), ProcessState::Killed);
7417        assert_eq!(
7418            engine.effective_process_state(3),
7419            ProcessState::Killed,
7420            "an untracked descendant killed with its ancestor reads KILLED too"
7421        );
7422    }
7423
7424    /// LRM 9.7.3: `kill()` on a process that already ended still terminates
7425    /// its live descendants, and the target keeps reading FINISHED.
7426    #[test]
7427    fn process_kill_of_a_finished_process_still_kills_its_live_descendants() {
7428        let mut engine = new_engine();
7429        register_expecting(
7430            &mut engine,
7431            script(|_, ops| {
7432                ops.process_track(0);
7433                Suspend::Done
7434            }),
7435            0,
7436        );
7437        let mut step = 0;
7438        register_expecting(
7439            &mut engine,
7440            script(move |_, ops| {
7441                step += 1;
7442                match step {
7443                    1 => Suspend::Delay(10),
7444                    2 => {
7445                        ops.process_request(1, ProcessRequest::Kill(0));
7446                        Suspend::ByRequest
7447                    }
7448                    _ => Suspend::Done,
7449                }
7450            }),
7451            1,
7452        );
7453        let mut mint =
7454            |_: &SpawnSpec| -> Box<dyn CompiledProcess> { Box::new(NeverResumedTwice::new()) };
7455        let kids = engine.driver.handle_fork(
7456            &mut engine.table,
7457            0,
7458            JoinKind::None,
7459            &[spawn_from(0)],
7460            &mut mint,
7461        );
7462        assert!(engine.test_process_exists(kids[0]));
7463        assert!(matches!(engine.run_until(None), RunResult::Completed));
7464        assert!(
7465            !engine.test_process_exists(kids[0]),
7466            "the live descendant of an already-finished process must die"
7467        );
7468        assert_eq!(engine.effective_process_state(0), ProcessState::Finished);
7469    }
7470
7471    /// LRM 9.7.1: a process may kill itself; it ends there (`process_request`
7472    /// reports it so the compiled caller can stop) and reads KILLED.
7473    #[test]
7474    fn process_self_kill_ends_the_caller_and_reports_it() {
7475        let mut engine = new_engine();
7476        let log = new_log();
7477        let l0 = log.clone();
7478        register_expecting(
7479            &mut engine,
7480            script(move |_, ops| {
7481                ops.process_track(0);
7482                let ends_me = ops.process_request(0, ProcessRequest::Kill(0));
7483                l0.lock().unwrap().push(format!("ends_requester={ends_me}"));
7484                Suspend::Done
7485            }),
7486            0,
7487        );
7488        assert!(matches!(engine.run_until(None), RunResult::Completed));
7489        assert_eq!(lines(&log), ["ends_requester=true"]);
7490        assert!(!engine.test_process_exists(0));
7491        assert_eq!(engine.effective_process_state(0), ProcessState::Killed);
7492    }
7493
7494    /// Killing the children a `wait fork` is blocked on releases the parent
7495    /// exactly once, only after the last one is gone.
7496    #[test]
7497    fn process_kill_of_fork_children_releases_the_wait_fork_parent() {
7498        let mut engine = new_engine();
7499        let log = new_log();
7500        let l0 = log.clone();
7501        register_expecting(
7502            &mut engine,
7503            script(move |arg, ops| match arg {
7504                ResumeArg::Start => Suspend::WaitFork,
7505                ResumeArg::AllForkChildrenDone => {
7506                    l0.lock()
7507                        .unwrap()
7508                        .push(format!("joined@{}", ops.now().ticks));
7509                    Suspend::Done
7510                }
7511                other => panic!("parent: unexpected {other:?}"),
7512            }),
7513            0,
7514        );
7515        let mut step = 0;
7516        register_expecting(
7517            &mut engine,
7518            script(move |_, ops| {
7519                step += 1;
7520                match step {
7521                    1 => Suspend::Delay(10),
7522                    2 => {
7523                        ops.process_request(1, ProcessRequest::Kill(2));
7524                        ops.process_request(1, ProcessRequest::Kill(3));
7525                        Suspend::ByRequest
7526                    }
7527                    _ => Suspend::Done,
7528                }
7529            }),
7530            1,
7531        );
7532        let mut mint =
7533            |_: &SpawnSpec| -> Box<dyn CompiledProcess> { Box::new(NeverResumedTwice::new()) };
7534        let kids = engine.driver.handle_fork(
7535            &mut engine.table,
7536            0,
7537            JoinKind::None,
7538            &[spawn_from(0), spawn_from(0)],
7539            &mut mint,
7540        );
7541        assert_eq!(kids, vec![2, 3]);
7542        assert!(matches!(engine.run_until(None), RunResult::Completed));
7543        assert_eq!(lines(&log), ["joined@10"]);
7544    }
7545
7546    /// LRM 9.7: `status()` is RUNNING for the caller, WAITING while the
7547    /// target is blocked, FINISHED once it ended.
7548    #[test]
7549    fn process_status_tracks_running_waiting_finished() {
7550        let mut engine = new_engine();
7551        let log = new_log();
7552        let l0 = log.clone();
7553        let mut step = 0;
7554        register_expecting(
7555            &mut engine,
7556            script(move |_, ops| {
7557                step += 1;
7558                match step {
7559                    1 => {
7560                        ops.process_track(0);
7561                        l0.lock()
7562                            .unwrap()
7563                            .push(format!("self={}", state_of(ops, 0)));
7564                        Suspend::Delay(10)
7565                    }
7566                    _ => Suspend::Done,
7567                }
7568            }),
7569            0,
7570        );
7571        let l1 = log.clone();
7572        let mut step = 0;
7573        register_expecting(
7574            &mut engine,
7575            script(move |_, ops| {
7576                step += 1;
7577                match step {
7578                    1 => Suspend::Delay(5),
7579                    2 => {
7580                        l1.lock().unwrap().push(format!("@5={}", state_of(ops, 0)));
7581                        Suspend::Delay(20)
7582                    }
7583                    _ => {
7584                        l1.lock().unwrap().push(format!("@25={}", state_of(ops, 0)));
7585                        Suspend::Done
7586                    }
7587                }
7588            }),
7589            1,
7590        );
7591        assert!(matches!(engine.run_until(None), RunResult::Completed));
7592        assert_eq!(lines(&log), ["self=1", "@5=2", "@25=0"]);
7593    }
7594
7595    /// LRM 9.7: `await()` blocks until the target ends; a target that
7596    /// already ended, or the caller itself, blocks nothing.
7597    #[test]
7598    fn process_await_blocks_until_the_target_ends() {
7599        let mut engine = new_engine();
7600        let log = new_log();
7601        let mut step = 0;
7602        register_expecting(
7603            &mut engine,
7604            script(move |_, ops| {
7605                step += 1;
7606                if step == 1 {
7607                    ops.process_track(0);
7608                    Suspend::Delay(30)
7609                } else {
7610                    Suspend::Done
7611                }
7612            }),
7613            0,
7614        );
7615        let (l1, mut step) = (log.clone(), 0);
7616        register_expecting(
7617            &mut engine,
7618            script(move |_, ops| {
7619                step += 1;
7620                match step {
7621                    1 => Suspend::Delay(5),
7622                    2 => {
7623                        ops.process_request(1, ProcessRequest::Await(0));
7624                        l1.lock().unwrap().push("awaiting@5".into());
7625                        Suspend::ByRequest
7626                    }
7627                    _ => {
7628                        l1.lock()
7629                            .unwrap()
7630                            .push(format!("awaited@{}", ops.now().ticks));
7631                        Suspend::Done
7632                    }
7633                }
7634            }),
7635            1,
7636        );
7637        let (l2, mut step) = (log.clone(), 0);
7638        register_expecting(
7639            &mut engine,
7640            script(move |_, ops| {
7641                step += 1;
7642                match step {
7643                    1 => Suspend::Delay(50),
7644                    2 => {
7645                        ops.process_request(2, ProcessRequest::Await(0));
7646                        Suspend::ByRequest
7647                    }
7648                    _ => {
7649                        l2.lock().unwrap().push(format!("late@{}", ops.now().ticks));
7650                        Suspend::Done
7651                    }
7652                }
7653            }),
7654            2,
7655        );
7656        let (l3, mut step) = (log.clone(), 0);
7657        register_expecting(
7658            &mut engine,
7659            script(move |_, ops| {
7660                step += 1;
7661                if step == 1 {
7662                    ops.process_request(3, ProcessRequest::Await(3));
7663                    Suspend::ByRequest
7664                } else {
7665                    l3.lock().unwrap().push(format!("self@{}", ops.now().ticks));
7666                    Suspend::Done
7667                }
7668            }),
7669            3,
7670        );
7671        assert!(matches!(engine.run_until(None), RunResult::Completed));
7672        assert_eq!(
7673            lines(&log),
7674            ["self@0", "awaiting@5", "awaited@30", "late@50"],
7675            "await wakes exactly at the target's end; an ended target / self \
7676             never blocks"
7677        );
7678    }
7679
7680    /// LRM 9.7.4 / 9.7.5: a suspended process stops advancing; `resume()`
7681    /// continues it; `resume()` on a process that is not suspended is a
7682    /// no-op.
7683    #[test]
7684    fn process_suspend_resume_periodic_process() {
7685        let mut engine = new_engine();
7686        let log = new_log();
7687        let (l0, mut ticks) = (log.clone(), 0);
7688        register_expecting(
7689            &mut engine,
7690            script(move |arg, ops| match arg {
7691                ResumeArg::Start => {
7692                    ops.process_track(0);
7693                    Suspend::Delay(10)
7694                }
7695                _ => {
7696                    ticks += 1;
7697                    l0.lock().unwrap().push(format!("tick@{}", ops.now().ticks));
7698                    if ticks == 4 {
7699                        Suspend::Done
7700                    } else {
7701                        Suspend::Delay(10)
7702                    }
7703                }
7704            }),
7705            0,
7706        );
7707        let (l1, mut step) = (log.clone(), 0);
7708        register_expecting(
7709            &mut engine,
7710            script(move |_, ops| {
7711                step += 1;
7712                match step {
7713                    1 => Suspend::Delay(5),
7714                    2 => {
7715                        // Not suspended: no effect.
7716                        ops.process_request(1, ProcessRequest::Resume(0));
7717                        Suspend::ByRequest
7718                    }
7719                    3 => Suspend::Delay(10),
7720                    4 => {
7721                        ops.process_request(1, ProcessRequest::Suspend(0));
7722                        l1.lock()
7723                            .unwrap()
7724                            .push(format!("@15 state={}", state_of(ops, 0)));
7725                        Suspend::ByRequest
7726                    }
7727                    5 => Suspend::Delay(30),
7728                    6 => {
7729                        l1.lock()
7730                            .unwrap()
7731                            .push(format!("@45 state={}", state_of(ops, 0)));
7732                        ops.process_request(1, ProcessRequest::Resume(0));
7733                        l1.lock()
7734                            .unwrap()
7735                            .push(format!("resumed state={}", state_of(ops, 0)));
7736                        Suspend::ByRequest
7737                    }
7738                    _ => Suspend::Done,
7739                }
7740            }),
7741            1,
7742        );
7743        assert!(matches!(engine.run_until(None), RunResult::Completed));
7744        assert_eq!(
7745            lines(&log),
7746            [
7747                "tick@10",
7748                "@15 state=3",
7749                "@45 state=3",
7750                "resumed state=2",
7751                "tick@45",
7752                "tick@55",
7753                "tick@65",
7754            ],
7755            "the tick due at 20 is held while suspended and replayed at the \
7756             resume (45); then the 10-unit period continues"
7757        );
7758    }
7759
7760    /// A process may suspend itself (`ByRequest` parks it) and another
7761    /// process resumes it.
7762    #[test]
7763    fn process_self_suspend_is_released_by_another_process() {
7764        let mut engine = new_engine();
7765        let log = new_log();
7766        let (l0, mut step) = (log.clone(), 0);
7767        register_expecting(
7768            &mut engine,
7769            script(move |_, ops| {
7770                step += 1;
7771                if step == 1 {
7772                    ops.process_track(0);
7773                    ops.process_request(0, ProcessRequest::Suspend(0));
7774                    l0.lock().unwrap().push("p0 suspending".into());
7775                    Suspend::ByRequest
7776                } else {
7777                    l0.lock()
7778                        .unwrap()
7779                        .push(format!("p0 resumed@{}", ops.now().ticks));
7780                    Suspend::Done
7781                }
7782            }),
7783            0,
7784        );
7785        let (l1, mut step) = (log.clone(), 0);
7786        register_expecting(
7787            &mut engine,
7788            script(move |_, ops| {
7789                step += 1;
7790                match step {
7791                    1 => Suspend::Delay(20),
7792                    2 => {
7793                        l1.lock()
7794                            .unwrap()
7795                            .push(format!("p0 state={}", state_of(ops, 0)));
7796                        ops.process_request(1, ProcessRequest::Resume(0));
7797                        Suspend::ByRequest
7798                    }
7799                    _ => Suspend::Done,
7800                }
7801            }),
7802            1,
7803        );
7804        assert!(matches!(engine.run_until(None), RunResult::Completed));
7805        assert_eq!(
7806            lines(&log),
7807            ["p0 suspending", "p0 state=3", "p0 resumed@20"]
7808        );
7809    }
7810
7811    fn new_semaphore(keys: i64, template: u32) -> u64 {
7812        let h = crate::objects::alloc(
7813            crate::netlist::ClassTemplateId(template),
7814            semaphore::SEMAPHORE_FIELD_WORDS,
7815        );
7816        assert!(semaphore::init(h, keys));
7817        h
7818    }
7819
7820    /// Classes plan Task 17 (LRM 15.3.3/15.3.4): a `get` that must wait yields
7821    /// (`SyncBlock` + `ByRequest`) and reads WAITING until the `put` that
7822    /// grants it queues `SyncWake`, which resumes it at the putter's time.
7823    #[test]
7824    fn semaphore_get_blocks_until_a_put_grants_it() {
7825        let h = new_semaphore(0, 17_100);
7826        let mut engine = new_engine();
7827        let log = new_log();
7828        let (l0, mut step) = (log.clone(), 0);
7829        register_expecting(
7830            &mut engine,
7831            script(move |_, ops| {
7832                step += 1;
7833                if step == 1 {
7834                    let blocked = semaphore::get(h, 0, 1, &mut |r| {
7835                        ops.process_request(0, r);
7836                    });
7837                    assert!(blocked, "an empty semaphore blocks");
7838                    Suspend::ByRequest
7839                } else {
7840                    l0.lock().unwrap().push(format!("got@{}", ops.now().ticks));
7841                    Suspend::Done
7842                }
7843            }),
7844            0,
7845        );
7846        let (l1, mut step) = (log.clone(), 0);
7847        register_expecting(
7848            &mut engine,
7849            script(move |_, ops| {
7850                step += 1;
7851                match step {
7852                    1 => Suspend::Delay(7),
7853                    _ => {
7854                        l1.lock()
7855                            .unwrap()
7856                            .push(format!("before put: state={}", state_of(ops, 0)));
7857                        assert!(semaphore::put(h, 1, &mut |r| {
7858                            ops.process_request(1, r);
7859                        }));
7860                        Suspend::Done
7861                    }
7862                }
7863            }),
7864            1,
7865        );
7866        assert!(matches!(engine.run_until(None), RunResult::Completed));
7867        assert_eq!(lines(&log), ["before put: state=2", "got@7"]);
7868        assert_eq!(semaphore::keys(h), Some(0));
7869    }
7870
7871    /// A process killed while blocked leaves the queue; a waiter behind it
7872    /// that the freed head was holding back is released at the kill.
7873    #[test]
7874    fn killing_a_blocked_get_releases_the_waiter_behind_it() {
7875        let h = new_semaphore(1, 17_101);
7876        let mut engine = new_engine();
7877        let log = new_log();
7878        let mut step = 0;
7879        register_expecting(
7880            &mut engine,
7881            script(move |_, ops| {
7882                step += 1;
7883                if step == 1 {
7884                    assert!(semaphore::get(h, 0, 2, &mut |r| {
7885                        ops.process_request(0, r);
7886                    }));
7887                    Suspend::ByRequest
7888                } else {
7889                    panic!("the killed waiter must never resume");
7890                }
7891            }),
7892            0,
7893        );
7894        let (l1, mut step) = (log.clone(), 0);
7895        register_expecting(
7896            &mut engine,
7897            script(move |_, ops| {
7898                step += 1;
7899                if step == 1 {
7900                    assert!(
7901                        semaphore::get(h, 1, 1, &mut |r| {
7902                            ops.process_request(1, r);
7903                        }),
7904                        "FIFO: queued behind the get(2) although a key is free"
7905                    );
7906                    Suspend::ByRequest
7907                } else {
7908                    l1.lock()
7909                        .unwrap()
7910                        .push(format!("p1 got@{}", ops.now().ticks));
7911                    Suspend::Done
7912                }
7913            }),
7914            1,
7915        );
7916        let (l2, mut step) = (log.clone(), 0);
7917        register_expecting(
7918            &mut engine,
7919            script(move |_, ops| {
7920                step += 1;
7921                match step {
7922                    1 => Suspend::Delay(5),
7923                    2 => {
7924                        l2.lock()
7925                            .unwrap()
7926                            .push(format!("waiters before kill: {}", semaphore::waiters(h)));
7927                        ops.process_request(2, ProcessRequest::Kill(0));
7928                        Suspend::ByRequest
7929                    }
7930                    _ => {
7931                        l2.lock()
7932                            .unwrap()
7933                            .push(format!("waiters after kill: {}", semaphore::waiters(h)));
7934                        Suspend::Done
7935                    }
7936                }
7937            }),
7938            2,
7939        );
7940        assert!(matches!(engine.run_until(None), RunResult::Completed));
7941        assert_eq!(
7942            lines(&log),
7943            [
7944                "waiters before kill: 2",
7945                // The killer continues at once; the released waiter is
7946                // activated, so it runs after the killer's segment.
7947                "waiters after kill: 0",
7948                "p1 got@5",
7949            ]
7950        );
7951        assert_eq!(semaphore::keys(h), Some(0));
7952    }
7953
7954    /// A process suspended with `p.suspend()` while blocked in `get` is
7955    /// granted its keys but stays parked until `p.resume()`.
7956    #[test]
7957    fn a_granted_get_of_a_suspended_process_waits_for_resume() {
7958        let h = new_semaphore(0, 17_102);
7959        let mut engine = new_engine();
7960        let log = new_log();
7961        let (l0, mut step) = (log.clone(), 0);
7962        register_expecting(
7963            &mut engine,
7964            script(move |_, ops| {
7965                step += 1;
7966                if step == 1 {
7967                    ops.process_track(0);
7968                    assert!(semaphore::get(h, 0, 1, &mut |r| {
7969                        ops.process_request(0, r);
7970                    }));
7971                    Suspend::ByRequest
7972                } else {
7973                    l0.lock().unwrap().push(format!("got@{}", ops.now().ticks));
7974                    Suspend::Done
7975                }
7976            }),
7977            0,
7978        );
7979        let mut step = 0;
7980        register_expecting(
7981            &mut engine,
7982            script(move |_, ops| {
7983                step += 1;
7984                match step {
7985                    1 => Suspend::Delay(1),
7986                    2 => {
7987                        ops.process_request(1, ProcessRequest::Suspend(0));
7988                        Suspend::ByRequest
7989                    }
7990                    3 => Suspend::Delay(1),
7991                    4 => {
7992                        semaphore::put(h, 1, &mut |r| {
7993                            ops.process_request(1, r);
7994                        });
7995                        Suspend::Delay(8)
7996                    }
7997                    5 => {
7998                        ops.process_request(1, ProcessRequest::Resume(0));
7999                        Suspend::ByRequest
8000                    }
8001                    _ => Suspend::Done,
8002                }
8003            }),
8004            1,
8005        );
8006        assert!(matches!(engine.run_until(None), RunResult::Completed));
8007        assert_eq!(lines(&log), ["got@10"]);
8008    }
8009}