vj-sim 0.1.0

Netlist, scheduler and process model of the vj SystemVerilog simulator.
Documentation
//! Task 9 (NL-103): fail-closed DPI/VPI call tagging (netlist-analysis.md
//! Decision 6/Decision 7/§E) and the Pure/DeferredSafe/Barrier(/
//! SpeculationUnsafe) ratio harness ARCHITECTURE §9's residual-risk item
//! (netlist-analysis.md RISKS #5) demands: "the ratio ... must be measured
//! on representative DPI-heavy testbenches ... to catch cases where nearly
//! everything is marked Barrier, tracked as a regression-monitored metric".
//!
//! **Deviation from the plan's literal "run once per call site found by
//! `ReadSetLattice`'s ... `DfaEventKind::Call` events" wording, for the
//! TOP-LEVEL call-site enumeration only.** A custom `sv_lang::dataflow::
//! Lattice`'s `transfer(&mut self, event: DfaEvent<'_>)` is a trait method
//! with an INPUT lifetime elided at the TRAIT's own definition — `rustc`
//! rejects narrowing it to the implementing type's own lifetime parameter
//! (`error[E0308]: method not compatible with trait`, tried and confirmed
//! empirically this task), so a `Lattice` impl can only ever *use* a
//! `Symbol`/`Expression` from an event for the duration of that one
//! `transfer` call, never store it in `Self` — exactly why `netlist::
//! lattice::ReadSetLattice` (Task 7) stores only the owned, context-free
//! `crate::symid::SymbolId`, never `Symbol<'d>` itself (see that module's own
//! doc comment). Top-level call-SITE enumeration (`collect_call_tags`)
//! therefore uses `sv_lang::Analysis::procedures(scope).call_expressions()`
//! (`slang::analysis::AnalyzedProcedure::getCallExpressions`) instead of a
//! dataflow `Call` event: the SAME `Analysis` object Task 8 already
//! established (`analysis::graph::build_driver_graph`) already
//! pre-computes exactly this list per procedure, with a real,
//! non-ephemeral `Expression<'d>` lifetime — never a second,
//! independently-reasoned traversal, and never the ephemeral-lifetime trap
//! dataflow events carry.
//!
//! **Deviation, documented (§E step 3's recursion is depth-1 only, not
//! depth-`RECURSION_DEPTH_BUDGET`).** §E step 3 wants to recurse into a
//! resolvable callee's OWN nested calls. Two mechanisms were tried and
//! confirmed NOT to give a way to do this against `sv-lang` 0.1.9's public
//! API: (a) a dataflow `Lattice`'s `Call` event cannot yield a `Symbol`
//! that survives past its own `transfer` call (see above) — only an owned
//! `crate::symid::SymbolId`, which `sv-lang` publishes no way to resolve back
//! into a `Symbol`; (b) `Analysis::procedures(scope)` does NOT expose an
//! arbitrary called `Subroutine` as its own independently-queryable
//! `AnalyzedProcedure` unless that subroutine is a direct, itself-analyzed
//! member of `scope` — confirmed empirically this task: for `module m;
//! function pure_add(...); ... endfunction always_ff @(posedge clk)
//! local_sum = pure_add(a,b); endmodule`, `slang_analysis.procedures
//! (m_body)` returns only the `always_ff` block's own `AnalyzedProcedure`,
//! never a second entry for `pure_add` (even though `pure_add` is a direct
//! member of `m`'s scope) — `Analysis` only independently analyzes a
//! subroutine as its own top-level procedure when it is itself a
//! `scope`-level analysis target (e.g. a package function), not when it is
//! merely called from another analyzed procedure. Consequence: this task's
//! `classify_by_recursing_into_body` proves `Pure` for a callee with an
//! EMPTY write-set (outside its own locals) and NO further calls of ANY
//! kind at all (`DfaEventKind::Call` count == 0, via the dataflow pass
//! that already computes the write-set) — a callee that itself makes ANY
//! further call, resolvable or not, conservatively classifies `Barrier`
//! rather than attempting to recurse into it. This is still a SOUND,
//! fail-closed answer (Decision 6's core invariant — never wrongly `Pure`
//! — holds exactly as before: a truly pure callee with no further calls
//! still comes back `Pure`; anything else conservatively comes back
//! `Barrier`, which is always a safe over-approximation), just not the
//! maximally-precise depth-`RECURSION_DEPTH_BUDGET` proof the plan
//! describes. `depth_budget`/`RECURSION_DEPTH_BUDGET` are still threaded
//! and enforced at the top level (§E step 4) so a future wave that adds a
//! `SymbolId -> Symbol` resolution table (this crate's `dedup::
//! DefinitionTable` is a precedent for building exactly that kind of
//! table) can re-enable genuine multi-level recursion on the SAME
//! plumbing without another signature change.
//!
//! `Analysis::procedures(scope)` also reports module-scope `Subroutine`
//! members alongside `ProceduralBlock`/`ContinuousAssign` members; this
//! module never enumerates a `Subroutine`'s OWN top-level call sites
//! (skipped by [`collect_call_tags`]) since its calls are only ever
//! visited as part of classifying whichever call site invokes it (§E step
//! 3) — a `Subroutine`'s internal calls are never independently
//! ratio-tracked, which would double-count them against the calling
//! site's own classification.

