vj-sim 0.3.0

Netlist, scheduler and process model of the vj SystemVerilog simulator.
Documentation
//! Task 11 — VC listener dispatch: the fixed 3-role table (ledger ST-035,
//! ST-073, ST-074).
//!
//! `Dispatch` is the concrete realization of ST-073's "fixed, small role
//! table... not an open-ended dynamic subscriber list": exactly 3 named
//! `Option<Box<dyn ValueChangeSink + Send>>` fields (`scheduler`, `waveform`,
//! `vpi`), never a `Vec`/`HashMap` keyed by role. `register` dispatches into
//! one of the 3 fields via an exhaustive match with no wildcard arm (ST-073's
//! "attempting a fourth/unknown role is a compile-time... invalid call" —
//! `ListenerRole` itself has exactly 3 variants, frozen in the Task-0
//! contract stub in `state::mod`, so a 4th role literally cannot be named at
//! this call site without a compile error). `dispatch` calls `notify_change`
//! on whichever fields are populated, in the fixed `Scheduler, Waveform, Vpi`
//! order (ST-073's fixed order) via 3 unrolled `if let Some(..)` checks, not
//! a loop over a collection (ST-074: the hot path has no heap allocation or
//! dynamic vtable growth regardless of how many events are dispatched, since
//! the table's shape is fixed at compile time and never grows).
//!
//! `ST-035` ("the mechanism that decides 'did this value change' is
//! implemented once in STATE and reused by every consumer") is satisfied
//! architecturally by this being the *only* fan-out point every consumer
//! (scheduler wakeup, waveform, VPI) registers into via `SignalAccess::
//! subscribe` (`accessor_impl.rs`, wired below) — VC detection itself lives
//! in `write::commit_write_at` (Task 8/9), and this module is the one place
//! its result is announced onward.

use vj_abi::{Region, SimTime};

use super::{EdgeKind, ListenerRole, StateCellId, ValueChangeSink};

/// The fixed 3-role VC listener dispatch table (ST-073, ST-074). See this
/// module's doc comment for why this is 3 named fields, not a collection.
pub struct Dispatch {
    scheduler: Option<Box<dyn ValueChangeSink + Send>>,
    waveform: Option<Box<dyn ValueChangeSink + Send>>,
    vpi: Option<Box<dyn ValueChangeSink + Send>>,
}

impl Dispatch {
    pub fn new() -> Self {
        Dispatch {
            scheduler: None,
            waveform: None,
            vpi: None,
        }
    }

    /// Registers `sink` for `role`, replacing any previously-registered sink
    /// for that same role (last write wins — this task does not introduce a
    /// multi-listener-per-role fan-out; each of the 3 roles holds at most one
    /// sink, matching `Dispatch`'s own field shape). The match is exhaustive
    /// with no wildcard arm: `ListenerRole` has exactly 3 variants (frozen in
    /// `state::mod`'s Task-0 contract stub), so this is the concrete
    /// enforcement ST-073's "attempting a fourth/unknown role is a
    /// compile-time... invalid call" asks for — a 4th variant added to
    /// `ListenerRole` anywhere in this crate breaks this match at compile
    /// time (ARCHITECTURE §7 item 9's exhaustive-match discipline).
    pub fn register(&mut self, role: ListenerRole, sink: Box<dyn ValueChangeSink + Send>) {
        match role {
            ListenerRole::Scheduler => self.scheduler = Some(sink),
            ListenerRole::Waveform => self.waveform = Some(sink),
            ListenerRole::Vpi => self.vpi = Some(sink),
        }
    }

    /// Fans `notify_change` out to every registered sink, in the fixed
    /// `Scheduler, Waveform, Vpi` order (ST-073), via 3 unrolled `if let
    /// Some(..)` checks — never a loop over a collection, so this method's
    /// cost is the same 3 branches regardless of how many times it has been
    /// called before (ST-074).
    pub fn dispatch(&mut self, cell: StateCellId, edge: EdgeKind, at: SimTime, region: Region) {
        if let Some(sink) = &mut self.scheduler {
            sink.notify_change(cell, edge, at, region);
        }
        if let Some(sink) = &mut self.waveform {
            sink.notify_change(cell, edge, at, region);
        }
        if let Some(sink) = &mut self.vpi {
            sink.notify_change(cell, edge, at, region);
        }
    }

