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}