use crate::netlist::{Netlist, TemplateId};
use crate::symid::SymbolUid;

use super::DpiVpiTag;

/// One aggregate metric per test corpus: the regression-monitored
/// Pure/DeferredSafe vs Barrier(/SpeculationUnsafe) ratio NL-103 requires.
/// Not part of [`super::StaticAnalysisView`] (that trait is per-call-site
/// `dpi_tag`); this is a harness-facing aggregation a corpus runner (out of
/// this wave — the `harness` track's own job, per the plan) calls once per
/// test corpus.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct DpiTagStats {
    pub pure: u32,
    pub deferred_safe: u32,
    pub barrier: u32,
    pub speculation_unsafe: u32,
}

impl DpiTagStats {
    /// The total number of call sites tagged.
    pub fn total(&self) -> u32 {
        self.pure + self.deferred_safe + self.barrier + self.speculation_unsafe
    }

    /// Barrier as a fraction of `total()`; `0.0` if `total() == 0` (no call
    /// sites at all — never a division-by-zero panic).
    pub fn barrier_ratio(&self) -> f64 {
        let total = self.total();
        if total == 0 {
            0.0
        } else {
            f64::from(self.barrier) / f64::from(total)
        }
    }
}

/// NL-103's own ACC, made concrete: the regression-monitored ratio metric
/// over every call site `analyze()` tagged.
pub fn dpi_tag_stats(result: &super::StaticAnalysisResult) -> DpiTagStats {
    let mut stats = DpiTagStats::default();
    for &tag in &result.call_tags {
        match tag {
            DpiVpiTag::Pure => stats.pure += 1,
            DpiVpiTag::DeferredSafe => stats.deferred_safe += 1,
            DpiVpiTag::Barrier => stats.barrier += 1,
            DpiVpiTag::SpeculationUnsafe => stats.speculation_unsafe += 1,
        }
    }
    stats
}

/// Task 9's own pinned recursion-depth budget (netlist-analysis.md OPEN
/// QUESTIONS #2, left unresolved there — resolved here as this task's own
/// scoped decision, per the plan's Deviation #4): how many further levels
/// of callee-body recursion [`classify_call_site`] is allowed to descend
/// into before defaulting to `Barrier` (§E step 4). See this module's own
/// doc comment for why the current implementation never actually reaches
/// depth > 1 in practice.
const RECURSION_DEPTH_BUDGET: u32 = 8;

/// Enumerates every DPI/VPI call site made directly by a process
/// (`ProceduralBlock`/`ContinuousAssign`) in the design — never a
/// `Subroutine`'s own internal calls, which are visited recursively as
/// part of classifying whichever call site invokes it, never independently
/// ratio-tracked (see this module's own doc comment) — and classifies each
/// (§E). One entry per `Template`, against its canonical representative
/// instance (the SAME restriction `graph::build_driver_graph` documents:
/// `sv_lang::Analysis` only has real procedure data against one
/// representative instance per deduped `Template`), in `Analysis::
/// procedures()`'s own declaration order, never fanned out per instance —
/// `CallSiteId` values are therefore per-`Template`, not per-
/// `InstantiatedSignalId`; NL-103's own ACC is a corpus-level ratio metric,
/// not a per-instance identity, so this is a deliberate, documented
/// simplification, not a gap.
pub(crate) fn collect_call_tags<'d>(
    netlist: &Netlist<'d>,
    slang_analysis: &sv_lang::Analysis<'d>,
    eval: &sv_lang::EvalSession<'d>,
) -> Vec<DpiVpiTag> {
    let mut tags = Vec::new();

    for template_index in 0..netlist.templates.len() {
        let tmpl_id = TemplateId(template_index as u32);
        let Some(rep_instance) =
            super::graph::representative_instance_of(netlist, slang_analysis, tmpl_id)
        else {
            continue;
        };
        let Some(body) = netlist.instance_symbol(rep_instance).instance_body() else {
            continue;
        };

        for proc in slang_analysis.procedures(body) {
            if proc.symbol().kind() == sv_lang::kinds::SymbolKind::Subroutine {
                continue;
            }
            for call_expr in proc.call_expressions() {
                tags.push(classify_call_expression(call_expr, eval));
            }
        }
    }

    tags
}