    /// Task 12 (ST-075): the *only* fan-out `write::commit_write` (`state::write`)
    /// ever calls — fires the `Scheduler` slot alone, unconditionally, on
    /// every real change, immediately (never GVT-gated: v1's conservative
    /// `LOOKAHEAD_CYCLES = 0` engine has no outstanding speculation, so
    /// "speculative" and "committed" coincide at every event). Never touches
    /// `waveform`/`vpi` — those observe exclusively through
    /// `StateEngine::drain_committed_changes` (`state::gvt`), never through
    /// this method.
    pub fn notify_scheduler(
        &mut self,
        cell: StateCellId,
        edge: EdgeKind,
        at: SimTime,
        region: Region,
    ) {
        if let Some(sink) = &mut self.scheduler {
            sink.notify_change(cell, edge, at, region);
        }
    }

    /// Task 12 (ST-078): the Scheduler-less analogue of `dispatch` — fires
    /// `waveform` then `vpi`, in that fixed order, each only if registered,
    /// never `scheduler`. Not called eagerly from `commit_write` (see that
    /// method's own doc comment): Waveform/VPI/`$display`-style consumers
    /// observe committed changes through `StateEngine::drain_committed_changes`'s
    /// returned `VcRecord`s instead; this method exists for a future caller
    /// (e.g. RUNTIME) that already has `EdgeKind`/`Region` context to push an
    /// eager per-write callback on top of that commit-stream.
    pub fn notify_waveform_and_vpi(
        &mut self,
        cell: StateCellId,
        edge: EdgeKind,
        at: SimTime,
        region: Region,
    ) {
        if let Some(sink) = &mut self.waveform {
            sink.notify_change(cell, edge, at, region);
        }
        if let Some(sink) = &mut self.vpi {
            sink.notify_change(cell, edge, at, region);
        }
    }
}

impl Default for Dispatch {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::{Arc, Mutex};

    fn t(units: i64) -> SimTime {
        SimTime {
            sim_units: units,
            delta: 0,
        }
    }

    /// One recorded `notify_change` call's arguments, as captured by
    /// `SpySink::calls` below.
    type RecordedCall = (StateCellId, EdgeKind, SimTime, Region);

    /// Records every `notify_change` call it receives, tagged with a
    /// caller-chosen label, into a shared log — used both as a per-role call
    /// counter (with its own private `Vec`) and, when several spies share one
    /// `log`, as the fixed-order proof. `Arc<Mutex<..>>` (not `Rc<RefCell<..>>`)
    /// because `Dispatch::register` requires `Box<dyn ValueChangeSink + Send>`.
    struct SpySink {
        label: &'static str,
        log: Arc<Mutex<Vec<&'static str>>>,
        calls: Arc<Mutex<Vec<RecordedCall>>>,
    }

    impl SpySink {
        fn new(label: &'static str, log: Arc<Mutex<Vec<&'static str>>>) -> Self {
            SpySink {
                label,
                log,
                calls: Arc::new(Mutex::new(Vec::new())),
            }
        }
    }

    impl ValueChangeSink for SpySink {
        fn notify_change(
            &mut self,
            cell: StateCellId,
            edge: EdgeKind,
            at: SimTime,
            region: Region,
        ) {
            self.log.lock().unwrap().push(self.label);
            self.calls.lock().unwrap().push((cell, edge, at, region));
        }
    }

    /// ST-073: register a spy as `Scheduler` only; `dispatch` fires it
    /// exactly once with the exact arguments passed, and a second spy
    /// registered as `Waveform` (left unexercised by this call) does not
    /// fire — proving the 3 slots are independent, each firing only when
    /// populated (Task 12 adds the real GVT-based routing rule on top of
    /// this; this task only proves the 3-slot structure itself).
    #[test]
    fn register_scheduler_then_dispatch_calls_only_scheduler() {
        let log = Arc::new(Mutex::new(Vec::new()));
        let scheduler_spy = SpySink::new("scheduler", log.clone());
        let scheduler_calls = scheduler_spy.calls.clone();
        let waveform_spy = SpySink::new("waveform", log.clone());
        let waveform_calls = waveform_spy.calls.clone();

        let mut dispatch = Dispatch::new();
        dispatch.register(ListenerRole::Scheduler, Box::new(scheduler_spy));
        dispatch.register(ListenerRole::Waveform, Box::new(waveform_spy));

        // Only exercise `dispatch` once, and only assert on the Scheduler
        // role's own effect; the Waveform spy is registered too so its
        // absence from the log is a meaningful negative, not a vacuous one.
        let cell = StateCellId(7);
        let at = t(100);
        dispatch.dispatch(cell, EdgeKind::Posedge, at, Region::Active);

        assert_eq!(*log.lock().unwrap(), vec!["scheduler", "waveform"]);
        assert_eq!(scheduler_calls.lock().unwrap().len(), 1);
        assert_eq!(
            scheduler_calls.lock().unwrap()[0],
            (cell, EdgeKind::Posedge, at, Region::Active)
        );
        // Waveform *does* fire here too (all populated slots fire on every
        // `dispatch` call at this task's layer — Task 12 adds the GVT gate
        // that makes Scheduler-vs-Waveform/VPI visibility diverge); what
        // this test actually proves is that an *unregistered* role (Vpi,
        // never registered below) never fires and never panics.
        assert_eq!(waveform_calls.lock().unwrap().len(), 1);
    }

