vj-sim 0.3.0

Netlist, scheduler and process model of the vj SystemVerilog simulator.
Documentation
//! `final` blocks: one dedicated post-simulation pass (Task 14,
//! `docs/superpowers/plans/2026-09-23-wave-A-sched.md`; ledger SC-077, SC-078).
//!
//! **Scope:** `final` blocks (`ProceduralBlockSymbol` kind `Final`) run
//! exactly once, in one dedicated pass strictly *after* `run_until` (Task 21)
//! returns — never folded into the 5-region timestep loop (SC-077) — and in
//! the same stable, canonical `registration_order` used everywhere else in
//! this crate, since the LRM leaves inter-block final order unspecified
//! (SC-078). [`run_finals`] carries no `Region`/`SchedTime` "current
//! timestep" parameter anywhere in its signature: that is what structurally
//! prevents it from being invoked *as part of* a region drain, all of which
//! take a `Region` (`process_table::form_ready_set`, `RegionQueues::push`).
//!
//! **Deviation from the plan's literal signature** (recorded in
//! `docs/CONTRACTS.md`'s r3 section, item 7): the plan names `run_finals`'s
//! second parameter `table: &mut ProcessTable`, but Task 12's already-shipped
//! `ResumeDriver::start` (`resume.rs`) takes a **shared** `&ProcessTable` (the
//! SC-061 fix — see `resume.rs`'s module doc), so a `&mut` borrow here would
//! not even compile against the real `start`. `run_finals` performs no table
//! mutation of its own (registration already happened before this pass runs),
//! so the minimal, additive fix is `table: &ProcessTable`.

use vj_abi::SimHandle;

use super::{ProcessId, ProcessTable, ResumeDriver, SchedulerOps};

/// One registered `final` block candidate (scheduler.md §8; SC-077, SC-078).
/// `registration_order` is the same crate-wide, monotonic, stable ordering
/// space `ProcessDescriptor::registration_order` uses (Task 11) — SC-078's
/// "one logical order, everywhere" invariant, not a second, `final`-local
/// numbering.
#[derive(Clone, Copy, Debug)]
pub struct FinalProcess {
    pub proc: ProcessId,
    pub registration_order: u64,
}

