vj-sim 0.3.0

Netlist, scheduler and process model of the vj SystemVerilog simulator.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
//! Task 4 (NL-028, NL-121): the full `Template` shape (netlist-analysis.md
//! KEY DATA STRUCTURES), plus `SubInstanceSlot` — the deduped body's own
//! enumeration of its child instantiations, a Template-level structure
//! DISTINCT from (but consistent in count/order with) the instance-level
//! `InstanceTree`/`InstanceNode` hierarchy Task 1/3 already build.
//!
//! `signals`/`memories`/`ports` (Task 5) and `processes`/
//! `continuous_assigns` (Task 7) are populated by later tasks; this task
//! only establishes the field shape (empty `Vec`s) so `Template`'s public
//! surface is stable from here on — no later task adds/removes a field.
//!
//! Task 5 (NL-029, NL-030, NL-120, NL-122): `SignalDecl`/`ValueDomain`/
//! `MemoryDecl`/`PortDecl` gain their real fields, and [`build_members`]
//! (called from `elaborate.rs`'s dedup pass, once per `Template`'s canonical
//! body) populates `signals`/`memories`/`ports` for real.

use crate::symid::SymbolUid;
use std::collections::HashMap;

use crate::netlist::{ElabError, SymbolKey, TemplateId, TemplateKey};

/// (NL-030) Whether a signal's bit width fits STATE's 64-bit "narrow" value
/// representation (ARCHITECTURE §6.2) or must spill to a wide, multi-word
/// one. The boundary is inclusive: exactly 64 bits is `Narrow`. A declared
/// memory array is never classified this way at all (NL-120) — `Memory`
/// exists on this enum only because netlist-analysis.md's own `ValueDomain`
/// shape includes it; this wave's `MemoryDecl` (below) has no `value_domain`
/// field of its own to hold it, so this variant is declared but never
/// constructed this wave (a later wave's per-memory-access IR is the natural
/// place for it).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ValueDomain {
    /// `bit_width <= 64`.
    Narrow { bits: u32 },
    /// `bit_width > 64`.
    Wide { bits: u32 },
    /// A declared memory array (NL-120) — never folded into `Narrow`/`Wide`.
    Memory,
}

/// (NL-029) Which of the three declaration forms a `SignalDecl` lowers.
/// `Net` carries the net's own `sv_lang::NetKind` (`wire`/`wand`/…) — see
/// `net_kind_of`'s doc comment (below) for a load-bearing deviation on how
/// this is resolved from the pinned `sv-lang = "=0.1.9"` API.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum SignalKind {
    Net(sv_lang::NetKind),
    Variable,
    Genvar,
}

/// (Revision, resolving a blocking review finding on NL-063 — narrower than
/// netlist-analysis.md's own richer `DriverEntry`, which additionally carries
/// `bit_range`/`strength`/`flags` for STATE's arena-conflict resolution, out
/// of this wave's 37 ledger IDs.) Only `driver_kind` (the row's literal
/// acceptance text) and `containing` (which process/continuous-assign owns
/// this driver) are populated, by Task 8's `populate_driver_kinds` — this
/// task (Task 5) only declares the type so `SignalDecl.driver_set`'s field
/// type compiles, and leaves every `driver_set` it builds empty (`Vec::new()`
/// — `sv_lang::Analysis`, the source of per-net driver data, does not exist
/// until `analysis::analyze` runs, strictly after `elaborate()` returns; see
/// the plan's Architecture section's load-bearing call-order note).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct DriverEntry {
    pub driver_kind: sv_lang::DriverKind,
    pub containing: SymbolKey,
}

/// A declared net/variable/genvar (NL-029). `key` is this member's
/// `SymbolKey` within the owning `Template` — the SAME dense index
/// `dedup::confirm_and_assign_symbol_keys` assigns from the identical
/// `canonical.members()` enumeration order, so `signals[i].key` is never a
/// second, independently-derived numbering.
#[derive(Clone, Debug, PartialEq)]
pub struct SignalDecl {
    pub key: SymbolKey,
    pub name: String,
    pub kind: SignalKind,
    pub value_domain: ValueDomain,
    pub driver_set: Vec<DriverEntry>,
}