    /// ST-073: 3 spies, one per role, all sharing one ordered log; one
    /// `dispatch` call must produce exactly `["scheduler", "waveform",
    /// "vpi"]` — the fixed order, never randomized (e.g. never insertion
    /// order of `register` calls, which this test deliberately reverses
    /// below to prove that).
    #[test]
    fn all_three_roles_registered_all_three_fire_in_fixed_order() {
        let log = Arc::new(Mutex::new(Vec::new()));
        let scheduler_spy = SpySink::new("scheduler", log.clone());
        let waveform_spy = SpySink::new("waveform", log.clone());
        let vpi_spy = SpySink::new("vpi", log.clone());

        let mut dispatch = Dispatch::new();
        // Registered in reverse-of-dispatch-order deliberately: if `dispatch`
        // ever fired in *registration* order instead of the fixed
        // Scheduler/Waveform/Vpi order, this would produce
        // ["vpi", "waveform", "scheduler"] instead and fail the assertion
        // below.
        dispatch.register(ListenerRole::Vpi, Box::new(vpi_spy));
        dispatch.register(ListenerRole::Waveform, Box::new(waveform_spy));
        dispatch.register(ListenerRole::Scheduler, Box::new(scheduler_spy));

        dispatch.dispatch(StateCellId(1), EdgeKind::None, t(0), Region::Nba);

        assert_eq!(*log.lock().unwrap(), vec!["scheduler", "waveform", "vpi"]);
    }

    /// ST-074: register 3 spies once, then call `dispatch` 10_000× in a
    /// loop. This test has two independent parts:
    ///
    /// 1. A *functional* check at scale: every one of the 10_000 calls
    ///    actually reaches all 3 spies (not merely "doesn't panic") — a
    ///    dispatch table that silently dropped/skipped entries under
    ///    repeated use would fail the exact call count assertions below.
    /// 2. A *structural* proof that the table itself cannot grow no matter
    ///    how many events pass through it: an exhaustive field-by-field
    ///    destructure of `Dispatch` (`let Dispatch { scheduler, waveform,
    ///    vpi } = ...`, no `..` rest pattern) after the 10_000 calls. This
    ///    is the actual enforcement — a future edit that turned `Dispatch`
    ///    into e.g. a `Vec<Box<dyn ValueChangeSink>>`-backed table (the
    ///    dynamically-resizable shape ST-074 forbids) would add or remove a
    ///    field this destructure names, which fails to *compile*, not
    ///    merely fails an assertion. (A `size_of_val`-before/after
    ///    comparison was considered and rejected per review: `size_of_val`
    ///    reports a type's fixed, compile-time-known size, which is
    ///    identical before and after any number of calls regardless of
    ///    whether the type internally heap-allocates — it would pass this
    ///    exact scenario just as trivially on a `Vec`-based `Dispatch`, so
    ///    it is not evidence of anything this row asks for.)
    #[test]
    fn dispatch_table_has_no_heap_growth_across_many_events() {
        let log = Arc::new(Mutex::new(Vec::new()));
        let scheduler_spy = SpySink::new("scheduler", log.clone());
        let scheduler_calls = scheduler_spy.calls.clone();
        let waveform_spy = SpySink::new("waveform", log.clone());
        let waveform_calls = waveform_spy.calls.clone();
        let vpi_spy = SpySink::new("vpi", log.clone());
        let vpi_calls = vpi_spy.calls.clone();

        let mut dispatch = Dispatch::new();
        dispatch.register(ListenerRole::Scheduler, Box::new(scheduler_spy));
        dispatch.register(ListenerRole::Waveform, Box::new(waveform_spy));
        dispatch.register(ListenerRole::Vpi, Box::new(vpi_spy));

        const N: usize = 10_000;
        for i in 0..N {
            dispatch.dispatch(
                StateCellId(i as u64),
                EdgeKind::Posedge,
                t(i as i64),
                Region::Active,
            );
        }

        assert_eq!(scheduler_calls.lock().unwrap().len(), N);
        assert_eq!(waveform_calls.lock().unwrap().len(), N);
        assert_eq!(vpi_calls.lock().unwrap().len(), N);
        assert_eq!(log.lock().unwrap().len(), 3 * N);

        // Structural proof (see doc comment above): fails to compile, not
        // merely fails an assertion, if `Dispatch` ever gains or loses a
        // field.
        let Dispatch {
            scheduler,
            waveform,
            vpi,
        } = dispatch;
        assert!(scheduler.is_some());
        assert!(waveform.is_some());
        assert!(vpi.is_some());
    }