/// Runs every registered `final` block exactly once, in `registration_order`
/// (stable, ascending — SC-078), sorted in place by this call itself (a
/// caller must not have to pre-sort `finals`). No `Region`/`SchedTime`
/// parameter anywhere in this signature (SC-077 — structurally cannot be
/// folded into the 5-region timestep loop, unlike every region-drain
/// primitive in this crate).
///
/// Each final process is started via [`ResumeDriver::start`] (SC-059's
/// "`resume(Start)` exactly once"), `.expect()`-ing Task 12's
/// `Result<Suspend, ResumeError>`: a final process id came from this same
/// `finals: Vec<FinalProcess>`, itself only ever populated from real
/// `ProcessTable::register` calls, so `UnknownProcess`/`AlreadyStarted` here
/// would mean this function's own caller passed a bogus or duplicate entry —
/// a programmer error worth failing loudly on, not a runtime condition this
/// function's signature needs to expose to its own caller.
pub fn run_finals(
    finals: &mut [FinalProcess],
    table: &ProcessTable,
    state: SimHandle,
    ops: &dyn SchedulerOps,
    driver: &ResumeDriver,
) {
    finals.sort_by_key(|f| f.registration_order);
    for f in finals.iter() {
        // A `final` block is a thread too (37.44): created as it starts and
        // deleted when it returns (it cannot suspend).
        super::fire_thread_event(
            ops,
            super::ThreadEvent::Start {
                pid: f.proc,
                parent: None,
                spawn: None,
            },
        );
        driver
            .start(table, f.proc, state, ops)
            .expect("registered final process must be startable");
        super::fire_thread_event(
            ops,
            super::ThreadEvent::End {
                pid: f.proc,
                killed: false,
            },
        );
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::sched::{
        CompiledProcess, PendingActivation, RegionQueues, ResumeArg, SchedTime, Suspend,
        form_ready_set,
    };
    use crate::state::{FourStateValue, StateCellId};
    use std::sync::{Arc, Mutex};
    use vj_abi::Region;

    /// A no-op `SchedulerOps` double — no test in this file inspects any of
    /// its call arguments, only the resulting `resume()` call log.
    struct NoopOps;
    impl SchedulerOps for NoopOps {
        fn now(&self) -> SchedTime {
            SchedTime { ticks: 0 }
        }
        fn current_region(&self) -> Region {
            Region::Active
        }
        fn stage_nba(&self, _cell: StateCellId, _value: FourStateValue, _source: ProcessId) {}
        fn commit_blocking_write(&self, _cell: StateCellId, _value: FourStateValue) {}
        fn request_finish(&self, _code: i32) {}
        fn defer_io(&self, _at: SchedTime, _region: Region, _cb: super::super::DeferredIoCallback) {
        }
        fn register_region_callback(
            &self,
            _region: Region,
            _at: SchedTime,
            _cb: Box<dyn super::super::RegionCallback>,
        ) {
        }
    }

    fn null_state() -> SimHandle {
        SimHandle(std::ptr::null_mut())
    }

    /// Records `(tag, was_a_start_call)` into a shared log every time it is
    /// resumed. `tag` lets a test identify *which* logical process ran
    /// without needing to know its dynamically-assigned `ProcessId` — used
    /// below to check `run_finals`'s actual dispatch order.
    struct LoggingProcess {
        tag: u64,
        log: Arc<Mutex<Vec<(u64, bool)>>>,
    }
    impl CompiledProcess for LoggingProcess {
        fn resume(
            &mut self,
            _state: SimHandle,
            arg: ResumeArg,
            _ops: &dyn SchedulerOps,
        ) -> Suspend {
            self.log
                .lock()
                .unwrap()
                .push((self.tag, matches!(arg, ResumeArg::Start)));
            Suspend::Done
        }
    }

    fn act(
        id: u64,
        proc: ProcessId,
        ticks: u64,
        reg_order: u64,
        arg: ResumeArg,
    ) -> PendingActivation {
        PendingActivation {
            id,
            proc,
            arg,
            reconv_iter: 0,
            logical_order: (SchedTime { ticks }, Region::Active, reg_order),
        }
    }

    /// SC-077 — structural half: `run_finals` takes no `Region`/`SchedTime`
    /// parameter, so it cannot be called from any region-drain call site
    /// (they all take `Region` — `form_ready_set`, `RegionQueues::push`).
    /// Behavioral half: register 3 "final" candidates that are never pushed
    /// into `RegionQueues`, run 2 unrelated Active-region
    /// `form_ready_set`/dispatch cycles that never mention them, and assert
    /// the final processes' own call log stays empty until `run_finals` is
    /// explicitly invoked. Fails if anything in the ordinary dispatch path
    /// (region push/drain, `ResumeDriver::start`/`resume_for`) implicitly
    /// reaches a registered-but-unqueued "final" process.
    #[test]
    fn finals_do_not_run_during_the_timestep_loop() {
        let final_log = Arc::new(Mutex::new(Vec::new()));
        let loop_log = Arc::new(Mutex::new(Vec::new()));
        let mut table = ProcessTable::new();
        let driver = ResumeDriver;
        let ops = NoopOps;

        // 3 final candidates: registered, but never pushed into RegionQueues.
        let mut finals = Vec::new();
        for tag in 0..3u64 {
            let proc = table.register(Box::new(LoggingProcess {
                tag,
                log: Arc::clone(&final_log),
            }));
            finals.push(FinalProcess {
                proc,
                registration_order: tag,
            });
        }

        // 2 unrelated processes driven through the ordinary Active-region
        // ready-set/dispatch path.
        let loop_proc_a = table.register(Box::new(LoggingProcess {
            tag: 100,
            log: Arc::clone(&loop_log),
        }));
        let loop_proc_b = table.register(Box::new(LoggingProcess {
            tag: 101,
            log: Arc::clone(&loop_log),
        }));

        let mut queues = RegionQueues::new();

        // Cycle 1: both loop processes' initial Start activation.
        queues
            .push(Region::Active, act(0, loop_proc_a, 0, 0, ResumeArg::Start))
            .unwrap();
        queues
            .push(Region::Active, act(1, loop_proc_b, 0, 1, ResumeArg::Start))
            .unwrap();
        let ready = form_ready_set(&mut queues, Region::Active).unwrap();
        assert_eq!(ready.len(), 2);
        for a in &ready {
            driver.start(&table, a.proc, null_state(), &ops).unwrap();
        }

        assert!(
            final_log.lock().unwrap().is_empty(),
            "final processes must not have been touched by cycle 1 of the \
             ordinary timestep loop"
        );

        // Cycle 2: both loop processes re-activated later in the same
        // (fictitious) run.
        queues
            .push(
                Region::Active,
                act(2, loop_proc_a, 5, 0, ResumeArg::TimeElapsed),
            )
            .unwrap();
        queues
            .push(
                Region::Active,
                act(3, loop_proc_b, 5, 1, ResumeArg::TimeElapsed),
            )
            .unwrap();
        let ready = form_ready_set(&mut queues, Region::Active).unwrap();
        assert_eq!(ready.len(), 2);
        for a in &ready {
            driver
                .resume_for(&table, a.proc, ResumeArg::TimeElapsed, null_state(), &ops)
                .unwrap();
        }

        assert!(
            final_log.lock().unwrap().is_empty(),
            "final processes must still be untouched after 2 full \
             Active-region form_ready_set/dispatch cycles that never \
             mentioned them"
        );
        assert_eq!(
            loop_log.lock().unwrap().len(),
            4,
            "sanity: the 2 unrelated processes really were dispatched twice each"
        );

        // Only now, via the explicit dedicated pass, do the final processes run.
        run_finals(&mut finals, &table, null_state(), &ops, &driver);
        let final_log = final_log.lock().unwrap();
        assert_eq!(
            final_log.len(),
            3,
            "run_finals must start every registered final process exactly once"
        );
        assert!(
            final_log.iter().all(|(_, is_start)| *is_start),
            "every final process must be reached via resume(Start), not some \
             other ResumeArg"
        );
    }

    /// SC-078 — 4 `FinalProcess` entries built in a shuffled `Vec` order but
    /// with fixed `registration_order` values `[3,1,0,2]`; `run_finals` must
    /// dispatch them `0,1,2,3` regardless of the input `Vec`'s own order.
    /// Run twice, each from a fresh shuffled input, and assert both runs
    /// produce the identical call sequence (determinism across "runs").
    /// Fails if `run_finals` trusts the input `Vec`'s order instead of
    /// sorting by `registration_order`.
    #[test]
    fn finals_run_in_stable_registration_order_across_repeated_runs() {
        fn run_once(orders_in_input_order: [u64; 4]) -> Vec<u64> {
            let log = Arc::new(Mutex::new(Vec::new()));
            let mut table = ProcessTable::new();
            let driver = ResumeDriver;
            let ops = NoopOps;

            let mut finals = Vec::new();
            for &registration_order in orders_in_input_order.iter() {
                let proc = table.register(Box::new(LoggingProcess {
                    tag: registration_order,
                    log: Arc::clone(&log),
                }));
                finals.push(FinalProcess {
                    proc,
                    registration_order,
                });
            }

            run_finals(&mut finals, &table, null_state(), &ops, &driver);
            log.lock().unwrap().iter().map(|(tag, _)| *tag).collect()
        }

        let run1 = run_once([3, 1, 0, 2]);
        assert_eq!(
            run1,
            vec![0, 1, 2, 3],
            "run_finals must dispatch strictly by ascending registration_order"
        );

        let run2 = run_once([2, 0, 3, 1]);
        assert_eq!(
            run2,
            vec![0, 1, 2, 3],
            "a second run from a differently-shuffled input must produce the \
             identical dispatch order (determinism across runs)"
        );
    }

    /// SC-078 — two `FinalProcess` groups standing in for two different
    /// hierarchical module instances, tagged with disjoint `registration_order`
    /// ranges (`[5, 2]` and `[4, 1]`) and interleaved into one `finals: Vec`.
    /// The merged run order must be purely numeric — `1, 2, 4, 5` — proving no
    /// per-instance grouping leaks into the dispatch order (the LRM leaves
    /// inter-block order unspecified only *within* the registration-order
    /// space, and this crate's own policy is that there is exactly one such
    /// space, never one per instance).
    #[test]
    fn finals_across_different_module_instances_still_use_one_global_order() {
        let log = Arc::new(Mutex::new(Vec::new()));
        let mut table = ProcessTable::new();
        let driver = ResumeDriver;
        let ops = NoopOps;

        // Instance A's final blocks: registration_order 5, then 2.
        // Instance B's final blocks: registration_order 4, then 1.
        // Interleaved into one Vec in an order that matches neither instance's
        // own internal order nor the final numeric order.
        let instance_a = [5u64, 2u64];
        let instance_b = [4u64, 1u64];
        let interleaved: Vec<u64> =
            vec![instance_a[0], instance_b[0], instance_a[1], instance_b[1]];

        let mut finals = Vec::new();
        for &registration_order in &interleaved {
            let proc = table.register(Box::new(LoggingProcess {
                tag: registration_order,
                log: Arc::clone(&log),
            }));
            finals.push(FinalProcess {
                proc,
                registration_order,
            });
        }

        run_finals(&mut finals, &table, null_state(), &ops, &driver);
        let order: Vec<u64> = log.lock().unwrap().iter().map(|(tag, _)| *tag).collect();
        assert_eq!(
            order,
            vec![1, 2, 4, 5],
            "the merged run order must be purely numeric by registration_order \
             -- no per-instance grouping may leak in"
        );
    }
}