Skip to main content

vj_sim/analysis/
dpi.rs

1//! Task 9 (NL-103): fail-closed DPI/VPI call tagging (netlist-analysis.md
2//! Decision 6/Decision 7/§E) and the Pure/DeferredSafe/Barrier(/
3//! SpeculationUnsafe) ratio harness ARCHITECTURE §9's residual-risk item
4//! (netlist-analysis.md RISKS #5) demands: "the ratio ... must be measured
5//! on representative DPI-heavy testbenches ... to catch cases where nearly
6//! everything is marked Barrier, tracked as a regression-monitored metric".
7//!
8//! **Deviation from the plan's literal "run once per call site found by
9//! `ReadSetLattice`'s ... `DfaEventKind::Call` events" wording, for the
10//! TOP-LEVEL call-site enumeration only.** A custom `sv_lang::dataflow::
11//! Lattice`'s `transfer(&mut self, event: DfaEvent<'_>)` is a trait method
12//! with an INPUT lifetime elided at the TRAIT's own definition — `rustc`
13//! rejects narrowing it to the implementing type's own lifetime parameter
14//! (`error[E0308]: method not compatible with trait`, tried and confirmed
15//! empirically this task), so a `Lattice` impl can only ever *use* a
16//! `Symbol`/`Expression` from an event for the duration of that one
17//! `transfer` call, never store it in `Self` — exactly why `netlist::
18//! lattice::ReadSetLattice` (Task 7) stores only the owned, context-free
19//! `crate::symid::SymbolId`, never `Symbol<'d>` itself (see that module's own
20//! doc comment). Top-level call-SITE enumeration (`collect_call_tags`)
21//! therefore uses `sv_lang::Analysis::procedures(scope).call_expressions()`
22//! (`slang::analysis::AnalyzedProcedure::getCallExpressions`) instead of a
23//! dataflow `Call` event: the SAME `Analysis` object Task 8 already
24//! established (`analysis::graph::build_driver_graph`) already
25//! pre-computes exactly this list per procedure, with a real,
26//! non-ephemeral `Expression<'d>` lifetime — never a second,
27//! independently-reasoned traversal, and never the ephemeral-lifetime trap
28//! dataflow events carry.
29//!
30//! **Deviation, documented (§E step 3's recursion is depth-1 only, not
31//! depth-`RECURSION_DEPTH_BUDGET`).** §E step 3 wants to recurse into a
32//! resolvable callee's OWN nested calls. Two mechanisms were tried and
33//! confirmed NOT to give a way to do this against `sv-lang` 0.1.9's public
34//! API: (a) a dataflow `Lattice`'s `Call` event cannot yield a `Symbol`
35//! that survives past its own `transfer` call (see above) — only an owned
36//! `crate::symid::SymbolId`, which `sv-lang` publishes no way to resolve back
37//! into a `Symbol`; (b) `Analysis::procedures(scope)` does NOT expose an
38//! arbitrary called `Subroutine` as its own independently-queryable
39//! `AnalyzedProcedure` unless that subroutine is a direct, itself-analyzed
40//! member of `scope` — confirmed empirically this task: for `module m;
41//! function pure_add(...); ... endfunction always_ff @(posedge clk)
42//! local_sum = pure_add(a,b); endmodule`, `slang_analysis.procedures
43//! (m_body)` returns only the `always_ff` block's own `AnalyzedProcedure`,
44//! never a second entry for `pure_add` (even though `pure_add` is a direct
45//! member of `m`'s scope) — `Analysis` only independently analyzes a
46//! subroutine as its own top-level procedure when it is itself a
47//! `scope`-level analysis target (e.g. a package function), not when it is
48//! merely called from another analyzed procedure. Consequence: this task's
49//! `classify_by_recursing_into_body` proves `Pure` for a callee with an
50//! EMPTY write-set (outside its own locals) and NO further calls of ANY
51//! kind at all (`DfaEventKind::Call` count == 0, via the dataflow pass
52//! that already computes the write-set) — a callee that itself makes ANY
53//! further call, resolvable or not, conservatively classifies `Barrier`
54//! rather than attempting to recurse into it. This is still a SOUND,
55//! fail-closed answer (Decision 6's core invariant — never wrongly `Pure`
56//! — holds exactly as before: a truly pure callee with no further calls
57//! still comes back `Pure`; anything else conservatively comes back
58//! `Barrier`, which is always a safe over-approximation), just not the
59//! maximally-precise depth-`RECURSION_DEPTH_BUDGET` proof the plan
60//! describes. `depth_budget`/`RECURSION_DEPTH_BUDGET` are still threaded
61//! and enforced at the top level (§E step 4) so a future wave that adds a
62//! `SymbolId -> Symbol` resolution table (this crate's `dedup::
63//! DefinitionTable` is a precedent for building exactly that kind of
64//! table) can re-enable genuine multi-level recursion on the SAME
65//! plumbing without another signature change.
66//!
67//! `Analysis::procedures(scope)` also reports module-scope `Subroutine`
68//! members alongside `ProceduralBlock`/`ContinuousAssign` members; this
69//! module never enumerates a `Subroutine`'s OWN top-level call sites
70//! (skipped by [`collect_call_tags`]) since its calls are only ever
71//! visited as part of classifying whichever call site invokes it (§E step
72//! 3) — a `Subroutine`'s internal calls are never independently
73//! ratio-tracked, which would double-count them against the calling
74//! site's own classification.
75
76use crate::netlist::{Netlist, TemplateId};
77use crate::symid::SymbolUid;
78
79use super::DpiVpiTag;
80
81/// One aggregate metric per test corpus: the regression-monitored
82/// Pure/DeferredSafe vs Barrier(/SpeculationUnsafe) ratio NL-103 requires.
83/// Not part of [`super::StaticAnalysisView`] (that trait is per-call-site
84/// `dpi_tag`); this is a harness-facing aggregation a corpus runner (out of
85/// this wave — the `harness` track's own job, per the plan) calls once per
86/// test corpus.
87#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
88pub struct DpiTagStats {
89    pub pure: u32,
90    pub deferred_safe: u32,
91    pub barrier: u32,
92    pub speculation_unsafe: u32,
93}
94
95impl DpiTagStats {
96    /// The total number of call sites tagged.
97    pub fn total(&self) -> u32 {
98        self.pure + self.deferred_safe + self.barrier + self.speculation_unsafe
99    }
100
101    /// Barrier as a fraction of `total()`; `0.0` if `total() == 0` (no call
102    /// sites at all — never a division-by-zero panic).
103    pub fn barrier_ratio(&self) -> f64 {
104        let total = self.total();
105        if total == 0 {
106            0.0
107        } else {
108            f64::from(self.barrier) / f64::from(total)
109        }
110    }
111}
112
113/// NL-103's own ACC, made concrete: the regression-monitored ratio metric
114/// over every call site `analyze()` tagged.
115pub fn dpi_tag_stats(result: &super::StaticAnalysisResult) -> DpiTagStats {
116    let mut stats = DpiTagStats::default();
117    for &tag in &result.call_tags {
118        match tag {
119            DpiVpiTag::Pure => stats.pure += 1,
120            DpiVpiTag::DeferredSafe => stats.deferred_safe += 1,
121            DpiVpiTag::Barrier => stats.barrier += 1,
122            DpiVpiTag::SpeculationUnsafe => stats.speculation_unsafe += 1,
123        }
124    }
125    stats
126}
127
128/// Task 9's own pinned recursion-depth budget (netlist-analysis.md OPEN
129/// QUESTIONS #2, left unresolved there — resolved here as this task's own
130/// scoped decision, per the plan's Deviation #4): how many further levels
131/// of callee-body recursion [`classify_call_site`] is allowed to descend
132/// into before defaulting to `Barrier` (§E step 4). See this module's own
133/// doc comment for why the current implementation never actually reaches
134/// depth > 1 in practice.
135const RECURSION_DEPTH_BUDGET: u32 = 8;
136
137/// Enumerates every DPI/VPI call site made directly by a process
138/// (`ProceduralBlock`/`ContinuousAssign`) in the design — never a
139/// `Subroutine`'s own internal calls, which are visited recursively as
140/// part of classifying whichever call site invokes it, never independently
141/// ratio-tracked (see this module's own doc comment) — and classifies each
142/// (§E). One entry per `Template`, against its canonical representative
143/// instance (the SAME restriction `graph::build_driver_graph` documents:
144/// `sv_lang::Analysis` only has real procedure data against one
145/// representative instance per deduped `Template`), in `Analysis::
146/// procedures()`'s own declaration order, never fanned out per instance —
147/// `CallSiteId` values are therefore per-`Template`, not per-
148/// `InstantiatedSignalId`; NL-103's own ACC is a corpus-level ratio metric,
149/// not a per-instance identity, so this is a deliberate, documented
150/// simplification, not a gap.
151pub(crate) fn collect_call_tags<'d>(
152    netlist: &Netlist<'d>,
153    slang_analysis: &sv_lang::Analysis<'d>,
154    eval: &sv_lang::EvalSession<'d>,
155) -> Vec<DpiVpiTag> {
156    let mut tags = Vec::new();
157
158    for template_index in 0..netlist.templates.len() {
159        let tmpl_id = TemplateId(template_index as u32);
160        let Some(rep_instance) =
161            super::graph::representative_instance_of(netlist, slang_analysis, tmpl_id)
162        else {
163            continue;
164        };
165        let Some(body) = netlist.instance_symbol(rep_instance).instance_body() else {
166            continue;
167        };
168
169        for proc in slang_analysis.procedures(body) {
170            if proc.symbol().kind() == sv_lang::kinds::SymbolKind::Subroutine {
171                continue;
172            }
173            for call_expr in proc.call_expressions() {
174                tags.push(classify_call_expression(call_expr, eval));
175            }
176        }
177    }
178
179    tags
180}
181
182/// Classifies one call-expression node: resolves its callee (`Expression::
183/// call_subroutine`) and hands off to [`classify_call_site`], or tags it
184/// `Barrier` directly if `sv-lang` cannot resolve a concrete callee
185/// `Symbol` at all (a system call like `$display`, or a call this pass
186/// cannot statically resolve, e.g. a virtual method call through a class
187/// handle — netlist-analysis.md §E step 2's "chandle/virtual/unresolvable"
188/// case) — Decision 6's explicit fail-closed default, never
189/// `Pure`/`DeferredSafe`.
190fn classify_call_expression<'d>(
191    expr: sv_lang::Expression<'d>,
192    eval: &sv_lang::EvalSession<'d>,
193) -> DpiVpiTag {
194    // An interface `extern` method runs module-side code the call's own stub
195    // does not show (LRM 25.7): fail closed.
196    if crate::netlist::itf::call_extern_targets(&expr).is_some() {
197        return DpiVpiTag::Barrier;
198    }
199    match expr.call_subroutine() {
200        Some(callee) => classify_call_site(callee, eval, RECURSION_DEPTH_BUDGET),
201        None => DpiVpiTag::Barrier,
202    }
203}
204
205/// True if `sub` is exported to C via an `export "DPI-C"` directive
206/// anywhere in the design (`Design::dpi_exports()`'s reverse index, §E
207/// step 1): a DPI-exported blocking task/function can suspend/resume
208/// across simulation time, which is unsound to ever speculate past.
209fn is_dpi_exported<'d>(sub: sv_lang::Symbol<'d>, eval: &sv_lang::EvalSession<'d>) -> bool {
210    eval.design()
211        .dpi_exports()
212        .any(|exp| exp.subroutine() == sub)
213}
214
215/// The classifier itself (Decision 6/§E): `call` is the already-resolved
216/// callee subroutine `Symbol` — the caller ([`classify_call_expression`]/
217/// this function's own recursive step) is the one place an unresolvable
218/// callee is handled, defaulting straight to `Barrier` WITHOUT ever
219/// reaching this function, so `Pure`/`DeferredSafe` here are only ever
220/// produced through an explicit, provable success path — never a
221/// wildcard/default arm (this task's own acceptance check).
222///
223/// **Deviation, documented (no caller write-set threaded through):** the
224/// plan's own signature for this function carries no caller-context
225/// parameter, so `DeferredSafe` ("writes only into things already inside
226/// the CALLER's own write-set") is not reachable by this pinned-wave
227/// implementation — any write the callee performs outside its own
228/// locals/return falls through to `Barrier` instead, which is still a
229/// sound, fail-closed answer (never wrongly promoted to `Pure`), just not
230/// the maximally-precise one `DeferredSafe` would be. A future wave that
231/// wants real `DeferredSafe` classification needs to widen this
232/// function's signature — recorded here rather than silently stubbed.
233fn classify_call_site<'d>(
234    call: sv_lang::Symbol<'d>,
235    eval: &sv_lang::EvalSession<'d>,
236    depth_budget: u32,
237) -> DpiVpiTag {
238    // §E step 1.
239    if is_dpi_exported(call, eval) {
240        return DpiVpiTag::SpeculationUnsafe;
241    }
242    // §E step 2: a DPI import has no `sv-lang`-visible body to recurse
243    // into — the decision record's explicit default, never Pure/
244    // DeferredSafe. `context`/plain DPI imports are not further
245    // distinguished in v1 (Decision 7: the `SpeculationUnsafe` split is
246    // inert for a v1 conservative engine, which treats both as a full
247    // barrier already).
248    if call
249        .subroutine_flags()
250        .contains(sv_lang::MethodFlags::DPI_IMPORT)
251    {
252        return DpiVpiTag::Barrier;
253    }
254    // §E step 4: recursion depth exceeded — never silently promoted to
255    // Pure/DeferredSafe.
256    if depth_budget == 0 {
257        return DpiVpiTag::Barrier;
258    }
259
260    classify_by_recursing_into_body(call, eval)
261}
262
263/// A per-callee-body dataflow pass (§E step 3): `sv_lang::dataflow::
264/// run_dataflow` accepts a `Subroutine` symbol directly as its `procedure`
265/// argument (`dataflow.rs`'s own doc: "an always/initial/final block, a
266/// subroutine, or a continuous assign"), so `call` itself is run through
267/// the SAME CFG-walking machinery Task 7's `ReadSetLattice` uses. Records
268/// only the owned, context-free `crate::symid::SymbolId` of each write target
269/// (never a live `Symbol<'d>` — see this module's own doc comment on why a
270/// custom `Lattice` cannot store one across `transfer` calls) plus whether
271/// ANY `Call` event occurred at all (see this module's own doc comment on
272/// why a nested call's identity cannot be recovered for genuine
273/// recursion).
274#[derive(Clone, Default)]
275struct WriteCallLattice {
276    writes: Vec<crate::symid::SymbolId>,
277    has_further_call: bool,
278}
279
280/// Every write/call event of the analysis run in progress, across ALL paths.
281///
282/// The purity test is a "may" question (does ANY path write outside the callee or call
283/// something?), so it cannot be read from the forward analysis's exit state: a `return`
284/// makes the remainder of its path unreachable, so the exit state of a function whose paths
285/// end in `return` no longer carries the events those paths saw, and every such function
286/// used to classify `Pure` (a fail-closed violation). `transfer` also records each event
287/// here; [`classify_by_recursing_into_body`] owns the accumulator for exactly one
288/// `run_dataflow` call (the analysis never re-enters itself on the same thread).
289#[derive(Default)]
290struct WriteCallAccum {
291    writes: Vec<crate::symid::SymbolId>,
292    has_further_call: bool,
293}
294
295thread_local! {
296    static WRITE_CALL_ACCUM: std::cell::RefCell<Option<WriteCallAccum>> =
297        const { std::cell::RefCell::new(None) };
298}
299
300impl sv_lang::dataflow::Lattice for WriteCallLattice {
301    fn top() -> Self {
302        Self::default()
303    }
304
305    fn join(&mut self, other: &Self) {
306        self.writes.extend(other.writes.iter().cloned());
307        self.has_further_call |= other.has_further_call;
308    }
309
310    fn transfer(&mut self, ev: sv_lang::dataflow::DfaEvent<'_>) {
311        use sv_lang::dataflow::DfaEventKind;
312        match ev.kind {
313            DfaEventKind::Write => {
314                if let Some(s) = ev.symbol {
315                    self.writes.push(s.uid());
316                    WRITE_CALL_ACCUM.with(|a| {
317                        if let Some(acc) = a.borrow_mut().as_mut() {
318                            acc.writes.push(s.uid());
319                        }
320                    });
321                }
322            }
323            DfaEventKind::Call => {
324                self.has_further_call = true;
325                WRITE_CALL_ACCUM.with(|a| {
326                    if let Some(acc) = a.borrow_mut().as_mut() {
327                        acc.has_further_call = true;
328                    }
329                });
330            }
331            DfaEventKind::Read => {
332                // Not relevant to §E's purity test (reads never disqualify
333                // Pure — only an external write or a further call does).
334            }
335        }
336    }
337}
338
339/// §E step 3's real body: `call`'s own write-set (excluding writes to its
340/// own locals/formals/return-value variable — `call.members()` plus
341/// `call.subroutine_return_val_var()` defensively, since a `Subroutine` is
342/// itself a `Scope`) must be empty, AND `call` must make no further call of
343/// ANY kind (see this module's own doc comment on why a further call
344/// cannot be recursively proven `Pure` rather than conservatively folded
345/// to `Barrier` in this pinned wave) — otherwise `Barrier` (see
346/// [`classify_call_site`]'s own doc comment on why `DeferredSafe` is not
347/// reachable here).
348fn classify_by_recursing_into_body<'d>(
349    call: sv_lang::Symbol<'d>,
350    eval: &sv_lang::EvalSession<'d>,
351) -> DpiVpiTag {
352    WRITE_CALL_ACCUM.with(|a| *a.borrow_mut() = Some(WriteCallAccum::default()));
353    let run = eval.run_dataflow::<WriteCallLattice>(call);
354    let state = WRITE_CALL_ACCUM
355        .with(|a| a.borrow_mut().take())
356        .unwrap_or_default();
357    if run.is_err() {
358        // Not analyzable at all (e.g. a synthesized stub for an
359        // unimplemented pure virtual method) — fail closed, never a panic.
360        return DpiVpiTag::Barrier;
361    }
362
363    if state.has_further_call {
364        return DpiVpiTag::Barrier;
365    }
366
367    let local_ids: std::collections::HashSet<crate::symid::SymbolId> = call
368        .members()
369        .map(|m| m.uid())
370        .chain(call.subroutine_return_val_var().map(|v| v.uid()))
371        .collect();
372    let writes_only_own_locals = state.writes.iter().all(|w| local_ids.contains(w));
373    if !writes_only_own_locals {
374        return DpiVpiTag::Barrier;
375    }
376
377    DpiVpiTag::Pure
378}