vj-sim 0.2.0

Netlist, scheduler and process model of the vj SystemVerilog simulator.
Documentation
//! Task 15 — Region-disjoint arena sub-ranges & single-authoritative-storage
//! (ledger ST-033, ST-122).
//!
//! **Scope note (from the plan):** the *real* static driver-graph clustering
//! pass that would produce a genuine per-region write-set partition is a
//! `netlist`/`analysis`-track deliverable that does not exist yet (no owning
//! track this wave — see `docs/CONTRACTS.md`'s `vj-sim::analysis` row).
//! This task does not itself allocate region-disjoint arena sub-ranges (that
//! would require the arena to know region assignments *before* the layout
//! builder runs, an ordering this crate cannot assume yet); it delivers the
//! *checkable invariant* — [`check_region_disjoint`] — wired to the real
//! region-assigning pass whenever `netlist::analysis` lands it in a later
//! wave, plus the runtime guard for **external** writes ([`ForceObserved`])
//! that ST-122's own acceptance text calls out as the one path that must
//! stay serialized rather than ever performing a direct disjoint-range write
//! (ARCHITECTURE §9: "structurally-impossible cross-partition writes is
//! weaker than it reads").
//!
//! **Deviation (binding per this task's own unresolved-review-findings
//! list):** the plan's Files list for this task names only
//! `state/region_check.rs` (new) and `state/mod.rs`, but the described
//! behavior and this task's own `force_driver_notifies_every_registered_
//! force_observed_listener` test require Task 14's `driver::force_driver`/
//! `release_driver` (`state/driver.rs`) to actually call the
//! [`ForceObserved`] listeners registered here, and the registry itself
//! (`StateEngine::force_observed`) needs a direct field to live in
//! (`state/instance.rs`) — the same "every resource STATE owns is a direct
//! field" discipline Task 14's own `trireg_charge` field already establishes
//! there. Both are edited as part of this task, documented here rather than
//! left implicit, matching the pattern Task 8's retrofit of Task 7's
//! `StateEngine` (`state/instance.rs`, listed under Files there) already
//! set.
//!
//! **`ForceObserved` vs. `docs/CONTRACTS.md`'s `analysis`-owned
//! `ForceObservedAction`/`StaticAnalysisView::on_force_observed`:** the
//! `vj-sim::analysis` contract row names a *static-analysis-facing*
//! ForceObserved hook (owned by the `netlist`/`analysis` track, invalidating
//! *that* track's own independence classification). The plan's own text for
//! this task defines a second, STATE-owned trait of the same name/method
//! shape, for a narrower purpose: the concrete runtime call site
//! `force_driver`/`release_driver` invoke directly, with no dependency on
//! `netlist`/`analysis` (which this crate does not depend on this wave).
//! Once `netlist::analysis` lands its own real hook, reconciling the two
//! (this trait becoming that hook's STATE-side registration point, or being
//! subsumed by it) is that future wave's job, not this one's — flagged here
//! rather than left as an unexplained name collision.

use std::collections::{HashMap, HashSet};

use vj_abi::Region;

use super::StateCellId;
use super::instance::StateEngine;

/// This task's stand-in for the netlist/analysis track's not-yet-built
/// static clustering pass's real `RegionId`/write-set output (ST-122) — a
/// real caller eventually supplies these from that pass; this wave's own
/// tests hand-build them directly.
#[derive(Clone, Debug)]
pub struct RegionAssignment {
    pub region: Region,
    pub cells: HashSet<StateCellId>,
}

/// The loud, typed diagnosis [`check_region_disjoint`] returns instead of a
/// generic "overlap detected" (ARCHITECTURE §7 item 9's "loud, typed,
/// diagnosable" discipline applied here): names the offending cell and both
/// of the (in `assignments`-slice-order) conflicting regions it was claimed
/// by.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct RegionOverlapError {
    pub cell: StateCellId,
    pub regions: (Region, Region),
}

/// A build-time check (ST-122) confirming no [`StateCellId`] appears in more
/// than one `assignments` entry's `cells` set. **Does not** itself allocate
/// region-disjoint arena sub-ranges (see this module's top doc comment) —
/// this function's only job is the checkable invariant. `engine` is accepted
/// (matching the plan's pinned signature and this crate's convention of
/// every region-facing query taking the owning `StateEngine`) but this
/// wave's check is purely a property of `assignments` itself — a future
/// wave that also validates each `StateCellId` against `engine`'s own
/// allocated cell table (`StateEngine::alloc_cell`) can extend this function
/// without changing its signature.
pub fn check_region_disjoint(
    _engine: &StateEngine,
    assignments: &[RegionAssignment],
) -> Result<(), RegionOverlapError> {
    let mut owner: HashMap<StateCellId, Region> = HashMap::new();
    for assignment in assignments {
        for &cell in &assignment.cells {
            match owner.get(&cell) {
                Some(&prior_region) => {
                    return Err(RegionOverlapError {
                        cell,
                        regions: (prior_region, assignment.region),
                    });
                }
                None => {
                    owner.insert(cell, assignment.region);
                }
            }
        }
    }
    Ok(())
}