/// Classifies one call-expression node: resolves its callee (`Expression::
/// call_subroutine`) and hands off to [`classify_call_site`], or tags it
/// `Barrier` directly if `sv-lang` cannot resolve a concrete callee
/// `Symbol` at all (a system call like `$display`, or a call this pass
/// cannot statically resolve, e.g. a virtual method call through a class
/// handle — netlist-analysis.md §E step 2's "chandle/virtual/unresolvable"
/// case) — Decision 6's explicit fail-closed default, never
/// `Pure`/`DeferredSafe`.
fn classify_call_expression<'d>(
    expr: sv_lang::Expression<'d>,
    eval: &sv_lang::EvalSession<'d>,
) -> DpiVpiTag {
    // An interface `extern` method runs module-side code the call's own stub
    // does not show (LRM 25.7): fail closed.
    if crate::netlist::itf::call_extern_targets(&expr).is_some() {
        return DpiVpiTag::Barrier;
    }
    match expr.call_subroutine() {
        Some(callee) => classify_call_site(callee, eval, RECURSION_DEPTH_BUDGET),
        None => DpiVpiTag::Barrier,
    }
}

/// True if `sub` is exported to C via an `export "DPI-C"` directive
/// anywhere in the design (`Design::dpi_exports()`'s reverse index, §E
/// step 1): a DPI-exported blocking task/function can suspend/resume
/// across simulation time, which is unsound to ever speculate past.
fn is_dpi_exported<'d>(sub: sv_lang::Symbol<'d>, eval: &sv_lang::EvalSession<'d>) -> bool {
    eval.design()
        .dpi_exports()
        .any(|exp| exp.subroutine() == sub)
}

/// The classifier itself (Decision 6/§E): `call` is the already-resolved
/// callee subroutine `Symbol` — the caller ([`classify_call_expression`]/
/// this function's own recursive step) is the one place an unresolvable
/// callee is handled, defaulting straight to `Barrier` WITHOUT ever
/// reaching this function, so `Pure`/`DeferredSafe` here are only ever
/// produced through an explicit, provable success path — never a
/// wildcard/default arm (this task's own acceptance check).
///
/// **Deviation, documented (no caller write-set threaded through):** the
/// plan's own signature for this function carries no caller-context
/// parameter, so `DeferredSafe` ("writes only into things already inside
/// the CALLER's own write-set") is not reachable by this pinned-wave
/// implementation — any write the callee performs outside its own
/// locals/return falls through to `Barrier` instead, which is still a
/// sound, fail-closed answer (never wrongly promoted to `Pure`), just not
/// the maximally-precise one `DeferredSafe` would be. A future wave that
/// wants real `DeferredSafe` classification needs to widen this
/// function's signature — recorded here rather than silently stubbed.
fn classify_call_site<'d>(
    call: sv_lang::Symbol<'d>,
    eval: &sv_lang::EvalSession<'d>,
    depth_budget: u32,
) -> DpiVpiTag {
    // §E step 1.
    if is_dpi_exported(call, eval) {
        return DpiVpiTag::SpeculationUnsafe;
    }
    // §E step 2: a DPI import has no `sv-lang`-visible body to recurse
    // into — the decision record's explicit default, never Pure/
    // DeferredSafe. `context`/plain DPI imports are not further
    // distinguished in v1 (Decision 7: the `SpeculationUnsafe` split is
    // inert for a v1 conservative engine, which treats both as a full
    // barrier already).
    if call
        .subroutine_flags()
        .contains(sv_lang::MethodFlags::DPI_IMPORT)
    {
        return DpiVpiTag::Barrier;
    }
    // §E step 4: recursion depth exceeded — never silently promoted to
    // Pure/DeferredSafe.
    if depth_budget == 0 {
        return DpiVpiTag::Barrier;
    }

    classify_by_recursing_into_body(call, eval)
}

