vj-sim 0.3.0

Netlist, scheduler and process model of the vj SystemVerilog simulator.
Documentation
//! ANALYSIS (`vj-sim::analysis`) — the WOCB static-analysis pass's
//! hand-off surface (netlist-analysis.md §Key Data Structures —
//! partitioning, CDC edges, DPI/VPI tagging, and the `ForceObserved`
//! runtime-invalidation hook ARCHITECTURE §4/§9 requires), plus (as of
//! Task 8) the real, `sv-lang`-`Analysis`-backed driver graph, WOCB
//! partitioning and clock-domain/CDC-edge inference: `crate::analysis::
//! analyze()`. See `docs/superpowers/plans/2026-09-23-wave-A-netlist.md`
//! Task 8.

#[cfg(feature = "frontend")]
use std::collections::HashMap;

use crate::netlist::InstantiatedSignalId;

// The real elaboration-lowering-backed implementation is frontend-only
// (this crate's `frontend` feature): `graph`/`clock` both name
// `sv_lang::Analysis`/`crate::netlist::Netlist<'d>` (CONTRACTS D4).
#[cfg(feature = "frontend")]
mod clock;
#[cfg(feature = "frontend")]
mod dpi;
#[cfg(feature = "frontend")]
pub(crate) mod graph;
#[cfg(feature = "frontend")]
mod inferred_clock;

#[cfg(feature = "frontend")]
pub use dpi::{DpiTagStats, dpi_tag_stats};
#[cfg(feature = "frontend")]
pub use graph::{DriverGraph, Partition, build_driver_graph, partition};
#[cfg(feature = "frontend")]
pub use inferred_clock::{infer_procedural_clock, infer_structural_clock};

#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub struct RegionId(pub u32);

#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub struct ClockDomainId(pub u32);

#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct CallSiteId(pub u32);

/// A CROSS-region edge, never a merge (netlist-analysis.md §C / ARCHITECTURE §4
/// item 4; ledger NL-059, NL-069, NL-077, NL-078, NL-084, NL-085): a net multiply
/// driven across provisional partitions forces a union instead — only a directed
/// *read* dependency on an already-resolved value from another partition is
/// eligible to stay a `CdcEdge`.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CdcClassification {
    Async,
    SameEdgeDifferentClock,
    Unknown,
}

/// Fail-closed by default: `Unproven` below `cdc_confidence_floor` degrades to a
/// partition merge, never stays an edge (netlist-analysis.md §C).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CdcConfidence {
    StaticallyProven,
    HeuristicIdiom,
    Unproven,
}

#[derive(Clone, Copy, Debug)]
pub struct CdcEdge {
    pub from_region: RegionId,
    pub to_region: RegionId,
    pub signal: InstantiatedSignalId,
    pub classification: CdcClassification,
    pub confidence: CdcConfidence,
}

/// Fail-closed DPI/VPI call tagging (netlist-analysis.md Decision 6/7; ledger
/// CG-105, ST-149, NL-092, NL-095, NL-100, DT-045): unprovable always defaults to
/// `Barrier`, never `Pure`.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum DpiVpiTag {
    Pure,
    DeferredSafe,
    Barrier,
    SpeculationUnsafe,
}

/// The `on_force_observed` runtime-checked escape hatch (ARCHITECTURE §4/§9,
/// netlist-analysis.md §G / DECISION_RECORD_CONFLICTS #1; ledger ST-121, ST-122,
/// NL-106, NL-108, NL-110, DT-047, RT-159): `force`/`release`/`vpi_put_value` on a
/// region-crossing net must call this so the engine can detect and fall back from
/// an invalidated independence assumption instead of trusting a stale one. Not a
/// static proof — a runtime-checked escape hatch, because any net is in general
/// VPI-addressable regardless of what elaboration could prove.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ForceObservedAction {
    NoOp,
    FenceRegion(RegionId),
    FenceCrossRegionWrite,
}

/// The `analyze()` hand-off surface (netlist-analysis.md's `impl
/// StaticAnalysisResult`; ledger NL-005, NL-006, NL-009, NL-053, NL-085,
/// NL-106), reframed as a trait for the same reason as
/// [`crate::netlist::NetlistView`].
pub trait StaticAnalysisView {
    fn partition_of(&self, sig: InstantiatedSignalId) -> RegionId;
    fn cdc_edges_into(&self, region: RegionId) -> Vec<CdcEdge>;
    fn dpi_tag(&self, call: CallSiteId) -> DpiVpiTag;
    /// The `ForceObserved` hook signature.
    fn on_force_observed(&self, sig: InstantiatedSignalId) -> ForceObservedAction;
}