/// The corresponding runtime guard for **external** writes (ST-122's own
/// acceptance text: "an external write (force/release/vpi_put_value) never
/// performs a direct disjoint-range write, only a serialized
/// `ForceObserved`/`on_force_observed` path"). `driver::force_driver`/
/// `release_driver` (Task 14, retrofitted by this task) each call every
/// listener registered via [`StateEngine::register_force_observed`], so a
/// future partitioning/independence assumption can invalidate itself the
/// instant an external `force`/`vpi_put_value` touches a region-crossing
/// net, rather than trusting a now-stale static classification (ARCHITECTURE
/// §4/§9).
pub trait ForceObserved {
    fn on_force_observed(&mut self, net: StateCellId);
}

impl StateEngine {
    /// Registers `listener` to be notified (in registration order) on every
    /// future `force`/`release` (`driver::force_driver`/`release_driver`).
    /// No upper bound on how many listeners can be registered (unlike
    /// `Dispatch`'s fixed 3-role table, ST-074) — `force`/`release` are rare,
    /// external, out-of-band operations, not the per-event hot path ST-074's
    /// no-heap-growth discipline targets.
    pub fn register_force_observed(&mut self, listener: Box<dyn ForceObserved>) {
        self.force_observed.push(listener);
    }

    /// Fans `on_force_observed(net)` out to every registered listener, in
    /// registration order. `pub(crate)`: the only callers are
    /// `driver::force_driver`/`release_driver` — never part of this crate's
    /// public API surface on its own.
    pub(crate) fn notify_force_observed(&mut self, net: StateCellId) {
        for listener in &mut self.force_observed {
            listener.on_force_observed(net);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::state::driver::{
        DriveStrength, DriveStrengthPair, DriverEntry, DriverSet, resolve_net_step,
    };
    use crate::state::instance::TemplateRegistry;
    use crate::state::write::SlotLocation;
    use crate::state::{FourStateValue, NetKind, SignalAccess, SlotClass};
    use std::cell::RefCell;
    use std::rc::Rc;
    use vj_abi::Bit4;

    fn fresh_engine() -> StateEngine {
        StateEngine::new(TemplateRegistry::new())
    }

    /// Allocates a fresh, zero-initialized 1-bit inline cell.
    fn scalar_cell(engine: &mut StateEngine) -> StateCellId {
        // SAFETY: `engine` is a local, single-owned value with no other live
        // borrow of the inline plane, and every test here is single-threaded
        // with no concurrent allocation.
        let word_offset = unsafe { engine.arena.alloc_inline_word() };
        let loc = SlotLocation {
            class: SlotClass::Inline,
            word_offset,
            bit_width: 1,
            elem_word_count: 1,
            row_stride_words: 0,
            num_rows: 0,
        };
        engine.alloc_cell(loc)
    }

    /// A scalar cell pre-seeded (via a real `commit_write`) to drive `bit`.
    fn seeded_scalar_cell(engine: &mut StateEngine, bit: Bit4) -> StateCellId {
        let cell = scalar_cell(engine);
        let (v, u) = bit.to_val_unk();
        let word = (v as u128) | ((u as u128) << 64);
        engine.commit_write(cell, FourStateValue::Narrow(word), &mut ());
        cell
    }

    fn strong_pair() -> DriveStrengthPair {
        DriveStrengthPair {
            s0: DriveStrength::Strong,
            s1: DriveStrength::Strong,
        }
    }

    /// ST-122: 2 cells, each claimed by exactly one, distinct
    /// `RegionAssignment` — no overlap, the check must pass.
    #[test]
    fn disjoint_region_assignments_pass_the_check() {
        let mut engine = fresh_engine();
        let cell_a = scalar_cell(&mut engine);
        let cell_b = scalar_cell(&mut engine);

        let assignments = vec![
            RegionAssignment {
                region: Region::Active,
                cells: HashSet::from([cell_a]),
            },
            RegionAssignment {
                region: Region::Nba,
                cells: HashSet::from([cell_b]),
            },
        ];

        assert_eq!(check_region_disjoint(&engine, &assignments), Ok(()));
    }

    /// ST-122: the same `StateCellId` present in both `RegionAssignment`s'
    /// `cells` sets must fail, naming exactly the two conflicting `Region`s
    /// (not a generic "overlap detected" with no diagnosis) — a check that
    /// merely returned `Err(())`, or that silently picked one region and
    /// ignored the conflict, would fail this test's field assertions below.
    #[test]
    fn a_cell_claimed_by_two_regions_fails_with_the_offending_cell_named() {
        let mut engine = fresh_engine();
        let shared_cell = scalar_cell(&mut engine);
        let solo_cell = scalar_cell(&mut engine);

        let assignments = vec![
            RegionAssignment {
                region: Region::Active,
                cells: HashSet::from([shared_cell, solo_cell]),
            },
            RegionAssignment {
                region: Region::Nba,
                cells: HashSet::from([shared_cell]),
            },
        ];

        let err = check_region_disjoint(&engine, &assignments)
            .expect_err("a cell present in two RegionAssignments must be rejected");
        assert_eq!(err.cell, shared_cell);
        assert_eq!(err.regions, (Region::Active, Region::Nba));
    }

    /// ST-122's external-write half: register 2 `ForceObserved` spies on
    /// `StateEngine`; call `driver::force_driver` (Task 14) on some net;
    /// assert both spies recorded exactly one `on_force_observed(net)` call.
    /// Fails if the external write path bypasses the notification (the
    /// silent independence-assumption-staleness bug ARCHITECTURE §9 flags).
    #[test]
    fn force_driver_notifies_every_registered_force_observed_listener() {
        struct Spy {
            calls: Rc<RefCell<Vec<StateCellId>>>,
        }
        impl ForceObserved for Spy {
            fn on_force_observed(&mut self, net: StateCellId) {
                self.calls.borrow_mut().push(net);
            }
        }

        let mut engine = fresh_engine();
        let resolved = scalar_cell(&mut engine);
        let mut set = DriverSet {
            resolved,
            drivers: vec![],
            net_kind: NetKind::Wire,
        };

        let spy1_calls = Rc::new(RefCell::new(Vec::new()));
        let spy2_calls = Rc::new(RefCell::new(Vec::new()));
        engine.register_force_observed(Box::new(Spy {
            calls: spy1_calls.clone(),
        }));
        engine.register_force_observed(Box::new(Spy {
            calls: spy2_calls.clone(),
        }));

        let (v, u) = Bit4::One.to_val_unk();
        let one = FourStateValue::Narrow((v as u128) | ((u as u128) << 64));
        crate::state::driver::force_driver(&mut engine, &mut set, resolved, one);

        assert_eq!(*spy1_calls.borrow(), vec![resolved]);
        assert_eq!(*spy2_calls.borrow(), vec![resolved]);
    }

    /// ST-033: write a value via `driver::resolve_net_step` (Task 14, an
    /// ordinary `Wire` `DriverSet`, not `UserDefined`), read it back both via
    /// `SignalAccess::read` (Task 9) and via `Arena` field access directly
    /// (test-only, since it's the same crate); assert bit-identical and,
    /// more importantly, assert mutating through one path (a second
    /// `commit_write`) is immediately visible through the other with no
    /// manual "sync" step — the structural absence of a sync step is what
    /// "single authoritative location" means operationally.
    #[test]
    fn no_second_authoritative_copy_exists_for_a_cell_read_through_two_apis() {
        let mut engine = fresh_engine();
        let resolved = scalar_cell(&mut engine);
        let driver_cell = seeded_scalar_cell(&mut engine, Bit4::One);
        let set = DriverSet {
            resolved,
            drivers: vec![DriverEntry {
                source: driver_cell,
                strength: strong_pair(),
                forced: false,
            }],
            net_kind: NetKind::Wire,
        };
        resolve_net_step(&mut engine, &set, &mut ()).unwrap();

        let word_offset = engine.cells[resolved.0 as usize].word_offset;

        let via_signal_access = match engine.read(resolved) {
            FourStateValue::Narrow(w) => w,
            FourStateValue::Wide { .. } => panic!("expected Narrow"),
        };
        let via_arena_field = engine.arena.inline()[word_offset as usize];
        assert_eq!(
            via_signal_access, via_arena_field,
            "both read APIs must observe the exact same storage"
        );

        // Mutate through a second, independent `commit_write` call and prove
        // both read paths reflect it immediately, with no manual sync step.
        let (v, u) = Bit4::Zero.to_val_unk();
        let new_word = (v as u128) | ((u as u128) << 64);
        engine.commit_write(resolved, FourStateValue::Narrow(new_word), &mut ());

        let via_signal_access_after = match engine.read(resolved) {
            FourStateValue::Narrow(w) => w,
            FourStateValue::Wide { .. } => panic!("expected Narrow"),
        };
        let via_arena_field_after = engine.arena.inline()[word_offset as usize];
        assert_eq!(via_signal_access_after, new_word);
        assert_eq!(via_arena_field_after, new_word);
        assert_eq!(via_signal_access_after, via_arena_field_after);
    }
}