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}