vj-sim 0.3.0

Netlist, scheduler and process model of the vj SystemVerilog simulator.
Documentation
//! SCHED::REGION_CALLBACK — Task 4 (`docs/superpowers/plans/2026-09-27-
//! wave-B-sched.md`): the `register_region_callback`/`RegionCallback`
//! surface `docs/design/runtime.md` §6 specifies for RUNTIME's
//! `$monitor`/`$strobe`/`cbReadOnlySynch`/`cbReadWriteSynch` delivery
//! (ledger SC-090), plus the scheduler-side half of SC-020 (`$strobe`/
//! `$monitor` must sample the final, post-delta-convergence value exactly
//! once per settled timestep — see `sched::control::Engine::
//! drain_region_callbacks`'s own doc comment for exactly where that
//! sampling point is pinned; the remainder of SC-020, RUNTIME actually
//! deferring its own argument re-evaluation, is RT-047/RT-036, B-runtime's
//! own open ledger rows, out of this track's ownership).
//!
//! **`1step` placement (SC-123), pinned here:** `1step` is *not*
//! `Suspend::Delay(0)` — `Delay(0)` re-enters `Region::Inactive`, which runs
//! strictly *before* `Region::Postponed`, the wrong side of the boundary
//! (`docs/design/scheduler.md`'s own Open Questions section flagged this
//! exact placement as unconfirmed). A process suspended on `1step` timing
//! instead registers itself as a [`RegionCallback`] for `Region::Postponed`
//! at the current [`SchedTime`]: its `invoke` runs strictly after every
//! *ordinary* `Region::Postponed` callback registered before it
//! (registration order — the same guarantee
//! `sched::control::Engine::drain_region_callbacks` gives any two
//! same-region callbacks), placing its continuation logically after this
//! timestep's Postponed region has fully settled, at the same logical time
//! — never racing another Postponed callback and never waiting for the next
//! tick. No SV-source construct lowers to this path yet this wave (no
//! `vj-codegen` track emits `OneStepDelayControl` this wave, confirmed
//! by this task's own audit), so this decision is pinned here and proved
//! against a synthetic mock-process fixture —
//! `sched::control::tests::sc123_1step_continuation_runs_after_every_
//! ordinary_postponed_callback_at_the_same_tick` — rather than an SV-source
//! end-to-end one.

use vj_abi::Region;

use crate::sched::SchedTime;
use crate::state::{FourStateValue, StateCellId};

/// `Engine::push_region_callback`'s one typed-error variant — the
/// `region_callback` analogue of [`crate::sched::RegionQueuePushError`]
/// (review fix, Task 4 r1): only `Region::Reactive` and `Region::Postponed`
/// are ever drained by `sched::control::Engine::drain_region_callbacks`
/// (called from `run_until`/`step_region` for exactly those two regions —
/// see that method's own doc comment). A callback registered for
/// `Region::Active`, `Region::Inactive` or `Region::Nba` would otherwise be
/// pushed into `Engine::region_callbacks` and never drained: not an error,
/// not a panic, not a log — just a silent leak that never fires. Mirroring
/// `RegionQueuePushError`'s own established precedent for exactly this
/// shape (a `Region`-typed parameter where not every variant is actually
/// serviceable), this must be a loud typed `Result`, never a
/// `debug_assert!` (which compiles to nothing in release builds and so
/// reproduces the exact silent-drop this type exists to prevent).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum RegionCallbackRegionError {
    /// `region` is not one `Engine::drain_region_callbacks` ever drains.
    /// Carries the offending region so the caller-bug diagnostic names it.
    NotDrained(Region),
}

impl std::fmt::Display for RegionCallbackRegionError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            RegionCallbackRegionError::NotDrained(region) => {
                write!(
                    f,
                    "{region:?} is never drained by Engine::drain_region_callbacks (only \
                     Region::Reactive and Region::Postponed are); a callback registered for it \
                     would never fire"
                )
            }
        }
    }
}

impl std::error::Error for RegionCallbackRegionError {}

/// The read-only view a [`RegionCallback`] gets. Deliberately narrower than
/// [`crate::sched::SchedulerOps`] — no writes, no `defer_io`:
/// `docs/design/runtime.md` §6's own "read back the current values" framing
/// means a region callback observes already-settled state, it never drives
/// further simulation.
pub trait SchedulerCtx {
    fn now(&self) -> SchedTime;
    fn read(&self, cell: StateCellId) -> FourStateValue;
    /// Registers `cb` for `region` again, from inside a running callback --
    /// it fires at that region of the NEXT timestep (this timestep's drain
    /// already took every callback that was due). `$monitor` re-arms itself
    /// this way to run at the end of every timestep. A pending callback
    /// never keeps a simulation alive: `run_until` still ends when the time
    /// wheel is empty.
    fn rearm(&self, region: vj_abi::Region, cb: Box<dyn RegionCallback>);
    /// Whether a `$monitor` watched cell changed since the last call (see
    /// `SchedulerOps::set_monitor_watch`); clears the flag.
    fn take_monitor_changed(&self) -> bool;
}

/// A callback the scheduler invokes exactly once, at a pinned
/// `(SchedTime, Region)` boundary (see
/// [`crate::sched::SchedulerOps::register_region_callback`] and
/// `sched::control::Engine::drain_region_callbacks`'s own doc comment for
/// exactly when).
pub trait RegionCallback: Send {
    fn invoke(&mut self, ctx: &dyn SchedulerCtx);
}

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

    struct RecordingCallback {
        seen: Arc<Mutex<Vec<FourStateValue>>>,
        cell: StateCellId,
    }

    impl RegionCallback for RecordingCallback {
        fn invoke(&mut self, ctx: &dyn SchedulerCtx) {
            self.seen.lock().unwrap().push(ctx.read(self.cell));
        }
    }

    struct FixedCtx {
        now: SchedTime,
        cell: StateCellId,
        value: FourStateValue,
    }

    impl SchedulerCtx for FixedCtx {
        fn now(&self) -> SchedTime {
            self.now
        }

        fn read(&self, cell: StateCellId) -> FourStateValue {
            assert_eq!(
                cell, self.cell,
                "this fixed test ctx only ever answers for the one cell it was built with"
            );
            self.value.clone()
        }

        fn rearm(&self, _region: vj_abi::Region, _cb: Box<dyn RegionCallback>) {
            panic!("this fixed test ctx is never asked to re-arm a callback");
        }

        fn take_monitor_changed(&self) -> bool {
            panic!("this fixed test ctx has no $monitor");
        }
    }

    /// Proves the trait mechanics compile and actually flow a `SchedulerCtx`
    /// read through to `invoke` (not merely that the types exist) — the
    /// Engine-integration half (real drain ordering/timing) lives in
    /// `sched::control::tests` per this task's own plan, since it needs a
    /// real `Engine`/`SchedulerOps`.
    #[test]
    fn region_callback_invoke_receives_the_ctx_and_can_read_the_named_cell() {
        let cell = StateCellId(7);
        let seen = Arc::new(Mutex::new(Vec::new()));
        let mut cb = RecordingCallback {
            seen: seen.clone(),
            cell,
        };
        let ctx = FixedCtx {
            now: SchedTime { ticks: 3 },
            cell,
            value: FourStateValue::Narrow(42),
        };

        cb.invoke(&ctx);

        let recorded = seen.lock().unwrap();
        assert_eq!(recorded.len(), 1, "invoke must call through exactly once");
        assert!(matches!(recorded[0], FourStateValue::Narrow(42)));
    }
}