/// A declared unpacked memory array (NL-120) — `element_bits` from the
/// array's element type's `Type::bit_width()`, `depth` from the unpacked
/// dimension's `Type::fixed_range()` span
/// (`(left - right).unsigned_abs() + 1`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MemoryDecl {
    pub key: SymbolKey,
    pub name: String,
    pub element_bits: u32,
    pub depth: u32,
}

/// (NL-037, NL-038 — Task 7 populates the `None` case only; Task 8 is where
/// `always_comb`/`always_latch` get a real `Implicit` and `always_ff`/plain
/// `always @(...)` get a real `Explicit`.) A process's static wake
/// condition.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub enum StaticSensitivity {
    /// `always_comb`/`always_latch` (Task 8): the procedure's own read-set,
    /// via the same `ReadSetLattice`/`run_dataflow` machinery `assign`
    /// statements use (Decision 1).
    Implicit { reads: Vec<SymbolKey> },
    /// `always_ff`/plain `always @(posedge/negedge ...)` (Task 8): the
    /// explicit edge-sensitivity list.
    Explicit {
        edges: Vec<(SymbolKey, crate::state::EdgeKind)>,
    },
    /// `initial`/`final` (Task 7, NL-037): no sensitivity at all — these
    /// run once, at time zero / end-of-simulation, never re-triggered by a
    /// signal change. Also the placeholder Task 7 leaves on
    /// `always`/`always_comb`/`always_latch`/`always_ff` process kinds
    /// until Task 8 fills in their real `Implicit`/`Explicit` derivation
    /// (documented per call site — see `build_processes`).
    #[default]
    None,
}

/// (NL-037, NL-038) One elaborated `initial`/`final`/`always*` procedural
/// block. `key` is this process's own `SymbolKey` within the owning
/// `Template` — the same dense, position-based numbering every other member
/// kind uses (`build_processes`'s own `scope.members().enumerate()` walk,
/// the identical enumeration `build_members`/`confirm_and_assign_symbol_keys`
/// already walk over the same canonical body).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ProcessTemplate {
    pub key: SymbolKey,
    pub kind: sv_lang::ProceduralBlockKind,
    pub sensitivity: StaticSensitivity,
}

/// (NL-034, NL-035) A continuous assignment — either a real `assign`
/// statement in this `Template`'s own scope (`is_output_port_connection ==
/// false`, `target`/`read_set` both resolved within this SAME scope), or a
/// synthetic entry modeling one instance port connection at the PARENT
/// scope's `Template` (LRM: "an instance port connection is, semantically,
/// a continuous assignment" — netlist-analysis.md Decision/§A.5): `target`
/// is then the CHILD port's own `SymbolKey` (within the CHILD `Template`'s
/// numbering — the same key `SubInstanceSlot::port_bindings`'s first tuple
/// element uses for the identical port), `read_set` is the connection's
/// bound expression's read-set resolved at the PARENT scope
/// (`exprwalk::expression_read_set`), and `is_output_port_connection`
/// mirrors the child port's own `PortDecl.direction` (`true` for `Output`,
/// `false` for `Input`/`Inout` — the field this task adds specifically so a
/// consumer can tell "a write INTO the child port" (input) apart from "a
/// write driven BY the child port" (output) without re-deriving direction a
/// second, independently-reasoned way).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ContAssignTemplate {
    pub target: SymbolKey,
    pub read_set: Vec<SymbolKey>,
    pub is_output_port_connection: bool,
}

/// (NL-122) A module/program/interface port's own *declaration* — direction,
/// resolved width, declaration order — populated purely from the module's
/// own header, independent of whether/how any particular instantiation site
/// connects it (distinct from port *connection* resolution, NL-035, which is
/// Task 7's job).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PortDecl {
    /// (Task 7 addition, documented deviation from Task 5's original
    /// shape — needed once port connections become synthetic
    /// `ContAssignTemplate`/`SubInstanceSlot::port_bindings` entries
    /// addressed BY this port's own `SymbolKey`, ledger NL-035/NL-121: a
    /// `Port` member occupies a real position in its owning scope's
    /// `members()` enumeration exactly like a `SignalDecl`/`MemoryDecl`
    /// does, but nothing before this task ever recorded it.) Same
    /// position-based numbering `build_members`'s own `scope.members().
    /// enumerate()` walk assigns everything else.
    pub key: SymbolKey,
    pub name: String,
    pub direction: PortDirection,
    pub bits: u32,
}

