vj_sim/analysis/mod.rs
1//! ANALYSIS (`vj-sim::analysis`) — the WOCB static-analysis pass's
2//! hand-off surface (netlist-analysis.md §Key Data Structures —
3//! partitioning, CDC edges, DPI/VPI tagging, and the `ForceObserved`
4//! runtime-invalidation hook ARCHITECTURE §4/§9 requires), plus (as of
5//! Task 8) the real, `sv-lang`-`Analysis`-backed driver graph, WOCB
6//! partitioning and clock-domain/CDC-edge inference: `crate::analysis::
7//! analyze()`. See `docs/superpowers/plans/2026-09-23-wave-A-netlist.md`
8//! Task 8.
9
10#[cfg(feature = "frontend")]
11use std::collections::HashMap;
12
13use crate::netlist::InstantiatedSignalId;
14
15// The real elaboration-lowering-backed implementation is frontend-only
16// (this crate's `frontend` feature): `graph`/`clock` both name
17// `sv_lang::Analysis`/`crate::netlist::Netlist<'d>` (CONTRACTS D4).
18#[cfg(feature = "frontend")]
19mod clock;
20#[cfg(feature = "frontend")]
21mod dpi;
22#[cfg(feature = "frontend")]
23pub(crate) mod graph;
24#[cfg(feature = "frontend")]
25mod inferred_clock;
26
27#[cfg(feature = "frontend")]
28pub use dpi::{DpiTagStats, dpi_tag_stats};
29#[cfg(feature = "frontend")]
30pub use graph::{DriverGraph, Partition, build_driver_graph, partition};
31#[cfg(feature = "frontend")]
32pub use inferred_clock::{infer_procedural_clock, infer_structural_clock};
33
34#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
35pub struct RegionId(pub u32);
36
37#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
38pub struct ClockDomainId(pub u32);
39
40#[derive(Clone, Copy, PartialEq, Eq, Debug)]
41pub struct CallSiteId(pub u32);
42
43/// A CROSS-region edge, never a merge (netlist-analysis.md §C / ARCHITECTURE §4
44/// item 4; ledger NL-059, NL-069, NL-077, NL-078, NL-084, NL-085): a net multiply
45/// driven across provisional partitions forces a union instead — only a directed
46/// *read* dependency on an already-resolved value from another partition is
47/// eligible to stay a `CdcEdge`.
48#[derive(Clone, Copy, PartialEq, Eq, Debug)]
49pub enum CdcClassification {
50 Async,
51 SameEdgeDifferentClock,
52 Unknown,
53}
54
55/// Fail-closed by default: `Unproven` below `cdc_confidence_floor` degrades to a
56/// partition merge, never stays an edge (netlist-analysis.md §C).
57#[derive(Clone, Copy, PartialEq, Eq, Debug)]
58pub enum CdcConfidence {
59 StaticallyProven,
60 HeuristicIdiom,
61 Unproven,
62}
63
64#[derive(Clone, Copy, Debug)]
65pub struct CdcEdge {
66 pub from_region: RegionId,
67 pub to_region: RegionId,
68 pub signal: InstantiatedSignalId,
69 pub classification: CdcClassification,
70 pub confidence: CdcConfidence,
71}
72
73/// Fail-closed DPI/VPI call tagging (netlist-analysis.md Decision 6/7; ledger
74/// CG-105, ST-149, NL-092, NL-095, NL-100, DT-045): unprovable always defaults to
75/// `Barrier`, never `Pure`.
76#[derive(Clone, Copy, PartialEq, Eq, Debug)]
77pub enum DpiVpiTag {
78 Pure,
79 DeferredSafe,
80 Barrier,
81 SpeculationUnsafe,
82}
83
84/// The `on_force_observed` runtime-checked escape hatch (ARCHITECTURE §4/§9,
85/// netlist-analysis.md §G / DECISION_RECORD_CONFLICTS #1; ledger ST-121, ST-122,
86/// NL-106, NL-108, NL-110, DT-047, RT-159): `force`/`release`/`vpi_put_value` on a
87/// region-crossing net must call this so the engine can detect and fall back from
88/// an invalidated independence assumption instead of trusting a stale one. Not a
89/// static proof — a runtime-checked escape hatch, because any net is in general
90/// VPI-addressable regardless of what elaboration could prove.
91#[derive(Clone, Copy, PartialEq, Eq, Debug)]
92pub enum ForceObservedAction {
93 NoOp,
94 FenceRegion(RegionId),
95 FenceCrossRegionWrite,
96}
97
98/// The `analyze()` hand-off surface (netlist-analysis.md's `impl
99/// StaticAnalysisResult`; ledger NL-005, NL-006, NL-009, NL-053, NL-085,
100/// NL-106), reframed as a trait for the same reason as
101/// [`crate::netlist::NetlistView`].
102pub trait StaticAnalysisView {
103 fn partition_of(&self, sig: InstantiatedSignalId) -> RegionId;
104 fn cdc_edges_into(&self, region: RegionId) -> Vec<CdcEdge>;
105 fn dpi_tag(&self, call: CallSiteId) -> DpiVpiTag;
106 /// The `ForceObserved` hook signature.
107 fn on_force_observed(&self, sig: InstantiatedSignalId) -> ForceObservedAction;
108}
109
110/// Task 8: a real, physical clock signal — `edge`-triggered — and the
111/// dense `ClockDomainId` every process whose `StaticSensitivity::Explicit`
112/// resolves to the SAME `clock_signal` (by `InstantiatedSignalId`
113/// equality, NL-076) shares.
114#[derive(Clone, Copy, Debug)]
115pub struct ClockDomain {
116 pub id: ClockDomainId,
117 pub clock_signal: InstantiatedSignalId,
118 pub edge: crate::state::EdgeKind,
119}
120
121/// Task 8: every `analyze()` failure. Kept even though this task's own
122/// implementation never constructs one (every fallible step it performs —
123/// driver/read-set resolution — degrades to a documented skip rather than
124/// an error, per `analysis::graph`'s own doc comments): the type is part of
125/// the plan's binding public signature (`analyze() -> Result<..,
126/// AnalysisError>`) and a future wave's DPI/VPI classification pass (Task
127/// 9) or dataflow-dependent Implicit-sensitivity derivation is the natural
128/// place an `Err` first becomes reachable.
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub enum AnalysisError {
131 Dataflow(String),
132 Unsupported(String),
133}
134
135impl std::fmt::Display for AnalysisError {
136 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137 match self {
138 AnalysisError::Dataflow(msg) => write!(f, "analysis dataflow error: {msg}"),
139 AnalysisError::Unsupported(msg) => write!(f, "unsupported construct: {msg}"),
140 }
141 }
142}
143
144impl std::error::Error for AnalysisError {}
145
146/// Task 8 (and harness Task 18 / VF-116, whose duplicate definition was folded into
147/// this one at the harness merge): the knobs `analyze()` takes. `Default` is the fail-closed
148/// configuration ARCHITECTURE §6.5 requires verbatim: a CDC classification
149/// below `cdc_confidence_floor` must degrade to a partition merge rather
150/// than stay a directed edge, and an unclassifiable DPI/VPI call defaults
151/// to the most conservative tag (`Barrier`), never `Pure`.
152#[derive(Clone, Copy, Debug)]
153pub struct AnalysisOptions {
154 pub cdc_confidence_floor: CdcConfidence,
155 pub dpi_default: DpiVpiTag,
156 pub max_partition_merge_passes: u32,
157}
158
159impl Default for AnalysisOptions {
160 fn default() -> Self {
161 AnalysisOptions {
162 cdc_confidence_floor: CdcConfidence::StaticallyProven,
163 dpi_default: DpiVpiTag::Barrier,
164 max_partition_merge_passes: 64,
165 }
166 }
167}
168
169/// Task 8: the real `analyze()` hand-off value — `partitions`/
170/// `clock_domains`/`cdc_edges` populated by the driver graph + WOCB
171/// partitioning + clock-domain/CDC-edge inference this task implements;
172/// `on_force_observed` stays on its safe placeholder body (out of this
173/// wave). Frontend-only (CONTRACTS D4): `partitions: Vec<Partition>` names
174/// a type that only exists under this crate's `frontend` feature
175/// (`Partition` lives in the `sv_lang::Analysis`-consuming `graph` module).
176#[cfg(feature = "frontend")]
177#[derive(Debug, Clone)]
178pub struct StaticAnalysisResult {
179 partitions: Vec<Partition>,
180 clock_domains: Vec<ClockDomain>,
181 cdc_edges: Vec<CdcEdge>,
182 /// `InstantiatedSignalId -> RegionId`, built once in `analyze()` from
183 /// `partitions`' own `.nets`/`.processes` content — never a second,
184 /// independently-reasoned reverse index.
185 partition_index: HashMap<InstantiatedSignalId, RegionId>,
186 /// Task 9 (NL-103): every DPI/VPI call site's tag, in `dpi::
187 /// collect_call_tags`'s own declaration order — `CallSiteId(i)` names
188 /// `call_tags[i]`, never a second, independently-reasoned index.
189 call_tags: Vec<DpiVpiTag>,
190}
191
192#[cfg(feature = "frontend")]
193impl StaticAnalysisResult {
194 /// Read-only access to a `Partition` by `RegionId` — `partitions`
195 /// itself stays a private field (consistent with `StaticAnalysisView`
196 /// being the crate's one query surface for everything else). Panics
197 /// only for a `RegionId` this `StaticAnalysisResult`'s own `analyze()`
198 /// call never produced — a programmer-error indexing precondition, the
199 /// same posture `Netlist::member_symbol` documents.
200 pub fn partition(&self, id: RegionId) -> &Partition {
201 &self.partitions[id.0 as usize]
202 }
203
204 /// Every distinct clock domain this analysis inferred (NL-076) — the
205 /// registry `ClockDomain.id` indexes into.
206 pub fn clock_domains(&self) -> &[ClockDomain] {
207 &self.clock_domains
208 }
209}
210
211#[cfg(feature = "frontend")]
212impl StaticAnalysisView for StaticAnalysisResult {
213 fn partition_of(&self, sig: InstantiatedSignalId) -> RegionId {
214 self.partition_index[&sig]
215 }
216
217 fn cdc_edges_into(&self, region: RegionId) -> Vec<CdcEdge> {
218 self.cdc_edges
219 .iter()
220 .filter(|e| e.to_region == region)
221 .copied()
222 .collect()
223 }
224
225 /// Task 9 (NL-103): the real per-call-site tag `dpi::collect_call_tags`
226 /// computed in `analyze()`. Panics only for a `CallSiteId` this
227 /// `StaticAnalysisResult`'s own `analyze()` call never produced — the
228 /// same programmer-error indexing precondition `partition()` documents.
229 fn dpi_tag(&self, call: CallSiteId) -> DpiVpiTag {
230 self.call_tags[call.0 as usize]
231 }
232
233 /// Out of this wave's 37 IDs (a future wave's runtime-checked escape
234 /// hatch): a safe no-op placeholder, never a panic.
235 fn on_force_observed(&self, _sig: InstantiatedSignalId) -> ForceObservedAction {
236 ForceObservedAction::NoOp
237 }
238}
239
240/// Task 8 (NL-007, NL-063, NL-076, NL-082): the real static-analysis pass —
241/// backfills `SignalDecl.driver_set` onto `netlist` (`graph::
242/// populate_driver_kinds`, NL-063's classification clause — the one
243/// mutation this whole pipeline performs on an already-elaborated
244/// `Netlist`, since `sv_lang::Analysis` does not exist until after
245/// `elaborate()` returns), builds the `{net,process}` driver graph
246/// (`graph::build_driver_graph`), partitions it (`graph::partition`),
247/// classifies every un-unioned cross-domain read edge as a `CdcEdge`
248/// (`clock::classify_cdc_edges`), and (Task 9, NL-103) fail-closed-
249/// classifies every DPI/VPI call site (`dpi::collect_call_tags`).
250///
251/// `eval` is accepted per the plan's binding signature (and the call-order
252/// this whole pipeline documents: `elaborate()`'s `EvalSession` borrow
253/// outlives this call); Task 9 is its first reader —
254/// `dpi::collect_call_tags`'s own `EvalSession::run_dataflow` calls
255/// recurse into a DPI/VPI call's callee body. `opts.dpi_default` is not
256/// itself consulted here: this task's classifier computes the real answer
257/// directly rather than short-circuiting to the configured default, which
258/// stays reserved for a future consumer that wants to skip classification
259/// entirely.
260#[cfg(feature = "frontend")]
261pub fn analyze<'d>(
262 netlist: &mut crate::netlist::Netlist<'d>,
263 slang_analysis: &sv_lang::Analysis<'d>,
264 eval: &sv_lang::EvalSession<'d>,
265 opts: AnalysisOptions,
266) -> Result<StaticAnalysisResult, AnalysisError> {
267 graph::populate_driver_kinds(netlist, slang_analysis);
268
269 let driver_graph = graph::build_driver_graph(netlist, slang_analysis);
270 let partitions = graph::partition(&driver_graph, opts.max_partition_merge_passes);
271 let cdc_edges = clock::classify_cdc_edges(&driver_graph, &partitions);
272 let call_tags = dpi::collect_call_tags(netlist, slang_analysis, eval);
273
274 let clock_domains = driver_graph
275 .clock_domain_registry()
276 .iter()
277 .enumerate()
278 .map(|(i, &(clock_signal, edge))| ClockDomain {
279 id: ClockDomainId(i as u32),
280 clock_signal,
281 edge,
282 })
283 .collect();
284
285 let mut partition_index = HashMap::new();
286 for p in &partitions {
287 for &sig in p.nets.iter().chain(p.processes.iter()) {
288 partition_index.insert(sig, p.id);
289 }
290 }
291
292 Ok(StaticAnalysisResult {
293 partitions,
294 clock_domains,
295 cdc_edges,
296 partition_index,
297 call_tags,
298 })
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304
305 /// VF-116: independent constants (never re-derived from `Default`'s own
306 /// body) so this test actually goes red if a future refactor silently
307 /// loosens either fail-closed default -- per the project's own
308 /// gates-that-cannot-fail lesson, a test that merely restates the impl
309 /// it is checking can never fail. (Implementer's manual check, per the
310 /// plan: temporarily flip `Default::default()`'s two fields, confirm
311 /// this test goes red, then revert -- done; both flips failed this
312 /// test as expected.)
313 #[test]
314 fn analysis_options_default_is_fail_closed() {
315 const EXPECTED_CDC_FLOOR: CdcConfidence = CdcConfidence::StaticallyProven;
316 const EXPECTED_DPI_DEFAULT: DpiVpiTag = DpiVpiTag::Barrier;
317
318 let opts = AnalysisOptions::default();
319 assert_eq!(opts.cdc_confidence_floor, EXPECTED_CDC_FLOOR);
320 assert_eq!(opts.dpi_default, EXPECTED_DPI_DEFAULT);
321 }
322}