Skip to main content

sv_sim_sim/analysis/
graph.rs

1//! Task 8 (NL-007, NL-063): the `{net,process}` driver-dependency graph and
2//! the WOCB partitioning pass over it (netlist-analysis.md §C.1;
3//! ARCHITECTURE §2/§6.5).
4//!
5//! **Load-bearing finding, TDD'd against real `sv-lang` output (verified
6//! this task): two anonymous members of the SAME kind in one scope
7//! (e.g. two `assign` statements, or an `assign` and a `ProceduralBlock`)
8//! all share the identical `SymbolId` — the scope's own hierarchical path**
9//! (confirmed empirically: `module m; assign x=a; assign y=b; endmodule`
10//! gives BOTH `ContinuousAssign` members `id() == SymbolId("m")`, and a
11//! module with one `assign` and one anonymous `always_ff` gives the
12//! `ContinuousAssign` AND the `ProceduralBlock` the SAME `id() ==
13//! SymbolId("m")` too). This makes the `SymbolId`-keyed
14//! `netlist::symbol_key_map` table (last-write-wins) UNSAFE for resolving a
15//! `DriverInfo::containing_symbol()`/`AnalyzedProcedure::symbol()` handle
16//! back to its owning member's `SymbolKey` — exactly the identity this
17//! module needs to build a `{net,process}` graph node for "the process a
18//! driver/read belongs to". This module therefore resolves a containing/
19//! procedure symbol to its `SymbolKey` via [`member_position_key`]: a
20//! direct positional scan of `scope.members()` using `Symbol`'s own
21//! pointer-identity `PartialEq` (`ast.rs:22589-22595`, "Symbol identity is
22//! its address"), which distinguishes two colliding-`id()` members
23//! correctly since it never touches `id()` at all. Net/variable resolution
24//! (which never collides — every named net's own `id()` is a real,
25//! unique hierarchical path) keeps using `netlist::symbol_key_map`, reused
26//! rather than re-derived, per this workspace's "never re-derived a second,
27//! independently-reasoned way" posture.
28
29use crate::symid::SymbolUid;
30use std::collections::{HashMap, HashSet};
31
32use crate::netlist::{
33    DriverEntry, InstanceId, InstantiatedSignalId, Netlist, NetlistView, StaticSensitivity,
34    SymbolKey, TemplateId,
35};
36
37use super::{ClockDomainId, RegionId};
38
39/// Whether a graph node is a net (a `Template.signals` member) or a process
40/// (a `ProceduralBlock`/`ContinuousAssign`/sub-`Instance` boundary driver).
41/// Crate-private: external consumers only ever see the resulting
42/// [`Partition::nets`]/[`Partition::processes`] split, never this tag
43/// itself.
44#[derive(Clone, Copy, PartialEq, Eq, Debug)]
45enum NodeKind {
46    Net,
47    Process,
48}
49
50/// One WOCB partition: a set of `{net,process}` graph nodes that must be
51/// evaluated together (netlist-analysis.md's `Partition`). `clock_domain`
52/// is `None` for a purely combinational/mixed region.
53#[derive(Clone, Debug)]
54pub struct Partition {
55    pub id: RegionId,
56    pub processes: Vec<InstantiatedSignalId>,
57    pub nets: Vec<InstantiatedSignalId>,
58    pub clock_domain: Option<ClockDomainId>,
59}
60
61/// The `{net,process}` driver-dependency graph (NL-063): `write_edges()` is
62/// `(process) -write-> (net)` for every real (non-input-port-boundary)
63/// `DriverInfo`; `read_edges()` is `(net) -read-> (process)` for every
64/// `AnalyzedProcedure::read_set()` entry whose net has at least one
65/// internal (non-input-port) driver somewhere in this graph — a pure
66/// top-level input has no driver of its own to synchronize with, so a read
67/// of it is never a partition-membership-relevant dependency (see
68/// [`build_driver_graph`]'s own doc comment for the fixture that pins this
69/// exact filter).
70#[derive(Default)]
71pub struct DriverGraph {
72    write_edges: Vec<(InstantiatedSignalId, InstantiatedSignalId)>,
73    read_edges: Vec<(InstantiatedSignalId, InstantiatedSignalId)>,
74    node_kinds: HashMap<InstantiatedSignalId, NodeKind>,
75    /// (Task 10, NL-117.) Every node this graph knows about (every
76    /// `Template.signals` member and every `Template.processes` member, fanned
77    /// out per instance), in first-seen, fully deterministic order (per-
78    /// `Template` declaration order, then `registration_order()`) — NOT
79    /// filtered to nodes that happen to appear in `write_edges`/`read_edges`.
80    /// [`partition`] seeds its node set from this list before walking edges,
81    /// so a net or process with zero write/read edges (e.g. a bare,
82    /// never-driven/never-read `interconnect` net) still gets its own
83    /// singleton `Partition` rather than silently vanishing from the graph
84    /// entirely — the concrete regression this field exists to fix (a
85    /// declared-but-unconnected signal is not the same thing as "does not
86    /// exist," and dropping it from partitioning outright is itself a form
87    /// of misclassifying it, which NL-117's own acceptance rules out).
88    all_nodes: Vec<InstantiatedSignalId>,
89    /// Every clocked process's own intrinsic clock domain (from its
90    /// `StaticSensitivity::Explicit`'s first edge) — the union-find's
91    /// starting point for domain propagation in [`partition`]. A node not
92    /// present here has no intrinsic domain of its own (combinational, or a
93    /// sub-instance boundary).
94    process_domain: HashMap<InstantiatedSignalId, ClockDomainId>,
95    /// `ClockDomainId(i)` names `domain_registry[i]` — `(clock signal,
96    /// edge)` — interned in first-discovered order (`registration_order()`,
97    /// then each instance's own `Template.processes` order): deterministic,
98    /// never a `HashMap`-iteration-order accident.
99    domain_registry: Vec<(InstantiatedSignalId, crate::state::EdgeKind)>,
100}
101
102impl DriverGraph {
103    /// `(process, net)` pairs — one per real `DriverEntry`.
104    pub fn write_edges(&self) -> &[(InstantiatedSignalId, InstantiatedSignalId)] {
105        &self.write_edges
106    }
107
108    /// `(net, process)` pairs — one per read-set entry (see this module's
109    /// own doc comment for the input-port exclusion).
110    pub fn read_edges(&self) -> &[(InstantiatedSignalId, InstantiatedSignalId)] {
111        &self.read_edges
112    }
113
114    /// The interned clock-signal registry (`analysis::analyze`'s own
115    /// source for `StaticAnalysisResult.clock_domains`) — crate-internal,
116    /// not part of this module's public surface (mirrors
117    /// `Netlist::member_symbol`'s own "internal handle no external
118    /// `NetlistView` consumer needs" reasoning).
119    pub(crate) fn clock_domain_registry(
120        &self,
121    ) -> &[(InstantiatedSignalId, crate::state::EdgeKind)] {
122        &self.domain_registry
123    }
124}
125
126/// Resolves a raw `sv_lang::Symbol` (a `DriverInfo::containing_symbol()` or
127/// an `AnalyzedProcedure::symbol()`) to its `SymbolKey` within `members` —
128/// the flat, position-ordered `scope.members()` list `SymbolKey`
129/// assignment is defined over everywhere else in this crate — via pointer
130/// identity, never `SymbolId` (see this module's own doc comment on why the
131/// latter is unsafe for a `ProceduralBlock`/`ContinuousAssign` container).
132fn member_position_key(
133    members: &[sv_lang::Symbol<'_>],
134    target: sv_lang::Symbol<'_>,
135) -> Option<SymbolKey> {
136    members
137        .iter()
138        .position(|&m| m == target)
139        .map(|i| SymbolKey(i as u32))
140}
141
142/// Resolves every real (non-input-port) driver of `net_symbol` to
143/// `(containing construct's SymbolKey, DriverKind)`. An input-port driver
144/// (`DriverInfo::is_input_port()`) is the parent-scope boundary case —
145/// already modeled by this pass's own synthetic port-connection machinery
146/// (`netlist::elaborate`'s `ContAssignTemplate`/`SubInstanceSlot`), never a
147/// node in THIS per-instance `{net,process}` graph — so it is filtered out
148/// here, the ONE place this decision is made (both [`build_driver_graph`]
149/// and [`populate_driver_kinds`] call this, never a second,
150/// independently-reasoned driver walk). A driver whose containing symbol
151/// cannot be positionally resolved within `members` (a construct this
152/// wave's graph does not model, e.g. a driver inside a different instance
153/// reached through an interface) is defensively skipped too — never a
154/// panic, and not a `sv_lang`/`sv_lang_kinds` kind-match this workspace's
155/// exhaustive-match rule binds (it's a symbol-identity lookup, not a kind
156/// dispatch).
157fn resolve_net_drivers<'d>(
158    slang_analysis: &sv_lang::Analysis<'d>,
159    net_symbol: sv_lang::Symbol<'d>,
160    members: &[sv_lang::Symbol<'d>],
161) -> Vec<(SymbolKey, sv_lang::DriverKind)> {
162    slang_analysis
163        .drivers(net_symbol)
164        .filter(|d| !d.is_input_port())
165        .filter_map(|d| {
166            member_position_key(members, d.containing_symbol()).map(|key| (key, d.kind()))
167        })
168        .collect()
169}
170
171/// **Second load-bearing finding, TDD'd against real `sv-lang` output
172/// (verified this task): `sv_lang::Analysis` (`slang::analysis::
173/// AnalysisManager`) tracks driver/procedure data per CANONICAL instance
174/// only** — confirmed empirically against a fixture with two instances
175/// (`i0`, `i1`) of one shared module: `slang_analysis.procedures(i0_body)
176/// .count() == 3` (i0 happened to be canonical) while
177/// `slang_analysis.procedures(i1_body).count() == 0`, even though Task 2's
178/// own `DisableInstanceCaching` finding already established `i0`'s and
179/// `i1`'s own AST bodies are genuinely distinct `Symbol`s (never pointer-
180/// shared). This is `AnalysisManager.h`'s own documented behavior ("driver
181/// state tracked PER CANONICAL INSTANCE") — a SEPARATE, analysis-layer-only
182/// canonicalization from `DisableInstanceCaching`'s elaboration-layer
183/// pointer-identity guarantee. Consequence: `Analysis::drivers`/
184/// `Analysis::procedures` can only ever be queried against ONE
185/// representative instance per deduped `Template` — never a genuinely
186/// per-instance call — so this function computes the write/read STRUCTURAL
187/// PATTERN (as `(SymbolKey, SymbolKey)` pairs) once per `Template`, against
188/// its own canonical instance (found by [`representative_instance_of`],
189/// which — unlike `populate_driver_kinds`' "any instance is correct,
190/// Decision 4 guarantees identical shape" reasoning — must specifically
191/// locate the one instance `Analysis` actually populated data for, not
192/// merely the lowest `InstanceId`), then FANS that pattern OUT onto every
193/// instance sharing the `Template`, using each instance's own `InstanceId`.
194/// Clock-domain seeding stays genuinely per-instance regardless (NL-076: it
195/// never calls `Analysis` at all — it reads `Template.processes`, already
196/// derived directly from raw `sv_lang` in `elaborate()`, so `i0`'s and
197/// `i1`'s own distinct `InstantiatedSignalId`s for their `clk` port already
198/// differ correctly with no canonicalization pitfall).
199///
200/// **Deviation from the plan's literal "built by zipping
201/// `Analysis::drivers()`/`Analysis::driver_handles()`" wording:** this
202/// implementation uses `Analysis::drivers()` (`DriverInfo`) alone —
203/// `DriverInfo` already exposes both `.kind()` (`DriverEntry.driver_kind`)
204/// and `.containing_symbol()` (the process-identity resolution this graph
205/// needs); `Analysis::driver_handles()`'s `ValueDriver` adds
206/// `.source()`/`.is_unidirectional_port()`/bit-range/override-range data no
207/// test in this task's plan section exercises, so it is not fetched. Adapts
208/// minimally, keeps the specified edge set.
209pub fn build_driver_graph<'d>(
210    netlist: &Netlist<'d>,
211    slang_analysis: &sv_lang::Analysis<'d>,
212) -> DriverGraph {
213    let mut graph = DriverGraph::default();
214    let mut domain_ids: HashMap<InstantiatedSignalId, ClockDomainId> = HashMap::new();
215
216    for template_index in 0..netlist.templates.len() {
217        let tmpl_id = TemplateId(template_index as u32);
218        let template = netlist.template(tmpl_id);
219        // Declaration order (NL-028), never a `HashSet`-iteration-order
220        // accident.
221        let net_key_order: Vec<SymbolKey> = template.signals.iter().map(|s| s.key).collect();
222        let net_key_set: HashSet<SymbolKey> = net_key_order.iter().copied().collect();
223
224        let Some(rep_instance) = representative_instance_of(netlist, slang_analysis, tmpl_id)
225        else {
226            continue;
227        };
228        let Some(body) = netlist.instance_symbol(rep_instance).instance_body() else {
229            continue;
230        };
231        let members: Vec<sv_lang::Symbol<'_>> = crate::netlist::module_items(&body);
232        let scope_keys = crate::netlist::symbol_key_map(body);
233
234        // Structural write pattern (SymbolKey -> SymbolKey), computed ONCE
235        // against the canonical representative.
236        let mut write_pattern: Vec<(SymbolKey, SymbolKey)> = Vec::new();
237        let mut has_internal_driver: HashSet<SymbolKey> = HashSet::new();
238        for &net_key in &net_key_order {
239            let net_symbol = netlist.member_symbol(rep_instance, net_key);
240            let drivers = resolve_net_drivers(slang_analysis, net_symbol, &members);
241            if !drivers.is_empty() {
242                has_internal_driver.insert(net_key);
243            }
244            for (proc_key, _kind) in drivers {
245                write_pattern.push((proc_key, net_key));
246            }
247        }
248
249        // Structural read pattern (SymbolKey -> SymbolKey), same
250        // canonical-representative restriction, filtered to nets with at
251        // least one internal driver (see this module's own doc comment on
252        // `DriverGraph`).
253        let mut read_pattern: Vec<(SymbolKey, SymbolKey)> = Vec::new();
254        for proc in slang_analysis.procedures(body) {
255            let Some(proc_key) = member_position_key(&members, proc.symbol()) else {
256                continue;
257            };
258            for r in proc.read_set() {
259                let Some(&net_key) = scope_keys.get(&r.symbol().uid()) else {
260                    continue;
261                };
262                if !net_key_set.contains(&net_key) || !has_internal_driver.contains(&net_key) {
263                    continue;
264                }
265                read_pattern.push((net_key, proc_key));
266            }
267        }
268
269        // Fan the structural pattern out onto every instance sharing this
270        // Template, in deterministic `registration_order()`.
271        for instance_id in netlist.registration_order() {
272            if netlist.instance(instance_id).template != tmpl_id {
273                continue;
274            }
275
276            for &net_key in &net_key_order {
277                let net_sig = InstantiatedSignalId {
278                    instance: instance_id,
279                    member: net_key,
280                };
281                graph.node_kinds.insert(net_sig, NodeKind::Net);
282                graph.all_nodes.push(net_sig);
283            }
284            for proc in &template.processes {
285                let proc_sig = InstantiatedSignalId {
286                    instance: instance_id,
287                    member: proc.key,
288                };
289                graph
290                    .node_kinds
291                    .entry(proc_sig)
292                    .or_insert(NodeKind::Process);
293                graph.all_nodes.push(proc_sig);
294            }
295            for &(proc_key, net_key) in &write_pattern {
296                let proc_sig = InstantiatedSignalId {
297                    instance: instance_id,
298                    member: proc_key,
299                };
300                let net_sig = InstantiatedSignalId {
301                    instance: instance_id,
302                    member: net_key,
303                };
304                graph
305                    .node_kinds
306                    .entry(proc_sig)
307                    .or_insert(NodeKind::Process);
308                graph.write_edges.push((proc_sig, net_sig));
309            }
310            for &(net_key, proc_key) in &read_pattern {
311                let proc_sig = InstantiatedSignalId {
312                    instance: instance_id,
313                    member: proc_key,
314                };
315                let net_sig = InstantiatedSignalId {
316                    instance: instance_id,
317                    member: net_key,
318                };
319                graph
320                    .node_kinds
321                    .entry(proc_sig)
322                    .or_insert(NodeKind::Process);
323                graph.read_edges.push((net_sig, proc_sig));
324            }
325
326            // Clock-domain seeding: genuinely per-instance (NL-076), never
327            // Analysis-dependent — see this function's own doc comment.
328            for proc in &template.processes {
329                if let StaticSensitivity::Explicit { edges } = &proc.sensitivity
330                    && let Some(&(clk_key, edge_kind)) = edges.first()
331                {
332                    let proc_sig = InstantiatedSignalId {
333                        instance: instance_id,
334                        member: proc.key,
335                    };
336                    let clk_sig = InstantiatedSignalId {
337                        instance: instance_id,
338                        member: clk_key,
339                    };
340                    let domain_id = *domain_ids.entry(clk_sig).or_insert_with(|| {
341                        let id = ClockDomainId(graph.domain_registry.len() as u32);
342                        graph.domain_registry.push((clk_sig, edge_kind));
343                        id
344                    });
345                    graph.process_domain.insert(proc_sig, domain_id);
346                }
347            }
348        }
349    }
350
351    graph
352}
353
354/// Finds the one instance of `tmpl` that `sv_lang::Analysis` actually
355/// populated driver/procedure data for (see [`build_driver_graph`]'s own
356/// doc comment on why this is NOT simply "the lowest `InstanceId`" in
357/// general — it happens to coincide with DFS-first-visited in the fixtures
358/// this task's tests use, but this function verifies it directly against
359/// `Analysis` itself rather than assuming that correlation holds). A
360/// `Template` with no processes AND no continuous assigns has nothing for
361/// `Analysis` to canonicalize differently across its instances, so the
362/// first one (`registration_order()`) is used without probing. Returns
363/// `None` only if `tmpl` has zero instances, which cannot happen for a
364/// `Netlist` this crate's own `elaborate()` produced (defensive only).
365///
366/// `pub(crate)` (Task 9): `analysis::dpi::collect_call_tags` needs the
367/// SAME canonical-representative resolution to enumerate `Analysis`-backed
368/// call sites (`Analysis::procedures(scope).call_expressions()`) — reused
369/// rather than re-derived, per this workspace's "never a second,
370/// independently-reasoned way" posture.
371pub(crate) fn representative_instance_of<'d>(
372    netlist: &Netlist<'d>,
373    slang_analysis: &sv_lang::Analysis<'d>,
374    tmpl: TemplateId,
375) -> Option<InstanceId> {
376    let template = netlist.template(tmpl);
377    let template_has_analyzable_members =
378        !template.processes.is_empty() || !template.continuous_assigns.is_empty();
379
380    let mut fallback = None;
381    for instance_id in netlist.registration_order() {
382        if netlist.instance(instance_id).template != tmpl {
383            continue;
384        }
385        if fallback.is_none() {
386            fallback = Some(instance_id);
387        }
388        if !template_has_analyzable_members {
389            return Some(instance_id);
390        }
391        let Some(body) = netlist.instance_symbol(instance_id).instance_body() else {
392            continue;
393        };
394        if slang_analysis.procedures(body).count() > 0 {
395            return Some(instance_id);
396        }
397    }
398    fallback
399}
400
401/// Task 5's driver-set backfill (NL-063's classification clause): populates
402/// `SignalDecl.driver_set` for every net across every `Template`, against
403/// ONE representative instance per `Template` (Decision 4 guarantees every
404/// deduped instance has an identical `DriverEntry` shape for the same
405/// member, so classifying via any one of them is correct). Mutates
406/// `netlist` in place — the one mutation this whole pipeline performs on an
407/// already-elaborated `Netlist`.
408///
409/// Implemented as two passes (an immutable read pass collecting every
410/// update, then a mutation pass applying them) rather than one interleaved
411/// pass: `Netlist::instance`/`instance_symbol`/`member_symbol` are
412/// `NetlistView`/inherent methods taking `&self` (the whole struct), so
413/// they cannot be called while `netlist.templates[i]` is borrowed
414/// mutably — a plain, standard borrow-checker-driven split, not a
415/// correctness-relevant design decision.
416pub(crate) fn populate_driver_kinds<'d>(
417    netlist: &mut Netlist<'d>,
418    slang_analysis: &sv_lang::Analysis<'d>,
419) {
420    let mut updates: Vec<(usize, usize, Vec<DriverEntry>)> = Vec::new();
421
422    for template_index in 0..netlist.templates.len() {
423        let tmpl_id = TemplateId(template_index as u32);
424        // Reuses `build_driver_graph`'s own canonical-instance resolution
425        // (never a second, independently-reasoned "pick a representative"
426        // rule) — required for correctness here too: `Analysis::drivers`
427        // only has real data against the SAME canonical instance `Analysis`
428        // itself chose (see `build_driver_graph`'s doc comment).
429        let Some(rep_instance) = representative_instance_of(netlist, slang_analysis, tmpl_id)
430        else {
431            // A `Template` with zero instances cannot happen post-elaborate
432            // (every `Template` is created because at least one instance's
433            // body deduped to it) — defensive only.
434            continue;
435        };
436
437        let Some(body) = netlist.instance_symbol(rep_instance).instance_body() else {
438            continue;
439        };
440        let members: Vec<sv_lang::Symbol<'_>> = crate::netlist::module_items(&body);
441
442        for (signal_index, signal) in netlist.templates[template_index].signals.iter().enumerate() {
443            let net_symbol = netlist.member_symbol(rep_instance, signal.key);
444            let entries: Vec<DriverEntry> =
445                resolve_net_drivers(slang_analysis, net_symbol, &members)
446                    .into_iter()
447                    .map(|(containing, driver_kind)| DriverEntry {
448                        driver_kind,
449                        containing,
450                    })
451                    .collect();
452            if !entries.is_empty() {
453                updates.push((template_index, signal_index, entries));
454            }
455        }
456    }
457
458    for (template_index, signal_index, entries) in updates {
459        netlist.templates[template_index].signals[signal_index].driver_set = entries;
460    }
461}
462
463/// The WOCB partitioning pass (Decision-record union-find): every write
464/// edge always unions process with net (LRM multi-driver atomicity falls
465/// out for free — two processes that both write one net both union with
466/// it, hence transitively with each other, with no special-casing). A read
467/// edge unions net with reader UNLESS both sides already carry a resolved,
468/// DIFFERING clock domain — a genuine cross-domain register-to-register
469/// read, which [`crate::analysis::clock::classify_cdc_edges`] turns into a
470/// `CdcEdge` instead (never a merge, per NL-082). Two same-domain clocked
471/// nodes (NL-076's fixture) also do NOT union on a read alone — finer
472/// partition granularity, still correct (each register independently reads
473/// old values within one clock domain) and not required to merge by
474/// anything this pass's tests assert. Iterated to a fixed point (a merge
475/// can turn a group's domain from `None` to `Some`, unblocking a
476/// previously-skipped union later in the same or a later pass), bounded by
477/// `max_passes`.
478pub fn partition(graph: &DriverGraph, max_passes: u32) -> Vec<Partition> {
479    // Deterministic node discovery: first-seen order across `all_nodes`
480    // (every net/process `build_driver_graph` knows about, declaration- then
481    // registration-ordered — NL-117: this is what guarantees a
482    // never-driven/never-read net, e.g. a bare `interconnect` declaration,
483    // still gets its own singleton `Partition` below instead of silently
484    // never becoming a node at all), THEN write_edges, THEN read_edges (both
485    // already declaration-ordered by `build_driver_graph`) — never a
486    // `HashMap`/`HashSet`-iteration-order accident. The edge scan after
487    // `all_nodes` is defensive-only in the common case (every edge endpoint
488    // is already one of `all_nodes`) but kept so this function still behaves
489    // correctly even against a `DriverGraph` built some other way.
490    let mut nodes: Vec<InstantiatedSignalId> = Vec::new();
491    let mut seen: HashSet<InstantiatedSignalId> = HashSet::new();
492    for &n in &graph.all_nodes {
493        if seen.insert(n) {
494            nodes.push(n);
495        }
496    }
497    for &(a, b) in graph.write_edges.iter().chain(graph.read_edges.iter()) {
498        for n in [a, b] {
499            if seen.insert(n) {
500                nodes.push(n);
501            }
502        }
503    }
504    let index_of: HashMap<InstantiatedSignalId, usize> =
505        nodes.iter().enumerate().map(|(i, &n)| (n, i)).collect();
506
507    let mut parent: Vec<usize> = (0..nodes.len()).collect();
508    let mut domain: Vec<Option<ClockDomainId>> = nodes
509        .iter()
510        .map(|n| graph.process_domain.get(n).copied())
511        .collect();
512
513    fn find(parent: &mut [usize], x: usize) -> usize {
514        if parent[x] != x {
515            parent[x] = find(parent, parent[x]);
516        }
517        parent[x]
518    }
519    fn union(parent: &mut [usize], domain: &mut [Option<ClockDomainId>], a: usize, b: usize) {
520        let ra = find(parent, a);
521        let rb = find(parent, b);
522        if ra == rb {
523            return;
524        }
525        parent[rb] = ra;
526        if domain[ra].is_none() {
527            domain[ra] = domain[rb];
528        }
529    }
530
531    // Phase 1: write edges always union.
532    for &(proc_sig, net_sig) in &graph.write_edges {
533        let (Some(&pi), Some(&ni)) = (index_of.get(&proc_sig), index_of.get(&net_sig)) else {
534            continue;
535        };
536        union(&mut parent, &mut domain, pi, ni);
537    }
538
539    // Phase 2: read edges union to a fixed point, unless both sides already
540    // have a resolved (possibly-differing) clock domain.
541    for _pass in 0..max_passes.max(1) {
542        let mut changed = false;
543        for &(net_sig, proc_sig) in &graph.read_edges {
544            let (Some(&ni), Some(&pi)) = (index_of.get(&net_sig), index_of.get(&proc_sig)) else {
545                continue;
546            };
547            let rn = find(&mut parent, ni);
548            let rp = find(&mut parent, pi);
549            if rn == rp {
550                continue;
551            }
552            if domain[rn].is_none() || domain[rp].is_none() {
553                union(&mut parent, &mut domain, rn, rp);
554                changed = true;
555            }
556        }
557        if !changed {
558            break;
559        }
560    }
561
562    // Assign RegionIds to the final distinct roots, in first-seen node
563    // order, and classify each node into its partition's `.nets`/
564    // `.processes`.
565    let mut region_of_root: HashMap<usize, RegionId> = HashMap::new();
566    let mut partitions: Vec<Partition> = Vec::new();
567    for (i, &sig) in nodes.iter().enumerate() {
568        let root = find(&mut parent, i);
569        let region_id = *region_of_root.entry(root).or_insert_with(|| {
570            let id = RegionId(partitions.len() as u32);
571            partitions.push(Partition {
572                id,
573                processes: Vec::new(),
574                nets: Vec::new(),
575                clock_domain: domain[root],
576            });
577            id
578        });
579        let kind = graph.node_kinds.get(&sig).copied().unwrap_or(NodeKind::Net);
580        let p = &mut partitions[region_id.0 as usize];
581        match kind {
582            NodeKind::Net => p.nets.push(sig),
583            NodeKind::Process => p.processes.push(sig),
584        }
585    }
586
587    partitions
588}