/// The directions a module-level port can declare
/// (`sv_lang::ArgumentDirection::{In,Out,InOut,Ref}`). A `Ref` port (LRM
/// 1800-2023 23.2.2.3) is not a connection at all: the port variable IS the
/// variable it is connected to (23.3.3.2, "references to the port variable
/// shall be treated as hierarchical references to the variable to which it is
/// connected"), so it has no driver and no continuous assignment.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum PortDirection {
    Input,
    Output,
    Inout,
    Ref,
}

/// A deduped module/program body's full compile-time inventory
/// (netlist-analysis.md's `Template`). `signals` is in source declaration
/// order (NL-028's own acceptance text); the other Vecs are populated by
/// later tasks in this wave, per the field docs above.
#[derive(Debug, Clone)]
pub struct Template {
    pub key: TemplateKey,
    pub definition_name: String,
    pub signals: Vec<SignalDecl>,
    pub memories: Vec<MemoryDecl>,
    pub processes: Vec<ProcessTemplate>,
    pub continuous_assigns: Vec<ContAssignTemplate>,
    pub sub_instances: Vec<SubInstanceSlot>,
    pub ports: Vec<PortDecl>,
}

/// Template-level child-instantiation record (NL-121) — DISTINCT from
/// `InstanceTree`/`InstanceNode` (instance-level, per-`InstanceId`,
/// NL-011/NL-023): this is the deduped body's OWN enumeration of its child
/// instantiations, letting CODEGEN/analysis walk the deduped `Template`
/// graph without re-deriving it from `InstanceTree` every time.
#[derive(Debug, Clone)]
pub struct SubInstanceSlot {
    /// One entry per port of the child template, in the child's own `ports`
    /// declaration order: `(child port's SymbolKey, read_set)`. `read_set`
    /// is the driving connection expression's read-set at the PARENT scope,
    /// empty for an unconnected port — the same `Vec<SymbolKey>`
    /// representation Task 7's synthetic `ContAssignTemplate::read_set`
    /// uses for the identical underlying `expression_read_set` computation.
    /// Left `Vec::new()` per slot by THIS task (structure-only — see this
    /// task's own test); Task 7 is where `expression_read_set` first exists
    /// and populates the real content.
    pub port_bindings: Vec<(SymbolKey, Vec<SymbolKey>)>,
    pub child_template: TemplateId,
}

/// Task 5 (NL-029, NL-030, NL-120, NL-122): walks `scope.members()` — the
/// SAME flat, declaration-order iteration `dedup::confirm_and_assign_symbol_keys`
/// already walks to assign `SymbolKey`s (`elaborate.rs`'s `dedup_templates`
/// calls both over the same canonical body, so `signals[i].key`/
/// `memories[i].key` line up with that same numbering by construction, never
/// a second, independently-derived index) — and classifies every member into
/// `Template.signals`/`memories`/`ports`. Every `sv_lang_kinds::SymbolKind`
/// this walk can legitimately see in a module/program body is explicitly
/// listed; anything else falls through to a typed
/// `ElabError::UnsupportedConstruct`, never a silent skip (ARCHITECTURE §7
/// item 9). Member kinds that are recognized-but-irrelevant to this task
/// (continuous assigns, procedural blocks, parameters, nested instances/
/// generate constructs, …) are listed explicitly too, contributing nothing —
/// this is the same "known-benign, not a blanket `_`" pattern
/// `elaborate.rs`'s `collect_instance_children_into` already uses for the
/// same reason.
/// A scope's members, split by kind: `(signals, memories, ports)`.
pub(crate) type ScopeMembers = (Vec<SignalDecl>, Vec<MemoryDecl>, Vec<PortDecl>);