/// Task 8: a real, physical clock signal — `edge`-triggered — and the
/// dense `ClockDomainId` every process whose `StaticSensitivity::Explicit`
/// resolves to the SAME `clock_signal` (by `InstantiatedSignalId`
/// equality, NL-076) shares.
#[derive(Clone, Copy, Debug)]
pub struct ClockDomain {
    pub id: ClockDomainId,
    pub clock_signal: InstantiatedSignalId,
    pub edge: crate::state::EdgeKind,
}

/// Task 8: every `analyze()` failure. Kept even though this task's own
/// implementation never constructs one (every fallible step it performs —
/// driver/read-set resolution — degrades to a documented skip rather than
/// an error, per `analysis::graph`'s own doc comments): the type is part of
/// the plan's binding public signature (`analyze() -> Result<..,
/// AnalysisError>`) and a future wave's DPI/VPI classification pass (Task
/// 9) or dataflow-dependent Implicit-sensitivity derivation is the natural
/// place an `Err` first becomes reachable.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AnalysisError {
    Dataflow(String),
    Unsupported(String),
}

impl std::fmt::Display for AnalysisError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            AnalysisError::Dataflow(msg) => write!(f, "analysis dataflow error: {msg}"),
            AnalysisError::Unsupported(msg) => write!(f, "unsupported construct: {msg}"),
        }
    }
}

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

/// Task 8 (and harness Task 18 / VF-116, whose duplicate definition was folded into
/// this one at the harness merge): the knobs `analyze()` takes. `Default` is the fail-closed
/// configuration ARCHITECTURE §6.5 requires verbatim: a CDC classification
/// below `cdc_confidence_floor` must degrade to a partition merge rather
/// than stay a directed edge, and an unclassifiable DPI/VPI call defaults
/// to the most conservative tag (`Barrier`), never `Pure`.
#[derive(Clone, Copy, Debug)]
pub struct AnalysisOptions {
    pub cdc_confidence_floor: CdcConfidence,
    pub dpi_default: DpiVpiTag,
    pub max_partition_merge_passes: u32,
}

impl Default for AnalysisOptions {
    fn default() -> Self {
        AnalysisOptions {
            cdc_confidence_floor: CdcConfidence::StaticallyProven,
            dpi_default: DpiVpiTag::Barrier,
            max_partition_merge_passes: 64,
        }
    }
}

/// Task 8: the real `analyze()` hand-off value — `partitions`/
/// `clock_domains`/`cdc_edges` populated by the driver graph + WOCB
/// partitioning + clock-domain/CDC-edge inference this task implements;
/// `on_force_observed` stays on its safe placeholder body (out of this
/// wave). Frontend-only (CONTRACTS D4): `partitions: Vec<Partition>` names
/// a type that only exists under this crate's `frontend` feature
/// (`Partition` lives in the `sv_lang::Analysis`-consuming `graph` module).
#[cfg(feature = "frontend")]
#[derive(Debug, Clone)]
pub struct StaticAnalysisResult {
    partitions: Vec<Partition>,
    clock_domains: Vec<ClockDomain>,
    cdc_edges: Vec<CdcEdge>,
    /// `InstantiatedSignalId -> RegionId`, built once in `analyze()` from
    /// `partitions`' own `.nets`/`.processes` content — never a second,
    /// independently-reasoned reverse index.
    partition_index: HashMap<InstantiatedSignalId, RegionId>,
    /// Task 9 (NL-103): every DPI/VPI call site's tag, in `dpi::
    /// collect_call_tags`'s own declaration order — `CallSiteId(i)` names
    /// `call_tags[i]`, never a second, independently-reasoned index.
    call_tags: Vec<DpiVpiTag>,
}

#[cfg(feature = "frontend")]
impl StaticAnalysisResult {
    /// Read-only access to a `Partition` by `RegionId` — `partitions`
    /// itself stays a private field (consistent with `StaticAnalysisView`
    /// being the crate's one query surface for everything else). Panics
    /// only for a `RegionId` this `StaticAnalysisResult`'s own `analyze()`
    /// call never produced — a programmer-error indexing precondition, the
    /// same posture `Netlist::member_symbol` documents.
    pub fn partition(&self, id: RegionId) -> &Partition {
        &self.partitions[id.0 as usize]
    }

    /// Every distinct clock domain this analysis inferred (NL-076) — the
    /// registry `ClockDomain.id` indexes into.
    pub fn clock_domains(&self) -> &[ClockDomain] {
        &self.clock_domains
    }
}

#[cfg(feature = "frontend")]
impl StaticAnalysisView for StaticAnalysisResult {
    fn partition_of(&self, sig: InstantiatedSignalId) -> RegionId {
        self.partition_index[&sig]
    }

