Skip to main content

pedalkernel/compiler/
boundary_rules.rs

1//! Centralized, **derived** port-classification "rules broker" (Phase 1).
2//!
3//! # What this is (and is NOT)
4//!
5//! Stage formation today scatters its boundary decisions across several
6//! analyses: `signal_flow` decides feedback vs cascade, `neighbor_roles`
7//! infers per-terminal Load/Ref/Signal roles, the modulation-sink machinery
8//! (`bind.rs`) knows transducer ports, and `graph` knows the rail sets. Each
9//! re-derives a slice of "what does this port DO" from the device models.
10//!
11//! This module is the single place that owns BOTH:
12//!   1. the **detection rules** — [`PortClass::from_component`] derives a port's
13//!      class from the *existing model facts* (no new hand-maintained tag), and
14//!   2. their **declarative consequences** — [`BoundaryPolicy`] is the whole
15//!      rule table in one [`BoundaryPolicy::classify`], and [`Directive`] is the
16//!      consequence a later formation phase would apply.
17//!
18//! **Phase 1 is behaviour-neutral.** Nothing here is wired into formation,
19//! `find_flow_groups`, partitioning, or stamping. [`Directive`] outputs are
20//! defined but UNUSED by compilation/audio. This mirrors how
21//! [`super::neighbor_roles`] landed safely: a pure classification + diagnostic
22//! that a *later* arbitration phase will consume.
23//!
24//! # Derivation provenance (which model fact each `PortClass` comes from)
25//!
26//! | `PortClass`           | Derived from                                                                 |
27//! |-----------------------|------------------------------------------------------------------------------|
28//! | [`PortClass::Rail`]   | `graph.gnd_node` / `vcc_node` / `supply_nodes` / `ac_ground_nodes`           |
29//! | [`PortClass::Transducer`] | `Component::modulation_sink(pin)` → `ModulationSinkKind` (the `.led`/`.cv`/`.iabc`/`.vgs` ports) |
30//! | [`PortClass::ControlInput`] | `Component::signal_terminals()` `Amplifier { input, control }` (grid/base/gate, op-amp pos+neg) |
31//! | [`PortClass::Conducting`] | everything else (stamped MNA/WDF terminals: plate, collector, R/C/L pins)  |
32//!
33//! The forward-vs-back-edge bit consumed by [`BoundaryPolicy::classify`] is the
34//! directed, rail-blocked signal flow from [`super::signal_flow`] (Defect B's
35//! `directed_signal_distances_from_in`). The broker does not re-derive flow.
36
37use std::collections::{HashMap, HashSet};
38
39use super::component::{Component, EdgeKind, ModulationSinkKind, SignalTerminals};
40use super::graph::{CircuitGraph, NodeId};
41
42// ═══════════════════════════════════════════════════════════════════════════
43// Coupling domain
44// ═══════════════════════════════════════════════════════════════════════════
45
46/// Physical coupling domain of a transducer port.
47///
48/// Derived from the [`ModulationSinkKind`] of a modulation-sink pin. A
49/// transducer port couples two otherwise galvanically-isolated networks
50/// through a NON-electrical medium (light, magnetic flux, …) — that medium is
51/// the `Domain`. A later formation phase routes such couplings with a
52/// one-sample delay across the domain boundary (see [`Directive::RouteDelayed`]
53/// / [`BoundaryPolicy::DelayedCoupling`]).
54///
55/// (The design note `reports/cross-network-coupling-design-2026-06-13.md`
56/// sketches a `CouplingDomain { Flux | Light | Thermal | … }`; this is the
57/// derived, model-grounded realisation of that idea — every variant maps to a
58/// concrete `ModulationSinkKind` the device models already emit.)
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
60pub enum Domain {
61    /// Optical coupling — photocoupler LED → CdS/LDR cell (`.led`).
62    Light,
63    /// Control-voltage / current coupling — JFET Vgs, MOSFET Vgs, OTA Iabc,
64    /// VCA CV, tube Vgk/Vg1k bias injection. The "control electrode driven by
65    /// an external modulation source" family.
66    Control,
67    /// Clock / sample-rate coupling — BBD clock, delay speed/time.
68    Clock,
69}
70
71impl Domain {
72    /// Derive the coupling domain from a modulation-sink kind.
73    pub fn from_sink_kind(kind: ModulationSinkKind) -> Self {
74        match kind {
75            ModulationSinkKind::PhotocouplerLed => Domain::Light,
76            ModulationSinkKind::JfetVgs
77            | ModulationSinkKind::MosfetVgs
78            | ModulationSinkKind::TriodeVgk
79            | ModulationSinkKind::VariMuVgk
80            | ModulationSinkKind::PentodeVg1k
81            | ModulationSinkKind::OtaIabc
82            | ModulationSinkKind::VcaCv
83            | ModulationSinkKind::SpringDwell => Domain::Control,
84            ModulationSinkKind::BbdClock
85            | ModulationSinkKind::DelaySpeed
86            | ModulationSinkKind::DelayTime => Domain::Clock,
87        }
88    }
89
90    /// Human-readable label for diagnostics.
91    pub fn label(self) -> &'static str {
92        match self {
93            Domain::Light => "Light",
94            Domain::Control => "Control",
95            Domain::Clock => "Clock",
96        }
97    }
98}
99
100// ═══════════════════════════════════════════════════════════════════════════
101// PortClass — DERIVED from the device models
102// ═══════════════════════════════════════════════════════════════════════════
103
104/// Device-agnostic classification of what a single port (a `component.pin`)
105/// *is*, derived entirely from existing model facts — no new annotation.
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
107pub enum PortClass {
108    /// An ordinary current-conducting terminal that gets MNA/WDF-stamped:
109    /// a tube plate / cathode, BJT collector / emitter, R/C/L pins, op-amp
110    /// output. The default for anything that is not a control / transducer /
111    /// rail port.
112    Conducting,
113    /// A high-impedance signal-control electrode: tube grid, BJT base, FET
114    /// gate, op-amp inverting/non-inverting input. Derived from
115    /// [`SignalTerminals::Amplifier`] `input`/`control`.
116    ControlInput,
117    /// A transducer port that couples across a non-electrical [`Domain`]
118    /// (photocoupler `.led`, OTA `.iabc`, VCA `.cv`, FET `.vgs`). Derived from
119    /// [`Component::modulation_sink`].
120    Transducer(Domain),
121    /// A supply / ground / AC-ground rail node. Derived from the graph rail
122    /// sets. (A *node* property; `from_component` cannot see it, so it is
123    /// supplied by [`PortClass::from_pin_in_graph`].)
124    Rail,
125}
126
127impl PortClass {
128    /// Derive the class of `pin` on `comp` from the device model alone.
129    ///
130    /// This is the component-fact half of the derivation (it cannot see graph
131    /// nodes, so it never returns [`PortClass::Rail`] — use
132    /// [`PortClass::from_pin_in_graph`] for the rail-aware classification).
133    ///
134    /// Precedence (most-specific first):
135    /// 1. A pin that is a [`Component::modulation_sink`] is a transducer port —
136    ///    `.led`, `.iabc`, `.cv`, `.vgs`. (A FET's `vgs`/`gate` IS a transducer
137    ///    port when read this way; that is the intended unification — the
138    ///    control electrode of an externally-modulated device is a transducer.)
139    /// 2. A pin that is the `input` or `control` of an [`SignalTerminals::Amplifier`]
140    ///    is a [`PortClass::ControlInput`] — grid / base / gate / op-amp pos+neg.
141    /// 3. Otherwise [`PortClass::Conducting`].
142    pub fn from_component(comp: &dyn Component, pin: &str) -> Self {
143        // (1) Transducer: dedicated modulation-sink port (the .led / .cv /
144        //     .iabc / .vgs machinery). Most specific — wins over ControlInput.
145        if let Some(sink) = comp.modulation_sink(pin) {
146            return PortClass::Transducer(Domain::from_sink_kind(sink.target_kind));
147        }
148        // (2) ControlInput: amplifier input / control electrode.
149        if let SignalTerminals::Amplifier { input, control, .. } = comp.signal_terminals() {
150            if pin == input || control == Some(pin) {
151                return PortClass::ControlInput;
152            }
153        }
154        // (3) Everything else conducts.
155        PortClass::Conducting
156    }
157
158    /// Rail-aware classification of `comp.pin` resolved against `graph`.
159    ///
160    /// Identical to [`PortClass::from_component`] except a pin whose resolved
161    /// node is a rail (gnd / vcc / named supply / AC-ground) returns
162    /// [`PortClass::Rail`]. The rail check takes precedence because a terminal
163    /// tied straight to a rail is electrically a rail port regardless of its
164    /// device role.
165    pub fn from_pin_in_graph(
166        comp: &dyn Component,
167        pin: &str,
168        node: NodeId,
169        graph: &CircuitGraph,
170    ) -> Self {
171        if node_is_rail(graph, node) {
172            return PortClass::Rail;
173        }
174        Self::from_component(comp, pin)
175    }
176
177    /// Human-readable label for diagnostics.
178    pub fn label(self) -> &'static str {
179        match self {
180            PortClass::Conducting => "Conducting",
181            PortClass::ControlInput => "ControlInput",
182            PortClass::Transducer(_) => "Transducer",
183            PortClass::Rail => "Rail",
184        }
185    }
186}
187
188/// True if `node` is a rail node (gnd / vcc / named supply / AC-ground).
189///
190/// Mirrors `signal_flow::rail_nodes`, but as a single-node predicate so the
191/// broker does not allocate the full set per query.
192/// True if a component carries inter-sample state/taus: a reactive edge (C / L)
193/// or a runtime-variable impedance (pot, photocoupler cell). Derived from the
194/// device model's edge kinds + `is_variable`, NOT a hand-maintained tag.
195fn component_has_memory(comp: &dyn Component) -> bool {
196    comp.is_variable()
197        || comp
198            .edges()
199            .iter()
200            .any(|e| matches!(e.kind, EdgeKind::Reactive))
201}
202
203/// True if `node` is a rail node (gnd / vcc / named supply / AC-ground).
204fn node_is_rail(graph: &CircuitGraph, node: NodeId) -> bool {
205    node == graph.gnd_node
206        || node == graph.vcc_node
207        || graph.supply_nodes.contains(&node)
208        || graph.ac_ground_nodes.contains(&node)
209}
210
211// ═══════════════════════════════════════════════════════════════════════════
212// Tight coupled link — the broker rule honored by the reachability/ordering
213// analyses (the "third cause" fix)
214// ═══════════════════════════════════════════════════════════════════════════
215
216/// True iff `(node_a, node_b)` is a **`Tight` coupled signal link** — i.e. the
217/// primary↔secondary winding pair of a plain two-port transformer that the
218/// reachability (`bias_analysis`) and stage-ordering (flow-distance BFS)
219/// analyses must TRAVERSE as a co-solved signal connection.
220///
221/// # Why this rule lives here (and what it means)
222///
223/// A transformer couples its primary and secondary through magnetic flux, NOT a
224/// galvanic edge — the link is recorded in [`CircuitGraph::coupled_nodes`], never
225/// in `graph.edges`. The two analyses BFS only `graph.edges`, so a mid-chain
226/// two-port transformer whose secondary feeds a SEPARATE downstream stage is
227/// invisible to them: the primary group looks like a rail-only stub (bias) and
228/// the secondary side is unreachable from `in`. The broker's [`BoundaryPolicy::Tight`]
229/// policy already says a conducting/cascade coupling is co-solved and traversable;
230/// this predicate is the concrete, model-grounded test the analyses consult to
231/// honor that policy across the magnetic link.
232///
233/// # Gate (deliberately narrow — see the LA-2A "third cause" task)
234///
235/// Fires ONLY when ALL hold, so it never touches couplings that already work:
236/// * `node_a`/`node_b` are coupled winding nodes of the SAME transformer, and
237/// * one is on the PRIMARY winding and the other on the SECONDARY (the
238///   cross-winding link — never an intra-winding `a↔b` pair), and
239/// * NEITHER node is a rail (the analyses traverse signal terminals only), and
240/// * the transformer is a plain TWO-PORT (`!has_tertiary()` — a tertiary /
241///   center-tapped 3-winding R-type adaptor is excluded; those work today), and
242/// * the SECONDARY node is NOT the global `out` (an output-transformer-to-`out`
243///   stage already orders/classifies correctly — fixing it would move amp
244///   goldens; this gate keeps `tweed_deluxe_5e3` et al. byte-identical).
245///
246/// The two analyses each call this once to decide "also traverse this coupled
247/// link", keeping the transformer-specific knowledge HERE in the broker.
248pub(super) fn is_tight_coupled_link(
249    graph: &CircuitGraph,
250    node_a: NodeId,
251    node_b: NodeId,
252) -> bool {
253    // The pair must be a recorded coupled link (magnetic, not a graph edge).
254    if !graph
255        .coupled_nodes
256        .get(&node_a)
257        .is_some_and(|others| others.contains(&node_b))
258    {
259        return false;
260    }
261    // Neither end may be a rail: the analyses traverse signal terminals only.
262    if node_is_rail(graph, node_a) || node_is_rail(graph, node_b) {
263        return false;
264    }
265    // Both ends must be transformer winding nodes of the SAME transformer, on
266    // OPPOSITE windings (the primary↔secondary cross-link).
267    let (Some(info_a), Some(info_b)) = (
268        graph.transformer_info.get(&node_a),
269        graph.transformer_info.get(&node_b),
270    ) else {
271        return false;
272    };
273    if info_a.comp_idx != info_b.comp_idx || info_a.is_secondary == info_b.is_secondary {
274        return false;
275    }
276    // Plain TWO-PORT only: exclude tertiary / 3-winding R-type adaptors.
277    let comp = &graph.components[info_a.comp_idx];
278    let Some(cfg) = comp.kind.transformer_config() else {
279        return false;
280    };
281    if cfg.has_tertiary() {
282        return false;
283    }
284    // The secondary winding must NOT be the global `out` node: an
285    // output-transformer-to-`out` stage already classifies/orders correctly and
286    // must stay byte-identical (tweed/bassman/marshall tripwire).
287    let secondary_node = if info_a.is_secondary { node_a } else { node_b };
288    if secondary_node == graph.out_node {
289        return false;
290    }
291    true
292}
293
294/// All `Tight` coupled-link neighbours of `node` (the broker's traversal hook for
295/// the reachability/ordering analyses). Each returned node is the OPPOSITE-winding
296/// terminal of a plain two-port transformer reachable from `node` via its magnetic
297/// coupling — i.e. every `n` for which [`is_tight_coupled_link`]`(graph, node, n)`
298/// holds. Empty for non-transformer nodes and for the excluded cases.
299pub(super) fn tight_coupled_neighbors(graph: &CircuitGraph, node: NodeId) -> Vec<NodeId> {
300    let Some(others) = graph.coupled_nodes.get(&node) else {
301        return Vec::new();
302    };
303    others
304        .iter()
305        .copied()
306        .filter(|&other| is_tight_coupled_link(graph, node, other))
307        .collect()
308}
309
310/// True iff the circuit contains at least one `Tight` coupled transformer link —
311/// i.e. a plain two-port (non-tertiary) transformer crossing a stage boundary
312/// whose secondary is not the global `out`. This is the circuit-level flag the
313/// stage-ordering pass uses to decide that the rail-crossing grid-hop distance is
314/// unreliable (a mid-chain magnetic gap is present) and the corrected, broker-
315/// coupled-link-aware flow distance must be used instead. False for ordinary amps
316/// (output transformer to `out`, or cap-coupled) — they keep their existing order.
317pub(super) fn has_tight_coupled_transformer(graph: &CircuitGraph) -> bool {
318    graph
319        .coupled_nodes
320        .iter()
321        .any(|(&node, others)| others.iter().any(|&o| is_tight_coupled_link(graph, node, o)))
322}
323
324// ═══════════════════════════════════════════════════════════════════════════
325// BoundaryPolicy — the WHOLE rule table in one place
326// ═══════════════════════════════════════════════════════════════════════════
327
328/// The boundary policy for an edge between two ports, given the directed
329/// signal-flow orientation and component memory.
330///
331/// This is the single source of truth for "how must an edge between these two
332/// kinds of ports be treated at a stage boundary". A later formation phase
333/// reads [`BoundaryPolicy::directive`] to decide whether to co-solve the two
334/// ends (tight) or cut + route them with a delay (sense / coupling).
335#[derive(Debug, Clone, Copy, PartialEq, Eq)]
336pub enum BoundaryPolicy {
337    /// Co-solve: the two ports belong to the same solved system (an ordinary
338    /// forward cascade edge, a conducting edge). No boundary cut.
339    Tight,
340    /// Cut + sense with a one-sample delay: a FEEDBACK edge into a control
341    /// electrode (e.g. an output tap rectified back into a sidechain grid).
342    /// The sense path is broken to keep the forward solve causal.
343    DelayedSense,
344    /// Cut + couple across a non-electrical [`Domain`] with a one-sample delay:
345    /// a transducer edge (photocoupler LED drive, OTA Iabc CV). The two
346    /// networks never share a node; the coupling is delivered next sample.
347    DelayedCoupling(Domain),
348}
349
350impl BoundaryPolicy {
351    /// The whole rule table. `ends` are the two port classes of an edge,
352    /// `flow_is_back_edge` is true when the edge runs *against* the directed
353    /// signal flow (feedback), and `has_memory` records whether the coupling
354    /// element carries state/taus (photocoupler cell, etc.).
355    ///
356    /// Rules (in priority order):
357    /// 1. an edge into a [`PortClass::Transducer`] → [`BoundaryPolicy::DelayedCoupling`].
358    /// 2. a BACK-edge into a [`PortClass::ControlInput`] → [`BoundaryPolicy::DelayedSense`].
359    /// 3. a FORWARD edge into a [`PortClass::ControlInput`] (cascade) → [`BoundaryPolicy::Tight`].
360    /// 4. everything else → [`BoundaryPolicy::Tight`].
361    ///
362    /// `has_memory` is part of the signature because a future refinement may
363    /// route a memoryless transducer differently from a stateful one; in the
364    /// Phase-1 table it does not change the outcome (a transducer coupling is
365    /// always delayed, with or without its own state).
366    pub fn classify(
367        ends: (PortClass, PortClass),
368        flow_is_back_edge: bool,
369        has_memory: bool,
370    ) -> Self {
371        let _ = has_memory; // reserved; see doc comment.
372        let (_from, to) = ends;
373        match to {
374            // (1) Into a transducer port → delayed cross-domain coupling.
375            PortClass::Transducer(domain) => BoundaryPolicy::DelayedCoupling(domain),
376            // (2)/(3) Into a control electrode: feedback senses delayed,
377            //         forward cascade co-solves.
378            PortClass::ControlInput => {
379                if flow_is_back_edge {
380                    BoundaryPolicy::DelayedSense
381                } else {
382                    BoundaryPolicy::Tight
383                }
384            }
385            // (4) Conducting / rail sinks co-solve.
386            PortClass::Conducting | PortClass::Rail => BoundaryPolicy::Tight,
387        }
388    }
389
390    /// The declarative consequence a formation consumer applies (Phase 1: all
391    /// consumers are absent, so this output is never acted on).
392    pub fn directive(&self) -> Directive {
393        match self {
394            BoundaryPolicy::Tight => Directive::CoSolve,
395            BoundaryPolicy::DelayedSense => Directive::NonMergeCut,
396            BoundaryPolicy::DelayedCoupling(domain) => Directive::RouteDelayed(*domain),
397        }
398    }
399}
400
401/// Declarative consequence of a [`BoundaryPolicy`] for a future formation pass.
402///
403/// **Unused by compilation/audio in Phase 1.** Defined so consumers (the next
404/// phase: `find_flow_groups` / partition / stamping) have a stable vocabulary.
405#[derive(Debug, Clone, Copy, PartialEq, Eq)]
406pub enum Directive {
407    /// Keep both ends in one solved system (no cut).
408    CoSolve,
409    /// Do not merge the two ends into one group; cut the boundary (the sense
410    /// path is delivered one sample late). This is the future "feedback
411    /// linearisation" / state-variable break point.
412    NonMergeCut,
413    /// Cut and route the coupling across the given [`Domain`] with a delay
414    /// (the future cross-network transducer stamp).
415    RouteDelayed(Domain),
416}
417
418// ═══════════════════════════════════════════════════════════════════════════
419// Public introspection API (tests + the future arbitration phase)
420// ═══════════════════════════════════════════════════════════════════════════
421
422/// One classified port of one component pin in a compiled circuit.
423#[derive(Debug, Clone, PartialEq, Eq)]
424pub struct ClassifiedPort {
425    /// Owning component id (e.g. "PC1").
426    pub comp_id: String,
427    /// Pin name (e.g. "led", "grid").
428    pub pin: String,
429    /// Derived class.
430    pub class: PortClass,
431}
432
433/// One classified edge (a `from_port -> to_port` boundary) in a compiled
434/// circuit, with its derived [`BoundaryPolicy`].
435#[derive(Debug, Clone, PartialEq, Eq)]
436pub struct ClassifiedEdge {
437    /// `(comp_id, pin)` of the upstream (flow-source) end.
438    pub from: (String, String),
439    /// `(comp_id, pin)` of the downstream (flow-sink) end.
440    pub to: (String, String),
441    /// Class of the sink port (the one the edge runs INTO).
442    pub to_class: PortClass,
443    /// Class of the source port.
444    pub from_class: PortClass,
445    /// Whether the edge runs against directed signal flow (feedback).
446    pub is_back_edge: bool,
447    /// The derived policy for this boundary.
448    pub policy: BoundaryPolicy,
449}
450
451/// Classify EVERY pin of EVERY component in `pedal` into a [`PortClass`].
452///
453/// Behaviour-neutral introspection entry point: it compiles the netlist into a
454/// circuit graph and derives a class for each declared, resolved pin. Pins not
455/// present in the netlist (no resolved node) are skipped. Used by the proof
456/// diagnostic and the future arbitration phase.
457pub fn classify_ports(pedal: &crate::dsl::PedalDef) -> Vec<ClassifiedPort> {
458    let graph = CircuitGraph::from_pedal(pedal);
459    classify_ports_in_graph(&graph)
460}
461
462/// Graph-level [`classify_ports`] (in-crate; the public wrapper compiles a
463/// `PedalDef` first).
464pub(super) fn classify_ports_in_graph(graph: &CircuitGraph) -> Vec<ClassifiedPort> {
465    let mut out = Vec::new();
466    for comp in graph.components.iter() {
467        let cfg = comp.kind.pin_config();
468        for &pin in cfg.valid_pins {
469            let key = format!("{}.{}", comp.id, pin);
470            let Some(&node) = graph.node_names.get(&key) else {
471                continue;
472            };
473            let class = PortClass::from_pin_in_graph(comp.kind.as_ref(), pin, node, graph);
474            out.push(ClassifiedPort {
475                comp_id: comp.id.clone(),
476                pin: pin.to_string(),
477                class,
478            });
479        }
480    }
481    out
482}
483
484/// Classify the boundary of every component edge in `pedal` into a
485/// [`BoundaryPolicy`], oriented by directed signal flow.
486///
487/// For each edge `(node_a, node_b)` owned by a component, the ends are NAMED
488/// (source vs sink) by the directed, rail-blocked signal distance from the
489/// circuit input (`signal_flow::directed_signal_distances_from_in`): the
490/// lower-distance end is the flow SOURCE. The BACK-edge bit, however, is derived
491/// from the output-side passive closure (see [`control_sink_is_back_edge`]) —
492/// a control electrode fed from the output is feedback — because in
493/// transformer-/tube-coupled circuits the directed-distance walk dead-ends at
494/// the magnetic/active coupling and cannot orient most edges. The sink port's
495/// class plus the back-edge bit drive [`BoundaryPolicy::classify`].
496///
497/// Behaviour-neutral: the returned policies are not applied to formation.
498pub fn classify_edges(pedal: &crate::dsl::PedalDef) -> Vec<ClassifiedEdge> {
499    let graph = CircuitGraph::from_pedal(pedal);
500    let dist = super::signal_flow::directed_signal_distances_from_in(&graph);
501
502    // node -> list of (comp_id, pin) declared at that node, for naming ends.
503    // The output-side passive closure is the back-edge oracle (see
504    // `control_sink_is_back_edge`).
505    let out_closure = passive_closure_from(&graph, graph.out_node);
506
507    // Resolve each component pin to its node once.
508    let mut out = Vec::new();
509    for comp in graph.components.iter() {
510        let has_memory = component_has_memory(comp.kind.as_ref());
511        for ce in comp.kind.edges() {
512            let a_key = format!("{}.{}", comp.id, ce.pin_a);
513            let b_key = format!("{}.{}", comp.id, ce.pin_b);
514            let (Some(&na), Some(&nb)) = (graph.node_names.get(&a_key), graph.node_names.get(&b_key))
515            else {
516                continue;
517            };
518
519            // Orient by directed signal distance when both ends are reachable
520            // (source = lower distance); otherwise keep the declared a->b order.
521            // Orientation only names the ends — the back-edge bit below is what
522            // drives the policy, and it is orientation-independent (keyed on the
523            // SINK control electrode's membership in the output closure).
524            let da = dist.get(&na).copied();
525            let db = dist.get(&nb).copied();
526            let a_first = match (da, db) {
527                (Some(da), Some(db)) => da <= db,
528                _ => true,
529            };
530            let (from_pin, from_node, to_pin, to_node) = if a_first {
531                (ce.pin_a, na, ce.pin_b, nb)
532            } else {
533                (ce.pin_b, nb, ce.pin_a, na)
534            };
535
536            let from_class =
537                PortClass::from_pin_in_graph(comp.kind.as_ref(), from_pin, from_node, &graph);
538            let to_class =
539                PortClass::from_pin_in_graph(comp.kind.as_ref(), to_pin, to_node, &graph);
540
541            // A control-electrode sink fed from the output side is a back-edge.
542            // Check BOTH ends so the orientation choice cannot hide a feedback
543            // sink: whichever end is the control input decides.
544            let back_to = control_sink_is_back_edge(to_class, to_node, &out_closure);
545            let back_from = control_sink_is_back_edge(from_class, from_node, &out_closure);
546            // Re-orient so the control-input feedback end is the SINK if needed.
547            let (from_pin, from_node, from_class, to_pin, to_node, to_class, back) =
548                if back_from && !back_to {
549                    (to_pin, to_node, to_class, from_pin, from_node, from_class, true)
550                } else {
551                    (from_pin, from_node, from_class, to_pin, to_node, to_class, back_to)
552                };
553
554            let policy = BoundaryPolicy::classify((from_class, to_class), back, has_memory);
555
556            out.push(ClassifiedEdge {
557                from: (comp.id.clone(), from_pin.to_string()),
558                to: (comp.id.clone(), to_pin.to_string()),
559                to_class,
560                from_class,
561                is_back_edge: back,
562                policy,
563            });
564        }
565    }
566    out
567}
568
569/// True if an edge into `to_class`/`to_node` is a FEEDBACK (back) edge.
570///
571/// Derivation: a control electrode that is **driven from the output side** is
572/// feedback. We define "output side" as the rail-blocked PASSIVE closure of the
573/// circuit `out` node (`out_closure`) — every node a passive network ties to the
574/// output without crossing a rail or an active device. A [`PortClass::ControlInput`]
575/// whose node lands in that closure is fed back from the output (an op-amp's
576/// inverting feedback input, a compressor's sidechain tap off `out`), so the
577/// edge into it is a back-edge. A forward cascade grid/base/gate sits OUTSIDE
578/// the output's passive closure and is therefore NOT a back-edge.
579///
580/// This reuses the same rail-blocked passive-walk idiom as
581/// `signal_flow`/`neighbor_roles` rather than the directed signal DISTANCE,
582/// because in transformer-/tube-coupled circuits the directed distance walk
583/// dead-ends at the magnetic/active coupling and cannot orient most edges.
584fn control_sink_is_back_edge(
585    to_class: PortClass,
586    to_node: NodeId,
587    out_closure: &HashSet<NodeId>,
588) -> bool {
589    to_class == PortClass::ControlInput && out_closure.contains(&to_node)
590}
591
592/// Classify the boundary along a passive PATH from `out`/a source node into a
593/// named control electrode, following the *graph topology* rather than a single
594/// component edge. This is the proof-diagnostic helper used to classify
595/// multi-hop boundaries such as LA-2A's `out -> ... -> V4.grid` feedback tap or
596/// the `V1.plate -> C_c1 -> Gain -> V2.grid` makeup cascade: it resolves the
597/// control electrode's node, derives its [`PortClass`], and determines feedback
598/// vs forward by whether the control electrode is fed from the output side (see
599/// [`control_sink_is_back_edge`]).
600///
601/// `control_pin_key` is a `"Comp.pin"` net key (e.g. `"V4.grid"`); the upstream
602/// key may be a reserved net (`"in"`, `"out"`). Returns `None` if a key
603/// resolves to no node.
604pub fn classify_control_path(
605    pedal: &crate::dsl::PedalDef,
606    control_pin_key: &str,
607    upstream_pin_key: &str,
608) -> Option<ClassifiedEdge> {
609    let graph = CircuitGraph::from_pedal(pedal);
610    let out_closure = passive_closure_from(&graph, graph.out_node);
611
612    let to_node = *graph.node_names.get(control_pin_key)?;
613    let from_node = *graph.node_names.get(upstream_pin_key)?;
614
615    // The SINK key must name a real component pin (it is the classified port).
616    let (to_comp, to_pin) = split_pin_key(control_pin_key)?;
617    let to_class = class_of_keyed_pin(&graph, &to_comp, &to_pin, to_node)?;
618
619    // The SOURCE key may be a reserved net (`in` / `out` / `gnd`) with no
620    // component pin — such a node is a conducting junction. Resolve its class
621    // from a component pin when the key has one, else treat it as Conducting
622    // (rail nodes still resolve to Rail via the node check).
623    let (from_comp, from_pin) = match split_pin_key(upstream_pin_key) {
624        Some((c, p)) => (c, p),
625        None => (upstream_pin_key.to_string(), String::new()),
626    };
627    let from_class = if from_pin.is_empty() {
628        if node_is_rail(&graph, from_node) {
629            PortClass::Rail
630        } else {
631            PortClass::Conducting
632        }
633    } else {
634        class_of_keyed_pin(&graph, &from_comp, &from_pin, from_node)
635            .unwrap_or(PortClass::Conducting)
636    };
637
638    let back = control_sink_is_back_edge(to_class, to_node, &out_closure);
639    let policy = BoundaryPolicy::classify((from_class, to_class), back, false);
640
641    Some(ClassifiedEdge {
642        from: (from_comp, from_pin),
643        to: (to_comp, to_pin),
644        to_class,
645        from_class,
646        is_back_edge: back,
647        policy,
648    })
649}
650
651/// Derive the [`PortClass`] of a `Comp.pin` already resolved to `node`.
652fn class_of_keyed_pin(
653    graph: &CircuitGraph,
654    comp_id: &str,
655    pin: &str,
656    node: NodeId,
657) -> Option<PortClass> {
658    let comp = graph.components.iter().find(|c| c.id == comp_id)?;
659    Some(PortClass::from_pin_in_graph(comp.kind.as_ref(), pin, node, graph))
660}
661
662/// Split a `"Comp.pin"` key into `(comp, pin)`.
663fn split_pin_key(key: &str) -> Option<(String, String)> {
664    let (comp, pin) = key.split_once('.')?;
665    Some((comp.to_string(), pin.to_string()))
666}
667
668/// Robustness probe: classify every pin of every component for `pedal` and
669/// confirm the derivation never panics and yields a class for each resolved
670/// pin. Returns the number of pins classified. Used by the corpus smoke gate.
671pub fn smoke_classify(pedal: &crate::dsl::PedalDef) -> usize {
672    let ports = classify_ports(pedal);
673    // Also run the edge classifier so its orientation/flow logic is smoked.
674    let _edges = classify_edges(pedal);
675    ports.len()
676}
677
678/// Passive (non-active, non-rail) closure from a seed node: all nodes reachable
679/// by walking only `SignalTerminals::Passive` edges, never expanding through a
680/// rail. This is the rail-blocked passive neighbourhood of a node — used to ask
681/// "is this control input driven from the output side" (feedback).
682fn passive_closure_from(graph: &CircuitGraph, seed: NodeId) -> HashSet<NodeId> {
683    use super::component::SignalTerminals;
684    let mut visited: HashSet<NodeId> = HashSet::new();
685    let mut stack = vec![seed];
686    visited.insert(seed);
687    while let Some(node) = stack.pop() {
688        if node != seed && node_is_rail(graph, node) {
689            continue;
690        }
691        for e in graph.edges.iter() {
692            let comp = &graph.components[e.comp_idx];
693            if !matches!(comp.kind.signal_terminals(), SignalTerminals::Passive) {
694                continue;
695            }
696            let other = if e.node_a == node {
697                e.node_b
698            } else if e.node_b == node {
699                e.node_a
700            } else {
701                continue;
702            };
703            if node_is_rail(graph, other) {
704                continue;
705            }
706            if visited.insert(other) {
707                stack.push(other);
708            }
709        }
710    }
711    visited
712}
713
714/// Set of all distinct [`PortClass`] discriminants seen in a corpus pass —
715/// helper for coverage assertions in the proof diagnostic.
716pub fn distinct_classes(ports: &[ClassifiedPort]) -> HashSet<&'static str> {
717    ports.iter().map(|p| p.class.label()).collect()
718}
719
720// ═══════════════════════════════════════════════════════════════════════════
721// Phase 2a — broker-DERIVED delayed-coupling cut set
722// ═══════════════════════════════════════════════════════════════════════════
723
724/// The set of edges a delayed-coupling formation pass must CUT before grouping,
725/// plus the tap-mouth boundary nodes each cut exposes as a stage port.
726///
727/// This is the Phase-2a consumer of the Phase-1 [`BoundaryPolicy`] rules: it
728/// DERIVES (does not hand-list) the passive edges that bridge a feedback
729/// DETECTOR front-end (whose control electrode is reached from the output side —
730/// a [`BoundaryPolicy::DelayedSense`] sink) to the forward audio network. Cutting
731/// those tap-mouth edges de-fuses the detector group from the forward chain so
732/// the forward solve stays causal (the detector is sensed one sample late by a
733/// later phase; 2a only de-fuses).
734///
735/// `cuts` is keyed by index into `graph.edges`; the [`Directive`] records WHY the
736/// edge was cut (`NonMergeCut` for a sense tap). `boundary_nodes` are the cut
737/// edges' endpoints — the tap-mouth nodes that each become a stage port.
738#[derive(Debug, Clone, Default)]
739pub struct DelayedCutSet {
740    /// Edge index (into `graph.edges`) → the directive that justifies the cut.
741    pub cuts: HashMap<usize, Directive>,
742    /// Tap-mouth nodes (cut-edge endpoints) to register as SPQR terminals.
743    pub boundary_nodes: Vec<NodeId>,
744    /// Component indices (into `graph.components`) of cross-network couplers
745    /// whose coupling edge (e.g. `EL_drive -> PC1.led`) is ALREADY isolated as
746    /// `EdgeKind::Behavioral` in Phase 1 — the graph builder never instantiates
747    /// that edge (`GraphRole::Edge` makes only the LDR side), so it is recorded
748    /// here for diagnostics only and NEVER added to `cuts` (no double-cut, no
749    /// double boundary-node).
750    pub already_isolated: Vec<usize>,
751}
752
753/// The nodes of every [`PortClass::ControlInput`] electrode that is fed from the
754/// output side (a [`BoundaryPolicy::DelayedSense`] sense sink) AND whose circuit
755/// contains a cross-network coupler — i.e. a true DELAYED feedback detector's
756/// control electrode (LA-2A's `V4.grid`).
757///
758/// Derivation: a control electrode whose node lands in `passive_closure_from(out)`
759/// is driven from the output (the back-edge oracle, reused verbatim). Gated on a
760/// `EdgeKind::Behavioral`-coupling component being present so it never fires on
761/// op-amp/tube/passive feedback. Returns an empty vec for forward-only / passive
762/// / resistive-feedback circuits.
763pub fn detector_control_nodes(graph: &CircuitGraph) -> Vec<NodeId> {
764    // A DELAYED detector closes its feedback loop through a CROSS-NETWORK
765    // COUPLER — a galvanically-isolated transducer whose coupling edge is
766    // declared `EdgeKind::Behavioral` (photocoupler LED, OTA, VCA: the
767    // modulation source and the controlled device live in SEPARATE networks).
768    // An op-amp's inverting feedback electrode is also a back-edge ControlInput
769    // in passive_closure_from(out), but its loop closes RESISTIVELY
770    // (instantaneous) — NOT a delayed detector. A tube's `vgk` modulation pin
771    // classifies Transducer(Control) but is INTERNAL to the device (no
772    // Behavioral coupling edge, same network) — also not a cross-network
773    // detector. Gate on the presence of a Behavioral-coupling component so the
774    // cut fires ONLY on true cross-network feedback detectors (opto levelers),
775    // never on op-amp/tube/passive feedback. (2b will tighten this to "the
776    // sidechain that actually drives the coupler"; 2a's presence gate is safe —
777    // see boundary_rules tests + the golden byte-identity gate.)
778    let has_cross_network_coupler = graph.components.iter().any(|comp| {
779        comp.kind
780            .edges()
781            .iter()
782            .any(|e| e.kind == EdgeKind::Behavioral)
783    });
784    if !has_cross_network_coupler {
785        return Vec::new();
786    }
787
788    let out_closure = passive_closure_from(graph, graph.out_node);
789    let mut seeds: Vec<NodeId> = Vec::new();
790    for comp in graph.components.iter() {
791        let cfg = comp.kind.pin_config();
792        for &pin in cfg.valid_pins {
793            let key = format!("{}.{}", comp.id, pin);
794            let Some(&node) = graph.node_names.get(&key) else {
795                continue;
796            };
797            let class = PortClass::from_pin_in_graph(comp.kind.as_ref(), pin, node, graph);
798            if class == PortClass::ControlInput
799                && out_closure.contains(&node)
800                && !seeds.contains(&node)
801            {
802                seeds.push(node);
803            }
804        }
805    }
806    seeds
807}
808
809/// All circuit nodes belonging to the DELAYED detector SUB-NETWORK (Phase 2b).
810///
811/// Seeded at the detector control electrode(s) ([`detector_control_nodes`]) and
812/// grown across EVERY graph edge (passive AND active device couplings, via
813/// `coupled_nodes`) so the whole sidechain — the front-end tap network, the
814/// sidechain amplifier tube(s), the driver tube, and the EL-drive winding —
815/// is captured. Traversal is BLOCKED at the forward boundary nodes (`in`/`out`)
816/// and at rails, so it never leaks into the forward audio path or the supplies.
817///
818/// Used by stage formation to mark detector stages `bypass_serial` (so they do
819/// NOT overwrite the forward serial signal) and to node-route the detector as
820/// its own sub-network reading the delayed taps. Empty when there is no
821/// cross-network detector (the common case), leaving all other circuits
822/// untouched.
823pub fn detector_subnetwork_nodes(graph: &CircuitGraph) -> HashSet<NodeId> {
824    let seeds = detector_control_nodes(graph);
825    let mut visited: HashSet<NodeId> = HashSet::new();
826    if seeds.is_empty() {
827        return visited;
828    }
829    // Forward boundary nodes are hard barriers: the detector sub-network must
830    // never absorb the global `in`/`out` (those belong to the forward path).
831    let blocked = |n: NodeId| -> bool {
832        n == graph.in_node || n == graph.out_node || node_is_rail(graph, n)
833    };
834    let mut stack: Vec<NodeId> = Vec::new();
835    for &s in &seeds {
836        if !blocked(s) && visited.insert(s) {
837            stack.push(s);
838        }
839    }
840    while let Some(node) = stack.pop() {
841        // Walk every graph edge incident on `node` (passive R/C/L AND the
842        // active-device virtual bridges in `graph.edges`).
843        for e in graph.edges.iter() {
844            let other = if e.node_a == node {
845                e.node_b
846            } else if e.node_b == node {
847                e.node_a
848            } else {
849                continue;
850            };
851            if blocked(other) {
852                continue;
853            }
854            if visited.insert(other) {
855                stack.push(other);
856            }
857        }
858        // Walk device couplings (transformer primary↔secondary, tube
859        // grid↔plate↔cathode) so the sidechain tubes + EL-drive winding join.
860        if let Some(others) = graph.coupled_nodes.get(&node) {
861            for &other in others {
862                if blocked(other) {
863                    continue;
864                }
865                if visited.insert(other) {
866                    stack.push(other);
867                }
868            }
869        }
870    }
871    visited
872}
873
874/// Public diagnostic view of the Phase-2a cut set for a `PedalDef`: the cut
875/// edges' owning component ids, and the already-isolated (Behavioral) coupling
876/// component ids. Mirrors [`classify_edges`] as the external-test entry point
877/// (the internal [`delayed_cut_edges`] takes a compiled `CircuitGraph`).
878#[derive(Debug, Clone, Default, PartialEq, Eq)]
879pub struct DelayedCutDiagnostic {
880    /// Component ids owning a cut tap-mouth edge (sorted, deduped).
881    pub cut_comp_ids: Vec<String>,
882    /// Component ids owning an already-isolated Behavioral coupling edge.
883    pub already_isolated_comp_ids: Vec<String>,
884}
885
886/// Compute the Phase-2a [`DelayedCutDiagnostic`] for a parsed `pedal`.
887pub fn delayed_cut_diagnostic(pedal: &crate::dsl::PedalDef) -> DelayedCutDiagnostic {
888    let graph = CircuitGraph::from_pedal(pedal);
889    let set = delayed_cut_edges(&graph);
890    let mut cut_comp_ids: Vec<String> = set
891        .cuts
892        .keys()
893        .map(|&eidx| graph.components[graph.edges[eidx].comp_idx].id.clone())
894        .collect();
895    cut_comp_ids.sort();
896    cut_comp_ids.dedup();
897    let mut already_isolated_comp_ids: Vec<String> = set
898        .already_isolated
899        .iter()
900        .map(|&cidx| graph.components[cidx].id.clone())
901        .collect();
902    already_isolated_comp_ids.sort();
903    already_isolated_comp_ids.dedup();
904    DelayedCutDiagnostic {
905        cut_comp_ids,
906        already_isolated_comp_ids,
907    }
908}
909
910/// Derive the delayed-coupling cut set for `graph` (Phase 2a).
911///
912/// # Derivation (broker-owned; formation only consults this)
913///
914/// 1. Find the DELAYED feedback-detector control electrodes via
915///    [`detector_control_nodes`] (a ControlInput in `passive_closure_from(out)`,
916///    gated on a cross-network `EdgeKind::Behavioral` coupler being present).
917/// 2. `detector_closure = ⋃ passive_closure_from(control_electrode)` — the
918///    passive front-end that reaches the sense sink(s) (the tap network).
919/// 3. CUT every passive edge with ONE endpoint a boundary seed (`in_node` /
920///    `out_node`) and the OTHER endpoint inside `detector_closure`. These are
921///    the tap-mouth edges (LA-2A: `C_sc` on the out side, the `R_ff`/fork arm
922///    on the in side). This is the gap-cut idiom, broker-driven.
923///
924/// `EdgeKind::Behavioral` coupling edges (the photocoupler LED drive) are
925/// already isolated in Phase 1; they are recorded in `already_isolated` and
926/// never added to `cuts`.
927pub fn delayed_cut_edges(graph: &CircuitGraph) -> DelayedCutSet {
928    let mut set = DelayedCutSet::default();
929
930    // (Diagnostics) record cross-network couplers whose coupling edge is a
931    // Behavioral edge (the LED side) — already isolated in Phase 1. The graph
932    // builder never instantiates that edge, so detect it from the component's
933    // edge declarations (mirrors `coupler_boundary_nodes`).
934    for (cidx, comp) in graph.components.iter().enumerate() {
935        if comp
936            .kind
937            .edges()
938            .iter()
939            .any(|e| e.kind == EdgeKind::Behavioral)
940        {
941            set.already_isolated.push(cidx);
942        }
943    }
944
945    // (1)+(2) Find every ControlInput electrode node fed from the output side
946    //     (a DelayedSense sense sink — the feedback DETECTOR's control electrode).
947    let detector_seeds = detector_control_nodes(graph);
948
949    if detector_seeds.is_empty() {
950        // No feedback-detector electrode → no delayed cut (the common case:
951        // forward cascades, passive pedals, op-amp gain). Empty cut set.
952        return set;
953    }
954
955    // (3) Union the passive closures seeded at each detector electrode — the
956    //     tap network reaching the sense sink(s).
957    let mut detector_closure: HashSet<NodeId> = HashSet::new();
958    for &seed in &detector_seeds {
959        detector_closure.extend(passive_closure_from(graph, seed));
960    }
961
962    // (4) Cut the tap-mouth edges: a passive edge with ONE endpoint a boundary
963    //     seed (in/out) and the OTHER inside the detector closure.
964    let seeds = [graph.in_node, graph.out_node];
965    let mut boundary: Vec<NodeId> = Vec::new();
966    for (eidx, e) in graph.edges.iter().enumerate() {
967        if graph.effective_edge_kind(eidx) == EdgeKind::Behavioral {
968            continue; // already isolated; never double-cut.
969        }
970        let comp = &graph.components[e.comp_idx];
971        if !matches!(comp.kind.signal_terminals(), SignalTerminals::Passive) {
972            continue;
973        }
974        let a_is_seed = seeds.contains(&e.node_a);
975        let b_is_seed = seeds.contains(&e.node_b);
976        // Exactly one endpoint is a boundary seed (the tap mouth straddles the
977        // boundary). An edge with BOTH ends seeds, or NEITHER, is not a mouth.
978        if a_is_seed == b_is_seed {
979            continue;
980        }
981        let interior = if a_is_seed { e.node_b } else { e.node_a };
982        if node_is_rail(graph, interior) {
983            continue; // e.g. the fork's `in -> gnd` arm is not a tap mouth.
984        }
985        if detector_closure.contains(&interior) {
986            set.cuts.insert(eidx, Directive::NonMergeCut);
987            for n in [e.node_a, e.node_b] {
988                if !boundary.contains(&n) {
989                    boundary.push(n);
990                }
991            }
992        }
993    }
994
995    // (4b — Phase 2b) Cut the FEED-FORWARD fork-arm mouth(s).
996    //
997    // The faithful LA-2A's Limit/Compress selector is wired as
998    // `in -> fork(LC, [gnd, R_ff.a])`. `expand_forks` turns this into TWO
999    // synthetic SwitchedResistor components, BOTH with `.a == in_node`:
1000    //   * path 0 (`__fork_N_path_0`): `in -> gnd`  — the Compress shunt, which
1001    //     in the DEFAULT (Compress) mode is the active/low-R arm and therefore
1002    //     SHORTS the live forward `in` straight to ground (it sits on the same
1003    //     node as `T_in.a`). This is the "4th-cause" collapse.
1004    //   * path 1 (`__fork_N_path_1`): `in -> R_ff.a` — the feed-forward trickle
1005    //     into the detector front-end (R_ff.a is in `detector_closure`).
1006    // Both arms must STOP conducting on the forward audio `in`: the fork + the
1007    // LC switch belong INSIDE the detector sub-network (where the switch gates
1008    // the feed-forward contribution against the feedback tap), NOT on the
1009    // forward path. Step (4) above already skips the `in -> gnd` arm (rail
1010    // interior) and would only cut the `in -> R_ff.a` arm — leaving the gnd
1011    // shunt to keep `in` shorted. So we DERIVE the full fork-arm cut here.
1012    //
1013    // Derivation (narrow — gated on `graph.fork_paths` membership + a sibling
1014    // of the SAME fork reaching the detector closure, so non-detector forks are
1015    // untouched): group every fork-path component by its controlling switch and
1016    // its source (seed) node; if ANY arm of that fork reaches the detector
1017    // closure (its non-seed endpoint is in `detector_closure`), cut EVERY arm of
1018    // that fork whose source is the seed — including the gnd-shunt arm whose
1019    // interior is the rail (the relaxation of step (4)'s rail-interior skip,
1020    // applied ONLY to fork arms with a detector-reaching sibling).
1021    if !graph.fork_paths.is_empty() {
1022        use std::collections::HashMap as Map;
1023        // Key a fork instance by (switch_id, source seed node). Value: the list
1024        // of (edge_idx, interior_node) arms whose source is that seed.
1025        let mut fork_arms: Map<(String, NodeId), Vec<(usize, NodeId)>> = Map::new();
1026        for (eidx, e) in graph.edges.iter().enumerate() {
1027            let Some(info) = graph.fork_paths.get(&e.comp_idx) else {
1028                continue;
1029            };
1030            // A fork-path component is `source -> .a` ... `.b -> dest`; the graph
1031            // edge is the resistor's `.a`/`.b` pair. Identify the seed (source)
1032            // endpoint and the interior (destination) endpoint.
1033            let a_is_seed = seeds.contains(&e.node_a);
1034            let b_is_seed = seeds.contains(&e.node_b);
1035            // Only fork arms whose SOURCE is a forward boundary seed matter here
1036            // (the `in`-side feed-forward mouth). An arm with neither end a seed
1037            // is internal to the detector and is handled by the mix, not a cut.
1038            if a_is_seed == b_is_seed {
1039                continue;
1040            }
1041            let interior = if a_is_seed { e.node_b } else { e.node_a };
1042            let seed = if a_is_seed { e.node_a } else { e.node_b };
1043            fork_arms
1044                .entry((info.switch_id.clone(), seed))
1045                .or_default()
1046                .push((eidx, interior));
1047        }
1048        for ((_switch, _seed), arms) in fork_arms.iter() {
1049            // Does ANY sibling arm of this fork reach the detector closure?
1050            let sibling_reaches_detector = arms
1051                .iter()
1052                .any(|&(_, interior)| detector_closure.contains(&interior));
1053            if !sibling_reaches_detector {
1054                continue; // non-detector fork — leave it entirely untouched.
1055            }
1056            // Cut EVERY arm of this fork at its source seed (including the
1057            // gnd-shunt arm whose interior is the rail — the narrow relaxation).
1058            for &(eidx, _interior) in arms {
1059                if set.cuts.insert(eidx, Directive::NonMergeCut).is_none() {
1060                    let e = &graph.edges[eidx];
1061                    for n in [e.node_a, e.node_b] {
1062                        if !node_is_rail(graph, n) && !boundary.contains(&n) {
1063                            boundary.push(n);
1064                        }
1065                    }
1066                }
1067            }
1068        }
1069    }
1070
1071    set.boundary_nodes = boundary;
1072    set
1073}