pub(crate) fn build_members<'d>(scope: sv_lang::Symbol<'d>) -> Result<ScopeMembers, ElabError> {
    use sv_lang_kinds::SymbolKind;

    let mut signals = Vec::new();
    let mut memories = Vec::new();
    let mut ports = Vec::new();

    for (index, member) in crate::netlist::state_items(&scope).into_iter().enumerate() {
        let key = SymbolKey(index as u32);
        match member.kind() {
            SymbolKind::Port => {
                ports.push(build_port_decl(member, key)?);
            }
            SymbolKind::Net | SymbolKind::Variable | SymbolKind::FormalArgument => {
                let ty = member.value_type().ok_or_else(|| {
                    ElabError::Frontend(format!(
                        "signal `{}` (kind {:?}) has no resolved value_type()",
                        member.hierarchical_path(),
                        member.kind(),
                    ))
                })?;
                if ty.is_unpacked_array() {
                    memories.push(build_memory_decl(member, key, ty)?);
                } else {
                    let kind = if member.kind() == SymbolKind::Net {
                        SignalKind::Net(net_kind_of_member(scope, &ty))
                    } else {
                        SignalKind::Variable
                    };
                    signals.push(SignalDecl {
                        key,
                        name: member.name().to_string(),
                        kind,
                        value_domain: value_domain_of(ty.bit_width(), &member.hierarchical_path())?,
                        driver_set: Vec::new(),
                    });
                }
            }
            SymbolKind::Genvar => {
                signals.push(SignalDecl {
                    key,
                    name: member.name().to_string(),
                    // A `genvar` is an elaboration-time-only 32-bit signed
                    // integer per LRM §27.5 — no `sv_lang::Type`/`value_type()`
                    // is associated with it (verified empirically: `value_type()`
                    // returns `None` for a `Genvar`-kind symbol), so its width
                    // is this fixed LRM constant, not a frontend-reported one.
                    value_domain: ValueDomain::Narrow { bits: 32 },
                    kind: SignalKind::Genvar,
                    driver_set: Vec::new(),
                });
            }
            // Known-benign, non-signal members a module/program body can
            // legitimately contain — explicitly enumerated (never a blanket
            // `_`) so a genuinely unrecognized kind still falls to the error
            // arm below. `Instance`/`InstanceArray`/`GenerateBlockArray`/
            // `GenerateBlock` are handled by the separate instance-hierarchy
            // walk (`elaborate.rs`); this wave's `Template.signals`/
            // `memories`/`ports` only cover members declared directly in
            // `scope`'s own flat `members()` list, matching `SymbolKey`
            // assignment's own flat walk (Task 3) — a signal declared inside
            // a `generate for` is a known, structural limitation inherited
            // from that same flat numbering, not something this task's own
            // walk special-cases away.
            SymbolKind::MultiPort
            | SymbolKind::InterfacePort
            | SymbolKind::Modport
            | SymbolKind::LetDecl
            | SymbolKind::MethodPrototype
            | SymbolKind::PrimitiveInstance
            | SymbolKind::NetAlias
            | SymbolKind::ClockingBlock
            | SymbolKind::ContinuousAssign
            | SymbolKind::Parameter
            | SymbolKind::TypeParameter
            | SymbolKind::ProceduralBlock
            | SymbolKind::StatementBlock
            | SymbolKind::Subroutine
            | SymbolKind::Sequence
            | SymbolKind::Property
            // A checker declaration (LRM 17.2) is a definition; its
            // instances' items are flattened into the scope by
            // `module_items`.
            | SymbolKind::Checker
            | SymbolKind::ElabSystemTask
            | SymbolKind::TypeAlias
            // A user-defined nettype (LRM 6.6.7) is a declaration: it has no
            // storage; its nets are ordinary `Net` members.
            | SymbolKind::NetType
            | SymbolKind::DefParam
            | SymbolKind::Specparam
            // A specify block contributes no STATE storage of its own
            // (`docs/superpowers/plans/2026-10-03-sdf.md` Task 4a) — see
            // `elaborate.rs`'s own, identically-shaped arm for why.
            | SymbolKind::SpecifyBlock
            // A covergroup declaration has no storage of its own (its
            // instances live in the coverage runtime, their handles in the
            // covergroup variables).
            | SymbolKind::CovergroupType
            // A class declared in a module: no module storage of its own.
            | SymbolKind::ClassType
            | SymbolKind::GenericClassDef
            | SymbolKind::ForwardingTypedef
            | SymbolKind::EmptyMember
            | SymbolKind::TransparentMember
            | SymbolKind::DeferredMember
            | SymbolKind::Attribute
            | SymbolKind::WildcardImport
            | SymbolKind::ExplicitImport
            | SymbolKind::Instance
            | SymbolKind::InstanceArray
            | SymbolKind::GenerateBlockArray
            | SymbolKind::GenerateBlock => {}
            other => {
                return Err(ElabError::UnsupportedConstruct {
                    hier_path: member.hierarchical_path(),
                    detail: format!(
                        "{other:?} member not yet lowered into Template.signals/memories/ports"
                    ),
                });
            }
        }
    }

    Ok((signals, memories, ports))
}

