Skip to main content

sv_sim_sim/process/
liveness.rs

1//! Task S3b (`docs/superpowers/plans/2026-09-26-wave-A2-slice.md`): the
2//! Activation Record frame layout -- declared SV locals get a permanent
3//! slot unconditionally (PR-017, PR-018), and a backward liveness fixpoint
4//! over the segmented [`super::cfg::ProcessCfg`] computes which
5//! compiler-generated temporaries (if any) also need one (PR-021, PR-016,
6//! PR-019). Ledger PR-015..PR-027, CG-100. See `docs/design/process.md` §3.
7//!
8//! **Why this task never actually constructs a `FrameSlotSource::Temporary`
9//! slot (load-bearing, read before touching `lower_to_segments`'s fill
10//! rule):** design.md §3 is explicit that the only thing liveness ever
11//! decides is "which LLVM SSA temporaries ... need a frame slot at all" --
12//! i.e. a *compiler-generated* value with no corresponding `sv_lang::Symbol`
13//! at all, minted only when CODEGEN (Task S3c) decomposes an expression into
14//! named intermediate LLVM values. At this task's level -- a
15//! not-yet-lowered-to-LLVM `ProcessCfg` over plain `sv_lang::Statement`/
16//! `Expression` handles -- no such value exists yet: every `NamedValue` leaf
17//! this file's backward fixpoint can observe resolves to a real
18//! `sv_lang::Symbol`, and in the A2 slice's restricted grammar (no
19//! functions/tasks, no nested automatic scopes) that symbol is always
20//! either (a) a local declared inside this very process (found via
21//! [`collect_declared_locals`], see its own doc comment for why that -- not
22//! a per-statement symbol accessor -- is the only available discovery path
23//! against the pinned `sv-lang`=0.1.9 C API) or (b) an external
24//! module-level port/signal, which does not belong in this process's frame
25//! at all regardless of how "live" it is (its value is always fetched
26//! through the accessor ABI on demand; STATE, not this frame, owns it).
27//! **Neither case is a compiler temporary.** The backward fixpoint below is
28//! still a real, generically-correct implementation over the *whole*
29//! referenced-symbol universe (not narrowed to declared locals up front --
30//! narrowing early would make this a weaker, less faithful implementation
31//! of PR-021's literal "standard GEN/KILL per statement" algorithm), because
32//! its live-in/live-out results are exactly what a future Task S3c revision
33//! of this module intersects with codegen's own materialized temporary set
34//! once one exists, and because PR-022's oracle self-check needs a real
35//! computed live-in set to check in the first place. For every fixture this
36//! task can construct, filtering that result down to "symbols with no
37//! declared-local match" is provably empty by the slice grammar's own
38//! shape -- which is exactly what `compiler_temporary_dead_before_suspend_
39//! gets_no_frame_slot` (PR-019) checks.
40
41use crate::symid::SymbolUid;
42use std::collections::HashSet;
43
44use crate::symid::SymbolId;
45use sv_lang::kinds::{ExpressionKind, SymbolKind};
46use sv_lang::{Expression, Statement, Symbol};
47
48use super::FrameSlot;
49use super::cfg::{
50    ArgBinding, LoweringError, ProcessCfg, RepeatCount, RepeatCounterId, SuspendPoint, Terminator,
51};
52
53/// One frame slot's compile-time layout entry (PR-025, PR-026).
54///
55/// **Deviation from the plan's literal field list** (`{offset, width_bits,
56/// source}`, no `repr`): the ledger's own PR-025/PR-026 text asks for a
57/// `repr: FourStateI2N | WideFourState` tag per slot, and `process::
58/// FrameSlot` (already committed, `Narrow4State{word_count}` /
59/// `Wide4State{aval_word_count, bval_word_count}`) is exactly that shape --
60/// reused here rather than re-declared, so there is exactly one "frame
61/// slot's physical shape" enum in this crate, not two.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct FrameSlotDesc {
64    /// Word offset within the process TEMPLATE's frame (PR-024: decided once
65    /// per template, at lowering-plan build time -- never per instance).
66    /// Units match `repr`'s own word-count field (a private, per-`FrameLayout`
67    /// bump allocation -- see [`compute_liveness`]'s doc comment on why this
68    /// is deliberately *not* `state::arena::Arena`-allocated).
69    pub offset: u32,
70    /// The signal's real, untruncated bit width (CG-100: never a `u16`-
71    /// clamped or otherwise narrowed copy -- see
72    /// `declared_local_width_is_never_truncated_even_at_the_64_bit_boundary`).
73    pub width_bits: u32,
74    pub repr: FrameSlot,
75    pub source: FrameSlotSource,
76    /// A variable shared with a fork branch (`ProcessCfg::captured`): the
77    /// slot is one word, a pointer to the cell holding the value (`repr` is
78    /// the value's shape).
79    pub cell: bool,
80    /// A cell variable of the process that forked this one (never
81    /// initialized here: it arrives with the parent's pointer).
82    pub captured: bool,
83}
84
85/// PR-025: `DeclaredVar` is a permanent slot (PR-017/018 -- unconditional,
86/// regardless of what the liveness fixpoint computes); `Temporary` is the
87/// liveness-driven kind this task's own fixtures never construct -- see this
88/// module's own doc comment for exactly why.
89///
90/// **`SyntheticCounter` (R-run Task 8):** a `repeat(N)` loop's own
91/// compiler-synthesized iteration counter (`process::cfg::
92/// RepeatCounterId`), minted only when a suspend somewhere in the loop's own
93/// body forces `process::cfg::CfgBuilder::walk_repeat_loop` to build a real
94/// CFG shape for it (see that id's own doc comment). Unlike `DeclaredVar`,
95/// there is no `sv_lang::Symbol` behind it at all -- it is PERMANENT for
96/// exactly the same reason `DeclaredVar` is (it must survive the loop's own
97/// suspend/resume call boundary), so [`compute_liveness`]/`lower_to_segments`
98/// treat the two identically for the "always in live_in/live_out" rule,
99/// differing only in how a caller resolves the slot to a physical pointer
100/// (`process_lower.rs`'s `counter_locals`, keyed by `RepeatCounterId`, not
101/// the `SymbolId`-keyed `frame_locals`/`cx.locals` a `DeclaredVar` uses).
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub enum FrameSlotSource {
104    DeclaredVar(SymbolId),
105    Temporary(u32),
106    SyntheticCounter(RepeatCounterId),
107}
108
109/// PR-025: the per-TEMPLATE frame layout `compute_liveness` produces.
110#[derive(Debug, Clone, Default, PartialEq, Eq)]
111pub struct FrameLayout {
112    pub slots: Vec<FrameSlotDesc>,
113}
114
115impl FrameLayout {
116    /// Total 8-byte words this layout occupies -- the `frame_word_count` a
117    /// `CompiledKernelProcess` needs to size its owned frame buffer.
118    pub fn total_words(&self) -> u32 {
119        self.slots
120            .iter()
121            .map(|s| match s.repr {
122                _ if s.cell => 1,
123                FrameSlot::Narrow4State { word_count } => u32::from(word_count),
124                FrameSlot::Wide4State {
125                    aval_word_count,
126                    bval_word_count,
127                } => aval_word_count + bval_word_count,
128                FrameSlot::Array { rows, row_words } => rows * u32::from(row_words),
129            })
130            .sum()
131    }
132}
133
134/// A value's stored width (`state::storage`), 0 for none.
135fn storage_width(ty: &sv_lang::Type) -> u64 {
136    crate::state::storage::storage_width(ty).unwrap_or(0)
137}
138
139/// An automatic `event` variable's frame representation: its handle, the
140/// object id in the event pool (`state::event_pool`), 0 = `null`.
141pub const EVENT_HANDLE_WIDTH: u64 = 64;
142
143/// A declared local's frame slot: `(width_bits, repr)` -- a packed value of
144/// up to 128 bits (2 planes, `2 * width` bits in 64-bit words), or a
145/// fixed-size unpacked array of such elements (`FrameSlot::Array`, width
146/// the element's). `None` for anything else.
147fn local_frame_slot(ty: &sv_lang::Type) -> Option<(u32, FrameSlot)> {
148    let words_for = |w: u64| (2 * w).div_ceil(64).max(1);
149    // A queue, dynamic or associative array is its 64-bit handle (as in
150    // STATE); a fixed-size array of them is rows of handles.
151    let is_collection = crate::state::storage::is_collection;
152    if ty.is_unpacked_array() && !is_collection(ty) {
153        let mut rows: u64 = 1;
154        let mut cur = *ty;
155        while cur.is_unpacked_array() && !is_collection(&cur) {
156            rows *= u64::from(cur.fixed_unpacked_array_range().width());
157            cur = cur.element_type()?;
158        }
159        let w = if cur.is_event() {
160            EVENT_HANDLE_WIDTH
161        } else {
162            storage_width(&cur)
163        };
164        if w == 0 || w > u64::from(u16::MAX) {
165            return None;
166        }
167        return Some((
168            w as u32,
169            FrameSlot::Array {
170                rows: u32::try_from(rows).ok()?,
171                row_words: words_for(w) as u16,
172            },
173        ));
174    }
175    let w = if ty.is_event() {
176        EVENT_HANDLE_WIDTH
177    } else {
178        storage_width(ty)
179    };
180    if w == 0 || w > u64::from(u16::MAX) {
181        return None;
182    }
183    Some((
184        w as u32,
185        FrameSlot::Narrow4State {
186            word_count: words_for(w) as u16,
187        },
188    ))
189}
190
191/// PR-021: the backward-fixpoint, `AnalyzedProcedure::read_set()`-oracle-
192/// seeded liveness computation, restricted to the slice's own AST-level
193/// fixtures (see this module's doc comment for why `slots` is always
194/// `DeclaredVar`-only here).
195///
196/// **Deviation on the oracle (PR-022):** the plan's literal signature is
197/// exactly `compute_liveness(cfg: &ProcessCfg<'d>) -> Result<...>`, with no
198/// extra parameter for `AnalyzedProcedure::read_set()`. Verified against the
199/// pinned `sv-lang`=0.1.9 API: a bare `Statement<'d>`/`Expression<'d>`
200/// handle (everything `ProcessCfg` carries) has no accessor back to the
201/// `Design`/`Analysis` that produced it, so a live `read_set()` genuinely
202/// cannot be obtained from `&ProcessCfg<'d>` alone -- only from a caller
203/// that already holds `design.analyze(..)` (a live `Analysis<'a>`, a
204/// *different* lifetime than `ProcessCfg`'s own `'d` elaboration brand).
205/// This function therefore derives its own whole-procedure read-set
206/// directly from `cfg`'s own AST (every `NamedValue` read reference
207/// anywhere in `cfg.blocks`, via the exact same walk the fixpoint itself
208/// uses) as a *self-consistency* oracle -- it still catches a real class of
209/// bug (a live-in symbol the fixpoint's own successor/GEN-KILL propagation
210/// pulled in from the wrong place), just not the independent-implementation
211/// class an external `AnalyzedProcedure::read_set()` cross-check would also
212/// catch. [`compute_liveness_with_oracle`] is the version a caller that
213/// *does* hold a live `Analysis` (this crate's own tests; Task S7's real
214/// integration glue) should call instead, passing slang's own `read_set()`
215/// as the independent oracle -- exactly what PR-022 asks for.
216pub fn compute_liveness<'d>(cfg: &ProcessCfg<'d>) -> Result<FrameLayout, LoweringError> {
217    let read_set = aggregate_all_uses(cfg);
218    compute_liveness_with_oracle(cfg, &read_set)
219}
220
221/// PR-022's real oracle-cross-checked path: `read_set_oracle` should be
222/// `AnalyzedProcedure::read_set()`'s symbols (mapped to [`SymbolId`] via
223/// `Symbol::id()`) whenever a caller has one; every live-in set the backward
224/// fixpoint computes is checked against it, hard-failing
225/// ([`LoweringError::liveness_oracle_mismatch`]) the moment it is not a
226/// subset.
227pub fn compute_liveness_with_oracle<'d>(
228    cfg: &ProcessCfg<'d>,
229    read_set_oracle: &HashSet<SymbolId>,
230) -> Result<FrameLayout, LoweringError> {
231    compute_liveness_impl(cfg, read_set_oracle, None)
232}
233
234/// PR-022's fault-injection seam, exposed so a test can prove the self-check
235/// in [`compute_liveness_with_oracle`] is actually wired -- not merely
236/// described -- rather than only ever observing it pass on legitimate input.
237/// Behaves exactly like [`compute_liveness_with_oracle`], except the given
238/// `bogus` symbol is unconditionally inserted into block 0's computed
239/// live-in set before the oracle check runs, so a caller can construct any
240/// ordinary process, pick a `SymbolId` that is provably outside its
241/// `read_set`, and confirm this returns
242/// `Err(LoweringError)` naming it.
243pub fn compute_liveness_fault_injected<'d>(
244    cfg: &ProcessCfg<'d>,
245    read_set_oracle: &HashSet<SymbolId>,
246    bogus: SymbolId,
247) -> Result<FrameLayout, LoweringError> {
248    compute_liveness_impl(cfg, read_set_oracle, Some(bogus))
249}
250
251fn compute_liveness_impl<'d>(
252    cfg: &ProcessCfg<'d>,
253    read_set_oracle: &HashSet<SymbolId>,
254    inject_bogus: Option<SymbolId>,
255) -> Result<FrameLayout, LoweringError> {
256    let declared = collect_declared_locals(cfg)?;
257    let declared_ids: HashSet<SymbolId> = declared.iter().map(|(id, _)| id.clone()).collect();
258
259    let mut live_in = backward_fixpoint(cfg);
260    if let Some(bogus) = inject_bogus {
261        // Test-only: force the fault into block 0's own live-in set,
262        // whatever `backward_fixpoint` legitimately computed for it.
263        if let Some(first) = live_in.first_mut() {
264            first.insert(bogus);
265        }
266    }
267
268    // PR-022: every computed live-in symbol, at every block, must be a
269    // whole-procedure read -- never silently ignored.
270    for set in &live_in {
271        for sym in set {
272            if !read_set_oracle.contains(sym) {
273                return Err(LoweringError::liveness_oracle_mismatch(sym.to_string()));
274            }
275        }
276    }
277
278    // PR-017/018: every declared local gets a permanent slot, unconditionally
279    // -- never derived from (added or removed by) the fixpoint above. See
280    // this module's doc comment for why `Temporary` is never populated here.
281    // The variables this process shares with its fork branches, and those
282    // it shares with the process that forked it: pointer slots.
283    let mut shared: HashSet<SymbolId> = HashSet::new();
284    for b in &cfg.blocks {
285        if let Terminator::ForkJoin { branches, .. } = &b.terminator {
286            for br in branches {
287                shared.extend(br.captured.iter().map(|v| v.uid()));
288            }
289        }
290    }
291    let mut declared = declared;
292    let mut from_parent: HashSet<SymbolId> = HashSet::new();
293    for v in &cfg.captured {
294        let id = v.uid();
295        if !declared.iter().any(|(d, _)| *d == id) {
296            shared.insert(id.clone());
297            from_parent.insert(id.clone());
298            declared.push((id, *v));
299        }
300    }
301    let mut slots = Vec::with_capacity(declared.len());
302    let mut next_offset: u32 = 0;
303    for (id, sym) in &declared {
304        let ty = sym.value_type().ok_or_else(|| {
305            LoweringError::malformed(format!("declared local {sym:?} has no value_type()"))
306        })?;
307        let (width_bits, repr) = local_frame_slot(&ty).ok_or_else(|| {
308            LoweringError::unsupported(format!(
309                "declared local {} of type {} has no frame representation (a value wider \
310                 than 128 bits, a dynamic array, a class handle, ...)",
311                sym.name(),
312                ty.to_sv_string()
313            ))
314        })?;
315        let words = match repr {
316            FrameSlot::Narrow4State { word_count } => u32::from(word_count),
317            FrameSlot::Array { rows, row_words } => rows * u32::from(row_words),
318            FrameSlot::Wide4State {
319                aval_word_count,
320                bval_word_count,
321            } => aval_word_count + bval_word_count,
322        };
323        let cell = shared.contains(id);
324        slots.push(FrameSlotDesc {
325            offset: next_offset,
326            width_bits,
327            repr,
328            source: FrameSlotSource::DeclaredVar(id.clone()),
329            cell,
330            captured: from_parent.contains(id),
331        });
332        next_offset += if cell { 1 } else { words };
333    }
334
335    // This task's own fixtures never populate a `Temporary` slot -- see this
336    // module's doc comment. `declared_ids` is retained (rather than removed
337    // as dead code) as the exact filter a future Task S3c revision applies
338    // to the fixpoint's raw `live_in`/`live_out` results once a genuine
339    // compiler temporary (a live symbol with no declared-local match) can
340    // exist; asserting it here would be a gate that cannot fail against
341    // today's grammar, so it is documented, not asserted.
342    let _ = &declared_ids;
343
344    // R-run Task 8: every `repeat(N)` loop's own synthetic counter
345    // (`FrameSlotSource::SyntheticCounter`'s own doc comment) also gets a
346    // permanent slot, unconditionally -- allocated after every declared
347    // local, in the same first-appearance-in-`cfg.blocks` order
348    // `collect_repeat_counters` discovers them (a `RepeatCounterId` is
349    // already dense/unique per process by construction, so no dedup pass is
350    // needed here the way `collect_declared_locals`'s `SymbolId`-keyed
351    // `HashSet` needs one).
352    for (counter, count) in collect_repeat_counters(cfg) {
353        // A `foreach` element count: a 64-bit counter.
354        let bit_width_u64 = match count {
355            RepeatCount::Const(_) | RepeatCount::Size(_) | RepeatCount::NestedSize { .. } => 64,
356            RepeatCount::Expr(count_expr) | RepeatCount::Inherited(count_expr) => count_expr
357                .expr_type()
358                .ok_or_else(|| {
359                    LoweringError::malformed(format!(
360                        "repeat({counter:?}) count expression has no expr_type()"
361                    ))
362                })?
363                .bit_width(),
364        };
365        if bit_width_u64 == 0 || bit_width_u64 > 64 {
366            // Same slice-scope restriction as a declared local's width
367            // (see the loop above) -- a named, loud rejection, never a
368            // silent truncation.
369            return Err(LoweringError::unsupported(format!(
370                "repeat({counter:?}) count expression has bit_width {bit_width_u64} (0 or \
371                 >64) -- wide/memory repeat counts are out of the A2 slice's scope \
372                 (PR-025/CG-100, extended to R-run Task 8's synthetic counters)"
373            )));
374        }
375        let width_bits = bit_width_u64 as u32;
376        let word_count_u64 = (2u64 * bit_width_u64).div_ceil(64).max(1);
377        let word_count = word_count_u64 as u16;
378        slots.push(FrameSlotDesc {
379            offset: next_offset,
380            width_bits,
381            repr: FrameSlot::Narrow4State { word_count },
382            source: FrameSlotSource::SyntheticCounter(counter),
383            cell: false,
384            captured: false,
385        });
386        next_offset += u32::from(word_count);
387    }
388
389    // An intra-assignment-timed assignment's captured right-hand side: a
390    // permanent temporary slot shaped like its target.
391    let ref_temps: HashSet<u32> = cfg
392        .blocks
393        .iter()
394        .flat_map(|b| b.bindings.iter())
395        .filter_map(|b| match b {
396            ArgBinding::RefOpen { temp, .. } => Some(*temp),
397            _ => None,
398        })
399        .collect();
400    for (temp, target) in cfg.captures.iter().enumerate() {
401        // A `ref` to a collection element keeps its runtime reference id.
402        if ref_temps.contains(&(temp as u32)) {
403            slots.push(FrameSlotDesc {
404                offset: next_offset,
405                width_bits: 64,
406                repr: FrameSlot::Narrow4State { word_count: 2 },
407                source: FrameSlotSource::Temporary(temp as u32),
408                cell: false,
409                captured: false,
410            });
411            next_offset += 2;
412            continue;
413        }
414        let ty = match cfg.capture_types.get(temp).copied().flatten() {
415            Some(formal) => formal.value_type(),
416            None => target.expr_type(),
417        }
418        .ok_or_else(|| {
419            LoweringError::malformed("intra-assignment timing target has no expr_type()")
420        })?;
421        let (width_bits, repr) = local_frame_slot(&ty).ok_or_else(|| {
422            LoweringError::unsupported(format!(
423                "an intra-assignment-timed assignment to a {} (no frame representation)",
424                ty.to_sv_string()
425            ))
426        })?;
427        let words = match repr {
428            FrameSlot::Narrow4State { word_count } => u32::from(word_count),
429            FrameSlot::Array { rows, row_words } => rows * u32::from(row_words),
430            FrameSlot::Wide4State {
431                aval_word_count,
432                bval_word_count,
433            } => aval_word_count + bval_word_count,
434        };
435        slots.push(FrameSlotDesc {
436            offset: next_offset,
437            width_bits,
438            repr,
439            source: FrameSlotSource::Temporary(temp as u32),
440            cell: false,
441            captured: false,
442        });
443        next_offset += words;
444    }
445
446    Ok(FrameLayout { slots })
447}
448
449/// R-run Task 8: every distinct `RepeatCounterId` a suspend-containing
450/// `repeat(N)` loop minted anywhere in `cfg.blocks`, paired with its own
451/// count expression, in block-index (allocation) order -- the CFG-level
452/// analogue of [`collect_declared_locals`], but reading `BasicBlock::
453/// repeat_init` (a plain per-block field this crate's own `process::cfg`
454/// already populates) rather than walking the AST for a `Symbol`, since a
455/// synthetic counter has no `Symbol` to discover in the first place.
456fn collect_repeat_counters<'d>(
457    cfg: &ProcessCfg<'d>,
458) -> Vec<(RepeatCounterId, crate::process::cfg::RepeatCount<'d>)> {
459    cfg.blocks
460        .iter()
461        .filter_map(|b| b.repeat_init.clone())
462        .collect()
463}
464
465/// PR-017: every declared SV local referenced anywhere in `cfg.body`'s
466/// subtree, in first-appearance-in-source (AST pre-order) discovery order,
467/// deduplicated by [`SymbolId`].
468///
469/// **Why this walks `Block` statements' `Statement::block_symbol()` rather
470/// than looking at each `VariableDeclaration` statement directly:** verified
471/// against the pinned `sv-lang`=0.1.9 C API (`CApiAst.cpp`) -- there is no
472/// `slang_stmt_*` accessor that recovers a `VariableDeclStatement`'s own
473/// declared symbol (confirmed: `Statement::children()`/`::expressions()`
474/// both come back empty for a `VariableDeclaration`-kind leaf). What the
475/// binding *does* expose is `Statement::block_symbol()`
476/// (`slang::ast::BlockStatement::blockSymbol`), and slang's own
477/// `Statement::bind` (`Statement.cpp`) gives an UNNAMED `begin`/`end` block a
478/// real (anonymously-named) `StatementBlockSymbol` scope whenever it
479/// contains at least one declaration -- confirmed live against this
480/// project's own `sv-lang` build (an unnamed `begin int x; ... end` yields
481/// `block_symbol() == Some(<anonymous>)`, whose `.members()` lists `x`),
482/// not merely inferred from the doc comment's narrower "unnamed -> None"
483/// phrasing (which describes the truly decl-free case only). Recursing over
484/// every `Block` statement in the tree and collecting each one's
485/// `block_symbol()`'s `Variable`-kind members is therefore the only
486/// available, non-panicking discovery path.
487fn collect_declared_locals<'d>(
488    cfg: &ProcessCfg<'d>,
489) -> Result<Vec<(SymbolId, Symbol<'d>)>, LoweringError> {
490    let mut out: Vec<(SymbolId, Symbol<'d>)> = Vec::new();
491    let mut seen: HashSet<SymbolId> = HashSet::new();
492    let mut stack = vec![cfg.body.as_sem_node()];
493    while let Some(node) = stack.pop() {
494        if let Some(stmt) = node.as_statement() {
495            for block_sym in super::cfg::block_scopes(&stmt) {
496                for member in block_sym.members() {
497                    // A static one is the instance's (a STATE signal).
498                    if member.kind() == SymbolKind::Variable
499                        && !crate::netlist::is_static_procedural_var(&member)
500                    {
501                        let id = member.uid();
502                        if seen.insert(id.clone()) {
503                            out.push((id, member));
504                        }
505                    }
506                }
507            }
508        }
509        stack.extend(super::cfg::live_children(&node));
510    }
511    // Pattern variables (LRM 12.6) are declared by no statement either.
512    for v in crate::patterns::pattern_vars(cfg.body.as_sem_node()) {
513        let id = v.uid();
514        if seen.insert(id.clone()) {
515            out.push((id, v));
516        }
517    }
518    // Formals/locals of tasks inlined into this process: declared by no
519    // `Block` under `cfg.body` (their declarations live in the task).
520    for sym in &cfg.inlined_locals {
521        let id = sym.uid();
522        if seen.insert(id.clone()) {
523            out.push((id, *sym));
524        }
525    }
526    Ok(out)
527}
528
529/// The ids of the variables `cfg` declares (its blocks' and its inlined
530/// tasks'), for `process::cfg`'s fork capture analysis.
531pub(crate) fn declared_local_ids(cfg: &ProcessCfg<'_>) -> HashSet<SymbolId> {
532    collect_declared_locals(cfg)
533        .map(|v| v.into_iter().map(|(id, _)| id).collect())
534        .unwrap_or_default()
535}
536
537/// One inlined-task [`ArgBinding`]'s reads and (whole-symbol) definition,
538/// in the same shape [`statement_use_def`] reports for a statement.
539fn binding_use_def<'d>(b: &ArgBinding<'d>) -> (Vec<Symbol<'d>>, Option<Symbol<'d>>) {
540    let mut uses = Vec::new();
541    match b {
542        ArgBinding::In { formal, actual } => {
543            collect_named_values(*actual, &mut uses);
544            (uses, Some(*formal))
545        }
546        ArgBinding::Init { var } => {
547            if let Some(init) = var.initializer() {
548                collect_named_values(init, &mut uses);
549            }
550            (uses, Some(*var))
551        }
552        ArgBinding::Out { actual, formal } => {
553            // Conservative: the target's own name counts as a use too (a
554            // select target is a read-modify-write), never as a kill.
555            collect_named_values(*actual, &mut uses);
556            uses.push(*formal);
557            (uses, None)
558        }
559        ArgBinding::Const { var, .. } | ArgBinding::ThisOf { var } => (uses, Some(*var)),
560        // Reads the handles the variable holds (and clears it).
561        ArgBinding::Release { var } => {
562            uses.push(*var);
563            (uses, None)
564        }
565        // A captured value lives in a compiler temporary, not a symbol.
566        ArgBinding::Capture { value, .. } => {
567            collect_named_values(*value, &mut uses);
568            (uses, None)
569        }
570        ArgBinding::AssignCaptured { target, .. } => {
571            collect_named_values(*target, &mut uses);
572            (uses, None)
573        }
574        // The value lives in a compiler temporary, not a symbol.
575        ArgBinding::FromCaptured { formal, .. } => (uses, Some(*formal)),
576        ArgBinding::ToCaptured { formal, .. } => {
577            uses.push(*formal);
578            (uses, None)
579        }
580        ArgBinding::CallResult { .. } | ArgBinding::ClassEventLeave => (uses, None),
581        // The caller's `this` (a compiler temporary, not a symbol).
582        ArgBinding::CaptureThis { .. } => (uses, None),
583        // The reference id lives in a compiler temporary.
584        ArgBinding::RefOpen { formal, actual, .. } => {
585            collect_named_values(*actual, &mut uses);
586            (uses, Some(*formal))
587        }
588        ArgBinding::RefClose { formal, actual, .. } => {
589            collect_named_values(*actual, &mut uses);
590            uses.push(*formal);
591            (uses, None)
592        }
593        ArgBinding::Step { var, .. } => {
594            uses.push(*var);
595            (uses, None)
596        }
597        ArgBinding::AssocKey {
598            var,
599            arr,
600            path,
601            next,
602        } => {
603            collect_named_values(*arr, &mut uses);
604            uses.extend(path.iter().map(|s| s.var));
605            if *next {
606                uses.push(*var);
607            }
608            (uses, Some(*var))
609        }
610    }
611}
612
613/// Every `NamedValue` expression's `referenced_symbol()`, reachable anywhere
614/// in `expr`'s subtree -- the one shared leaf-collection primitive both
615/// [`statement_use_def`] and the terminator-condition walks below reuse
616/// (mirrors `cfg.rs`'s own `contains_suspend_trigger` traversal shape).
617fn collect_named_values<'d>(expr: Expression<'d>, out: &mut Vec<Symbol<'d>>) {
618    let mut stack = vec![expr.as_sem_node()];
619    while let Some(node) = stack.pop() {
620        if let Some(e) = node.as_expression()
621            && crate::symid::is_value_name(e.kind())
622            && let Some(sym) = e.referenced_symbol()
623        {
624            out.push(sym);
625        }
626        // A call to a function inlined into the process reads its result
627        // variable (`CfgBuilder::hoist_suspending_function_calls`).
628        if let Some(e) = node.as_expression()
629            && e.kind() == ExpressionKind::Call
630            && e.is_system_call() != Some(true)
631            && let Some(callee) = e.call_subroutine()
632            && callee.subroutine_kind() == Some(sv_lang::SubroutineKind::Function)
633            && let Some(rv) = callee.subroutine_return_val_var()
634        {
635            out.push(rv);
636        }
637        stack.extend(node.children());
638    }
639}
640
641/// The symbols read by the index expressions of `e` when it selects an
642/// element of an unpacked array (`ea[i]`, `ea[i][j]`); nothing for any other
643/// event expression.
644fn event_index_reads<'d>(e: Expression<'d>, out: &mut Vec<Symbol<'d>>) {
645    let mut cur = e;
646    while cur.kind() == ExpressionKind::ElementSelect {
647        if let Some(sel) = cur.selector() {
648            collect_named_values(sel, out);
649        }
650        match cur.select_value() {
651            Some(base) => cur = base,
652            None => break,
653        }
654    }
655}
656
657/// One statement's GEN (read uses) / KILL (the one symbol it defines, if
658/// any) sets, per PR-021's "standard GEN/KILL per statement". The slice's
659/// grammar only ever defines a symbol through a whole-signal blocking/
660/// nonblocking `Assignment` with a bare `NamedValue` lvalue (its own "Slice
661/// scope" -- no concatenation LHS); anything else conservatively contributes
662/// no KILL (safe -- it only ever makes a symbol stay live *longer* than a
663/// fully precise analysis would, never shorter, which cannot manufacture a
664/// false oracle mismatch since every collected use still comes from a real
665/// read reference in the AST).
666fn statement_use_def<'d>(stmt: &Statement<'d>) -> (Vec<Symbol<'d>>, Option<Symbol<'d>>) {
667    if let Some(top) = stmt.expressions().into_iter().next()
668        && top.kind() == ExpressionKind::Assignment
669    {
670        let mut uses = Vec::new();
671        let mut def = None;
672        if let Some(lhs) = top.left() {
673            if crate::symid::is_value_name(lhs.kind()) {
674                def = lhs.referenced_symbol();
675            } else {
676                // Out-of-scope lvalue shape (e.g. a select) -- conservatively
677                // treat it as a read too, never as a silent def.
678                collect_named_values(lhs, &mut uses);
679            }
680        }
681        if let Some(rhs) = top.right() {
682            collect_named_values(rhs, &mut uses);
683        }
684        return (uses, def);
685    }
686
687    let mut uses = Vec::new();
688    for e in stmt.expressions() {
689        collect_named_values(e, &mut uses);
690    }
691    (uses, None)
692}
693
694fn successors(t: &Terminator<'_>) -> Vec<u32> {
695    match t {
696        Terminator::Fallthrough(b) => vec![b.0],
697        Terminator::Branch {
698            then_blk, else_blk, ..
699        } => vec![then_blk.0, else_blk.0],
700        Terminator::Assertion {
701            pass_blk,
702            fail_blk,
703            done_blk,
704            ..
705        } => vec![pass_blk.0, fail_blk.0, done_blk.0],
706        Terminator::Suspend { resume_blk, .. } => vec![resume_blk.0],
707        Terminator::End { loop_back } => loop_back.iter().map(|b| b.0).collect(),
708        // Task 1: each of these is an unconditional edge to exactly one
709        // other block in this same `cfg.blocks` graph -- `Loop`'s real
710        // per-iteration decision lives on `entry`'s own (separately
711        // visited) terminator, never on `Loop` itself (mirrors
712        // `lower_terminator`'s identical `Goto(entry)` mapping).
713        Terminator::Loop { entry, .. } => vec![entry.0],
714        Terminator::ForkJoin { join_blk, .. } => vec![join_blk.0],
715        // R-run Task 8: same two-successor shape as `Branch` above (this is
716        // `repeat`'s own header decision), just no `cond: Expression` to
717        // additionally walk in `terminator_uses` below.
718        Terminator::RepeatCheck {
719            then_blk, else_blk, ..
720        } => vec![then_blk.0, else_blk.0],
721        Terminator::Case {
722            arms, default_blk, ..
723        }
724        | Terminator::Switch {
725            arms, default_blk, ..
726        } => arms
727            .iter()
728            .chain(std::iter::once(default_blk))
729            .map(|b| b.0)
730            .collect(),
731    }
732}
733
734/// **Verified against real `sv-lang`=0.1.9 `AnalyzedProcedure::read_set()`
735/// output (not assumed):** a `SignalEventControl`'s edge signal (`clk` in
736/// `@(posedge clk)`) does NOT appear in `read_set()` -- it is a sensitivity
737/// *trigger*, not a data read, in slang's own dataflow model (consistent
738/// with `always_comb`'s implicit sensitivity list being derived separately
739/// from the read set, never by re-adding an already-explicit edge signal to
740/// it). `SuspendPoint::Edge`'s signal is therefore deliberately excluded
741/// here -- including it made the real, non-fault-injected path in
742/// `liveness_never_computes_a_symbol_outside_the_procedures_read_set` fail
743/// against a genuine `AnalyzedProcedure::read_set()` oracle before this fix.
744/// `SuspendPoint::Delay`'s expression is, by contrast, an ordinary value
745/// expression (the slice only ever elaborates it to a constant, so this
746/// never contributes a `NamedValue` in practice here, but the general case
747/// -- an expression delay -- is out of A2-slice scope entirely, PR-006) and
748/// is included for the same reason a `Branch` condition is.
749fn terminator_uses<'d>(t: &Terminator<'d>, out: &mut Vec<Symbol<'d>>) {
750    match t {
751        Terminator::Branch { cond, .. } => collect_named_values(*cond, out),
752        Terminator::Assertion { stmt, .. } => {
753            if let Some(c) = crate::process::cfg::assertion_cond(stmt) {
754                collect_named_values(c, out);
755            }
756        }
757        // What selects the arm (a virtual interface, weights, a selector)
758        // is read.
759        Terminator::Switch { on, .. } => {
760            for e in on.reads() {
761                collect_named_values(e, out);
762            }
763        }
764        // The selector and every label are read to pick the arm.
765        Terminator::Case { stmt, .. } => {
766            for e in case_reads(stmt) {
767                collect_named_values(e, out);
768            }
769        }
770        Terminator::Suspend {
771            point: SuspendPoint::Delay(e, _),
772            ..
773        } => collect_named_values(*e, out),
774        // Task 1: `wait(expr)` is a genuine data read (unlike an edge
775        // trigger) -- same treatment as a `Branch` condition.
776        Terminator::Suspend {
777            point: SuspendPoint::Level(e),
778            ..
779        } => collect_named_values(*e, out),
780        // Task 6: `ImplicitComb`'s captured read-set IS genuine data reads
781        // (the guarded body's own AST-derived sensitivity list) -- same
782        // treatment as `Level`'s condition above, extended to every member.
783        Terminator::Suspend {
784            point: SuspendPoint::ImplicitComb(exprs),
785            ..
786        } => {
787            for e in exprs {
788                collect_named_values(*e, out);
789            }
790        }
791        // Task 1: `EdgeList`'s member signals are sensitivity triggers, not
792        // data reads -- same verified-against-`read_set()` reasoning as
793        // plain `Edge` above, extended to every member. Task 6: the revised
794        // `CycleDelay`'s `clock` is a trigger too (an edge-
795        // sensitivity subscription, never a data read), same reasoning as
796        // plain `Edge`/`CycleDelay` before this task.
797        // The index of an event-array element in an event control
798        // (`@(ea[k])`) is evaluated when the wait starts (LRM 1800-2023
799        // 15.5.1): a data read, though the event itself is only a trigger.
800        Terminator::Suspend {
801            point: SuspendPoint::Edge { signal, iff, .. },
802            ..
803        } => {
804            event_index_reads(*signal, out);
805            // The `iff` guard is a data read at every wake (LRM 1800-2023
806            // 9.4.2.3), so a local read only there must outlive the suspend.
807            if let Some(g) = iff {
808                collect_named_values(*g, out);
809            }
810        }
811        Terminator::Suspend {
812            point: SuspendPoint::EdgeList(entries, iffs),
813            ..
814        } => {
815            for (e, _) in entries {
816                event_index_reads(*e, out);
817            }
818            for g in iffs.iter().flatten() {
819                collect_named_values(*g, out);
820            }
821        }
822        Terminator::Suspend {
823            point:
824                SuspendPoint::CycleDelay { .. }
825                | SuspendPoint::OneStep
826                | SuspendPoint::WaitFork
827                | SuspendPoint::ByRequest,
828            ..
829        }
830        | Terminator::Fallthrough(_)
831        | Terminator::End { .. }
832        | Terminator::Loop { .. }
833        | Terminator::ForkJoin { .. }
834        // R-run Task 8: `RepeatCheck` references no `sv_lang::Symbol` at all
835        // (its own counter is compiler-synthesized, `FrameSlotSource::
836        // SyntheticCounter`'s own doc comment) -- nothing for this
837        // Symbol-liveness walk to collect. Its own `repeat_init` count
838        // expression's symbols are, like `step_exprs`', deliberately not
839        // walked here either -- see `BasicBlock::step_exprs`'s own doc
840        // comment for why omitting them does not under-approximate any
841        // `DeclaredVar`/`SyntheticCounter` slot's permanence (both are
842        // unconditionally permanent regardless of this fixpoint's result).
843        | Terminator::RepeatCheck { .. } => {}
844    }
845}
846
847/// PR-021: the backward fixpoint itself, over the whole referenced-symbol
848/// universe (see this module's doc comment for why narrowing to declared
849/// locals up front would be a weaker implementation of the same algorithm).
850/// Returns `live_in[block_index]` for every block in `cfg.blocks`.
851fn backward_fixpoint<'d>(cfg: &ProcessCfg<'d>) -> Vec<HashSet<SymbolId>> {
852    let n = cfg.blocks.len();
853    let mut live_in: Vec<HashSet<SymbolId>> = vec![HashSet::new(); n];
854    let mut changed = true;
855    while changed {
856        changed = false;
857        for i in (0..n).rev() {
858            let block = &cfg.blocks[i];
859            let mut live: HashSet<SymbolId> = HashSet::new();
860            for succ in successors(&block.terminator) {
861                live.extend(live_in[succ as usize].iter().cloned());
862            }
863            let mut term_uses = Vec::new();
864            terminator_uses(&block.terminator, &mut term_uses);
865            for sym in term_uses {
866                live.insert(sym.uid());
867            }
868            for stmt in block.stmts.iter().rev() {
869                let (uses, def) = statement_use_def(stmt);
870                if let Some(d) = def {
871                    live.remove(&d.uid());
872                }
873                for u in uses {
874                    live.insert(u.uid());
875                }
876            }
877            for b in block.bindings.iter().rev() {
878                let (uses, def) = binding_use_def(b);
879                if let Some(d) = def {
880                    live.remove(&d.uid());
881                }
882                for u in uses {
883                    live.insert(u.uid());
884                }
885            }
886            if live != live_in[i] {
887                live_in[i] = live;
888                changed = true;
889            }
890        }
891    }
892    live_in
893}
894
895/// This task's own (see [`compute_liveness`]'s doc comment) self-derived
896/// oracle: every symbol read anywhere in `cfg`'s statements or terminator
897/// conditions, via the exact same [`collect_named_values`] leaf collector
898/// the fixpoint itself uses.
899fn aggregate_all_uses<'d>(cfg: &ProcessCfg<'d>) -> HashSet<SymbolId> {
900    let mut out = HashSet::new();
901    for block in &cfg.blocks {
902        for b in &block.bindings {
903            let (uses, _def) = binding_use_def(b);
904            for u in uses {
905                out.insert(u.uid());
906            }
907        }
908        for stmt in &block.stmts {
909            let (uses, _def) = statement_use_def(stmt);
910            for u in uses {
911                out.insert(u.uid());
912            }
913        }
914        let mut term_uses = Vec::new();
915        terminator_uses(&block.terminator, &mut term_uses);
916        for u in term_uses {
917            out.insert(u.uid());
918        }
919    }
920    out
921}
922
923// PR-023's negative gate lives in `tests/process_liveness.rs` as a
924// source-text scan of this file (mirrors `sched`'s own `scan_support`
925// pattern) -- not here, since a self-referential "this file does not
926// contain the string X" check belongs with the other structural/negative
927// gates, not inline in the implementation it is scanning.
928
929/// The expressions a [`Terminator::Case`] statement evaluates to choose its
930/// arm: for a `case`, the selector, then every item's labels (a `case ...
931/// inside` range label's bounds included, as children of its `ValueRange`);
932/// for a `unique`/`unique0`/`priority` `if` series, every condition.
933pub fn case_reads<'d>(stmt: &sv_lang::Statement<'d>) -> Vec<sv_lang::Expression<'d>> {
934    use sv_lang::kinds::StatementKind;
935    if stmt.kind() == StatementKind::Conditional {
936        // A series the CFG could not lower was rejected when it was built.
937        return crate::process::cfg::if_series(*stmt)
938            .map(|c| {
939                c.links
940                    .iter()
941                    .flat_map(|l| l.conditions().into_iter().map(|c| c.expr()))
942                    .collect()
943            })
944            .unwrap_or_default();
945    }
946    if stmt.kind() == StatementKind::PatternCase {
947        // The matched expression and every item's `&&&` filter (a pattern
948        // itself reads nothing: only constants and bindings).
949        return crate::patterns::pattern_case_expr(*stmt)
950            .into_iter()
951            .chain(stmt.pattern_case_items().iter().filter_map(|i| i.filter()))
952            .collect();
953    }
954    stmt.expr()
955        .into_iter()
956        .chain(stmt.case_items().iter().flat_map(|i| i.expressions()))
957        .collect()
958}
959
960#[cfg(test)]
961mod tests {
962    use super::*;
963
964    /// CG-100's compile-time half (the actual spill/fill IR round-trip is
965    /// Task S3c/S4's job, once a real spill site exists -- see this crate's
966    /// deviations note): `FrameSlotDesc::width_bits` must be the real,
967    /// untruncated bit width, and `repr`'s `word_count` derived from the
968    /// FULL `2*width_bits` (never `width_bits` alone, which would silently
969    /// halve the unknown/xz plane's storage). A 64-bit declared local is
970    /// exactly the boundary case: `word_count` must be 2 (128 bits: 64 val +
971    /// 64 unk), never 1 (which would be truncating the unk plane away
972    /// entirely).
973    #[test]
974    fn declared_local_width_is_never_truncated_even_at_the_64_bit_boundary() {
975        let design = crate::state::compile_source(
976            "module m; initial begin automatic logic [63:0] w; w = 0; end endmodule\n",
977        )
978        .unwrap();
979        let body = design
980            .top_instances()
981            .next()
982            .unwrap()
983            .instance_body()
984            .unwrap();
985        let block = body
986            .members()
987            .find(|s| s.kind() == sv_lang::kinds::SymbolKind::ProceduralBlock)
988            .unwrap();
989        let root = block.body().unwrap();
990        let cfg = super::super::cfg::build_cfg(&root, super::super::cfg::ProcessLoopKind::OneShot)
991            .unwrap();
992        let frame = compute_liveness(&cfg).unwrap();
993        assert_eq!(frame.slots.len(), 1);
994        assert_eq!(frame.slots[0].width_bits, 64, "width must not be truncated");
995        match frame.slots[0].repr {
996            FrameSlot::Narrow4State { word_count } => {
997                assert_eq!(
998                    word_count, 2,
999                    "a 64-bit value's val+unk packing needs 2 u64 words, not 1"
1000                );
1001            }
1002            other => panic!("expected Narrow4State for a 64-bit local, got {other:?}"),
1003        }
1004    }
1005
1006    /// Mutation-coverage regression: `backward_fixpoint`'s per-statement GEN/
1007    /// KILL step must apply KILL (remove the statement's own def) *before*
1008    /// GEN (insert its uses) -- `live_in = (live_out - def) ∪ use`, per
1009    /// PR-021's own "standard GEN/KILL per statement" formula this module's
1010    /// doc comment cites. A statement whose LHS symbol also appears on its
1011    /// own RHS (`a = a + 1`) is exactly the case that distinguishes the two
1012    /// orders: swapping them (GEN then KILL) would insert `a` and then
1013    /// immediately remove it again, silently dropping a symbol that is
1014    /// genuinely read in this same statement from the computed live-in set.
1015    /// `compute_liveness`'s own PR-022 oracle self-check cannot catch this
1016    /// class of bug (it only rejects an over-approximated live-in symbol,
1017    /// never an under-approximated one), so this test calls
1018    /// `backward_fixpoint` directly.
1019    #[test]
1020    fn self_referencing_statement_keeps_its_own_rhs_use_live_into_the_block() {
1021        let design = crate::state::compile_source(
1022            "module m; initial begin int a; a = a + 1; end endmodule\n",
1023        )
1024        .unwrap();
1025        let body = design
1026            .top_instances()
1027            .next()
1028            .unwrap()
1029            .instance_body()
1030            .unwrap();
1031        let block = body
1032            .members()
1033            .find(|s| s.kind() == sv_lang::kinds::SymbolKind::ProceduralBlock)
1034            .unwrap();
1035        let root = block.body().unwrap();
1036        let cfg = super::super::cfg::build_cfg(&root, super::super::cfg::ProcessLoopKind::OneShot)
1037            .unwrap();
1038
1039        let read_set = aggregate_all_uses(&cfg);
1040        let a_id = read_set
1041            .iter()
1042            .next()
1043            .expect(
1044                "`a` is read on the RHS of `a = a + 1`, so the whole-procedure read-set \
1045                     oracle must contain exactly its SymbolId",
1046            )
1047            .clone();
1048        assert_eq!(
1049            read_set.len(),
1050            1,
1051            "the only symbol ever read in this fixture is `a`"
1052        );
1053
1054        let live_in = backward_fixpoint(&cfg);
1055        assert!(
1056            live_in.iter().any(|set| set.contains(&a_id)),
1057            "`a` is used on the RHS of the very statement that defines it -- it must be live \
1058             going into that block, but backward_fixpoint's computed live-in sets {live_in:?} \
1059             contain no block with `a`"
1060        );
1061    }
1062
1063    /// LRM 1800-2023 9.4.2.3: an `iff` guard (on a plain edge or an event-list
1064    /// member) is evaluated at every wake, a data read of its operands. A local
1065    /// read only there is therefore live across the suspend; the backward
1066    /// fixpoint must see both guards.
1067    #[test]
1068    fn a_symbol_read_only_in_an_iff_guard_is_live_at_the_suspend() {
1069        let design = crate::state::compile_source(
1070            "module m; logic c, d; initial begin automatic int g; automatic int h; \
1071             @(posedge d); @(posedge c iff g); @(posedge c iff h or posedge d); end endmodule\n",
1072        )
1073        .unwrap();
1074        let body = design
1075            .top_instances()
1076            .next()
1077            .unwrap()
1078            .instance_body()
1079            .unwrap();
1080        let block = body
1081            .members()
1082            .find(|s| s.kind() == sv_lang::kinds::SymbolKind::ProceduralBlock)
1083            .unwrap();
1084        let root = block.body().unwrap();
1085        let cfg = super::super::cfg::build_cfg(&root, super::super::cfg::ProcessLoopKind::OneShot)
1086            .unwrap();
1087        let live_in = backward_fixpoint(&cfg);
1088        let all: HashSet<SymbolId> = live_in.iter().flatten().cloned().collect();
1089        let names: Vec<String> = all.iter().map(|id| format!("{id:?}")).collect();
1090        for local in ["g", "h"] {
1091            assert!(
1092                names
1093                    .iter()
1094                    .any(|n| n.ends_with(local) || n.contains(&format!(".{local}"))),
1095                "`{local}` is read only by an iff guard but is not live: {names:?}"
1096            );
1097        }
1098    }
1099}