    fn cdc_edges_into(&self, region: RegionId) -> Vec<CdcEdge> {
        self.cdc_edges
            .iter()
            .filter(|e| e.to_region == region)
            .copied()
            .collect()
    }

    /// Task 9 (NL-103): the real per-call-site tag `dpi::collect_call_tags`
    /// computed in `analyze()`. Panics only for a `CallSiteId` this
    /// `StaticAnalysisResult`'s own `analyze()` call never produced — the
    /// same programmer-error indexing precondition `partition()` documents.
    fn dpi_tag(&self, call: CallSiteId) -> DpiVpiTag {
        self.call_tags[call.0 as usize]
    }

    /// Out of this wave's 37 IDs (a future wave's runtime-checked escape
    /// hatch): a safe no-op placeholder, never a panic.
    fn on_force_observed(&self, _sig: InstantiatedSignalId) -> ForceObservedAction {
        ForceObservedAction::NoOp
    }
}

/// Task 8 (NL-007, NL-063, NL-076, NL-082): the real static-analysis pass —
/// backfills `SignalDecl.driver_set` onto `netlist` (`graph::
/// populate_driver_kinds`, NL-063's classification clause — the one
/// mutation this whole pipeline performs on an already-elaborated
/// `Netlist`, since `sv_lang::Analysis` does not exist until after
/// `elaborate()` returns), builds the `{net,process}` driver graph
/// (`graph::build_driver_graph`), partitions it (`graph::partition`),
/// classifies every un-unioned cross-domain read edge as a `CdcEdge`
/// (`clock::classify_cdc_edges`), and (Task 9, NL-103) fail-closed-
/// classifies every DPI/VPI call site (`dpi::collect_call_tags`).
///
/// `eval` is accepted per the plan's binding signature (and the call-order
/// this whole pipeline documents: `elaborate()`'s `EvalSession` borrow
/// outlives this call); Task 9 is its first reader —
/// `dpi::collect_call_tags`'s own `EvalSession::run_dataflow` calls
/// recurse into a DPI/VPI call's callee body. `opts.dpi_default` is not
/// itself consulted here: this task's classifier computes the real answer
/// directly rather than short-circuiting to the configured default, which
/// stays reserved for a future consumer that wants to skip classification
/// entirely.
#[cfg(feature = "frontend")]
pub fn analyze<'d>(
    netlist: &mut crate::netlist::Netlist<'d>,
    slang_analysis: &sv_lang::Analysis<'d>,
    eval: &sv_lang::EvalSession<'d>,
    opts: AnalysisOptions,
) -> Result<StaticAnalysisResult, AnalysisError> {
    graph::populate_driver_kinds(netlist, slang_analysis);

    let driver_graph = graph::build_driver_graph(netlist, slang_analysis);
    let partitions = graph::partition(&driver_graph, opts.max_partition_merge_passes);
    let cdc_edges = clock::classify_cdc_edges(&driver_graph, &partitions);
    let call_tags = dpi::collect_call_tags(netlist, slang_analysis, eval);

    let clock_domains = driver_graph
        .clock_domain_registry()
        .iter()
        .enumerate()
        .map(|(i, &(clock_signal, edge))| ClockDomain {
            id: ClockDomainId(i as u32),
            clock_signal,
            edge,
        })
        .collect();

    let mut partition_index = HashMap::new();
    for p in &partitions {
        for &sig in p.nets.iter().chain(p.processes.iter()) {
            partition_index.insert(sig, p.id);
        }
    }

    Ok(StaticAnalysisResult {
        partitions,
        clock_domains,
        cdc_edges,
        partition_index,
        call_tags,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    /// VF-116: independent constants (never re-derived from `Default`'s own
    /// body) so this test actually goes red if a future refactor silently
    /// loosens either fail-closed default -- per the project's own
    /// gates-that-cannot-fail lesson, a test that merely restates the impl
    /// it is checking can never fail. (Implementer's manual check, per the
    /// plan: temporarily flip `Default::default()`'s two fields, confirm
    /// this test goes red, then revert -- done; both flips failed this
    /// test as expected.)
    #[test]
    fn analysis_options_default_is_fail_closed() {
        const EXPECTED_CDC_FLOOR: CdcConfidence = CdcConfidence::StaticallyProven;
        const EXPECTED_DPI_DEFAULT: DpiVpiTag = DpiVpiTag::Barrier;

        let opts = AnalysisOptions::default();
        assert_eq!(opts.cdc_confidence_floor, EXPECTED_CDC_FLOOR);
        assert_eq!(opts.dpi_default, EXPECTED_DPI_DEFAULT);
    }
}