/// (NL-037, Task 8/NL-007's own sensitivity assertion) Walks `scope.members()`
/// — the SAME flat, declaration-order enumeration
/// `build_members`/`confirm_and_assign_symbol_keys` already walk over the
/// same canonical body, so `processes[i].key` lines up with that same
/// `SymbolKey` numbering, never a second, independently-derived index — and
/// lowers every `ProceduralBlock` member into a `ProcessTemplate`.
/// Exhaustive over `ProceduralBlockKind`'s 6 documented variants
/// (`ast.rs:5938-5951`, no `#[non_exhaustive]`): `Initial`/`Final` get
/// `StaticSensitivity::None` (NL-037's own acceptance); `AlwaysFF` gets a
/// real `Explicit` derivation from its own root timing control (NL-038's
/// explicit-sensitivity half — the only half this task's own tests,
/// NL-007/NL-076/NL-082, exercise). `Always`/`AlwaysComb`/`AlwaysLatch` stay
/// on the `StaticSensitivity::None` placeholder Task 7 left them on:
/// deriving their real `Implicit` read-set-based sensitivity is NL-038's
/// other half, not among this task's assigned ledger IDs (NL-007, NL-063,
/// NL-076, NL-082) and not exercised by any of their tests — left as
/// documented remaining work rather than an unverified guess (this
/// workspace's "no test that would fail if it broke" posture).
pub(crate) fn build_processes(
    scope: sv_lang::Symbol<'_>,
    scope_keys: &HashMap<crate::symid::SymbolId, SymbolKey>,
) -> Result<Vec<ProcessTemplate>, ElabError> {
    use sv_lang_kinds::SymbolKind;

    let mut processes = Vec::new();
    for (index, member) in crate::netlist::module_items(&scope).into_iter().enumerate() {
        if member.kind() != SymbolKind::ProceduralBlock {
            continue;
        }
        let key = SymbolKey(index as u32);
        let kind = member.procedural_block_procedure_kind();
        let sensitivity = match kind {
            sv_lang::ProceduralBlockKind::Initial | sv_lang::ProceduralBlockKind::Final => {
                StaticSensitivity::None
            }
            sv_lang::ProceduralBlockKind::AlwaysFF => {
                let stmt = crate::process::cfg::proc_body(&member).ok_or_else(|| {
                    ElabError::Frontend(format!(
                        "always_ff `{}` has no body()",
                        member.hierarchical_path()
                    ))
                })?;
                let tc = stmt
                    .timing_control()
                    .ok_or_else(|| ElabError::UnsupportedConstruct {
                        hier_path: member.hierarchical_path(),
                        detail: "always_ff block with no root timing control (dynamic/nested \
                                  sensitivity is not yet lowered by this elaboration pass)"
                            .to_string(),
                    })?;
                if is_inferred_clock_event(tc)
                    || crate::sva::sampled::mentions_global_clock_event(tc)
                {
                    // A checker procedure's `@clock` with `clock` left at its default
                    // `$inferred_clock` (LRM 17.4) waits on the default clocking of
                    // the scope that instantiates the checker. Resolving it needs the
                    // syntax trees, which elaboration does not have, so the lowering
                    // (`process::cfg`'s `resolve_inferred_clock`) resolves it; this
                    // deduplicated template carries no static edge for the process,
                    // which therefore is not seeded into the clock-domain graph.
                    // `@$global_clock` (14.14) likewise needs the global clocking
                    // declaration in scope, which only the syntax trees tell;
                    // so does one member of an event list
                    // (`@($global_clock or posedge rst)`), which the lowering
                    // resolves member by member (`process::cfg`).
                    StaticSensitivity::None
                } else {
                    let edges =
                        explicit_sensitivity_edges(tc, scope_keys, &member.hierarchical_path())?;
                    if edges.is_empty() {
                        StaticSensitivity::None
                    } else {
                        StaticSensitivity::Explicit { edges }
                    }
                }
            }
            // NL-038's Implicit half (AlwaysComb/AlwaysLatch) and plain
            // `always`'s sensitivity are out of this task's scope — see this
            // function's own doc comment above.
            sv_lang::ProceduralBlockKind::Always
            | sv_lang::ProceduralBlockKind::AlwaysComb
            | sv_lang::ProceduralBlockKind::AlwaysLatch => StaticSensitivity::None,
        };
        processes.push(ProcessTemplate {
            key,
            kind,
            sensitivity,
        });
    }
    Ok(processes)
}