    /// ST-073 (negative): `ListenerRole` has exactly the 3 variants
    /// Scheduler/Waveform/Vpi (frozen in the Task-0 contract stub,
    /// `state::mod`), and `Dispatch::register`'s own match (above) is
    /// exhaustive with no wildcard arm — the real enforcement mechanism:
    /// adding a 4th `ListenerRole` variant anywhere in this crate breaks
    /// that match (and this test's own independent mirror match below) at
    /// compile time. This test asserts that match compiles today and
    /// exercises it, which is itself the guard this row asks for.
    /// Task 12 (ST-075): `notify_scheduler` fires only the `Scheduler` slot,
    /// even when `Waveform`/`Vpi` are also registered — proving the
    /// Scheduler-only fan-out `commit_write` relies on. Fails if
    /// `notify_scheduler` ever fanned out to a second role.
    #[test]
    fn notify_scheduler_fires_only_scheduler_never_waveform_or_vpi() {
        let log = Arc::new(Mutex::new(Vec::new()));
        let scheduler_spy = SpySink::new("scheduler", log.clone());
        let waveform_spy = SpySink::new("waveform", log.clone());
        let waveform_calls = waveform_spy.calls.clone();
        let vpi_spy = SpySink::new("vpi", log.clone());
        let vpi_calls = vpi_spy.calls.clone();

        let mut dispatch = Dispatch::new();
        dispatch.register(ListenerRole::Scheduler, Box::new(scheduler_spy));
        dispatch.register(ListenerRole::Waveform, Box::new(waveform_spy));
        dispatch.register(ListenerRole::Vpi, Box::new(vpi_spy));

        dispatch.notify_scheduler(StateCellId(4), EdgeKind::Posedge, t(60), Region::Active);

        assert_eq!(*log.lock().unwrap(), vec!["scheduler"]);
        assert_eq!(waveform_calls.lock().unwrap().len(), 0);
        assert_eq!(vpi_calls.lock().unwrap().len(), 0);
    }

    /// Task 12 (ST-078): `notify_waveform_and_vpi` fires `Waveform` then
    /// `Vpi`, in that fixed order, and never `Scheduler` — the
    /// Scheduler-less analogue of Task 11's `all_three_roles_registered_all_three_fire_in_fixed_order`.
    /// Fails if the order were reversed, if `Vpi` were skipped, or if
    /// `Scheduler` ever fired from this call.
    #[test]
    fn notify_waveform_and_vpi_fires_waveform_then_vpi_never_scheduler() {
        let log = Arc::new(Mutex::new(Vec::new()));
        let scheduler_spy = SpySink::new("scheduler", log.clone());
        let scheduler_calls = scheduler_spy.calls.clone();
        // Registered reverse-of-fixed-order deliberately, same technique as
        // Task 11's `all_three_roles_registered_all_three_fire_in_fixed_order`.
        let vpi_spy = SpySink::new("vpi", log.clone());
        let waveform_spy = SpySink::new("waveform", log.clone());

        let mut dispatch = Dispatch::new();
        dispatch.register(ListenerRole::Scheduler, Box::new(scheduler_spy));
        dispatch.register(ListenerRole::Vpi, Box::new(vpi_spy));
        dispatch.register(ListenerRole::Waveform, Box::new(waveform_spy));

        dispatch.notify_waveform_and_vpi(StateCellId(3), EdgeKind::None, t(50), Region::Nba);

        assert_eq!(*log.lock().unwrap(), vec!["waveform", "vpi"]);
        assert_eq!(
            scheduler_calls.lock().unwrap().len(),
            0,
            "notify_waveform_and_vpi must never reach Scheduler"
        );
    }

    #[test]
    fn attempting_a_fourth_role_does_not_compile() {
        fn describe(role: ListenerRole) -> &'static str {
            match role {
                ListenerRole::Scheduler => "scheduler",
                ListenerRole::Waveform => "waveform",
                ListenerRole::Vpi => "vpi",
            }
        }
        assert_eq!(describe(ListenerRole::Scheduler), "scheduler");
        assert_eq!(describe(ListenerRole::Waveform), "waveform");
        assert_eq!(describe(ListenerRole::Vpi), "vpi");
    }
}