/// A per-callee-body dataflow pass (§E step 3): `sv_lang::dataflow::
/// run_dataflow` accepts a `Subroutine` symbol directly as its `procedure`
/// argument (`dataflow.rs`'s own doc: "an always/initial/final block, a
/// subroutine, or a continuous assign"), so `call` itself is run through
/// the SAME CFG-walking machinery Task 7's `ReadSetLattice` uses. Records
/// only the owned, context-free `crate::symid::SymbolId` of each write target
/// (never a live `Symbol<'d>` — see this module's own doc comment on why a
/// custom `Lattice` cannot store one across `transfer` calls) plus whether
/// ANY `Call` event occurred at all (see this module's own doc comment on
/// why a nested call's identity cannot be recovered for genuine
/// recursion).
#[derive(Clone, Default)]
struct WriteCallLattice {
    writes: Vec<crate::symid::SymbolId>,
    has_further_call: bool,
}

/// Every write/call event of the analysis run in progress, across ALL paths.
///
/// The purity test is a "may" question (does ANY path write outside the callee or call
/// something?), so it cannot be read from the forward analysis's exit state: a `return`
/// makes the remainder of its path unreachable, so the exit state of a function whose paths
/// end in `return` no longer carries the events those paths saw, and every such function
/// used to classify `Pure` (a fail-closed violation). `transfer` also records each event
/// here; [`classify_by_recursing_into_body`] owns the accumulator for exactly one
/// `run_dataflow` call (the analysis never re-enters itself on the same thread).
#[derive(Default)]
struct WriteCallAccum {
    writes: Vec<crate::symid::SymbolId>,
    has_further_call: bool,
}

thread_local! {
    static WRITE_CALL_ACCUM: std::cell::RefCell<Option<WriteCallAccum>> =
        const { std::cell::RefCell::new(None) };
}

impl sv_lang::dataflow::Lattice for WriteCallLattice {
    fn top() -> Self {
        Self::default()
    }

    fn join(&mut self, other: &Self) {
        self.writes.extend(other.writes.iter().cloned());
        self.has_further_call |= other.has_further_call;
    }

    fn transfer(&mut self, ev: sv_lang::dataflow::DfaEvent<'_>) {
        use sv_lang::dataflow::DfaEventKind;
        match ev.kind {
            DfaEventKind::Write => {
                if let Some(s) = ev.symbol {
                    self.writes.push(s.uid());
                    WRITE_CALL_ACCUM.with(|a| {
                        if let Some(acc) = a.borrow_mut().as_mut() {
                            acc.writes.push(s.uid());
                        }
                    });
                }
            }
            DfaEventKind::Call => {
                self.has_further_call = true;
                WRITE_CALL_ACCUM.with(|a| {
                    if let Some(acc) = a.borrow_mut().as_mut() {
                        acc.has_further_call = true;
                    }
                });
            }
            DfaEventKind::Read => {
                // Not relevant to §E's purity test (reads never disqualify
                // Pure — only an external write or a further call does).
            }
        }
    }
}

/// §E step 3's real body: `call`'s own write-set (excluding writes to its
/// own locals/formals/return-value variable — `call.members()` plus
/// `call.subroutine_return_val_var()` defensively, since a `Subroutine` is
/// itself a `Scope`) must be empty, AND `call` must make no further call of
/// ANY kind (see this module's own doc comment on why a further call
/// cannot be recursively proven `Pure` rather than conservatively folded
/// to `Barrier` in this pinned wave) — otherwise `Barrier` (see
/// [`classify_call_site`]'s own doc comment on why `DeferredSafe` is not
/// reachable here).
fn classify_by_recursing_into_body<'d>(
    call: sv_lang::Symbol<'d>,
    eval: &sv_lang::EvalSession<'d>,
) -> DpiVpiTag {
    WRITE_CALL_ACCUM.with(|a| *a.borrow_mut() = Some(WriteCallAccum::default()));
    let run = eval.run_dataflow::<WriteCallLattice>(call);
    let state = WRITE_CALL_ACCUM
        .with(|a| a.borrow_mut().take())
        .unwrap_or_default();
    if run.is_err() {
        // Not analyzable at all (e.g. a synthesized stub for an
        // unimplemented pure virtual method) — fail closed, never a panic.
        return DpiVpiTag::Barrier;
    }

    if state.has_further_call {
        return DpiVpiTag::Barrier;
    }

    let local_ids: std::collections::HashSet<crate::symid::SymbolId> = call
        .members()
        .map(|m| m.uid())
        .chain(call.subroutine_return_val_var().map(|v| v.uid()))
        .collect();
    let writes_only_own_locals = state.writes.iter().all(|w| local_ids.contains(w));
    if !writes_only_own_locals {
        return DpiVpiTag::Barrier;
    }

    DpiVpiTag::Pure
}