/// Whether `tc` is a lone `@clock` signal event whose `clock` is the call of
/// `$inferred_clock`, or an event list with such a member (`@(clock or posedge
/// z)`, resolved member by member in `process::cfg`; see [`build_processes`]).
fn is_inferred_clock_event(tc: sv_lang::TimingControl<'_>) -> bool {
    if tc.kind() == sv_lang_kinds::TimingControlKind::EventList {
        return tc
            .children()
            .into_iter()
            .filter_map(|c| c.as_timing_control())
            .any(is_inferred_clock_event);
    }
    tc.kind() == sv_lang_kinds::TimingControlKind::SignalEvent
        && tc
            .children()
            .into_iter()
            .find_map(|c| c.as_expression())
            .is_some_and(|e| crate::process::cfg::is_inferred_clock_call(&e))
}

/// Task 8 (NL-038's explicit half): recursively lowers a timing control's
/// own edge-sensitivity entries. Exhaustive over `TimingControlKind` (which
/// IS `#[non_exhaustive]` upstream — this match still ends in a typed-error
/// wildcard arm per ARCHITECTURE §7 item 9, never a silent skip):
/// `SignalEvent` (`@(posedge clk)`) yields one `(SymbolKey, EdgeKind)` pair
/// from its own edge + its single expression child's referenced symbol;
/// `EventList` (`@(posedge clk or negedge rst_n)`, `@(a, b)`) recurses into
/// every child timing control and concatenates. Any other kind
/// (`Delay`/`ImplicitEvent`/`RepeatedEvent`/…) is a construct this v1
/// elaboration slice does not lower into an explicit sensitivity list — a
/// typed `ElabError::UnsupportedConstruct`, never a silent empty list
/// (which would be indistinguishable from "no sensitivity" and would wrongly
/// classify a real sequential process as combinational downstream).
fn explicit_sensitivity_edges(
    tc: sv_lang::TimingControl<'_>,
    scope_keys: &HashMap<crate::symid::SymbolId, SymbolKey>,
    context_hier_path: &str,
) -> Result<Vec<(SymbolKey, crate::state::EdgeKind)>, ElabError> {
    use sv_lang_kinds::TimingControlKind;

    match tc.kind() {
        TimingControlKind::SignalEvent => {
            // `@$global_clock`, alone or in an event list, never gets here:
            // `build_processes` leaves it to the lowering.
            debug_assert!(!crate::sva::sampled::is_global_clock_event(tc));
            let edge = edge_kind_of(tc.edge());
            let expr = tc
                .children()
                .into_iter()
                .find_map(|c| c.as_expression())
                .ok_or_else(|| {
                    ElabError::Frontend(format!(
                        "SignalEvent timing control in `{context_hier_path}` has no expression \
                         child naming the watched signal"
                    ))
                })?;
            // LRM 9.4.2: an event expression may be any expression (`@(!rst)`,
            // `@(a & b)`): it fires on a value change, with no single watched
            // signal. Such an entry names no clock for the domain graph, which
            // reads only the list's first edge as a hint; the lowering waits on
            // the expression itself, so the entry is left out here.
            let Some(sym) = expr.referenced_symbol() else {
                return Ok(Vec::new());
            };
            // LRM 25.4/25.5: a signal reached through an interface port (or a
            // modport of it) lives in the interface instance's scope, not this
            // body's: like a non-signal expression it names no local clock, and
            // the lowering waits on the expression itself.
            if !scope_keys.contains_key(&sym.uid())
                && !sym
                    .hierarchical_path()
                    .starts_with(&format!("{context_hier_path}."))
            {
                return Ok(Vec::new());
            }
            let key = *scope_keys.get(&sym.uid()).ok_or_else(|| {
                ElabError::Frontend(format!(
                    "SignalEvent timing control in `{context_hier_path}` names `{}`, not a \
                     member of its own scope's SymbolKey table",
                    sym.hierarchical_path()
                ))
            })?;
            Ok(vec![(key, edge)])
        }
        TimingControlKind::EventList => {
            let mut out = Vec::new();
            for child in tc.children() {
                let child_tc = child.as_timing_control().ok_or_else(|| {
                    ElabError::Frontend(format!(
                        "EventList timing control in `{context_hier_path}` has a non-timing-\
                         control child"
                    ))
                })?;
                out.extend(explicit_sensitivity_edges(
                    child_tc,
                    scope_keys,
                    context_hier_path,
                )?);
            }
            Ok(out)
        }
        other => Err(ElabError::UnsupportedConstruct {
            hier_path: context_hier_path.to_string(),
            detail: format!(
                "{other:?} timing control kind in an explicit sensitivity list is not yet \
                 lowered by this elaboration pass"
            ),
        }),
    }
}

/// Mirrors `sv_lang::EdgeKind`'s four variants onto `crate::state::EdgeKind`
/// (`state/mod.rs`'s own doc comment: "the `netlist` track owns the real
/// conversion once it wires the `sv-lang` dependency"). Exhaustive.
fn edge_kind_of(edge: sv_lang::EdgeKind) -> crate::state::EdgeKind {
    match edge {
        sv_lang::EdgeKind::None => crate::state::EdgeKind::None,
        sv_lang::EdgeKind::PosEdge => crate::state::EdgeKind::Posedge,
        sv_lang::EdgeKind::NegEdge => crate::state::EdgeKind::Negedge,
        sv_lang::EdgeKind::BothEdges => crate::state::EdgeKind::BothEdges,
    }
}

fn build_port_decl(port: sv_lang::Symbol<'_>, key: SymbolKey) -> Result<PortDecl, ElabError> {
    let direction = port_direction_of(port.port_direction());
    let ty = port.port_type().ok_or_else(|| {
        ElabError::Frontend(format!(
            "port `{}` has no resolved port_type()",
            port.hierarchical_path(),
        ))
    })?;
    let bits = u32::try_from(ty.bit_width()).map_err(|_| {
        ElabError::Frontend(format!(
            "port `{}` has an out-of-range bit width {}",
            port.hierarchical_path(),
            ty.bit_width(),
        ))
    })?;
    Ok(PortDecl {
        key,
        name: port.name().to_string(),
        direction,
        bits,
    })
}

fn build_memory_decl(
    member: sv_lang::Symbol<'_>,
    key: SymbolKey,
    ty: sv_lang::Type<'_>,
) -> Result<MemoryDecl, ElabError> {
    let element = ty.element_type().ok_or_else(|| {
        ElabError::Frontend(format!(
            "memory `{}` is an unpacked array with no element_type()",
            member.hierarchical_path(),
        ))
    })?;
    let element_bits = u32::try_from(element.bit_width()).map_err(|_| {
        ElabError::Frontend(format!(
            "memory `{}` has an out-of-range element bit width {}",
            member.hierarchical_path(),
            element.bit_width(),
        ))
    })?;
    let range = ty.fixed_range();
    let depth = range
        .left
        .checked_sub(range.right)
        .map(|span| span.unsigned_abs())
        .and_then(|span| span.checked_add(1))
        .ok_or_else(|| {
            ElabError::Frontend(format!(
                "memory `{}` has an out-of-range depth (range {:?})",
                member.hierarchical_path(),
                range,
            ))
        })?;
    Ok(MemoryDecl {
        key,
        name: member.name().to_string(),
        element_bits,
        depth,
    })
}

/// (Load-bearing deviation.) sv-lang 0.1.9's C API exposes no accessor from a
/// `Net`-kind `Symbol` to its own `NetType` symbol: `NetSymbol::netType`
/// (`slang::ast::NetSymbol`'s own field, the thing that actually distinguishes
/// `wire` from `wand`/`wor`/`tri`/…) is never bridged to the C API. Verified
/// exhaustively against `/root/lab/slang/source/capi/CApiAst.cpp`: every one
/// of the 6 call sites touching `NetSymbol` there exposes only
/// `expansionHint`/`getChargeStrength`/`getDelay`/`getDriveStrength`/
/// `isImplicit` — never the `netType` reference. `Symbol::net_kind()` itself
/// (`slang_net_type_net_kind`) hard-requires `sym->kind == SymbolKind::NetType`
/// and returns `NetKind::Unknown` for anything else, including a `Net`-kind
/// symbol; confirmed empirically too (a scratch integration test compiling
/// `module m; wire w; wand aw; endmodule` and calling `.net_kind()` on both
/// `Net`-kind members returned `Unknown` for both).
///
/// This pass therefore resolves a net's `NetKind` via the enclosing scope's
/// `Symbol::default_net_type()`, which is *exactly* correct for an implicit
/// net (`net_is_implicit()` — LRM-mandated: an implicit net is always the
/// scope's default net type) and is this wave's only tested/required case
/// for an explicit declaration too (a bare `wire w;`, matching NL-029's own
/// fixture, with no `` `default_nettype `` override — confirmed empirically:
/// `body.default_net_type().unwrap().net_kind() == NetKind::Wire` for exactly
/// that fixture). An explicit net whose own keyword differs from the scope's
/// ambient default (e.g. an explicit `wand w;` while the default nettype is
/// still `wire`) cannot be distinguished with the pinned API — this is a
/// documented limitation of this wave's implementation, not a silently wrong
/// answer glossed over: `scope` is always the exact scope `member` was
/// declared in (`elaborate.rs`'s `dedup_templates` calls [`build_members`]
/// with the canonical instance body itself as `scope`), so this is never
/// weaker than "the correct answer for every net this wave's ledger rows
/// actually require."
fn net_kind_of(scope: sv_lang::Symbol<'_>) -> sv_lang::NetKind {
    scope
        .default_net_type()
        .map(|net_type| net_type.net_kind())
        .unwrap_or(sv_lang::NetKind::Unknown)
}

/// (Task 10, NL-117 — additive to `net_kind_of`'s own documented deviation,
/// not a replacement for it.) `net_kind_of`'s scope-default heuristic cannot
/// distinguish an EXPLICIT `interconnect w;` declaration from the enclosing
/// scope's ambient default net type (the same load-bearing API gap
/// documented on `net_kind_of` above) — EXCEPT that `interconnect` is
/// independently, reliably detectable a different way: LRM-mandated,
/// `interconnect` nets have no fixed data type until resolved by connection,
/// and this pinned `sv-lang = "=0.1.9"` represents that as a 0-bit placeholder
/// `Type` on the net's own `value_type()` — confirmed empirically this task
/// (`module m; interconnect w; wire ordinary; wire [3:0] wide; endmodule`,
/// `has_errors() == false`): `w.value_type().unwrap().bit_width() == 0`
/// while `ordinary`'s is `1` and `wide`'s is `4`. Every OTHER LRM net-type
/// keyword (`wire`/`wand`/`wor`/`tri`/…) always carries a concrete >=1-bit
/// packed data type, so a 0-bit `Type` on a `Net`-kind symbol's own
/// `value_type()` cannot arise from any case other than `interconnect` — this
/// check is therefore checked FIRST and is exact (never a guess), falling
/// back to [`net_kind_of`]'s existing (still-limited, still-documented)
/// scope-default heuristic for every other case, which this task does not
/// change.
fn net_kind_of_member(scope: sv_lang::Symbol<'_>, ty: &sv_lang::Type<'_>) -> sv_lang::NetKind {
    if ty.bit_width() == 0 {
        return sv_lang::NetKind::Interconnect;
    }
    net_kind_of(scope)
}

fn port_direction_of(direction: sv_lang::ArgumentDirection) -> PortDirection {
    match direction {
        sv_lang::ArgumentDirection::In => PortDirection::Input,
        sv_lang::ArgumentDirection::Out => PortDirection::Output,
        sv_lang::ArgumentDirection::InOut => PortDirection::Inout,
        sv_lang::ArgumentDirection::Ref => PortDirection::Ref,
    }
}

fn value_domain_of(bits: u64, hier_path: &str) -> Result<ValueDomain, ElabError> {
    let bits32 = u32::try_from(bits).map_err(|_| {
        ElabError::Frontend(format!(
            "signal `{hier_path}` has an out-of-range bit width {bits}"
        ))
    })?;
    Ok(if bits32 <= 64 {
        ValueDomain::Narrow { bits: bits32 }
    } else {
        ValueDomain::Wide { bits: bits32 }
    })
}