Skip to main content

pedalkernel/compiler/
spqr_build.rs

1//! SPQR stage builder: converts `SpqrStage` descriptors into runnable WDF stages,
2//! and provides the `compile_via_spqr()` entry point for the full pipeline.
3//!
4//! Separation of concerns:
5//! - `spqr.rs`: graph decomposition + classification (topology + component semantics)
6//! - `rigid/`: StageStats + RigidOptimization decision rules + rigid stage builders
7//! - `spqr_build.rs`: stage construction + full pipeline entry point
8
9use super::build::create_root;
10use super::classify::NonlinearKind;
11use super::compiled::{CompiledPedal, RailSaturation, Stage, StageRef};
12use super::dyn_node::DynNode;
13use super::graph::{CircuitGraph, NodeId};
14use super::rigid::{
15    build_general_mna_from_edges, build_general_mna_from_edges_with_hints,
16    build_general_mna_from_edges_with_supply, build_rigid, build_rigid_from_group,
17    build_rigid_from_group_with_hints, build_rigid_without_iir,
18};
19use super::spqr::{spqr_decompose, spqr_to_stages, SpqrStage};
20use super::stage::{IirStage, MultiNlStage, RootKind, StateSpaceStage, WdfStage};
21use super::wdf_leaf::{LeafKind, WdfLeaf, WdfVoltageSource};
22use crate::dsl::{PedalDef, TransformerConfig};
23use crate::oversampling::{Oversampler, OversamplingFactor};
24use pedalkernel_rt::boundary_math::{MnaNodeId, MnaPortTerminals, MnaVariableResistorBinding};
25use pedalkernel_rt::elements::JaCoreModel;
26use pedalkernel_rt::thermal::ThermalModel;
27use pedalkernel_rt::tree::{MnaSystem, WdfPort};
28
29/// A stage built from the SPQR pipeline.
30pub(super) enum BuiltStage {
31    Wdf(WdfStage),
32    Iir(IirStage),
33    StateSpace(StateSpaceStage),
34    MultiNl(MultiNlStage),
35    BlackFeedback(super::stage::BlackFeedbackStage),
36    Blockwise(pedalkernel_rt::stage::BlockwiseStage),
37    SerialDelayedFeedback(pedalkernel_rt::stage::SerialDelayedFeedbackStage),
38}
39
40impl BuiltStage {
41    /// Extract as WdfStage, panicking if it's a different type. For tests.
42    #[cfg(test)]
43    pub(super) fn into_wdf(self) -> WdfStage {
44        match self {
45            BuiltStage::Wdf(w) => w,
46            _ => panic!("Expected WdfStage"),
47        }
48    }
49
50    /// Extract as IirStage, panicking if it's a different type. For tests.
51    #[cfg(test)]
52    pub(super) fn into_iir(self) -> IirStage {
53        match self {
54            BuiltStage::Iir(i) => i,
55            _ => panic!("Expected IirStage"),
56        }
57    }
58
59    /// Extract as StateSpaceStage, panicking if it's a different type. For tests.
60    #[cfg(test)]
61    pub(super) fn into_state_space(self) -> StateSpaceStage {
62        match self {
63            BuiltStage::StateSpace(s) => s,
64            _ => panic!("Expected StateSpaceStage"),
65        }
66    }
67
68    /// Extract as MultiNlStage, panicking if it's a different type. For tests.
69    #[cfg(test)]
70    pub(super) fn into_multi_nl(self) -> MultiNlStage {
71        match self {
72            BuiltStage::MultiNl(m) => m,
73            _ => panic!("Expected MultiNlStage"),
74        }
75    }
76}
77
78// ═══════════════════════════════════════════════════════════════════════════
79// Full pipeline entry point
80// ═══════════════════════════════════════════════════════════════════════════
81
82/// Compile a `.pedal` circuit via the SPQR pipeline with default options.
83pub fn compile_via_spqr(pedal: &PedalDef, sample_rate: f64) -> Result<CompiledPedal, String> {
84    compile_via_spqr_with_options(
85        pedal,
86        sample_rate,
87        super::compile::CompileOptions::default(),
88    )
89}
90
91/// Compile a `.pedal` circuit via the SPQR pipeline.
92///
93/// Full flow: PedalDef → CircuitGraph → feedback groups → per-group
94/// SPQR decompose → classify → build stages → CompiledPedal.
95///
96/// Returns `Err` if any stage can't be built (unsupported topology).
97pub fn compile_via_spqr_with_options(
98    pedal: &PedalDef,
99    sample_rate: f64,
100    options: super::compile::CompileOptions,
101) -> Result<CompiledPedal, String> {
102    use super::signal_flow::find_flow_groups;
103
104    let mut graph = CircuitGraph::from_pedal(pedal);
105    // Resolve envelope-modulated component edge kinds (audit gap G2):
106    // `EF.out -> J.vgs` reclassifies the JFET's drain–source edge to Linear
107    // so it compiles as a `jfet_vr` variable-resistor leaf the envelope
108    // binding can reach. Restricted to envelope followers so LFO-modulated
109    // circuits keep their existing compilation behavior.
110    super::graph::resolve_components_by(&mut graph, pedal, |c| {
111        c.kind
112            .as_any()
113            .downcast_ref::<super::components::EnvelopeFollower>()
114            .is_some()
115    });
116    // Connectivity-based completeness check: every active device that declares
117    // `terminal_requirements()` must have its Required neighbour roles present
118    // (e.g. a triode must have a Load reachable from an output terminal). This
119    // is additive and behaviour-neutral — it only rejects circuits that are
120    // genuinely missing a required neighbour; it never changes how complete
121    // circuits compile. Gated false-positive-free on the working corpus.
122    super::neighbor_roles::validate_completeness(&graph)?;
123    let supply_voltage = pedal.supplies.first().map_or(9.0, |s| s.config.voltage);
124    let delay_lines = build_delay_line_bindings(pedal, sample_rate);
125    // Behavioral islands (bbd(), vca(), ...) lower to per-instance runtime DSP
126    // blocks via the DspBlock registry, not the WDF/MNA core. The mandatory-
127    // lowering gate (architecture debt §4) rejects any registered-island
128    // type_tag with no DspBlock to lower it (currently vco()). Runtime
129    // instances are built + bound by `dsp_block::bind_runtime_all` after the
130    // CompiledPedal is constructed; `has_blocks` is the presence predicate
131    // that gates group splitting and terminal injection below.
132    super::dsp_block::reject_unlowered_behavioral(pedal)?;
133    let has_blocks = super::dsp_block::any_block_has_components(pedal);
134
135    // When ports are declared, the first input port replaces `in` and
136    // the first output port replaces `out` as the circuit's I/O nodes.
137    if !pedal.ports.is_empty() {
138        if let Some(first_in) = pedal
139            .ports
140            .iter()
141            .find(|p| p.direction == pedalkernel_rt::PortDirection::Input)
142        {
143            if let Some(&node_id) = graph.node_names.get(&first_in.name) {
144                graph.in_node = node_id;
145            }
146        }
147        if let Some(first_out) = pedal
148            .ports
149            .iter()
150            .find(|p| p.direction == pedalkernel_rt::PortDirection::Output)
151        {
152            if let Some(&node_id) = graph.node_names.get(&first_out.name) {
153                graph.out_node = node_id;
154            }
155        }
156    }
157
158    // Collect all non-bridge edges (passive + NL + VCVS)
159    let active_set: std::collections::HashSet<usize> =
160        graph.active_edge_indices.iter().copied().collect();
161    let all_edges: Vec<usize> = (0..graph.edges.len())
162        .filter(|i| !active_set.contains(i))
163        .collect();
164
165    if all_edges.is_empty() && delay_lines.is_empty() && !has_blocks {
166        return Err("No circuit edges found".to_string());
167    }
168
169    if all_edges.is_empty() {
170        let mut compiled = CompiledPedal {
171            stages: Vec::new(),
172            stage_route_plan: pedalkernel_rt::processor::StageRoutePlan::default(),
173            push_pull_stages: Vec::new(),
174            pre_gain: 1.0,
175            output_gain: 1.0,
176            rail_saturation: RailSaturation::None,
177            rail_sat_oversampler: Oversampler::new(options.oversampling),
178            sample_rate: sample_rate as crate::Wave,
179            controls: Vec::new(),
180            gain_range: (0.0, 1.0),
181            supply_voltage: supply_voltage as crate::Wave,
182            oversampling: options.oversampling,
183            lfos: Vec::new(),
184            envelopes: Vec::new(),
185            bbds: Vec::new(),
186            delay_lines,
187            springs: Vec::new(),
188            vcos: Vec::new(),
189            vcas: Vec::new(),
190            thermal: if options.thermal {
191                Some(ThermalModel::silicon_standard(sample_rate as crate::Wave))
192            } else {
193                None
194            },
195            tolerance_seed: 0,
196            opamp_stages: Vec::new(),
197            power_supply: None,
198            metrics_accumulator: None,
199            metrics_buffer: None,
200            #[cfg(feature = "diag")]
201            diag_ring: None,
202            input_loading: None,
203            output_loading: None,
204            output_dc_block: None,
205            sidechains: Vec::new(),
206            subcircuit_processors: Vec::new(),
207            subcircuit_routing: Vec::new(),
208            subcircuit_output_idx: None,
209            subcircuit_outputs: Vec::new(),
210            pot_smoothers: Vec::new(),
211            wiper_dividers: Vec::new(),
212            pot_mirrors: hashbrown::HashMap::new(),
213            base_grid_bias: 0.0,
214            multi_nl_recompute_counter: 0,
215            node_signals: Vec::new(),
216            triggers: Vec::new(),
217            bbd_wet_mix: 0.5,
218            bbd_mix_pot_id: None,
219            original_passive_values: hashbrown::HashMap::new(),
220            ports: Vec::new(),
221            port_values: Vec::new(),
222            internal_ports: Vec::new(),
223            boundary_loads: Vec::new(),
224            detector_led_coupling: None,
225            initialized: false,
226        };
227        compiled.set_supply_voltage(supply_voltage as crate::Wave);
228        super::spqr_control::bind_controls(pedal, &mut compiled);
229        super::dsp_block::bind_runtime_all(pedal, &mut compiled, sample_rate)?;
230        return Ok(compiled);
231    }
232
233    if options.collapse_nl {
234        let stage = build_general_mna_from_edges_with_hints(
235            &all_edges,
236            &graph,
237            sample_rate,
238            &pedal.init_hints,
239        )?;
240        let mut compiled = CompiledPedal {
241            stages: vec![Stage::MultiNl(stage)],
242            stage_route_plan: pedalkernel_rt::processor::StageRoutePlan::default(),
243            push_pull_stages: Vec::new(),
244            pre_gain: 1.0,
245            output_gain: 1.0,
246            rail_saturation: RailSaturation::None,
247            rail_sat_oversampler: Oversampler::new(options.oversampling),
248            sample_rate: sample_rate as crate::Wave,
249            controls: Vec::new(),
250            gain_range: (0.0, 1.0),
251            supply_voltage: supply_voltage as crate::Wave,
252            oversampling: options.oversampling,
253            lfos: Vec::new(),
254            envelopes: Vec::new(),
255            bbds: Vec::new(),
256            delay_lines,
257            springs: Vec::new(),
258            vcos: Vec::new(),
259            vcas: Vec::new(),
260            thermal: None,
261            tolerance_seed: 0,
262            opamp_stages: Vec::new(),
263            power_supply: None,
264            metrics_accumulator: None,
265            metrics_buffer: None,
266            #[cfg(feature = "diag")]
267            diag_ring: None,
268            input_loading: None,
269            output_loading: None,
270            output_dc_block: None,
271            sidechains: Vec::new(),
272            subcircuit_processors: Vec::new(),
273            subcircuit_routing: Vec::new(),
274            subcircuit_output_idx: None,
275            subcircuit_outputs: Vec::new(),
276            pot_smoothers: Vec::new(),
277            wiper_dividers: Vec::new(),
278            pot_mirrors: hashbrown::HashMap::new(),
279            base_grid_bias: 0.0,
280            multi_nl_recompute_counter: 0,
281            node_signals: Vec::new(),
282            triggers: Vec::new(),
283            bbd_wet_mix: 0.5,
284            bbd_mix_pot_id: None,
285            original_passive_values: hashbrown::HashMap::new(),
286            ports: Vec::new(),
287            port_values: Vec::new(),
288            internal_ports: Vec::new(),
289            boundary_loads: Vec::new(),
290            detector_led_coupling: None,
291            initialized: false,
292        };
293        compiled.set_supply_voltage(supply_voltage as crate::Wave);
294        super::spqr_control::bind_controls(pedal, &mut compiled);
295        super::dsp_block::bind_runtime_all(pedal, &mut compiled, sample_rate)?;
296        return Ok(compiled);
297    }
298
299    // Step 1: Partition edges into signal flow groups.
300    // Each group = mutually-dependent components that must share a stage.
301    // Uses directed dependency graph: cycles = co-solved, acyclic = sequential.
302    eprintln!(
303        "  [compile] Step 1: find_flow_groups ({} edges)...",
304        all_edges.len()
305    );
306    // Phase 2a: the same broker cut set find_flow_groups consulted. Threaded
307    // into the merge passes so a cut tap-mouth edge can never be re-absorbed
308    // back across the delayed-coupling boundary.
309    let cut_edges = super::boundary_rules::delayed_cut_edges(&graph);
310    // Detector control-electrode nodes (DelayedSense sinks) — used below to mark
311    // the de-fused detector group bypass_serial so it can't hijack the chain.
312    let detector_seed_nodes: std::collections::HashSet<super::graph::NodeId> =
313        super::boundary_rules::detector_control_nodes(&graph)
314            .into_iter()
315            .collect();
316    let mut feedback_groups = super::signal_flow::find_flow_groups(&all_edges, &graph);
317    merge_cross_reactive_groups_into_active_groups(&mut feedback_groups, &graph, &cut_edges);
318    merge_input_coupling_into_active_groups(&mut feedback_groups, &graph, &cut_edges);
319    // DSP-block pedals: bbd(), vca(), ... are GraphRole::Virtual, so the
320    // netlist is galvanically cut at their in/out pins. Split any group that
321    // spans a behavioral gap (the sides stay "connected" through ground only)
322    // into separate serial stages — a stage built across the gap probes 0.
323    if has_blocks {
324        super::dsp_block::split_groups_at_behavioral_gaps(&mut feedback_groups, &graph, pedal);
325    }
326    eprintln!("  [compile] Step 1 done: {} groups", feedback_groups.len());
327
328    // Step 1b: Compute signal flow distance for each group via BFS from in_node.
329    // Each group's distance = min BFS hop count from in_node to any node in the group.
330    // This gives the correct processing order (input coupling first, clipping second,
331    // tone third, etc.) regardless of the order find_flow_groups returned them.
332    let group_flow_distances = compute_group_flow_distances(&feedback_groups, &graph);
333
334    // Broker (d-2): does this circuit have a mid-chain Tight transformer coupling
335    // (plain two-port, secondary != out)? If so, the rail-crossing grid-hop walk
336    // used to order triode stages under-counts everything behind the magnetic gap,
337    // and the corrected broker-coupled-link-aware `group_flow_distances` must be
338    // used for those stages instead. Computed once (cheap; consults the broker).
339    let has_tight_coupled_transformer =
340        super::boundary_rules::has_tight_coupled_transformer(&graph);
341
342    // Step 1c: Classify each group as signal path or static bias.
343    // Static bias groups (VCC dividers) are bypassed in the serial audio
344    // chain — they still process and meter, but don't overwrite the signal.
345    let group_bias: Vec<_> = feedback_groups
346        .iter()
347        .map(|g| super::bias::classify_group_bias(g, &graph))
348        .collect();
349
350    // Build a map from node → DC bias voltage across all StaticBias groups.
351    // Used to set op-amp v_max from the circuit's actual bias network.
352    let mut bias_node_voltages: std::collections::BTreeMap<super::graph::NodeId, f64> =
353        std::collections::BTreeMap::new();
354    for kind in &group_bias {
355        if let super::bias::GroupBiasKind::StaticBias { dc_voltages } = kind {
356            bias_node_voltages.extend(dc_voltages);
357        }
358    }
359
360    // Step 2: SPQR decompose each group independently.
361    // DSP-block signal pins are behavioral stage boundaries: treat them like
362    // global terminals so the group on each side of a galvanic gap gets a port
363    // there (otherwise the dangling side has no entry/exit node and its stage
364    // probes 0). Boundary nodes are gathered from every registered DspBlock.
365    let mut terminals = vec![graph.in_node, graph.out_node];
366    for node in super::dsp_block::all_boundary_nodes(pedal, &graph) {
367        if !terminals.contains(&node) {
368            terminals.push(node);
369        }
370    }
371    // Cross-network couplers (photocoupler LED/LDR) are galvanically isolated:
372    // each side's port nodes must be SPQR terminals so the side that does not
373    // carry the global in/out signal still gets a stage port (otherwise its
374    // dangling group probes 0). Kept as a sibling of `all_boundary_nodes` so
375    // the DspBlock registry and the coupler concern stay cleanly separated.
376    for node in coupler_boundary_nodes(&graph) {
377        if !terminals.contains(&node) {
378            terminals.push(node);
379        }
380    }
381    // Phase 2a: each side of a broker-cut delayed-coupling boundary becomes a
382    // stage port — register the tap-mouth nodes as SPQR terminals (sibling of
383    // `coupler_boundary_nodes`) so the de-fused detector group and the forward
384    // group each get an entry/exit node at the cut.
385    for &node in &cut_edges.boundary_nodes {
386        if !terminals.contains(&node) {
387            terminals.push(node);
388        }
389    }
390    let mut stages: Vec<Stage> = Vec::new();
391    let mut stage_comp_ids: Vec<Vec<String>> = Vec::new();
392    let mut bkm_consumed_comp_ids: std::collections::HashSet<String> =
393        std::collections::HashSet::new();
394    // Component ids of transformer-SECONDARY load groups consumed by the
395    // transformer-reflection fusion (their edges are solved inside the fused
396    // primary-side PassiveRType MNA). Groups made entirely of these ids are
397    // skipped — the magnetic-coupling analogue of `bkm_consumed_comp_ids`.
398    let mut xfmr_consumed_comp_ids: std::collections::HashSet<String> =
399        std::collections::HashSet::new();
400    // Component ids of a trailing collector-load-and-output chain fused into a
401    // non-feedback multi-BJT (Fuzz-Face / Sunflower) group's general-MNA build
402    // (their edges are solved inside that group's MNA). Groups made entirely of
403    // these ids are skipped — the non-feedback-branch analogue of the
404    // feedback-branch C1 output-fusion consumption (bead pedalkernel-guwp).
405    let mut collector_chain_consumed_comp_ids: std::collections::HashSet<String> =
406        std::collections::HashSet::new();
407    // Boundary-load decision table rows recorded during stage building;
408    // resolved to final stage indices after the last stage sort.
409    let mut pending_boundary_loads: Vec<super::boundary_load::PendingBoundaryLoad> = Vec::new();
410
411    // Helper: push a BuiltStage into the unified stages vec.
412    // `flow_distance` comes from BFS-computed signal flow distance.
413    // `label` is the debug component names (zero cost in release).
414    // `bypass` is true for static bias groups (not on audio path).
415    macro_rules! push_stage {
416        ($built:expr, $flow_distance:expr, $label:expr, $bypass:expr, $comp_ids:expr) => {
417            match $built {
418                BuiltStage::Wdf(mut wdf) => {
419                    wdf.signal_flow_distance = $flow_distance;
420                    wdf.bypass_serial = $bypass;
421                    #[cfg(debug_assertions)]
422                    {
423                        wdf.debug_label = $label;
424                    }
425                    // Generate K-method lookup table for NL roots
426                    // Skipped when skip_k_tables is set (debug builds).
427                    if wdf.k_table.is_none()
428                        && !options.skip_k_tables
429                        && !wdf.is_source_follower
430                        && root_supports_k_table(&wdf.root)
431                    {
432                        let stage_n = stages.len();
433                        eprintln!("  K-table: generating for stage {stage_n}...");
434                        wdf.k_table = super::k_method::generate_k_table(&mut wdf);
435                        if let Some(ref kt) = wdf.k_table {
436                            eprintln!(
437                                "  K-table generated: {}D, {} entries",
438                                kt.dims,
439                                kt.entries.len()
440                            );
441                        }
442                    }
443                    stages.push(Stage::Wdf(wdf));
444                    stage_comp_ids.push($comp_ids.clone());
445                }
446                BuiltStage::Iir(mut iir) => {
447                    iir.signal_flow_distance = $flow_distance;
448                    iir.bypass_serial = $bypass;
449                    #[cfg(debug_assertions)]
450                    {
451                        iir.debug_label = $label;
452                    }
453                    stages.push(Stage::Iir(iir));
454                    stage_comp_ids.push($comp_ids.clone());
455                }
456                BuiltStage::StateSpace(mut ss) => {
457                    ss.signal_flow_distance = $flow_distance;
458                    ss.bypass_serial = $bypass;
459                    #[cfg(debug_assertions)]
460                    {
461                        ss.debug_label = $label;
462                    }
463                    stages.push(Stage::StateSpace(ss));
464                    stage_comp_ids.push($comp_ids.clone());
465                }
466                BuiltStage::MultiNl(mut mnl) => {
467                    mnl.signal_flow_distance = $flow_distance;
468                    mnl.bypass_serial = $bypass;
469                    #[cfg(debug_assertions)]
470                    {
471                        mnl.debug_label = $label;
472                    }
473                    stages.push(Stage::MultiNl(mnl));
474                    stage_comp_ids.push($comp_ids.clone());
475                }
476                BuiltStage::BlackFeedback(mut bf) => {
477                    bf.signal_flow_distance = $flow_distance;
478                    bf.bypass_serial = $bypass;
479                    #[cfg(debug_assertions)]
480                    {
481                        bf.debug_label = $label;
482                    }
483                    stages.push(Stage::BlackFeedback(bf));
484                    stage_comp_ids.push($comp_ids.clone());
485                }
486                BuiltStage::Blockwise(mut bkm) => {
487                    let mut consumed: std::collections::HashSet<String> = bkm
488                        .coupling_elements
489                        .iter()
490                        .map(|element| element.comp_id.clone())
491                        .chain(
492                            bkm.coupling_passives
493                                .iter()
494                                .map(|passive| passive.comp_id.clone()),
495                        )
496                        .collect();
497                    consumed.remove("");
498                    if !consumed.is_empty() {
499                        for idx in (0..stages.len()).rev() {
500                            let ids = &stage_comp_ids[idx];
501                            if !ids.is_empty() && ids.iter().all(|id| consumed.contains(id)) {
502                                stages.remove(idx);
503                                stage_comp_ids.remove(idx);
504                            }
505                        }
506                        bkm_consumed_comp_ids.extend(consumed);
507                    }
508                    // Blockwise stages set their own flow distance (0 = primary
509                    // signal path, processes before output coupling stages).
510                    // Don't override with the feedback group's inflated distance.
511                    bkm.bypass_serial = $bypass;
512                    bkm.init_buffers();
513                    stages.push(Stage::Blockwise(bkm));
514                    stage_comp_ids.push($comp_ids.clone());
515                }
516                BuiltStage::SerialDelayedFeedback(mut serial) => {
517                    serial.signal_flow_distance = $flow_distance;
518                    serial.bypass_serial = $bypass;
519                    #[cfg(debug_assertions)]
520                    {
521                        serial.debug_label = $label;
522                    }
523                    stages.push(Stage::SerialDelayedFeedback(serial));
524                    stage_comp_ids.push($comp_ids.clone());
525                }
526            }
527        };
528    }
529
530    // Build order: non-feedback groups first so the clipping stage can read
531    // input coupling impedance (Ri) from already-built preceding stages.
532    // Signal flow order is preserved via `gi` (original group index from
533    // find_flow_groups), which becomes signal_flow_distance on each stage.
534    let mut build_order: Vec<(usize, &super::signal_flow::FlowGroup)> =
535        feedback_groups.iter().enumerate().collect();
536    // Track which ground-clip groups were merged into another group's stage
537    let mut ground_clip_built: std::collections::HashSet<usize> = std::collections::HashSet::new();
538    // Track which passive edges were absorbed into a triode-context MNA stage.
539    // Their containing groups will be skipped to avoid double-processing.
540    let mut triode_absorbed_edges: std::collections::HashSet<usize> =
541        std::collections::HashSet::new();
542    // Edge guard (pedalkernel-ffkl): edges DELIBERATELY excluded from stage
543    // assembly, with the exclusion reason logged at the exclusion site.
544    let mut guard_excluded_edges: std::collections::HashSet<usize> =
545        std::collections::HashSet::new();
546    build_order.sort_by_key(|(_, g)| if g.has_feedback() { 1 } else { 0 });
547
548    for &(gi, group) in &build_order {
549        if group.all_edges().is_empty() {
550            continue;
551        }
552
553        // Skip passive groups whose edges were ALL absorbed into a triode-context MNA stage.
554        // Groups with only some absorbed edges: their group_edges will be filtered below.
555        {
556            let group_edges = group.all_edges();
557            if !group_edges.is_empty()
558                && group_edges
559                    .iter()
560                    .all(|eidx| triode_absorbed_edges.contains(eidx))
561            {
562                #[cfg(test)]
563                eprintln!("  group {gi}: all edges absorbed into triode stage, skipping");
564                continue;
565            }
566        }
567
568        let group_comp_ids: Vec<String> = {
569            let mut names: Vec<String> = group
570                .all_edges()
571                .iter()
572                .map(|&eidx| graph.components[graph.edges[eidx].comp_idx].id.clone())
573                .collect();
574            names.sort();
575            names.dedup();
576            names
577        };
578        if !group_comp_ids.is_empty()
579            && group_comp_ids
580                .iter()
581                .all(|id| bkm_consumed_comp_ids.contains(id))
582        {
583            #[cfg(test)]
584            eprintln!("  → group {gi} already consumed by BKM coupling");
585            continue;
586        }
587        if !group_comp_ids.is_empty()
588            && group_comp_ids
589                .iter()
590                .all(|id| xfmr_consumed_comp_ids.contains(id))
591        {
592            #[cfg(test)]
593            eprintln!("  → group {gi} already consumed by transformer-secondary reflection");
594            continue;
595        }
596        if !group_comp_ids.is_empty()
597            && group_comp_ids
598                .iter()
599                .all(|id| collector_chain_consumed_comp_ids.contains(id))
600        {
601            #[cfg(test)]
602            eprintln!("  → group {gi} already consumed by collector-chain output fusion");
603            continue;
604        }
605
606        // Build debug label from component names (zero cost in release)
607        #[cfg(debug_assertions)]
608        let group_label = group_comp_ids.join(",");
609        #[cfg(not(debug_assertions))]
610        let group_label = String::new();
611
612        // Static bias groups bypass the serial audio chain. Transistor-only
613        // nonlinear modulator groups that do not reach the output also bypass:
614        // they are solved for their local operating point but must not become
615        // an independent serial audio stage.
616        let has_signal_transformer =
617            group_has_signal_transformer_boundary(group, &graph, graph.in_node, graph.out_node);
618        // Phase 2a: the de-fused detector group (holds the DelayedSense control
619        // electrode, no non-cut path to in/out after the tap-mouth cut) computes
620        // state/metering but must NOT hijack the forward serial signal — bypass
621        // it. Its input is floating in 2a (driven by internal delayed ports in
622        // 2b); de-fusing + feeding the forward path is all 2a proves.
623        let is_detector_bypass = is_delayed_detector_group(group, &graph, &detector_seed_nodes);
624        let is_bypass = ((matches!(
625            group_bias[gi],
626            super::bias::GroupBiasKind::StaticBias { .. }
627        ) || is_nonlinear_modulator_group(group, &graph))
628            && !has_signal_transformer)
629            || is_detector_bypass;
630
631        #[cfg(test)]
632        {
633            let edges = group.all_edges();
634            let edge_names: Vec<String> = edges
635                .iter()
636                .map(|&eidx| {
637                    let comp = &graph.components[graph.edges[eidx].comp_idx];
638                    format!("{}({:?})", comp.id, graph.effective_edge_kind(eidx))
639                })
640                .collect();
641            eprintln!(
642                "group {gi}: feedback={} edges={:?}",
643                group.has_feedback(),
644                edge_names
645            );
646        }
647
648        if is_nonlinear_modulator_group(group, &graph) {
649            #[cfg(test)]
650            eprintln!("  → nonlinear modulator group consumed by control analysis");
651            // Edge guard: deliberate exclusion, with a reason. (When the
652            // modulator classifier MISfires — e.g. the fuzz-face family's
653            // audio-path BJT core consumed here, leaving a unity passthrough —
654            // that is the classifier's known bug, pedalkernel-129p family,
655            // not an unaccounted edge.)
656            guard_excluded_edges.extend(group.all_edges());
657            continue;
658        }
659
660        if group.has_feedback() {
661            // ── Blockwise check for feedback groups (e.g. ladder with resonance)
662            let group_edges = group.all_edges();
663            if !options.skip_blockwise {
664                eprintln!(
665                    "  [compile] group {gi}: blockwise check ({} edges)...",
666                    group_edges.len()
667                );
668                if let Some(built_stages) = super::blockwise::try_build_blockwise(
669                    &group_edges,
670                    &graph,
671                    &terminals,
672                    sample_rate,
673                    &bias_node_voltages,
674                    supply_voltage,
675                    &pedal.ports,
676                    options.force_serial_blockwise,
677                    options.force_serial_blockwise_feedback_gain,
678                    options.disable_iir,
679                    options.coupled_blockwise_newton,
680                    &pedal.init_hints,
681                ) {
682                    for built in built_stages {
683                        push_stage!(
684                            built,
685                            group_flow_distances[gi],
686                            group_label.clone(),
687                            is_bypass,
688                            group_comp_ids.clone()
689                        );
690                    }
691                    continue;
692                }
693            }
694
695            // Compute bias-derived v_max for op-amps in this group.
696            // Find the VCVS component, look up its pos/neg pin nodes in the
697            // bias voltage map, and call apply_bias to get rail limits.
698            let bias_v_max =
699                compute_bias_v_max_for_group(group, &graph, &bias_node_voltages, supply_voltage);
700
701            // ── Output-load fusion (BA283-class, narrow + gated) ──────────
702            // A multi-BJT DC-coupled-feedback group about to be built as ONE
703            // general-MNA stage (the dc_qpoint path) never feels a load placed
704            // in a downstream stage — downstream impedance does not reflect
705            // back into this stage's scattering. Fuse the trailing output
706            // group (coupling cap + grounded load at the global `out`) into
707            // this build so the load-current demand is part of the solved
708            // network. ANALYSIS (what hangs off the output boundary) and
709            // POLICY (whether it may fuse — gated exactly like the dc_qpoint
710            // seed + servo) live in `boundary_load`; everything else is
711            // byte-identical. Analysis + disposition are recorded on
712            // `CompiledPedal::boundary_loads` for dashboards, fused or not.
713            let mut build_edges = group.all_edges();
714            let boundary_analysis = super::boundary_load::analyze_trailing_output_load(
715                gi,
716                &feedback_groups,
717                &graph,
718                &cut_edges,
719            );
720            let load_disposition = match &boundary_analysis {
721                Some(load) => {
722                    match super::boundary_load::gate_fusion(load, group, &graph, supply_voltage) {
723                        Ok(()) => super::boundary_load::LoadDisposition::FusedUpstream {
724                            consumed_group: load.group,
725                        },
726                        Err(reason) => super::boundary_load::LoadDisposition::Unloaded { reason },
727                    }
728                }
729                None => super::boundary_load::LoadDisposition::Unloaded {
730                    reason: super::boundary_load::REASON_NO_TRAILING_LOAD,
731                },
732            };
733            let fused_output_group = load_disposition.fused_group();
734            if let Some(src_gi) = fused_output_group {
735                // Consume the analyzed load model: its edges ARE the trailing
736                // group's edges (same vec, same order).
737                let extra = boundary_analysis
738                    .as_ref()
739                    .map(|load| load.model.edges().to_vec())
740                    .unwrap_or_default();
741                #[cfg(test)]
742                {
743                    let names: Vec<&str> = extra
744                        .iter()
745                        .map(|&eidx| graph.components[graph.edges[eidx].comp_idx].id.as_str())
746                        .collect();
747                    eprintln!(
748                        "  [compile] group {gi}: fused trailing output load group {src_gi} \
749                         {names:?} into multi-BJT DC-feedback MNA"
750                    );
751                }
752                for eidx in extra {
753                    if !build_edges.contains(&eidx) {
754                        build_edges.push(eidx);
755                    }
756                }
757            }
758            // Refresh the stage label / comp ids so the fused stage owns the
759            // load components (stage_comp_ids drives later stage lookups).
760            let (group_label, group_comp_ids) = if fused_output_group.is_some() {
761                let mut names: Vec<String> = build_edges
762                    .iter()
763                    .map(|&eidx| graph.components[graph.edges[eidx].comp_idx].id.clone())
764                    .collect();
765                names.sort();
766                names.dedup();
767                #[cfg(debug_assertions)]
768                let label = names.join(",");
769                #[cfg(not(debug_assertions))]
770                let label = String::new();
771                (label, names)
772            } else {
773                (group_label, group_comp_ids)
774            };
775
776            let mut built = build_rigid_from_group_with_hints(
777                build_edges,
778                &graph,
779                sample_rate,
780                Some(group),
781                supply_voltage,
782                bias_v_max,
783                !options.disable_iir,
784                &pedal.init_hints,
785            )
786            .map_err(|e| format!("Group {gi}: {e}"))?;
787
788            // Fix gain for feedback_opamp stages where Ri is in a preceding
789            // stage. Find the input coupling group's edges touching the active
790            // element's input pin, sum their resistance as Ri, compute gain.
791            if let BuiltStage::Wdf(ref mut wdf) = built {
792                if let Some(ref mut opamp) = wdf.feedback_opamp {
793                    if opamp.gain().abs() <= 1.01 {
794                        let input_node = group.active_edges.iter().find_map(|&eidx| {
795                            let comp = &graph.components[graph.edges[eidx].comp_idx];
796                            if let super::component::SignalTerminals::Amplifier { input, .. } =
797                                comp.kind.signal_terminals()
798                            {
799                                let key = format!("{}.{input}", comp.id);
800                                graph.node_names.get(&key).copied()
801                            } else {
802                                None
803                            }
804                        });
805
806                        if let Some(neg) = input_node {
807                            // Follow the ground-leg chain from neg through resistors
808                            // to GND across ALL non-feedback groups. This captures
809                            // multi-hop impedance like R5→R6→Gain_A→GND.
810                            let non_fb_edges: Vec<usize> = build_order
811                                .iter()
812                                .filter(|(_, g)| !g.has_feedback())
813                                .flat_map(|(_, g)| g.all_edges())
814                                .collect();
815
816                            // BFS from neg through resistive edges to GND.
817                            // Track fixed resistors and pot components separately.
818                            let is_gnd = |n: super::graph::NodeId| -> bool {
819                                n == graph.gnd_node || graph.ac_ground_nodes.contains(&n)
820                            };
821                            let mut ri_fixed = 0.0f64;
822                            let mut ri_pot_id: Option<String> = None;
823                            let mut ri_pot_max_r = 0.0f64;
824                            let mut ri_pot_taper = crate::dsl::PotTaper::B;
825                            let mut visited = std::collections::HashSet::new();
826                            let mut frontier = vec![neg];
827                            visited.insert(neg);
828
829                            while let Some(node) = frontier.pop() {
830                                for &eidx in &non_fb_edges {
831                                    let e = &graph.edges[eidx];
832                                    let (touches, other) = if e.node_a == node {
833                                        (true, e.node_b)
834                                    } else if e.node_b == node {
835                                        (true, e.node_a)
836                                    } else {
837                                        (false, node)
838                                    };
839                                    if !touches || visited.contains(&other) {
840                                        continue;
841                                    }
842                                    let comp = &graph.components[e.comp_idx];
843                                    if comp.kind.is_pot() {
844                                        if let Some(max_r) = comp.kind.resistance() {
845                                            ri_pot_id = Some(comp.id.clone());
846                                            ri_pot_max_r = max_r;
847                                            ri_pot_taper = comp
848                                                .kind
849                                                .pot_taper()
850                                                .unwrap_or(crate::dsl::PotTaper::B);
851                                            ri_fixed += max_r * 0.5; // default position
852                                            visited.insert(other);
853                                            if !is_gnd(other) {
854                                                frontier.push(other);
855                                            }
856                                        }
857                                    } else if let Some(r) = comp.kind.resistance() {
858                                        ri_fixed += r;
859                                        visited.insert(other);
860                                        if !is_gnd(other) {
861                                            frontier.push(other);
862                                        }
863                                    }
864                                }
865                            }
866
867                            // Also include pendant resistors from the feedback group
868                            for &eidx in group.pendant_edges.iter() {
869                                let e = &graph.edges[eidx];
870                                if e.node_a != neg && e.node_b != neg {
871                                    continue;
872                                }
873                                if let Some(r) = graph.components[e.comp_idx].kind.resistance() {
874                                    ri_fixed += r;
875                                }
876                            }
877
878                            let ri = ri_fixed;
879                            if ri > 0.0 {
880                                let rf: f64 = group
881                                    .feedback_edges
882                                    .iter()
883                                    .filter_map(|&eidx| {
884                                        graph.components[graph.edges[eidx].comp_idx]
885                                            .kind
886                                            .resistance()
887                                    })
888                                    .sum();
889                                if rf > 0.0 {
890                                    opamp.set_gain((rf / ri) as crate::Wave);
891                                    wdf.feedback_ri = ri as crate::Wave;
892                                    if let Some(pot_id) = ri_pot_id {
893                                        wdf.feedback_ri_pot_id = Some(pot_id);
894                                        wdf.feedback_ri_fixed_r = (ri_fixed - ri_pot_max_r * 0.5) as crate::Wave;
895                                        wdf.feedback_ri_pot_max_r = ri_pot_max_r as crate::Wave;
896                                        wdf.feedback_ri_pot_taper = ri_pot_taper;
897                                    }
898                                }
899                            }
900                        }
901                    }
902                }
903            }
904
905            // Ground-leg Ri fix for BlackFeedback stages — BFS chain from neg to GND.
906            // Always run (not just for gain≈1) to find pot-controlled Ri.
907            if let BuiltStage::BlackFeedback(ref mut bf) = built {
908                {
909                    let input_node = group.active_edges.iter().find_map(|&eidx| {
910                        let comp = &graph.components[graph.edges[eidx].comp_idx];
911                        if let super::component::SignalTerminals::Amplifier { input, .. } =
912                            comp.kind.signal_terminals()
913                        {
914                            let key = format!("{}.{input}", comp.id);
915                            graph.node_names.get(&key).copied()
916                        } else {
917                            None
918                        }
919                    });
920                    if let Some(neg) = input_node {
921                        // Compute Ri: BFS from neg through passive edges to
922                        // GND. Finds the full ground-leg impedance chain
923                        // (e.g. R5 → R6 → Gain_A → GND) regardless of which
924                        // FlowGroup the edges belong to.
925                        //
926                        // Exclude:
927                        // - Feedback edges (Rf path, neg→out)
928                        // - Active edges (opamp itself)
929                        // - Edges at opamp output nodes (summing inputs)
930                        // - Edges at opamp input nodes (don't cross to pos bias)
931                        // - Edges at the global in/out nodes
932                        let feedback_set: std::collections::HashSet<usize> =
933                            group.feedback_edges.iter().copied().collect();
934                        let active_set: std::collections::HashSet<usize> =
935                            group.active_edges.iter().copied().collect();
936                        let mut barrier_nodes: std::collections::HashSet<super::graph::NodeId> =
937                            graph
938                                .nullor_pins
939                                .iter()
940                                .flat_map(|p| vec![p.out_node, p.pos_node, p.neg_node])
941                                .collect();
942                        barrier_nodes.insert(graph.in_node);
943                        barrier_nodes.insert(graph.out_node);
944                        // Allow traversal FROM neg (the starting point)
945                        barrier_nodes.remove(&neg);
946
947                        let is_gnd = |n: super::graph::NodeId| -> bool {
948                            n == graph.gnd_node || graph.ac_ground_nodes.contains(&n)
949                        };
950
951                        let mut ri_fixed = 0.0f64;
952                        let mut ri_pot_id: Option<String> = None;
953                        let mut ri_pot_max_r = 0.0f64;
954                        let mut ri_pot_taper = crate::dsl::PotTaper::B;
955                        let mut visited = std::collections::HashSet::new();
956                        let mut frontier = vec![neg];
957                        visited.insert(neg);
958
959                        while let Some(node) = frontier.pop() {
960                            // Search ALL graph edges (not just non-feedback)
961                            for (eidx, e) in graph.edges.iter().enumerate() {
962                                if feedback_set.contains(&eidx) || active_set.contains(&eidx) {
963                                    continue;
964                                }
965                                let (touches, other) = if e.node_a == node {
966                                    (true, e.node_b)
967                                } else if e.node_b == node {
968                                    (true, e.node_a)
969                                } else {
970                                    (false, node)
971                                };
972                                if !touches {
973                                    continue;
974                                }
975                                // Allow multiple edges to GND (it's a shared
976                                // rail), but skip already-visited non-rail nodes
977                                if visited.contains(&other) && !is_gnd(other) {
978                                    continue;
979                                }
980                                // Don't cross into barrier nodes (opamp pins,
981                                // global in/out — not ground-leg paths)
982                                if barrier_nodes.contains(&other) {
983                                    continue;
984                                }
985                                let comp = &graph.components[e.comp_idx];
986                                // Only follow passive edges with resistance
987                                if comp.kind.is_pot() {
988                                    if let Some(max_r) = comp.kind.resistance() {
989                                        ri_pot_id = Some(comp.id.clone());
990                                        ri_pot_max_r = max_r;
991                                        ri_pot_taper = comp
992                                            .kind
993                                            .pot_taper()
994                                            .unwrap_or(crate::dsl::PotTaper::B);
995                                        ri_fixed += ri_pot_taper.apply(0.5) as f64 * max_r;
996                                        visited.insert(other);
997                                        if !is_gnd(other) {
998                                            frontier.push(other);
999                                        }
1000                                    }
1001                                } else if let Some(r) = comp.kind.resistance() {
1002                                    ri_fixed += r;
1003                                    visited.insert(other);
1004                                    if !is_gnd(other) {
1005                                        frontier.push(other);
1006                                    }
1007                                } else if comp.kind.capacitance().is_some() {
1008                                    // Reactive elements: traverse through
1009                                    // (C_gnd bypass) but don't add resistance
1010                                    visited.insert(other);
1011                                    if !is_gnd(other) {
1012                                        frontier.push(other);
1013                                    }
1014                                }
1015                            }
1016                        }
1017
1018                        if ri_fixed > 0.0 {
1019                            bf.set_ri(ri_fixed as crate::Wave);
1020                        }
1021                        // Store ground-leg pot mapping for runtime Ri updates
1022                        if let Some(pot_id) = ri_pot_id {
1023                            let fixed_without_pot =
1024                                ri_fixed - ri_pot_taper.apply(0.5) as f64 * ri_pot_max_r;
1025                            bf.ri_pot_comp_id = Some(pot_id);
1026                            bf.ri_fixed_r = fixed_without_pot as crate::Wave;
1027                            bf.ri_pot_max_r = ri_pot_max_r as crate::Wave;
1028                            bf.ri_pot_taper = ri_pot_taper;
1029                        }
1030                    }
1031                }
1032            }
1033
1034            push_stage!(
1035                built,
1036                group_flow_distances[gi],
1037                group_label.clone(),
1038                is_bypass,
1039                group_comp_ids.clone()
1040            );
1041
1042            // Output-load fusion: the trailing `{Cout, RL}` group was already
1043            // built as its own passive stage (non-feedback groups build
1044            // first). Its components are now solved INSIDE the fused MNA —
1045            // remove the standalone stage so the load isn't applied twice
1046            // (same consumption mechanism as blockwise coupling above).
1047            if let Some(src_gi) = fused_output_group {
1048                let mut src_ids: Vec<String> = feedback_groups[src_gi]
1049                    .all_edges()
1050                    .iter()
1051                    .map(|&eidx| graph.components[graph.edges[eidx].comp_idx].id.clone())
1052                    .collect();
1053                src_ids.sort();
1054                src_ids.dedup();
1055                // Exclude the fused stage just pushed (last index).
1056                for idx in (0..stages.len().saturating_sub(1)).rev() {
1057                    if stage_comp_ids[idx] == src_ids {
1058                        #[cfg(test)]
1059                        eprintln!(
1060                            "  [compile] group {gi}: removed standalone trailing \
1061                             output stage {idx} ({src_ids:?}) — consumed by fusion"
1062                        );
1063                        stages.remove(idx);
1064                        stage_comp_ids.remove(idx);
1065                        break;
1066                    }
1067                }
1068            }
1069
1070            // Record the boundary-load decision (fused or declined) for this
1071            // stage's output boundary. `stage_key` = the stage's minimum
1072            // stable component id — the same deterministic identity the final
1073            // stage sort uses — resolved to a stage index once ordering is
1074            // final. Purely descriptive: nothing downstream of compilation
1075            // reads it.
1076            pending_boundary_loads.push(super::boundary_load::PendingBoundaryLoad {
1077                stage_key: group_comp_ids
1078                    .iter()
1079                    .min()
1080                    .cloned()
1081                    .unwrap_or_else(|| "~".to_string()),
1082                boundary_node: boundary_analysis
1083                    .as_ref()
1084                    .map(|load| load.boundary_node)
1085                    .unwrap_or(graph.out_node),
1086                model: boundary_analysis
1087                    .as_ref()
1088                    .map(|load| load.model.summarize(&graph))
1089                    .unwrap_or(pedalkernel_rt::processor::BoundaryLoadSummary::Unloaded),
1090                disposition: load_disposition.summarize(),
1091            });
1092        } else if is_pot_divider_group(group, &graph) {
1093            #[cfg(test)]
1094            eprintln!("  → POT DIVIDER group: {:?}", group.all_edges());
1095            // Pot voltage divider: both halves in one stage.
1096            // Build directly as Parallel(aw, wb) with ShortCircuit root.
1097            let built = build_pot_divider(group, &graph, sample_rate);
1098            match built {
1099                Ok(built_stage) => {
1100                    push_stage!(
1101                        built_stage,
1102                        group_flow_distances[gi],
1103                        group_label.clone(),
1104                        is_bypass,
1105                        group_comp_ids.clone()
1106                    );
1107                }
1108                Err(e) => return Err(format!("Group {gi} (pot): {e}")),
1109            }
1110        } else if is_ground_clip_group(group, &graph) {
1111            // Standalone NL element to ground (RAT/Klon hard-clip diodes).
1112            // Collect ALL ground-clip groups at the same signal node and merge
1113            // them into a single DiodePair stage. Skip individual groups that
1114            // were already merged into a preceding group.
1115            if !ground_clip_built.contains(&gi) {
1116                // Find all other ground-clip groups sharing the same signal node
1117                let signal_node = get_ground_clip_signal_node(group, &graph);
1118                let mut merged_edges: Vec<usize> = group.all_edges();
1119                for &(gj, other_group) in &build_order {
1120                    if gj != gi
1121                        && !ground_clip_built.contains(&gj)
1122                        && is_ground_clip_group(other_group, &graph)
1123                        && get_ground_clip_signal_node(other_group, &graph) == signal_node
1124                    {
1125                        merged_edges.extend(other_group.all_edges());
1126                        ground_clip_built.insert(gj);
1127                    }
1128                }
1129                ground_clip_built.insert(gi);
1130                // Rebuild label from all merged edges (not just first group)
1131                #[cfg(debug_assertions)]
1132                let merged_label = {
1133                    let mut names: Vec<&str> = merged_edges
1134                        .iter()
1135                        .map(|&eidx| graph.components[graph.edges[eidx].comp_idx].id.as_str())
1136                        .collect();
1137                    names.dedup();
1138                    names.join(",")
1139                };
1140                #[cfg(not(debug_assertions))]
1141                let merged_label = String::new();
1142                if let Some(built) = build_ground_clip_stage(&merged_edges, &graph, sample_rate) {
1143                    let merged_comp_ids: Vec<String> = {
1144                        let mut names: Vec<String> = merged_edges
1145                            .iter()
1146                            .map(|&eidx| graph.components[graph.edges[eidx].comp_idx].id.clone())
1147                            .collect();
1148                        names.sort();
1149                        names.dedup();
1150                        names
1151                    };
1152                    #[cfg(test)]
1153                    eprintln!(
1154                        "  Ground-clip stage built: {:?}",
1155                        std::mem::discriminant(&built)
1156                    );
1157                    push_stage!(
1158                        built,
1159                        group_flow_distances[gi],
1160                        merged_label,
1161                        is_bypass,
1162                        merged_comp_ids
1163                    );
1164                }
1165            }
1166        } else {
1167            // Extract pot dividers from the group BEFORE SPQR processing.
1168            // Pots need dedicated pot-divider stages with correct complement
1169            // handling. If left inside a larger SPQR tree, the WDF wave
1170            // propagation produces V-shaped output at extreme positions.
1171            let group_edges = group.all_edges();
1172            // Remove any edges already absorbed into a triode-context MNA stage.
1173            let mut remaining_edges: Vec<usize> = group_edges
1174                .iter()
1175                .copied()
1176                .filter(|eidx| !triode_absorbed_edges.contains(eidx))
1177                .collect();
1178
1179            // Find pot component indices with 2 edges in this group.
1180            // Only extract pots that are OUTPUT dividers (wiper → out or
1181            // downstream group). Pots whose wiper stays internal (gain
1182            // controls, impedance elements) must NOT be extracted.
1183            let group_edge_set: std::collections::HashSet<usize> =
1184                group_edges.iter().copied().collect();
1185            let mut pot_edge_pairs: Vec<(usize, Vec<usize>)> = Vec::new();
1186            {
1187                let mut pot_edges: std::collections::BTreeMap<usize, Vec<usize>> =
1188                    std::collections::BTreeMap::new();
1189                for &eidx in &group_edges {
1190                    let comp = &graph.components[graph.edges[eidx].comp_idx];
1191                    if comp.kind.is_pot() {
1192                        pot_edges
1193                            .entry(graph.edges[eidx].comp_idx)
1194                            .or_default()
1195                            .push(eidx);
1196                    }
1197                }
1198                for (comp_idx, edges) in pot_edges {
1199                    if edges.len() != 2 {
1200                        continue;
1201                    }
1202                    // Check if the pot's wiper node connects to edges OUTSIDE
1203                    // this group (it's an output divider) or only to edges
1204                    // INSIDE this group (it's an internal impedance element).
1205                    // Find the wiper node: the node shared by both pot edges.
1206                    let e0 = &graph.edges[edges[0]];
1207                    let e1 = &graph.edges[edges[1]];
1208                    let wiper_node = if e0.node_a == e1.node_a || e0.node_a == e1.node_b {
1209                        Some(e0.node_a)
1210                    } else if e0.node_b == e1.node_a || e0.node_b == e1.node_b {
1211                        Some(e0.node_b)
1212                    } else {
1213                        None
1214                    };
1215                    let is_output_divider = if let Some(wiper) = wiper_node {
1216                        // Wiper connects to edges outside this group?
1217                        graph.edges.iter().enumerate().any(|(eidx, e)| {
1218                            !group_edge_set.contains(&eidx)
1219                                && (e.node_a == wiper || e.node_b == wiper)
1220                        })
1221                    } else {
1222                        false
1223                    };
1224                    if is_output_divider {
1225                        pot_edge_pairs.push((comp_idx, edges));
1226                    }
1227                }
1228            }
1229
1230            // Build each pot as a standalone pot-divider stage
1231            for (_, pot_edges) in &pot_edge_pairs {
1232                // Create a temporary FlowGroup with just the pot edges
1233                let pot_group = super::signal_flow::FlowGroup {
1234                    active_edges: Vec::new(),
1235                    feedback_edges: Vec::new(),
1236                    pendant_edges: pot_edges.clone(),
1237                    ground_shunt_edges: Vec::new(),
1238                };
1239                if let Ok(built) = build_pot_divider(&pot_group, &graph, sample_rate) {
1240                    let pot_comp_ids: Vec<String> = {
1241                        let mut names: Vec<String> = pot_edges
1242                            .iter()
1243                            .map(|&eidx| graph.components[graph.edges[eidx].comp_idx].id.clone())
1244                            .collect();
1245                        names.sort();
1246                        names.dedup();
1247                        names
1248                    };
1249                    #[cfg(debug_assertions)]
1250                    let pot_label = {
1251                        let comp = &graph.components[graph.edges[pot_edges[0]].comp_idx];
1252                        comp.id.clone()
1253                    };
1254                    #[cfg(not(debug_assertions))]
1255                    let pot_label = String::new();
1256                    push_stage!(
1257                        built,
1258                        group_flow_distances[gi],
1259                        pot_label,
1260                        is_bypass,
1261                        pot_comp_ids
1262                    );
1263                    // Remove pot edges from remaining
1264                    for &eidx in pot_edges {
1265                        remaining_edges.retain(|&e| e != eidx);
1266                    }
1267                }
1268            }
1269
1270            // Linear (gate-modulated) JFET groups: keep only edges that are
1271            // graph-connected to the JFET's drain–source path through
1272            // non-ground nodes. Signal-flow grouping claims the gate-bias
1273            // network into the JFET's group (the gate is the amplifier
1274            // "input"), but a JFET resolved to a variable resistor conducts
1275            // audio only drain–source; gate-bias edges share no signal node
1276            // and would compile into a bogus series chain that silences the
1277            // stage (audit gap G2, fet_leveler).
1278            {
1279                let linear_jfet_edges: Vec<usize> = remaining_edges
1280                    .iter()
1281                    .copied()
1282                    .filter(|&eidx| {
1283                        graph.effective_edge_kind(eidx) == super::component::EdgeKind::Linear
1284                            && graph.components[graph.edges[eidx].comp_idx].kind.is_jfet()
1285                    })
1286                    .collect();
1287                if !linear_jfet_edges.is_empty() {
1288                    let is_ground = |n: super::graph::NodeId| -> bool {
1289                        n == graph.gnd_node || graph.ac_ground_nodes.contains(&n)
1290                    };
1291                    let mut keep_nodes: std::collections::HashSet<super::graph::NodeId> =
1292                        linear_jfet_edges
1293                            .iter()
1294                            .flat_map(|&eidx| {
1295                                let e = &graph.edges[eidx];
1296                                [e.node_a, e.node_b]
1297                            })
1298                            .filter(|&n| !is_ground(n))
1299                            .collect();
1300                    // Grow the connected component over non-ground nodes.
1301                    loop {
1302                        let mut grew = false;
1303                        for &eidx in &remaining_edges {
1304                            let e = &graph.edges[eidx];
1305                            let touches = (keep_nodes.contains(&e.node_a) && !is_ground(e.node_a))
1306                                || (keep_nodes.contains(&e.node_b) && !is_ground(e.node_b));
1307                            if touches {
1308                                for n in [e.node_a, e.node_b] {
1309                                    if !is_ground(n) && keep_nodes.insert(n) {
1310                                        grew = true;
1311                                    }
1312                                }
1313                            }
1314                        }
1315                        if !grew {
1316                            break;
1317                        }
1318                    }
1319                    remaining_edges.retain(|&eidx| {
1320                        let e = &graph.edges[eidx];
1321                        linear_jfet_edges.contains(&eidx)
1322                            || (!is_ground(e.node_a) && keep_nodes.contains(&e.node_a))
1323                            || (!is_ground(e.node_b) && keep_nodes.contains(&e.node_b))
1324                    });
1325                }
1326            }
1327
1328            // Process remaining non-pot edges through SPQR
1329            let mut group_edges = remaining_edges;
1330            if group_edges.is_empty() {
1331                continue;
1332            } // All edges were pots
1333
1334            // ── Collector-load-and-output chain fusion (Fuzz-Face / Sunflower,
1335            // bead pedalkernel-guwp) ─────────────────────────────────────────
1336            // A coupled multi-Ge fuzz pair lands in a NON-feedback active group
1337            // (the R1 base↔emitter feedback is DC, not a passive cycle edge, so
1338            // `has_feedback()` is false) and builds through THIS `else` branch —
1339            // never the feedback-arm C1 output fusion at the top of the loop.
1340            // Its Q2 collector chain (`R3→BIAS→SUNDIAL→R4→gnd` + `C3→VOLUME→out`)
1341            // is a SEPARATE trailing flow group, so this group's general-MNA
1342            // solve leaves the collector a dangling node (no load line, ~1 GΩ
1343            // port self-reflection) AND extracts the output in a stage that
1344            // never sees the collector swing → silence (−74.8 dB). Fuse the
1345            // whole chain into THIS group's build set so the collector gets a
1346            // real load line and `out` becomes a group extract node. ANALYSIS +
1347            // POLICY (the same 2-BJT + dc_qpoint gate as the BA283 output
1348            // fusion) live in `boundary_load`; the chain group is consumed via a
1349            // skip-set (it may build before OR after this group), and the
1350            // decision is recorded on `CompiledPedal::boundary_loads`.
1351            let chain_analysis = super::boundary_load::analyze_trailing_output_load(
1352                gi,
1353                &feedback_groups,
1354                &graph,
1355                &cut_edges,
1356            );
1357            let mut chain_group_label = group_label.clone();
1358            let mut chain_group_comp_ids = group_comp_ids.clone();
1359            // Only the collector-chain shape fuses here; the BA283 / pot-load /
1360            // pot-divider shapes belong to the feedback arm and never reach a
1361            // non-feedback active group (so non-chain analyses are ignored and
1362            // this whole block is byte-identical for every other pedal).
1363            if let Some(load) = chain_analysis.as_ref().filter(|l| {
1364                matches!(
1365                    l.model,
1366                    super::boundary_load::BoundaryLoadModel::CollectorChainIntoOut { .. }
1367                )
1368            }) {
1369                let already_consumed = load.model.edges().iter().any(|&eidx| {
1370                    collector_chain_consumed_comp_ids
1371                        .contains(&graph.components[graph.edges[eidx].comp_idx].id)
1372                });
1373                let disposition = if already_consumed {
1374                    // A chain group can only be consumed once (pathological
1375                    // shared-chain shapes must not fuse twice).
1376                    super::boundary_load::LoadDisposition::Unloaded {
1377                        reason: "gate:load-group-already-consumed",
1378                    }
1379                } else {
1380                    match super::boundary_load::gate_fusion(load, group, &graph, supply_voltage) {
1381                        Ok(()) => {
1382                            for &eidx in load.model.edges() {
1383                                if !group_edges.contains(&eidx) {
1384                                    group_edges.push(eidx);
1385                                }
1386                            }
1387                            // The fused build set now owns the chain components;
1388                            // refresh label / comp ids so later stage lookups
1389                            // and the boundary-load stage_key see them.
1390                            let mut names: Vec<String> = group_edges
1391                                .iter()
1392                                .map(|&eidx| {
1393                                    graph.components[graph.edges[eidx].comp_idx].id.clone()
1394                                })
1395                                .collect();
1396                            names.sort();
1397                            names.dedup();
1398                            #[cfg(debug_assertions)]
1399                            {
1400                                chain_group_label = names.join(",");
1401                            }
1402                            chain_group_comp_ids = names;
1403                            for &eidx in load.model.edges() {
1404                                collector_chain_consumed_comp_ids
1405                                    .insert(graph.components[graph.edges[eidx].comp_idx].id.clone());
1406                            }
1407                            // Remove the standalone chain stage if it already
1408                            // built (non-feedback order is enumeration order, so
1409                            // it may precede OR follow this group).
1410                            let mut chain_ids: Vec<String> = load
1411                                .model
1412                                .edges()
1413                                .iter()
1414                                .map(|&eidx| {
1415                                    graph.components[graph.edges[eidx].comp_idx].id.clone()
1416                                })
1417                                .collect();
1418                            chain_ids.sort();
1419                            chain_ids.dedup();
1420                            for idx in (0..stages.len()).rev() {
1421                                if stage_comp_ids[idx] == chain_ids {
1422                                    #[cfg(test)]
1423                                    eprintln!(
1424                                        "  [compile] group {gi}: removed standalone collector \
1425                                         chain stage {idx} ({chain_ids:?}) — consumed by fusion"
1426                                    );
1427                                    stages.remove(idx);
1428                                    stage_comp_ids.remove(idx);
1429                                    break;
1430                                }
1431                            }
1432                            super::boundary_load::LoadDisposition::FusedUpstream {
1433                                consumed_group: load.group,
1434                            }
1435                        }
1436                        Err(reason) => {
1437                            super::boundary_load::LoadDisposition::Unloaded { reason }
1438                        }
1439                    }
1440                };
1441                pending_boundary_loads.push(super::boundary_load::PendingBoundaryLoad {
1442                    stage_key: chain_group_comp_ids
1443                        .iter()
1444                        .min()
1445                        .cloned()
1446                        .unwrap_or_else(|| "~".to_string()),
1447                    boundary_node: load.boundary_node,
1448                    model: load.model.summarize(&graph),
1449                    disposition: disposition.summarize(),
1450                });
1451            }
1452            let group_label = chain_group_label;
1453            let group_comp_ids = chain_group_comp_ids;
1454
1455            let group_has_runtime_pot = group_edges
1456                .iter()
1457                .any(|&eidx| graph.components[graph.edges[eidx].comp_idx].kind.is_pot());
1458            let group_has_nonlinear = group_edges.iter().any(|&eidx| {
1459                graph.effective_edge_kind(eidx) == super::component::EdgeKind::Nonlinear
1460            });
1461            if group_has_runtime_pot && group_has_nonlinear {
1462                #[cfg(test)]
1463                eprintln!("  → rigid whole-group: NL group contains runtime pot");
1464                let built = build_rigid_from_group_with_hints(
1465                    group_edges,
1466                    &graph,
1467                    sample_rate,
1468                    Some(group),
1469                    supply_voltage,
1470                    None,
1471                    !options.disable_iir,
1472                    &pedal.init_hints,
1473                )
1474                .map_err(|e| format!("Group {gi}: {e}"))?;
1475                push_stage!(
1476                    built,
1477                    group_flow_distances[gi],
1478                    group_label.clone(),
1479                    is_bypass,
1480                    group_comp_ids.clone()
1481                );
1482                continue;
1483            }
1484
1485            // ── Triode-with-grid detection: common-cathode triodes need the
1486            // full MNA context path for correct DC self-bias.
1487            //
1488            // Signal flow analysis may classify a standalone common-cathode triode
1489            // either as a group with only the triode NL edge (no passive claiming,
1490            // no feedback) OR as a group that already includes the triode's passive
1491            // context (plate load, cathode bias, cathode bypass cap, grid leak).
1492            //
1493            // In both cases SPQR would produce a bare WdfStage with a TriodeRoot.
1494            // While the WDF tree correctly models the passive network, the K-table
1495            // interpolation introduces ~4 dB RMS error versus SPICE for high-voltage
1496            // stages because the K-table sweep doesn't capture the dynamic cathode
1497            // self-bias feedback accurately at large signal levels.
1498            //
1499            // Fix: detect ANY non-feedback group containing exactly ONE triode NL
1500            // edge (with a grid node). Route it through the full MNA path
1501            // (build_general_mna_from_edges_with_supply + apply_triode_dc_qpoint)
1502            // which gives ~0.1 dB RMS accuracy versus SPICE.
1503            //
1504            // For 1-edge groups: collect context edges via BFS.
1505            // For n-edge groups: use group_edges directly (already has context).
1506            {
1507                let nl_triode_edges: Vec<usize> = group_edges
1508                    .iter()
1509                    .copied()
1510                    .filter(|&eidx| {
1511                        if graph.effective_edge_kind(eidx) != super::component::EdgeKind::Nonlinear
1512                        {
1513                            return false;
1514                        }
1515                        let e = &graph.edges[eidx];
1516                        let comp = &graph.components[e.comp_idx];
1517                        matches!(
1518                            comp.kind.classify_nonlinear(
1519                                &comp.id,
1520                                e.node_a,
1521                                e.node_b,
1522                                graph.gnd_node,
1523                                &graph.node_names,
1524                            ),
1525                            Some((
1526                                super::classify::NonlinearKind::Triode {
1527                                    grid_node: Some(_),
1528                                    ..
1529                                },
1530                                _
1531                            ))
1532                        )
1533                    })
1534                    .collect();
1535
1536                if nl_triode_edges.len() == 1 {
1537                    let nl_edge_idx = nl_triode_edges[0];
1538                    let e = &graph.edges[nl_edge_idx];
1539                    let comp = &graph.components[e.comp_idx];
1540                    if let Some((
1541                        super::classify::NonlinearKind::Triode {
1542                            grid_node: Some(grid_node),
1543                            ..
1544                        },
1545                        _,
1546                    )) = comp.kind.classify_nonlinear(
1547                        &comp.id,
1548                        e.node_a,
1549                        e.node_b,
1550                        graph.gnd_node,
1551                        &graph.node_names,
1552                    ) {
1553                        // For 1-edge groups collect context; for multi-edge groups
1554                        // the context is already in group_edges — use them directly.
1555                        let mut context_edges = if group_edges.len() == 1 {
1556                            collect_triode_context_edges(nl_edge_idx, &graph, &all_edges)
1557                        } else {
1558                            group_edges.clone()
1559                        };
1560                        #[cfg(test)]
1561                        eprintln!(
1562                            "  group {gi}: triode-with-grid ({} group edges → {} context edges)",
1563                            group_edges.len(),
1564                            context_edges.len(),
1565                        );
1566
1567                        // ── Transformer-secondary load reflection at the
1568                        // triode-context MNA (pedalkernel-ffkl / GAP F) ──
1569                        // If this build set owns a transformer primary edge
1570                        // (LA-2A: V3's cathode follower drives T_out), find
1571                        // the grounded resistive load on its secondary (the
1572                        // C4 analysis) and fuse those edges into the build so
1573                        // the general-MNA transformer stamp solves the
1574                        // reflection in-stage: the load current flows through
1575                        // the ideal turns-ratio branch and the follower feels
1576                        // n²·R. The standalone load group is consumed
1577                        // (absorbed-edge skip + built-stage removal below)
1578                        // and the decision is recorded on the boundary-load
1579                        // table either way.
1580                        let mut xfmr_fused: Option<(
1581                            super::boundary_load::TransformerSecondaryLoad,
1582                            Vec<String>,
1583                        )> = None;
1584                        if let Some(load) =
1585                            super::boundary_load::analyze_transformer_secondary_load_in_edges(
1586                                &context_edges,
1587                                gi,
1588                                &feedback_groups,
1589                                &graph,
1590                                &cut_edges,
1591                            )
1592                        {
1593                            let load_already_consumed =
1594                                load.model.edges().iter().any(|&eidx| {
1595                                    xfmr_consumed_comp_ids.contains(
1596                                        &graph.components[graph.edges[eidx].comp_idx].id,
1597                                    )
1598                                });
1599                            let mut fused_load_ids: Option<Vec<String>> = None;
1600                            let disposition = if load_already_consumed {
1601                                super::boundary_load::LoadDisposition::Unloaded {
1602                                    reason: "gate:load-group-already-consumed",
1603                                }
1604                            } else {
1605                                match super::boundary_load::
1606                                    gate_triode_context_transformer_reflection()
1607                                {
1608                                    Ok(()) => {
1609                                        for &eidx in load.model.edges() {
1610                                            if !context_edges.contains(&eidx) {
1611                                                context_edges.push(eidx);
1612                                            }
1613                                        }
1614                                        let mut load_ids: Vec<String> = load
1615                                            .model
1616                                            .edges()
1617                                            .iter()
1618                                            .map(|&eidx| {
1619                                                graph.components
1620                                                    [graph.edges[eidx].comp_idx]
1621                                                    .id
1622                                                    .clone()
1623                                            })
1624                                            .collect();
1625                                        load_ids.sort();
1626                                        load_ids.dedup();
1627                                        #[cfg(test)]
1628                                        eprintln!(
1629                                            "  [compile] group {gi}: reflected transformer-\
1630                                             secondary load group {} (n={}, r_reflected={:.1}) \
1631                                             into triode-context MNA",
1632                                            load.load_group, load.turns_ratio, load.r_reflected
1633                                        );
1634                                        fused_load_ids = Some(load_ids);
1635                                        super::boundary_load::
1636                                            LoadDisposition::ReflectedThroughTransformer {
1637                                            consumed_group: load.load_group,
1638                                            turns_ratio: load.turns_ratio,
1639                                            r_reflected: load.r_reflected,
1640                                        }
1641                                    }
1642                                    Err(reason) => {
1643                                        super::boundary_load::LoadDisposition::Unloaded {
1644                                            reason,
1645                                        }
1646                                    }
1647                                }
1648                            };
1649                            match fused_load_ids {
1650                                // Fused: the row is recorded after the stage
1651                                // push (its stage_key needs the fused comp-id
1652                                // set).
1653                                Some(ids) => xfmr_fused = Some((load, ids)),
1654                                // Analyzed but declined: record for the
1655                                // dashboard now — build proceeds unchanged.
1656                                None => pending_boundary_loads.push(
1657                                    super::boundary_load::PendingBoundaryLoad {
1658                                        stage_key: group_comp_ids
1659                                            .iter()
1660                                            .min()
1661                                            .cloned()
1662                                            .unwrap_or_else(|| "~".to_string()),
1663                                        boundary_node: load.boundary_node,
1664                                        model: load.model.summarize(&graph),
1665                                        disposition: disposition.summarize(),
1666                                    },
1667                                ),
1668                            }
1669                        }
1670
1671                        // Mark all non-NL context edges as absorbed so their
1672                        // groups are skipped later (with a fused load this
1673                        // also consumes the standalone secondary-load group).
1674                        for &eidx in &context_edges {
1675                            if graph.effective_edge_kind(eidx)
1676                                != super::component::EdgeKind::Nonlinear
1677                            {
1678                                triode_absorbed_edges.insert(eidx);
1679                            }
1680                        }
1681                        let built = super::rigid::build_general_mna_from_edges_with_supply(
1682                            &context_edges,
1683                            &graph,
1684                            sample_rate,
1685                            supply_voltage,
1686                        )
1687                        .map_err(|e| format!("Group {gi} (triode-context MNA): {e}"))?;
1688                        // A triode's serial position is normally its grid's hop
1689                        // distance from `in` (`bfs_dist_from_in_node`). That walk
1690                        // is undirected AND crosses rails, so once a circuit has a
1691                        // mid-chain plain two-port transformer (whose magnetic
1692                        // primary↔secondary coupling is NOT a graph edge), every
1693                        // triode downstream of the gap is reached only via rail
1694                        // shortcuts and gets spuriously SMALL distances — scrambling
1695                        // the serial order (LA-2A's output group sorting ahead of
1696                        // V1/V2). `group_flow_distances[gi]` is the corrected
1697                        // rail-blocked, directed, broker-coupled-link-aware distance
1698                        // (d-2, now honoring the Tight link). Defer to it ONLY when
1699                        // the circuit actually has such a Tight coupling — which is
1700                        // exactly the case the rail-crossing grid walk gets wrong.
1701                        // Circuits without a mid-chain two-port (ordinary tube amps:
1702                        // their output transformer is its OWN group and its
1703                        // secondary is `out`, which the broker excludes; cap-coupled
1704                        // amps with no transformer at all) keep the existing grid
1705                        // distance, byte-for-byte. (Broker consult only.)
1706                        let triode_flow_dist = if has_tight_coupled_transformer {
1707                            group_flow_distances[gi]
1708                        } else {
1709                            bfs_dist_from_in_node(grid_node, &graph)
1710                                .unwrap_or(group_flow_distances[gi])
1711                        };
1712                        if let Some((load, load_ids)) = xfmr_fused {
1713                            // Fused stage owns the reflected load components:
1714                            // label / comp ids come from the full fused edge
1715                            // set so later stage lookups (and the boundary-
1716                            // load stage_key) see them. Only the transformer
1717                            // family takes this arm — everyone else keeps the
1718                            // group ids byte-identical.
1719                            let mut fused_comp_ids: Vec<String> = context_edges
1720                                .iter()
1721                                .map(|&eidx| {
1722                                    graph.components[graph.edges[eidx].comp_idx].id.clone()
1723                                })
1724                                .collect();
1725                            fused_comp_ids.sort();
1726                            fused_comp_ids.dedup();
1727                            #[cfg(debug_assertions)]
1728                            let fused_label = fused_comp_ids.join(",");
1729                            #[cfg(not(debug_assertions))]
1730                            let fused_label = String::new();
1731                            push_stage!(
1732                                BuiltStage::MultiNl(built),
1733                                triode_flow_dist,
1734                                fused_label,
1735                                is_bypass,
1736                                fused_comp_ids.clone()
1737                            );
1738
1739                            // Consume the standalone load group: the absorbed-
1740                            // edge skip handles the not-yet-built order; if it
1741                            // already built (group enumeration order), remove
1742                            // its stage. Same de-duplication contract as the
1743                            // C4 passive-path fusion.
1744                            for id in &load_ids {
1745                                xfmr_consumed_comp_ids.insert(id.clone());
1746                            }
1747                            for idx in (0..stages.len().saturating_sub(1)).rev() {
1748                                if stage_comp_ids[idx] == load_ids {
1749                                    #[cfg(test)]
1750                                    eprintln!(
1751                                        "  [compile] group {gi}: removed standalone \
1752                                         secondary-load stage {idx} ({load_ids:?}) — consumed \
1753                                         by triode-context transformer reflection"
1754                                    );
1755                                    stages.remove(idx);
1756                                    stage_comp_ids.remove(idx);
1757                                    break;
1758                                }
1759                            }
1760                            pending_boundary_loads.push(
1761                                super::boundary_load::PendingBoundaryLoad {
1762                                    stage_key: fused_comp_ids
1763                                        .iter()
1764                                        .min()
1765                                        .cloned()
1766                                        .unwrap_or_else(|| "~".to_string()),
1767                                    boundary_node: load.boundary_node,
1768                                    model: load.model.summarize(&graph),
1769                                    disposition:
1770                                        super::boundary_load::LoadDisposition::
1771                                            ReflectedThroughTransformer {
1772                                            consumed_group: load.load_group,
1773                                            turns_ratio: load.turns_ratio,
1774                                            r_reflected: load.r_reflected,
1775                                        }
1776                                        .summarize(),
1777                                },
1778                            );
1779                        } else {
1780                            push_stage!(
1781                                BuiltStage::MultiNl(built),
1782                                triode_flow_dist,
1783                                group_label.clone(),
1784                                is_bypass,
1785                                group_comp_ids.clone()
1786                            );
1787                        }
1788                        continue;
1789                    }
1790                }
1791            }
1792
1793            // MERGE NOTE: #85 added a blockwise check here, but the branch's
1794            // restructured flow already performs the blockwise split for both
1795            // feedback groups (above) and non-feedback groups (the
1796            // `if !group.has_feedback()` block below, guarded by
1797            // `!options.skip_blockwise`). The #85 copy was a duplicate against
1798            // the pre-rework structure and is dropped to avoid double-processing.
1799
1800            if !group.has_feedback() {
1801                // Restrict to ONLY the op-amp neg/pos input nodes — not all
1802                // intermediate nodes of the feedback group. Using all nodes was
1803                // too broad: a series input resistor (e.g. R_in feeding the
1804                // mid-node of a bridged-T) would be flagged as feedforward
1805                // because its output node touches the feedback group's interior.
1806                // The intent is to detect direct-connect modulator paths
1807                // (LED→LDR, photocoupler) from an op-amp output to an op-amp
1808                // input pin, not ordinary series input resistors.
1809                // Exclude gnd_node from feedback_nodes: op-amps whose pos
1810                // (or neg) input ties directly to ground are not meaningful
1811                // feedforward targets — gnd is universally reachable and would
1812                // cause false positives (e.g. C_tone.b → gnd flagged as
1813                // feedforward from U1.out → gnd).
1814                let feedback_nodes: std::collections::HashSet<super::graph::NodeId> = graph
1815                    .nullor_pins
1816                    .iter()
1817                    .flat_map(|pins| [pins.neg_node, pins.pos_node].into_iter())
1818                    .filter(|&n| n != graph.gnd_node)
1819                    .collect();
1820                let main_outputs: std::collections::HashSet<super::graph::NodeId> = graph
1821                    .nullor_pins
1822                    .iter()
1823                    .map(|pins| pins.out_node)
1824                    .chain(std::iter::once(graph.in_node))
1825                    .collect();
1826
1827                // F2 (B2/B3a): an edge spanning {in / op-amp out} ↔
1828                // {feedback-group node} is NOT automatically a parallel
1829                // feedforward branch. Series couplers (input cap before an
1830                // inverting input; R→C interstage coupling) match that shape
1831                // too, and lowering one to an additive Passthrough bridge
1832                // cancels the signal: the open-circuited stage emits −x and
1833                // the blend computes x + (−x) = 0. Only build the bridge when
1834                // the branch is genuinely parallel:
1835                //   (a) the convergence node is an op-amp input pin (nullor
1836                //       neg/pos) of a feedback group, AND
1837                //   (b) a second serial path from the source into that same
1838                //       feedback group exists — the group's own claimed edges
1839                //       also reach the source node (mirrors the
1840                //       sources_with_feedback check in the phase-2 detector
1841                //       below).
1842                // When either fails, fall through to the normal serial
1843                // (blockwise/SPQR) build.
1844                let is_audio_node = |n: super::graph::NodeId| -> bool {
1845                    n != graph.gnd_node
1846                        && n != graph.vcc_node
1847                        && !graph.supply_nodes.contains(&n)
1848                        && !graph.ac_ground_nodes.contains(&n)
1849                };
1850                let genuinely_parallel =
1851                    |source: super::graph::NodeId, converge: super::graph::NodeId| -> bool {
1852                        if !is_audio_node(converge) {
1853                            return false;
1854                        }
1855                        feedback_groups.iter().any(|fg| {
1856                            if !fg.has_feedback() {
1857                                return false;
1858                            }
1859                            // (a) converge is a nullor input pin of this group.
1860                            let pin_match = fg.active_edges.iter().any(|&ae| {
1861                                let ae_comp = graph.edges[ae].comp_idx;
1862                                graph.nullor_pins.iter().any(|p| {
1863                                    p.comp_idx == ae_comp
1864                                        && (p.neg_node == converge || p.pos_node == converge)
1865                                })
1866                            });
1867                            if !pin_match {
1868                                return false;
1869                            }
1870                            // (b) the group's own edges reach the source node.
1871                            fg.all_edges().iter().any(|&fe| {
1872                                let f = &graph.edges[fe];
1873                                f.node_a == source || f.node_b == source
1874                            })
1875                        })
1876                    };
1877
1878                let mut built_feedforward = false;
1879                for &eidx in &group_edges {
1880                    let e = &graph.edges[eidx];
1881                    let comp = &graph.components[e.comp_idx];
1882                    let Some(leaf) = comp.kind.make_leaf(&comp.id, sample_rate) else {
1883                        continue;
1884                    };
1885
1886                    let source = if main_outputs.contains(&e.node_a)
1887                        && feedback_nodes.contains(&e.node_b)
1888                    {
1889                        Some(e.node_a)
1890                    } else if main_outputs.contains(&e.node_b) && feedback_nodes.contains(&e.node_a)
1891                    {
1892                        Some(e.node_b)
1893                    } else {
1894                        None
1895                    };
1896
1897                    let Some(source) = source else {
1898                        continue;
1899                    };
1900
1901                    let converge = if source == e.node_a {
1902                        e.node_b
1903                    } else {
1904                        e.node_a
1905                    };
1906                    if !genuinely_parallel(source, converge) {
1907                        continue;
1908                    }
1909
1910                    let mut wdf = WdfStage::new(
1911                        with_voltage_source(leaf),
1912                        RootKind::Passthrough,
1913                        Oversampler::new(OversamplingFactor::X1),
1914                    );
1915                    wdf.signal_flow_distance = group_flow_distances[gi];
1916                    wdf.is_feedforward = true;
1917                    wdf.injection_node_id = source;
1918                    #[cfg(debug_assertions)]
1919                    {
1920                        wdf.debug_label = comp.id.clone();
1921                    }
1922                    stages.push(Stage::Wdf(wdf));
1923                    stage_comp_ids.push(vec![comp.id.clone()]);
1924                    built_feedforward = true;
1925                }
1926
1927                if built_feedforward {
1928                    continue;
1929                }
1930            }
1931
1932            // ── Transformer-secondary load reflection (GAP F, narrow + gated) ──
1933            // A transformer's PRIMARY-side stage never feels a load on its
1934            // SECONDARY: the windings couple magnetically (`coupled_nodes`),
1935            // not conductively, so the loaded secondary always splits into its
1936            // own flow group. The primary-side solve then terminates the
1937            // transformer with an OPEN secondary (bare magnetizing branch) and
1938            // the standalone load stage divides by an arbitrary source
1939            // resistance — measured +20…+26 dB vs ngspice on the CE → 10:1 OT
1940            // → 1 kΩ probes (the LA-2A GAP F step-down family).
1941            //
1942            // Fix: fuse the secondary load-group edges into THIS group's build
1943            // and lower via `build_passive_rtype_stage`. Its MNA stamps the
1944            // full transformer skeleton (DCR + leakage + magnetizing/JA core +
1945            // ideal turns-ratio branch) with REAL secondary nodes, so the load
1946            // current flows through the turns-ratio stamp and the primary
1947            // feels `n²·R` — the reflection is performed by the same model
1948            // that matches the ngspice coupled-inductor fixtures to ~−90 dB
1949            // when transformer and load share a stage. The standalone load
1950            // stage is consumed (skip-set + built-stage removal) so the load
1951            // is never applied twice. ANALYSIS (the exact recognized shape)
1952            // and POLICY (passive-only target, `PK_XFMR_REFLECT_DISABLE`
1953            // opt-out) live in `boundary_load`; the decision — reflected or
1954            // declined — is recorded on `CompiledPedal::boundary_loads`.
1955            {
1956                let xfmr_analysis = super::boundary_load::analyze_transformer_secondary_load(
1957                    gi,
1958                    &feedback_groups,
1959                    &graph,
1960                    &cut_edges,
1961                );
1962                if let Some(load) = xfmr_analysis {
1963                    // A load group can only be consumed ONCE. Two transformers
1964                    // whose secondaries share a single load group (pathological
1965                    // but constructible) must not both fuse it — the second
1966                    // fusion would stamp the load edges into two stages
1967                    // (double loading) with no standalone stage left to remove.
1968                    let load_already_consumed = load.model.edges().iter().any(|&eidx| {
1969                        xfmr_consumed_comp_ids
1970                            .contains(&graph.components[graph.edges[eidx].comp_idx].id)
1971                    });
1972                    let mut fused: Option<(WdfStage, Vec<usize>)> = None;
1973                    let disposition = if load_already_consumed {
1974                        super::boundary_load::LoadDisposition::Unloaded {
1975                            reason: "gate:load-group-already-consumed",
1976                        }
1977                    } else {
1978                        match super::boundary_load::gate_transformer_reflection(group, &graph) {
1979                            Ok(()) => {
1980                                let mut fused_edges = group_edges.clone();
1981                                for &eidx in load.model.edges() {
1982                                    if !fused_edges.contains(&eidx) {
1983                                        fused_edges.push(eidx);
1984                                    }
1985                                }
1986                                match build_passive_rtype_stage(
1987                                    &fused_edges,
1988                                    &graph,
1989                                    sample_rate,
1990                                    &bias_node_voltages,
1991                                ) {
1992                                    Some(wdf) => {
1993                                        #[cfg(test)]
1994                                        eprintln!(
1995                                            "  [compile] group {gi}: reflected transformer-secondary \
1996                                             load group {} (n={}, r_reflected={:.1}) into primary-side \
1997                                             PassiveRType MNA",
1998                                            load.load_group, load.turns_ratio, load.r_reflected
1999                                        );
2000                                        fused = Some((wdf, fused_edges));
2001                                        super::boundary_load::LoadDisposition::ReflectedThroughTransformer {
2002                                            consumed_group: load.load_group,
2003                                            turns_ratio: load.turns_ratio,
2004                                            r_reflected: load.r_reflected,
2005                                        }
2006                                    }
2007                                    // Lowering failed (degenerate terminals) — leave
2008                                    // the boundary open and fall through to the
2009                                    // normal build path; nothing is consumed.
2010                                    None => super::boundary_load::LoadDisposition::Unloaded {
2011                                        reason: "gate:passive-rtype-lowering-failed",
2012                                    },
2013                                }
2014                            }
2015                            Err(reason) => {
2016                                super::boundary_load::LoadDisposition::Unloaded { reason }
2017                            }
2018                        }
2019                    };
2020                    if let Some((wdf, fused_edges)) = fused {
2021                        // Fused stage owns the load components: refresh label /
2022                        // comp ids so later stage lookups see them.
2023                        let mut fused_comp_ids: Vec<String> = fused_edges
2024                            .iter()
2025                            .map(|&eidx| graph.components[graph.edges[eidx].comp_idx].id.clone())
2026                            .collect();
2027                        fused_comp_ids.sort();
2028                        fused_comp_ids.dedup();
2029                        #[cfg(debug_assertions)]
2030                        let fused_label = fused_comp_ids.join(",");
2031                        #[cfg(not(debug_assertions))]
2032                        let fused_label = String::new();
2033                        push_stage!(
2034                            BuiltStage::Wdf(wdf),
2035                            group_flow_distances[gi],
2036                            fused_label,
2037                            is_bypass,
2038                            fused_comp_ids.clone()
2039                        );
2040
2041                        // Consume the standalone load group: skip it if it has
2042                        // not built yet; remove its stage if it already has
2043                        // (non-feedback build order is group-enumeration order,
2044                        // so both orders occur). Same de-duplication contract
2045                        // as the C1 trailing-output fusion.
2046                        let mut load_ids: Vec<String> = load
2047                            .model
2048                            .edges()
2049                            .iter()
2050                            .map(|&eidx| graph.components[graph.edges[eidx].comp_idx].id.clone())
2051                            .collect();
2052                        load_ids.sort();
2053                        load_ids.dedup();
2054                        for id in &load_ids {
2055                            xfmr_consumed_comp_ids.insert(id.clone());
2056                        }
2057                        // Exclude the fused stage just pushed (last index).
2058                        for idx in (0..stages.len().saturating_sub(1)).rev() {
2059                            if stage_comp_ids[idx] == load_ids {
2060                                #[cfg(test)]
2061                                eprintln!(
2062                                    "  [compile] group {gi}: removed standalone secondary-load \
2063                                     stage {idx} ({load_ids:?}) — consumed by transformer \
2064                                     reflection"
2065                                );
2066                                stages.remove(idx);
2067                                stage_comp_ids.remove(idx);
2068                                break;
2069                            }
2070                        }
2071                        pending_boundary_loads.push(super::boundary_load::PendingBoundaryLoad {
2072                            stage_key: fused_comp_ids
2073                                .iter()
2074                                .min()
2075                                .cloned()
2076                                .unwrap_or_else(|| "~".to_string()),
2077                            boundary_node: load.boundary_node,
2078                            model: load.model.summarize(&graph),
2079                            disposition: disposition.summarize(),
2080                        });
2081                        continue;
2082                    }
2083                    // Analyzed but declined (gate / lowering failure): record
2084                    // the row for the dashboard and fall through to the normal
2085                    // build path — byte-identical behavior.
2086                    pending_boundary_loads.push(super::boundary_load::PendingBoundaryLoad {
2087                        stage_key: group_comp_ids
2088                            .iter()
2089                            .min()
2090                            .cloned()
2091                            .unwrap_or_else(|| "~".to_string()),
2092                        boundary_node: load.boundary_node,
2093                        model: load.model.summarize(&graph),
2094                        disposition: disposition.summarize(),
2095                    });
2096                }
2097            }
2098
2099            // ── Blockwise check: can this group be split into chained NL blocks?
2100            if !options.skip_blockwise {
2101                if let Some(built_stages) = super::blockwise::try_build_blockwise(
2102                    &group_edges,
2103                    &graph,
2104                    &terminals,
2105                    sample_rate,
2106                    &bias_node_voltages,
2107                    supply_voltage,
2108                    &pedal.ports,
2109                    options.force_serial_blockwise,
2110                    options.force_serial_blockwise_feedback_gain,
2111                    options.disable_iir,
2112                    options.coupled_blockwise_newton,
2113                    &pedal.init_hints,
2114                ) {
2115                    for built in built_stages {
2116                        push_stage!(
2117                            built,
2118                            group_flow_distances[gi],
2119                            group_label.clone(),
2120                            is_bypass,
2121                            group_comp_ids.clone()
2122                        );
2123                    }
2124                    continue; // Skip normal SPQR path
2125                }
2126            } // !skip_blockwise
2127
2128            let group_terminals = compute_group_terminals(&group_edges, &graph, &terminals);
2129            #[cfg(test)]
2130            eprintln!("  SPQR terminals for group: {:?}", group_terminals);
2131
2132            {
2133                let spqr_tree =
2134                    spqr_decompose(&group_edges, &group_terminals, &graph, graph.gnd_node);
2135                let spqr_stages = spqr_to_stages(&spqr_tree, &graph, sample_rate);
2136
2137                #[cfg(test)]
2138                {
2139                    let node_type = if spqr_tree.is_rigid() {
2140                        "R"
2141                    } else if matches!(&spqr_tree, super::spqr::SpqrNode::S { .. }) {
2142                        "S"
2143                    } else if matches!(&spqr_tree, super::spqr::SpqrNode::P { .. }) {
2144                        "P"
2145                    } else {
2146                        "Q"
2147                    };
2148                    eprintln!(
2149                        "  SPQR result: {node_type}-node → {} stages",
2150                        spqr_stages.len()
2151                    );
2152                }
2153
2154                // Count NL stages from the SPQR decomposition.
2155                // Passive WDF stages (ShortCircuit/Passthrough roots) do not
2156                // process nonlinear devices. If the group has NL components but
2157                // SPQR produces no NlWdf or Rigid stages, the NL devices were
2158                // silently dropped as Q-nodes. Fall back to build_rigid_from_group
2159                // to ensure they're processed through the general MNA + NR path.
2160                let spqr_has_nl_stage = spqr_stages
2161                    .iter()
2162                    .any(|s| matches!(s, SpqrStage::NlWdf { .. } | SpqrStage::Rigid { .. }));
2163
2164                if group_has_nonlinear && !spqr_has_nl_stage {
2165                    // NL devices were dropped by SPQR (cross-coupled topology with
2166                    // no R-node, e.g. BJT astable multivibrator). Use the general
2167                    // MNA path with init hints so all NL devices are compiled.
2168                    eprintln!(
2169                        "  [compile] group {gi}: SPQR dropped NL devices, falling back to rigid MNA"
2170                    );
2171                    let built = build_rigid_from_group_with_hints(
2172                        group_edges,
2173                        &graph,
2174                        sample_rate,
2175                        Some(group),
2176                        supply_voltage,
2177                        None,
2178                        !options.disable_iir,
2179                        &pedal.init_hints,
2180                    )
2181                    .map_err(|e| format!("Group {gi} (rigid fallback): {e}"))?;
2182                    push_stage!(
2183                        built,
2184                        group_flow_distances[gi],
2185                        group_label.clone(),
2186                        is_bypass,
2187                        group_comp_ids.clone()
2188                    );
2189                    continue;
2190                } else if !group_has_nonlinear && spqr_stages.is_empty() && group_has_runtime_pot {
2191                    // All-passive group that reduced to a rigid (non-series-
2192                    // parallel) R-node AND carries a runtime pot: spqr_to_dyn_node
2193                    // returns None for the R-node, so the AllPassive arm of
2194                    // collect_stages warns and drops the entire stage — and its
2195                    // declared pots vanish with it (symptom: dead Tone/Level
2196                    // controls, unity passthrough).
2197                    //
2198                    // The pot guard is load-bearing: an all-passive group with NO
2199                    // pot (e.g. a bare input/output coupling RC feeding an active
2200                    // device's pin) is HARMLESS to drop — the serial chain carries
2201                    // the signal through as a passthrough, which is correct. Only a
2202                    // dropped group that owns a CONTROL needs rebuilding, and only
2203                    // then is it worth the risk of mis-terminating a coupling
2204                    // network as a standalone 2-port (which silences it — observed
2205                    // on dyna_comp's pot-less input-coupling group).
2206                    //
2207                    // Rebuild it exactly the way the normal `SpqrStage::Rigid`
2208                    // all-passive branch does — via `build_passive_rtype_stage`,
2209                    // which lowers the group into a WDF PassiveRType stage with
2210                    // terminal-derived input/output ports (compute_group_terminals)
2211                    // and live pot leaves (both 2-terminal rheostats like Tone and
2212                    // 3-terminal wiper dividers like Level). This preserves the
2213                    // mid-chain 2-port transfer; the generic rigid MNA builder does
2214                    // NOT — it models a 1-port (voltage-source-in / sample-at-`out`)
2215                    // and silences any mid-chain passive group that does not contain
2216                    // the global `out` (output port unbound -> zero c-vector).
2217                    // Narrow: only fires when SPQR produced ZERO stages for an
2218                    // all-passive group that owns a pot control.
2219                    let built = if let Some(wdf) = build_passive_rtype_stage(
2220                        &group_edges,
2221                        &graph,
2222                        sample_rate,
2223                        &bias_node_voltages,
2224                    ) {
2225                        eprintln!(
2226                            "  [compile] group {gi}: SPQR dropped all-passive stage (rigid R-node), rebuilt as PassiveRType WDF"
2227                        );
2228                        BuiltStage::Wdf(wdf)
2229                    } else {
2230                        // PassiveRType could not lower this group (e.g. degenerate
2231                        // terminals); last-resort rigid MNA so the stage at least
2232                        // exists rather than being silently dropped.
2233                        eprintln!(
2234                            "  [compile] group {gi}: SPQR dropped all-passive stage; PassiveRType lowering failed, using rigid MNA"
2235                        );
2236                        build_rigid_from_group_with_hints(
2237                            group_edges,
2238                            &graph,
2239                            sample_rate,
2240                            Some(group),
2241                            supply_voltage,
2242                            None,
2243                            !options.disable_iir,
2244                            &pedal.init_hints,
2245                        )
2246                        .map_err(|e| format!("Group {gi} (all-passive rigid fallback): {e}"))?
2247                    };
2248                    push_stage!(
2249                        built,
2250                        group_flow_distances[gi],
2251                        group_label.clone(),
2252                        is_bypass,
2253                        group_comp_ids.clone()
2254                    );
2255                    continue;
2256                }
2257
2258                for stage in spqr_stages {
2259                    let built = build_spqr_stage_with_options(
2260                        stage,
2261                        &graph,
2262                        sample_rate,
2263                        options.disable_iir,
2264                        &pedal.init_hints,
2265                        supply_voltage,
2266                        &bias_node_voltages,
2267                    )
2268                    .map_err(|e| format!("Group {gi}: {e}"))?;
2269                    push_stage!(
2270                        built,
2271                        group_flow_distances[gi],
2272                        group_label.clone(),
2273                        is_bypass,
2274                        group_comp_ids.clone()
2275                    );
2276                }
2277            }
2278        }
2279    }
2280
2281    // ── Edge accounting guard (pedalkernel-ffkl) ─────────────────────────────
2282    // After stage assembly every graph edge must be accounted for: solved in
2283    // some stage (its component id is claimed by a stage's comp-id set),
2284    // consumed by an explicit mechanism (triode-context absorption, blockwise
2285    // coupling, transformer reflection, ground-clip merge, delayed-cut carry),
2286    // or deliberately excluded with a reason (op-amp nullor edges lower via
2287    // the op-amp pipeline; behavioral coupler edges bind at bind-time). An
2288    // edge that falls through means a component silently vanished from the
2289    // compiled circuit — the LA-2A T_out failure family. Mode is governed by
2290    // PK_EDGE_GUARD (error by default; see `report_dropped_edges`).
2291    //
2292    // NOTE this comp-id-granular sweep is the OUTER net; the INNER net is the
2293    // builder-level guard in `rigid::general::stamp_passive_edges`, which
2294    // catches edge-granular drops inside a build (a stage can claim a comp id
2295    // while dropping one of its edges — exactly how T_out was lost while V3's
2296    // stage label listed it).
2297    {
2298        use super::component::EdgeKind;
2299        let claimed_comp_ids: std::collections::HashSet<&String> =
2300            stage_comp_ids.iter().flatten().collect();
2301        let ground_clip_edges: std::collections::HashSet<usize> = ground_clip_built
2302            .iter()
2303            .flat_map(|&gi| feedback_groups[gi].all_edges())
2304            .collect();
2305        let mut unaccounted: Vec<usize> = Vec::new();
2306        for eidx in 0..graph.edges.len() {
2307            let comp = &graph.components[graph.edges[eidx].comp_idx];
2308            let kind = graph.effective_edge_kind(eidx);
2309            // Vcvs (op-amp nullor) and Vccs (OTA transconductance) edges
2310            // lower via the active-IC pipeline (opamp analysis / OTA
2311            // envelope resolution), Behavioral edges bind at bind-time —
2312            // deliberate exclusions with a home elsewhere.
2313            let accounted =
2314                matches!(kind, EdgeKind::Vcvs | EdgeKind::Vccs | EdgeKind::Behavioral)
2315                    || claimed_comp_ids.contains(&comp.id)
2316                    || triode_absorbed_edges.contains(&eidx)
2317                    || guard_excluded_edges.contains(&eidx)
2318                    || cut_edges.cuts.contains_key(&eidx)
2319                    || bkm_consumed_comp_ids.contains(&comp.id)
2320                    || xfmr_consumed_comp_ids.contains(&comp.id)
2321                    || ground_clip_edges.contains(&eidx);
2322            if !accounted {
2323                unaccounted.push(eidx);
2324            }
2325        }
2326        if !unaccounted.is_empty() && std::env::var("PK_EDGE_GUARD_DEBUG").is_ok() {
2327            eprintln!(
2328                "[edge-guard-debug] stages={} stage_comp_ids={stage_comp_ids:?} \
2329                 absorbed={triode_absorbed_edges:?} cuts={:?} bkm={bkm_consumed_comp_ids:?} \
2330                 xfmr={xfmr_consumed_comp_ids:?}",
2331                stages.len(),
2332                cut_edges.cuts.keys().collect::<Vec<_>>(),
2333            );
2334        }
2335        report_dropped_edges(
2336            "stage assembly",
2337            &unaccounted,
2338            &graph,
2339            // pedalkernel-x5ac (promoted in the y9hz batch): error by
2340            // default, like the builder-level guard — corpus is quiet now
2341            // that the blockwise Some(empty) ghost drop is fixed.
2342            EdgeGuardMode::Error,
2343        )?;
2344    }
2345
2346    // ── Output-follower injection wiring (GAP 4c, pedalkernel-0lsv) ────────
2347    // Runs BEFORE the sorts, where `stage_comp_ids[si]` is still 1:1 with
2348    // `stages[si]` (the sorts move the fields we set along with each stage).
2349    //
2350    // A non-inverting output FOLLOWER (uberdrive/lgsm Q2 emitter follower, fed
2351    // IC2.out -> C7 -> volume divider -> Q2.base) is a directed dead-end under the
2352    // stage-ordering metric, so `compute_group_flow_distances` re-slots its group
2353    // into the MAX-band retry (3a) — but its MultiNl stage still reads the serial
2354    // `signal`, which is 0.0 there. Give it a value to read: set the MultiNl
2355    // stage's `injection_node_id` to the upstream op-amp output node (IC2.out, a
2356    // node the tone stage already produces) and make that upstream stage PUBLISH
2357    // to `node_signals` (`output_node_id`). The follower's own in-group MNA (which
2358    // spans C7 / R11 / VOLUME / R12 / C8) performs the volume division against the
2359    // injected value — no new node_signals writer is needed (design variant b).
2360    //
2361    // GATE: only groups that are a directed dead-end (`directed` reaches no group
2362    // node) yet reachable once op-amp control pins are crossed
2363    // (`boundary_reachable` finite) qualify — exactly the 3a retry set. LA-2A /
2364    // pultec / neve photocoupler and tube groups inject at a device pin (not a
2365    // control-crossing boundary; no op-amp control pin exists to cross) and do NOT
2366    // qualify, so their goldens stay byte-identical.
2367    {
2368        use super::graph::NodeId;
2369        let follower_debug = std::env::var("PK_FOLLOWER_DEBUG").is_ok();
2370        let directed = super::signal_flow::directed_signal_distances_from_in(&graph);
2371        let boundary = super::signal_flow::boundary_reachable_distances_from_in(&graph);
2372
2373        let mut follower_injections: Vec<(NodeId, Vec<String>, Vec<String>)> = Vec::new();
2374        for (gi, group) in feedback_groups.iter().enumerate() {
2375            if group.has_feedback() || group.active_edges.is_empty() {
2376                continue;
2377            }
2378            let edges = group.all_edges();
2379            if edges.is_empty() {
2380                continue;
2381            }
2382            let edge_set: std::collections::HashSet<usize> = edges.iter().copied().collect();
2383            let group_nodes: std::collections::HashSet<NodeId> = edges
2384                .iter()
2385                .flat_map(|&eidx| {
2386                    let e = &graph.edges[eidx];
2387                    [e.node_a, e.node_b]
2388                })
2389                .collect();
2390            // Directed dead-end (no group node reachable under the base metric)
2391            // yet reachable once control pins are crossed.
2392            let directed_reachable = group_nodes.iter().any(|n| directed.contains_key(n));
2393            let boundary_reachable = group_nodes.iter().any(|n| boundary.contains_key(n));
2394            // Injection node = the group's INPUT BOUNDARY node: a group node also
2395            // touched by an edge OUTSIDE this group (so an upstream stage drives
2396            // it) and NOT a rail. Deterministic: nearest to `in` under the
2397            // control-crossing metric, ties broken by NodeId. This mirrors
2398            // general.rs `find_input_boundary_node`, so the runtime read node ==
2399            // the compile-time adapted-VS injection boundary (uberdrive/lgsm:
2400            // C7.b/R11.a).
2401            let is_rail = |n: NodeId| {
2402                n == graph.gnd_node
2403                    || n == graph.vcc_node
2404                    || graph.supply_nodes.contains(&n)
2405                    || graph.ac_ground_nodes.contains(&n)
2406            };
2407            let boundary_nodes: std::collections::HashSet<NodeId> = graph
2408                .edges
2409                .iter()
2410                .enumerate()
2411                .filter(|(eidx, _)| !edge_set.contains(eidx))
2412                .flat_map(|(_, e)| [e.node_a, e.node_b])
2413                .filter(|n| group_nodes.contains(n) && !is_rail(*n))
2414                .collect();
2415            let inj_node = boundary_nodes
2416                .iter()
2417                .copied()
2418                .filter(|n| boundary.contains_key(n) && *n != graph.in_node)
2419                .min_by_key(|n| (*boundary.get(n).unwrap_or(&usize::MAX), *n));
2420            if follower_debug {
2421                let names: Vec<String> = group_nodes
2422                    .iter()
2423                    .map(|&n| {
2424                        graph
2425                            .node_names
2426                            .iter()
2427                            .find(|(_, &id)| id == n)
2428                            .map(|(k, _)| k.clone())
2429                            .unwrap_or_else(|| format!("n{n}"))
2430                    })
2431                    .collect();
2432                let comps: Vec<String> = edges
2433                    .iter()
2434                    .map(|&eidx| graph.components[graph.edges[eidx].comp_idx].id.clone())
2435                    .collect();
2436                eprintln!(
2437                    "[PK_FOLLOWER] group {gi}: directed_reachable={directed_reachable} \
2438                     boundary_reachable={boundary_reachable} inj_node={inj_node:?} \
2439                     comps={comps:?} nodes={names:?}"
2440                );
2441            }
2442            if directed_reachable || !boundary_reachable {
2443                continue;
2444            }
2445            let Some(inj_node) = inj_node else {
2446                continue;
2447            };
2448            let comp_ids: Vec<String> = edges
2449                .iter()
2450                .map(|&eidx| graph.components[graph.edges[eidx].comp_idx].id.clone())
2451                .collect();
2452            // Producer comps: components of OUTSIDE edges touching the injection
2453            // boundary node — the upstream stage that drives it and must publish
2454            // its per-sample value at `inj_node` to the bus (e.g. C7, whose `.a`
2455            // is IC2.out and `.b` is the boundary). Ordered nearest-`in` first so
2456            // the true upstream (not a same-node sibling shunt) is tried first.
2457            let mut producers: Vec<(usize, String)> = graph
2458                .edges
2459                .iter()
2460                .enumerate()
2461                .filter(|(eidx, e)| {
2462                    !edge_set.contains(eidx) && (e.node_a == inj_node || e.node_b == inj_node)
2463                })
2464                .map(|(_, e)| {
2465                    let other = if e.node_a == inj_node { e.node_b } else { e.node_a };
2466                    let d = boundary.get(&other).copied().unwrap_or(usize::MAX);
2467                    (d, graph.components[e.comp_idx].id.clone())
2468                })
2469                .collect();
2470            producers.sort();
2471            let producer_ids: Vec<String> = producers.into_iter().map(|(_, id)| id).collect();
2472            follower_injections.push((inj_node, comp_ids, producer_ids));
2473        }
2474
2475        for (inj_node, comp_ids, producer_ids) in &follower_injections {
2476            // Wire the follower's MultiNl stage to READ the injection node.
2477            let mut wired = false;
2478            for (si, stage) in stages.iter_mut().enumerate() {
2479                if let Stage::MultiNl(m) = stage {
2480                    if m.injection_node_id != usize::MAX {
2481                        continue;
2482                    }
2483                    if stage_comp_ids[si].iter().any(|id| comp_ids.contains(id)) {
2484                        m.injection_node_id = *inj_node;
2485                        wired = true;
2486                        if follower_debug {
2487                            eprintln!(
2488                                "  → output follower: MultiNl stage {si} reads node {inj_node} \
2489                                 (injection), was serial-in 0.0"
2490                            );
2491                        }
2492                        break;
2493                    }
2494                }
2495            }
2496            if !wired {
2497                if follower_debug {
2498                    eprintln!(
2499                        "[PK_FOLLOWER] NOT wired: inj_node={inj_node} follower_comps={comp_ids:?}"
2500                    );
2501                }
2502                continue;
2503            }
2504            // Make the upstream producer stage PUBLISH the injection node to the
2505            // bus. The producer owns an outside edge touching `inj_node` (e.g. the
2506            // tone-stage output coupling that terminates at C7.b).
2507            for prod_id in producer_ids {
2508                let mut published = false;
2509                for (si, stage) in stages.iter_mut().enumerate() {
2510                    if !stage_comp_ids[si].iter().any(|id| id == prod_id) {
2511                        continue;
2512                    }
2513                    let ok = match stage {
2514                        Stage::Wdf(w) if !w.bypass_serial && w.output_node_id == usize::MAX => {
2515                            w.output_node_id = *inj_node;
2516                            true
2517                        }
2518                        Stage::BlackFeedback(b)
2519                            if !b.bypass_serial && b.output_node_id == usize::MAX =>
2520                        {
2521                            b.output_node_id = *inj_node;
2522                            true
2523                        }
2524                        Stage::MultiNl(m)
2525                            if !m.bypass_serial && m.output_node_id == usize::MAX =>
2526                        {
2527                            m.output_node_id = *inj_node;
2528                            true
2529                        }
2530                        // The tone stage is typically a StateSpace (uberdrive
2531                        // IC2). Setting its output_node_id makes it PUBLISH the
2532                        // node to the bus; the runtime StateSpace path then routes
2533                        // to node_signals instead of the serial chain, which is
2534                        // correct here — the follower (the only consumer of the
2535                        // tone output) reads it from the bus and re-drives serial.
2536                        Stage::StateSpace(ss) if !ss.bypass_serial && ss.output_node_id == usize::MAX => {
2537                            ss.output_node_id = *inj_node;
2538                            true
2539                        }
2540                        // Already publishes some node (or is a bypass/serial-only
2541                        // stage): if it already writes `inj_node`, we are done.
2542                        Stage::Wdf(w) if w.output_node_id == *inj_node => true,
2543                        Stage::BlackFeedback(b) if b.output_node_id == *inj_node => true,
2544                        Stage::MultiNl(m) if m.output_node_id == *inj_node => true,
2545                        Stage::StateSpace(ss) if ss.output_node_id == *inj_node => true,
2546                        _ => false,
2547                    };
2548                    if ok {
2549                        published = true;
2550                        if follower_debug {
2551                            eprintln!(
2552                                "  → output follower: upstream stage {si} ({prod_id}) publishes \
2553                                 node {inj_node} to bus"
2554                            );
2555                        }
2556                        break;
2557                    }
2558                }
2559                if published {
2560                    break;
2561                }
2562            }
2563        }
2564    }
2565
2566    // Defect B tertiary tiebreak: when two stages share a signal_flow_distance,
2567    // resolve their order DETERMINISTICALLY by the minimum stable component id
2568    // in each stage (netlist names are stable across compiles/processes). This
2569    // makes the serial chain reproducible even when `find_flow_groups` returns
2570    // tied groups in a process-dependent order. `stage_comp_ids` is populated in
2571    // both debug and release. A stage with no comp ids sorts last among its tie.
2572    let mut stage_min_comp_id: Vec<String> = stage_comp_ids
2573        .iter()
2574        .map(|ids| ids.iter().min().cloned().unwrap_or_else(|| "~".to_string()))
2575        .collect();
2576    let stage_dist = |s: &Stage| -> usize {
2577        match s {
2578            Stage::Wdf(w) => w.signal_flow_distance,
2579            Stage::Iir(i) => i.signal_flow_distance,
2580            Stage::StateSpace(ss) => ss.signal_flow_distance,
2581            Stage::MultiNl(m) => m.signal_flow_distance,
2582            Stage::BlackFeedback(b) => b.signal_flow_distance,
2583            Stage::Blockwise(k) => k.signal_flow_distance,
2584            Stage::KMethod { .. } => usize::MAX,
2585            Stage::SerialDelayedFeedback(s) => s.signal_flow_distance,
2586        }
2587    };
2588
2589    // Sort stages by (signal_flow_distance, min_comp_id). Index-permutation
2590    // based so the parallel `stage_min_comp_id` table stays aligned through the
2591    // sort (the second, post-feedforward re-sort below reuses it).
2592    // (`stage_comp_ids` is intentionally left in its build order — the VCO
2593    // generator pass below indexes it by the same pre-sort positions it always
2594    // has, so we do not perturb that pre-existing behavior.)
2595    {
2596        let mut perm: Vec<usize> = (0..stages.len()).collect();
2597        perm.sort_by(|&a, &b| {
2598            stage_dist(&stages[a])
2599                .cmp(&stage_dist(&stages[b]))
2600                .then_with(|| stage_min_comp_id[a].cmp(&stage_min_comp_id[b]))
2601        });
2602        apply_permutation(&mut stages, &perm);
2603        apply_permutation(&mut stage_min_comp_id, &perm);
2604    }
2605
2606    // ── Generator (VCO, N=0) source placement (spec §4) ───────────────────
2607    // A generator has no audio input — there is no galvanic gap to split.
2608    // Instead its wave-output node is a *source seed*: the runtime injects the
2609    // VCO sample into `node_signals` at that node (processor.rs VCO tick loop),
2610    // and the stage immediately downstream (e.g. the output protection resistor
2611    // VCO1.saw -> R_saw -> out) must READ that node rather than the serial
2612    // chain. Mark that consuming stage feedforward with injection_node_id =
2613    // the generator output node, reusing the existing port-write/feedforward
2614    // read path. No new per-sample machinery — this is the §4 N=0 branch made
2615    // real, the analogue of the input-port VS for a pure source.
2616    {
2617        // Generator output nodes from every block whose io() has no audio input.
2618        let mut gen_out_nodes: Vec<super::graph::NodeId> = Vec::new();
2619        for block in super::dsp_block::all_generator_output_nodes(pedal, &graph) {
2620            if !gen_out_nodes.contains(&block) {
2621                gen_out_nodes.push(block);
2622            }
2623        }
2624        for vn in gen_out_nodes {
2625            // The consuming stage is the one whose group has an edge touching
2626            // the generator output node (the first passive the VCO drives).
2627            let mut consumer_comp_ids: std::collections::HashSet<String> =
2628                std::collections::HashSet::new();
2629            for e in &graph.edges {
2630                if e.node_a == vn || e.node_b == vn {
2631                    consumer_comp_ids.insert(graph.components[e.comp_idx].id.clone());
2632                }
2633            }
2634            if consumer_comp_ids.is_empty() {
2635                continue;
2636            }
2637            for (si, stage) in stages.iter_mut().enumerate() {
2638                if let Stage::Wdf(w) = stage {
2639                    if w.is_feedforward || w.bypass_serial {
2640                        continue;
2641                    }
2642                    let ids = &stage_comp_ids[si];
2643                    if ids.iter().any(|id| consumer_comp_ids.contains(id)) {
2644                        w.is_feedforward = true;
2645                        w.injection_node_id = vn;
2646                        #[cfg(test)]
2647                        eprintln!(
2648                            "  → generator source: stage {si} reads VCO node {vn} (feedforward)"
2649                        );
2650                        break;
2651                    }
2652                }
2653            }
2654        }
2655    }
2656
2657    // ── Feedforward detection ─────────────────────────────────────────────
2658    // Detect non-feedback stages that fan out from a shared source node
2659    // and converge at a summing point. These stages read from node_signals
2660    // instead of the serial chain, and add their output to the signal.
2661    //
2662    // Phase 1: collect (stage_index, injection_node) pairs.
2663    // Phase 2: apply flags to stages.
2664    {
2665        // BFS node distances for feedforward stage ordering
2666        let node_dist = {
2667            use std::collections::{BTreeMap, VecDeque};
2668            let mut dist: BTreeMap<usize, usize> = BTreeMap::new();
2669            let mut queue: VecDeque<usize> = VecDeque::new();
2670            dist.insert(graph.in_node, 0);
2671            queue.push_back(graph.in_node);
2672            let mut adj: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
2673            for e in &graph.edges {
2674                adj.entry(e.node_a).or_default().push(e.node_b);
2675                adj.entry(e.node_b).or_default().push(e.node_a);
2676            }
2677            while let Some(node) = queue.pop_front() {
2678                let d = dist[&node];
2679                if let Some(neighbors) = adj.get(&node) {
2680                    for &next in neighbors {
2681                        if let std::collections::btree_map::Entry::Vacant(e) = dist.entry(next) {
2682                            e.insert(d + 1);
2683                            queue.push_back(next);
2684                        }
2685                    }
2686                }
2687            }
2688            dist
2689        };
2690
2691        // Main path output nodes: ALL active element outputs + in_node.
2692        // This includes both feedback group op-amps AND unity followers
2693        // (which have nullor_pins but no feedback group).
2694        let mut main_path_output_nodes: std::collections::HashSet<super::graph::NodeId> =
2695            std::collections::HashSet::new();
2696        main_path_output_nodes.insert(graph.in_node);
2697        for pins in &graph.nullor_pins {
2698            main_path_output_nodes.insert(pins.out_node);
2699        }
2700
2701        // Collect feedback group input nodes (where feedforward paths converge).
2702        // A feedback group's input node is where non-feedback groups can feed into.
2703        let mut feedback_input_nodes: std::collections::HashSet<super::graph::NodeId> =
2704            std::collections::HashSet::new();
2705        let is_audio_convergence_node = |node: super::graph::NodeId| -> bool {
2706            node != graph.gnd_node
2707                && node != graph.vcc_node
2708                && !graph.supply_nodes.contains(&node)
2709                && !graph.ac_ground_nodes.contains(&node)
2710        };
2711        for group in feedback_groups.iter() {
2712            if !group.has_feedback() {
2713                continue;
2714            }
2715            for &eidx in group.active_edges.iter() {
2716                let e = &graph.edges[eidx];
2717                if let Some(pins) = graph.nullor_pins.iter().find(|p| p.comp_idx == e.comp_idx) {
2718                    if is_audio_convergence_node(pins.neg_node) {
2719                        feedback_input_nodes.insert(pins.neg_node);
2720                    }
2721                    if is_audio_convergence_node(pins.pos_node) {
2722                        feedback_input_nodes.insert(pins.pos_node);
2723                    }
2724                }
2725            }
2726        }
2727
2728        // Phase 1: A non-feedback group is feedforward if:
2729        //   - It shares an input node with a main-path output (taps from main path)
2730        //   - It shares an output node with a feedback group's input (converges)
2731        // This uses actual node connectivity, not distance heuristics.
2732        let mut ff_candidates: Vec<(usize, usize, String)> = Vec::new();
2733        for (gi, group) in feedback_groups.iter().enumerate() {
2734            if group.has_feedback() {
2735                continue;
2736            }
2737
2738            let group_nodes: std::collections::HashSet<super::graph::NodeId> = group
2739                .all_edges()
2740                .iter()
2741                .flat_map(|&eidx| {
2742                    let e = &graph.edges[eidx];
2743                    vec![e.node_a, e.node_b]
2744                })
2745                .collect();
2746
2747            // Does this group tap from a main-path output?
2748            // Prefer op-amp output nodes over in_node — in_node is only
2749            // the source for the very first stage, not mid-chain feedforward.
2750            let source_node = group_nodes
2751                .iter()
2752                .find(|&&n| main_path_output_nodes.contains(&n) && n != graph.in_node)
2753                .or_else(|| group_nodes.iter().find(|&&n| n == graph.in_node))
2754                .copied();
2755
2756            // Does this group converge at a feedback group's input?
2757            let converges = group_nodes.iter().any(|n| feedback_input_nodes.contains(n));
2758
2759            if let Some(src) = source_node {
2760                if converges {
2761                    // This is a feedforward path: taps from main path AND
2762                    // converges at a feedback input. It's parallel to the
2763                    // main serial chain.
2764                    let dist = group_flow_distances[gi];
2765                    let first_comp = group
2766                        .all_edges()
2767                        .first()
2768                        .map(|&eidx| graph.components[graph.edges[eidx].comp_idx].id.clone())
2769                        .unwrap_or_default();
2770                    ff_candidates.push((dist, src, first_comp));
2771                }
2772            }
2773        }
2774
2775        // Phase 2: apply feedforward flags.
2776        // A candidate is feedforward if a feedback stage ALSO taps from the same
2777        // source. This means there's a main path (through the feedback stage) and
2778        // a parallel path (through this non-feedback group). Without a feedback
2779        // stage at the same source, the non-feedback group IS the serial chain.
2780        let sources_with_feedback: std::collections::HashSet<usize> = ff_candidates
2781            .iter()
2782            .filter_map(|(_, inj, _)| {
2783                // Check if any feedback group also taps from this source node
2784                let has_fb = feedback_groups.iter().any(|g| {
2785                    g.has_feedback()
2786                        && g.all_edges().iter().any(|&eidx| {
2787                            let e = &graph.edges[eidx];
2788                            e.node_a == *inj || e.node_b == *inj
2789                        })
2790                });
2791                if has_fb {
2792                    Some(*inj)
2793                } else {
2794                    None
2795                }
2796            })
2797            .collect();
2798        for (dist, inj_node, comp_name) in &ff_candidates {
2799            if !sources_with_feedback.contains(inj_node) {
2800                continue; // No feedback stage at this source — this is serial, not feedforward
2801            }
2802            for stage in &mut stages {
2803                if let Stage::Wdf(w) = stage {
2804                    if w.signal_flow_distance == *dist && !w.bypass_serial && !w.is_feedforward {
2805                        #[cfg(debug_assertions)]
2806                        let matches = w.debug_label.contains(comp_name.as_str());
2807                        #[cfg(not(debug_assertions))]
2808                        let matches = true;
2809                        if matches {
2810                            w.is_feedforward = true;
2811                            w.injection_node_id = *inj_node;
2812                            // Set flow distance based on the injection node's
2813                            // BFS distance from in_node. The feedforward stage
2814                            // must process AFTER signal arrives at the tap point.
2815                            if let Some(&node_d) = node_dist.get(inj_node) {
2816                                w.signal_flow_distance = node_d;
2817                            }
2818                            #[cfg(test)]
2819                            eprintln!(
2820                                "  → feedforward: [{}] inj_node={} dist→{}",
2821                                w.debug_label, inj_node, w.signal_flow_distance
2822                            );
2823                            break;
2824                        }
2825                    }
2826                }
2827            }
2828        }
2829
2830        // Set output_node_id on upstream stages that feed feedforward stages.
2831        // The upstream stage is the one whose output node matches the feedforward's
2832        // injection_node_id. It must write to node_signals so feedforward stages
2833        // can read from it.
2834        let ff_inj_nodes: std::collections::HashSet<usize> = stages
2835            .iter()
2836            .filter_map(|s| {
2837                if let Stage::Wdf(w) = s {
2838                    if w.is_feedforward {
2839                        Some(w.injection_node_id)
2840                    } else {
2841                        None
2842                    }
2843                } else {
2844                    None
2845                }
2846            })
2847            .collect();
2848
2849        if !ff_inj_nodes.is_empty() {
2850            for stage in &mut stages {
2851                match stage {
2852                    Stage::Wdf(w) if !w.is_feedforward && !w.bypass_serial => {
2853                        // Check if this stage's group outputs to a feedforward injection node.
2854                        // The output node is the last node in the stage's signal path.
2855                        if w.output_node_id == usize::MAX {
2856                            // Find the group's output node from its debug label (match nullor_pins)
2857                            for pins in &graph.nullor_pins {
2858                                if ff_inj_nodes.contains(&pins.out_node) {
2859                                    // Check if this stage contains the active element
2860                                    #[cfg(debug_assertions)]
2861                                    {
2862                                        let comp_id = &graph.components[pins.comp_idx].id;
2863                                        if w.debug_label.contains(comp_id.as_str()) {
2864                                            w.output_node_id = pins.out_node;
2865                                            break;
2866                                        }
2867                                    }
2868                                }
2869                            }
2870                        }
2871                    }
2872                    Stage::BlackFeedback(b)
2873                        if !b.bypass_serial && b.output_node_id == usize::MAX =>
2874                    {
2875                        // BlackFeedback stages also need output_node_id for feedforward
2876                        for pins in &graph.nullor_pins {
2877                            if ff_inj_nodes.contains(&pins.out_node) {
2878                                #[cfg(debug_assertions)]
2879                                {
2880                                    let comp_id = &graph.components[pins.comp_idx].id;
2881                                    if b.debug_label.contains(comp_id.as_str()) {
2882                                        b.output_node_id = pins.out_node;
2883                                        break;
2884                                    }
2885                                }
2886                            }
2887                        }
2888                    }
2889                    _ => {}
2890                }
2891            }
2892        }
2893    }
2894
2895    // Re-sort: feedforward stages may have changed distance.
2896    // Secondary key: feedforward stages sort AFTER non-feedforward at same
2897    // distance. Tertiary key (Defect B): min stable component id, for
2898    // deterministic ordering among otherwise-tied stages.
2899    {
2900        let ff_of = |s: &Stage| -> u8 {
2901            match s {
2902                Stage::Wdf(w) => w.is_feedforward as u8,
2903                _ => 0,
2904            }
2905        };
2906        let mut perm: Vec<usize> = (0..stages.len()).collect();
2907        perm.sort_by(|&a, &b| {
2908            stage_dist(&stages[a])
2909                .cmp(&stage_dist(&stages[b]))
2910                .then_with(|| ff_of(&stages[a]).cmp(&ff_of(&stages[b])))
2911                .then_with(|| stage_min_comp_id[a].cmp(&stage_min_comp_id[b]))
2912        });
2913        apply_permutation(&mut stages, &perm);
2914        apply_permutation(&mut stage_min_comp_id, &perm);
2915    }
2916
2917    // F13b: wire parallel branches into the convergence mixer. The convergence
2918    // stage reads its driver voltages from `node_signals`, so each upstream
2919    // active branch whose output node is one of those drivers must PUBLISH its
2920    // per-sample output to the bus (and stop overwriting the serial chain — the
2921    // convergence stage owns the serial output). Branches keep reading the serial
2922    // `signal`, which is the shared drive (stage 0's conditioned trigger): because
2923    // branches publish to the bus and never to serial, sibling branches all see
2924    // the same drive instead of cascading.
2925    {
2926        let driver_nodes: std::collections::HashSet<NodeId> = stages
2927            .iter()
2928            .filter_map(|s| match s {
2929                Stage::Wdf(w) => w.convergence.as_ref(),
2930                _ => None,
2931            })
2932            .flat_map(|cs| cs.branches.iter().map(|b| b.source_node_id))
2933            .collect();
2934        if !driver_nodes.is_empty() {
2935            for stage in &mut stages {
2936                if let Stage::StateSpace(ss) = stage {
2937                    if let Some(out_node) = ss.output_binding.map(|b| b.binding_id.get()) {
2938                        if driver_nodes.contains(&out_node) {
2939                            ss.output_node_id = out_node;
2940                        }
2941                    }
2942                }
2943            }
2944        }
2945    }
2946
2947    // When thermal is enabled, snapshot base BJT models so apply_thermal()
2948    // can modulate them without accumulating multipliers.
2949    if options.thermal {
2950        for stage in &mut stages {
2951            if let Stage::Wdf(wdf) = stage {
2952                if let RootKind::Bjt(bjt) = &wdf.root {
2953                    wdf.base_bjt_model = Some(bjt.model);
2954                }
2955            }
2956        }
2957    }
2958
2959    // Envelope-follower → JFET-leaf modulation bindings (audit gap G2).
2960    let envelopes = super::bind::build_envelope_jfet_bindings(pedal, &stages, sample_rate);
2961
2962    let mut compiled = CompiledPedal {
2963        stages,
2964        stage_route_plan: pedalkernel_rt::processor::StageRoutePlan::default(),
2965        push_pull_stages: Vec::new(),
2966        pre_gain: 1.0,
2967        output_gain: 1.0,
2968        rail_saturation: RailSaturation::None,
2969        rail_sat_oversampler: Oversampler::new(options.oversampling),
2970        sample_rate: sample_rate as crate::Wave,
2971        controls: Vec::new(),
2972        gain_range: (0.0, 1.0),
2973        supply_voltage: supply_voltage as crate::Wave,
2974        oversampling: options.oversampling,
2975        lfos: Vec::new(),
2976        envelopes,
2977        bbds: Vec::new(),
2978        delay_lines,
2979        springs: Vec::new(),
2980        vcos: Vec::new(),
2981        vcas: Vec::new(),
2982        thermal: if options.thermal {
2983            Some(ThermalModel::silicon_standard(sample_rate as crate::Wave))
2984        } else {
2985            None
2986        },
2987        tolerance_seed: 0,
2988        opamp_stages: Vec::new(),
2989        power_supply: None,
2990        metrics_accumulator: None,
2991        metrics_buffer: None,
2992        #[cfg(feature = "diag")]
2993        diag_ring: None,
2994        input_loading: None,
2995        output_loading: None,
2996        output_dc_block: None,
2997        sidechains: Vec::new(),
2998        subcircuit_processors: Vec::new(),
2999        subcircuit_routing: Vec::new(),
3000        subcircuit_output_idx: None,
3001        subcircuit_outputs: Vec::new(),
3002        pot_smoothers: Vec::new(),
3003        wiper_dividers: Vec::new(),
3004        pot_mirrors: hashbrown::HashMap::new(),
3005        base_grid_bias: 0.0,
3006        multi_nl_recompute_counter: 0,
3007        node_signals: Vec::new(),
3008        triggers: Vec::new(),
3009        bbd_wet_mix: 0.5,
3010        bbd_mix_pot_id: None,
3011        original_passive_values: hashbrown::HashMap::new(),
3012        ports: Vec::new(),
3013        port_values: Vec::new(),
3014        internal_ports: Vec::new(),
3015        // Boundary-load decision table: pending rows recorded at the fusion
3016        // call site, resolved against the FINAL stage order via the
3017        // `stage_min_comp_id` table (kept aligned with `stages` through both
3018        // permutation sorts above).
3019        boundary_loads: super::boundary_load::resolve_boundary_load_bindings(
3020            pending_boundary_loads,
3021            &stage_min_comp_id,
3022        ),
3023        detector_led_coupling: None,
3024        initialized: false,
3025    };
3026    compiled.set_supply_voltage(supply_voltage as crate::Wave);
3027
3028    // Boundary-load decision table diagnostic (bead pedalkernel-lkf1.2) —
3029    // cfg(test)-gated like the other `[compile]` prints; purely additive, no
3030    // behavior. One line per analyzed output boundary so a silently-Unloaded
3031    // boundary is a visible, greppable fact with its reason. An EMPTY table is
3032    // itself a triage signal (no feedback group reached the general-MNA call
3033    // site — e.g. the whole pedal compiled via blockwise/other paths), distinct
3034    // from a populated row with `Unloaded{reason}`.
3035    #[cfg(test)]
3036    {
3037        if compiled.boundary_loads.is_empty() {
3038            eprintln!(
3039                "  [compile] boundary-load table: EMPTY — no feedback group reached the \
3040                 general-MNA call site (output boundary unanalyzed)"
3041            );
3042        }
3043        for b in &compiled.boundary_loads {
3044            let stage = if b.upstream_stage == usize::MAX {
3045                "?".to_string()
3046            } else {
3047                b.upstream_stage.to_string()
3048            };
3049            eprintln!(
3050                "  [compile] boundary-load: stage {stage} @ node {} — {:?} ⇒ {:?}",
3051                b.boundary_node, b.model, b.disposition
3052            );
3053        }
3054    }
3055
3056    // Bind pot controls to their stages (WDF, IIR, MultiNl).
3057    super::spqr_control::bind_controls(pedal, &mut compiled);
3058
3059    // Lower + bind every registered DSP block's runtime instances (BBD
3060    // clock/feedback/mix pots and clock LFOs; VCA gain + envelope-follower CV
3061    // bindings). Must run after the stage list is final: VCA binding resolves
3062    // detector taps against `compiled.stages`.
3063    super::dsp_block::bind_runtime_all(pedal, &mut compiled, sample_rate)?;
3064
3065    if pedal.calibrate {
3066        super::calibrate::calibrate_output(&mut compiled);
3067    }
3068
3069    // Bind named ports: resolve port names to graph NodeIds.
3070    if !pedal.ports.is_empty() {
3071        let mut port_bindings = Vec::new();
3072        let mut port_values = Vec::new();
3073        for (i, port_def) in pedal.ports.iter().enumerate() {
3074            let node_id = graph
3075                .node_names
3076                .get(&port_def.name)
3077                .copied()
3078                .unwrap_or(usize::MAX);
3079            #[cfg(test)]
3080            if node_id == usize::MAX {
3081                eprintln!(
3082                    "  WARNING: port '{}' not found in graph node_names",
3083                    port_def.name
3084                );
3085            }
3086            port_bindings.push(pedalkernel_rt::processor::NamedPortBinding {
3087                name: port_def.name.clone(),
3088                direction: port_def.direction,
3089                index: i,
3090                node_id,
3091                default_value: 0.0,
3092                stage_idx: usize::MAX, // resolved below for input ports
3093            });
3094            port_values.push(0.0);
3095        }
3096        // For each input port, find the component connected to the port
3097        // node and wrap its WDF leaf with a named VS in series.
3098        // The VS drives current through the component into the circuit,
3099        // modelling a voltage source at the port jack.
3100        for port_binding in &mut port_bindings {
3101            if port_binding.direction != pedalkernel_rt::PortDirection::Input {
3102                continue;
3103            }
3104            if port_binding.node_id == graph.in_node {
3105                continue; // Main input already has VS from with_voltage_source()
3106            }
3107            // Find the component connected to this port node
3108            let port_comp_id: Option<String> = graph
3109                .edges
3110                .iter()
3111                .find(|e| e.node_a == port_binding.node_id || e.node_b == port_binding.node_id)
3112                .map(|e| graph.components[e.comp_idx].id.clone());
3113
3114            if let Some(comp_id) = port_comp_id {
3115                // Find the WDF stage containing this component and wrap
3116                // its leaf with a named VS in series. Record stage index
3117                // so runtime only touches this stage for this port.
3118                let mut injected = false;
3119                for (si, stage) in compiled.stages.iter_mut().enumerate() {
3120                    if let pedalkernel_rt::processor::Stage::Wdf(ref mut wdf) = stage {
3121                        // Primary path: NL-root WDF tree leaf wrapping.
3122                        let found = wdf.tree.wrap_leaf_with_vs(&comp_id, &port_binding.name);
3123                        if found {
3124                            wdf.tree.recompute();
3125                            port_binding.stage_idx = si;
3126                            #[cfg(test)]
3127                            eprintln!(
3128                                "  Injected port VS '{}' at leaf '{}' in stage {si}",
3129                                port_binding.name, comp_id
3130                            );
3131                            injected = true;
3132                            break;
3133                        }
3134                        // Fallback: PassiveRType MNA superposition injection.
3135                        if wdf.register_port_vs_injection(&port_binding.name, port_binding.node_id)
3136                        {
3137                            port_binding.stage_idx = si;
3138                            #[cfg(test)]
3139                            eprintln!(
3140                                "  Injected port VS '{}' at node {} in stage {si} (PassiveRType MNA)",
3141                                port_binding.name, port_binding.node_id
3142                            );
3143                            injected = true;
3144                            break;
3145                        }
3146                    }
3147                }
3148                let _ = injected;
3149            }
3150        }
3151
3152        compiled.ports = port_bindings;
3153        compiled.port_values = port_values;
3154    }
3155
3156    // Phase 2b: build the internal delayed-port table for a DELAYED detector.
3157    // Compiler-synthesized (separate from the user `ports` Vec): cross-sample
3158    // (z⁻¹) carries that deliver the cut forward taps into the de-fused detector
3159    // sub-network one sample late, and store the detector output (`EL_drive`) for
3160    // Phase 3. NARROW: fires only when `detector_control_nodes` found a true
3161    // cross-network feedback detector (LA-2A opto leveler) AND the broker cut a
3162    // tap mouth — non-detector circuits get an empty table (byte-identical).
3163    populate_detector_internal_ports(
3164        &mut compiled,
3165        &graph,
3166        &cut_edges,
3167        &detector_seed_nodes,
3168        &stage_comp_ids,
3169    );
3170
3171    // Cache raw pointers to all VS leaves for zero-cost runtime access.
3172    // Must be after port binding (wrap_leaf_with_vs) and recompute.
3173    compiled.cache_all_vs_pointers();
3174
3175    Ok(compiled)
3176}
3177
3178/// Phase 2b — populate `compiled.internal_ports` for a DELAYED feedback detector.
3179///
3180/// The broker (`delayed_cut_edges`) de-fuses the detector from the forward audio
3181/// path by cutting the tap-mouth edges (the `out -> C_sc` feedback mouth and the
3182/// `in -> fork`/`R_ff` feed-forward mouth — see step 4 + 4b of `delayed_cut_edges`).
3183/// After the cut the forward `in` is no longer shorted by the Compress fork arm,
3184/// and the de-fused detector sub-chain (front-end → V4 → V5 → `EL_drive`) solves
3185/// on the program signal it still sees through the forward serial routing (the
3186/// feed-forward tap), producing a program-dependent EL-drive value.
3187///
3188/// This routine wires the genuinely-new cross-sample (z⁻¹) piece — the EL-DRIVE
3189/// CARRY port — that captures and stores that detector output:
3190///
3191///   * EL-DRIVE CARRY port: `source = the Behavioral coupler's LED-anode node`
3192///     (the detector output, `EL_drive.b == PC1.led.a`), `consumer = usize::MAX`
3193///     (carry-only). The detector's solved EL-drive value is captured every
3194///     sample into `prev_value`, AVAILABLE for Phase 3 (which will set
3195///     `consumer = PC1.led` to apply gain reduction from the carried value) and
3196///     for the 2b tracking measurement (`la2a_detector_el_drive_tracks_program`).
3197///     NO gain reduction is applied here — 2b only COMPUTES and STORES it.
3198///
3199/// To make the EL-drive node observable for the carry, the stage that owns the
3200/// EL-drive driver component is told to PUBLISH its solved output node into
3201/// `node_signals` (`output_node_id = el_node`); this does NOT alter the serial
3202/// audio signal, it only ALSO exposes the node for the end-of-sample z⁻¹ capture.
3203///
3204/// The DELAYED feedback-tap mix (re-injecting the `out` node into the detector
3205/// front one sample late) belongs to Phase 3, where the GR loop is actually
3206/// closed; in 2b the detector is open-loop (no GR) so the feed-forward solve
3207/// suffices to demonstrate program tracking. The internal-port table + carry
3208/// generalize the `SidechainProcessor.cv_delayed` precedent and subsume it.
3209fn populate_detector_internal_ports(
3210    compiled: &mut CompiledPedal,
3211    graph: &CircuitGraph,
3212    cut_edges: &super::boundary_rules::DelayedCutSet,
3213    detector_seeds: &std::collections::HashSet<super::graph::NodeId>,
3214    stage_comp_ids: &[Vec<String>],
3215) {
3216    use super::component::EdgeKind;
3217    use pedalkernel_rt::processor::{InternalPortBinding, Stage};
3218
3219    // Narrow gate: only a true delayed detector with a broker tap-mouth cut.
3220    if detector_seeds.is_empty() || cut_edges.cuts.is_empty() {
3221        return;
3222    }
3223
3224    // The detector OUTPUT node = the cross-network coupler's LED-anode node
3225    // (the node that drives the photocoupler LED — `EL_drive.b == PC1.led.a`).
3226    // Derive it from the Behavioral coupling edge's `pin_a` (the driven side).
3227    // Also record the coupler component id so we can EXCLUDE it when finding the
3228    // detector-side driver component that owns that node.
3229    let mut el_drive_node: Option<super::graph::NodeId> = None;
3230    let mut coupler_comp_id: Option<String> = None;
3231    for comp in &graph.components {
3232        for edge in comp.kind.edges() {
3233            if edge.kind != EdgeKind::Behavioral {
3234                continue;
3235            }
3236            if let Some(&n) = graph.node_names.get(&format!("{}.{}", comp.id, edge.pin_a)) {
3237                el_drive_node = Some(n);
3238                coupler_comp_id = Some(comp.id.clone());
3239            }
3240        }
3241    }
3242
3243    let Some(el_node) = el_drive_node else {
3244        return;
3245    };
3246
3247    // The detector-side component that DRIVES el_node (e.g. the `EL_drive`
3248    // transformer winding `EL_drive.b`) — any conductive component, other than
3249    // the coupler, with a graph edge on el_node.
3250    let driver_comp_id: Option<String> = graph
3251        .edges
3252        .iter()
3253        .filter(|e| e.node_a == el_node || e.node_b == el_node)
3254        .map(|e| graph.components[e.comp_idx].id.clone())
3255        .find(|id| Some(id) != coupler_comp_id.as_ref());
3256
3257    // Make the stage that owns the EL-drive driver PUBLISH its solved output node
3258    // value into `node_signals` (set `output_node_id = el_node`). The non-feed-
3259    // forward WDF stage publish path (`processor.rs`) then writes el_node every
3260    // sample, where the carry-only internal port captures it at end-of-sample.
3261    // This does NOT change the serial audio signal (the stage still drives the
3262    // chain as before) — it only ALSO publishes the node for the z⁻¹ capture.
3263    if let Some(ref drv) = driver_comp_id {
3264        for (si, comp_ids) in stage_comp_ids.iter().enumerate() {
3265            if comp_ids.iter().any(|c| c == drv) {
3266                if let Some(stage) = compiled.stages.get_mut(si) {
3267                    match stage {
3268                        Stage::Wdf(w) => w.output_node_id = el_node,
3269                        Stage::MultiNl(m) => m.output_node_id = el_node,
3270                        _ => {}
3271                    }
3272                }
3273                break;
3274            }
3275        }
3276    }
3277
3278    // EL-drive carry-only port (the detector output, for Phase 3 + measurement).
3279    // carry_idx 0 = the EL-drive carry the Phase-3 LED coupling reads.
3280    let internal_ports = vec![InternalPortBinding {
3281        source_node_id: el_node,
3282        consumer_node_id: usize::MAX,
3283        gain: 1.0,
3284        prev_value: 0.0,
3285    }];
3286
3287    #[cfg(test)]
3288    {
3289        eprintln!(
3290            "  [2b] detector internal-ports: el_node={el_node} driver={driver_comp_id:?} \
3291             coupler={coupler_comp_id:?} in={} out={} cut_boundary={:?}",
3292            graph.in_node, graph.out_node, cut_edges.boundary_nodes
3293        );
3294    }
3295
3296    compiled.internal_ports = internal_ports;
3297
3298    // ---- Phase 3: close the GR loop (LED -> cell -> forward shunt). --------
3299    // The carry above stores the detector's solved EL-drive value one sample
3300    // late. Synthesize the optical coupling that turns that value into actual
3301    // gain reduction: drive the photocoupler (`coupler_comp_id` = PC1) LED from
3302    // |EL_drive| each sample. The runtime reads `internal_ports[0].prev_value`
3303    // (the z⁻¹ closure), rectifies + normalizes it, and calls `set_led_drive`,
3304    // darkening the LDR shunt leaf in the FORWARD divider -> downward GR.
3305    if let Some(coupler) = coupler_comp_id {
3306        // Find the WDF stage that owns the photocoupler's LDR leaf (its
3307        // conductive forward-divider position — the shunt cell ahead of V1).
3308        let mut led_stage_idx: Option<usize> = None;
3309        for (si, comp_ids) in stage_comp_ids.iter().enumerate() {
3310            if comp_ids.iter().any(|c| c == &coupler) {
3311                led_stage_idx = Some(si);
3312                break;
3313            }
3314        }
3315        if let Some(stage_idx) = led_stage_idx {
3316            // Normalization: a loud-program detector drives EL_drive to ~15 V
3317            // peak (see la2a_detector_el_drive_tracks_program); quiet ~0.05 V.
3318            // scale = 1/15 maps loud -> ~full illumination, quiet -> near dark.
3319            // The T4B two-rate cell (set_led_drive) provides attack/release.
3320            compiled.detector_led_coupling = Some(pedalkernel_rt::processor::DetectorLedCoupling {
3321                carry_idx: 0,
3322                comp_id: coupler,
3323                stage_idx,
3324                scale: 1.0 / 15.0,
3325            });
3326            #[cfg(test)]
3327            eprintln!(
3328                "  [3] detector LED coupling: carry=0 comp={:?} stage={stage_idx} scale={}",
3329                compiled.detector_led_coupling.as_ref().map(|c| &c.comp_id),
3330                1.0 / 15.0
3331            );
3332        }
3333    }
3334}
3335
3336/// Build the initial `DelayLineBinding`s (with real tap ratios and configured
3337/// medium zones). Delegates to the F15 `delay_lowering` [`DspBlock`], which is
3338/// the single owner of delay-line lowering; `dsp_block::bind_runtime_all` later
3339/// rebuilds + binds these idempotently. Called here so the early presence
3340/// guard (`delay_lines.is_empty()`) and the no-edge `CompiledPedal` branches
3341/// see the correct instances.
3342fn build_delay_line_bindings(
3343    pedal: &PedalDef,
3344    sample_rate: f64,
3345) -> Vec<pedalkernel_rt::processor::DelayLineBinding> {
3346    super::delay_lowering::build_delay_line_bindings(pedal, sample_rate)
3347}
3348
3349/// SPQR terminal nodes contributed by cross-network couplers.
3350///
3351/// A cross-network coupler (today: `photocoupler`) declares a `Behavioral`
3352/// edge between two galvanically-isolated terminals (the LED side) ALONGSIDE a
3353/// conductive `graph_role` edge (the LDR side). The two sides live in different
3354/// electrical networks and must never be fused (see the photocoupler component
3355/// and `find_flow_groups`). For SPQR to emit a proper stage port on each side,
3356/// the (isolated) LED side must be a global terminal — the same treatment
3357/// `in_node`/`out_node` and DspBlock boundary pins receive.
3358///
3359/// Detection is generic (no component-type matching): a component qualifies
3360/// when its `graph_role` is a conductive edge role (so it is a coupler with a
3361/// real LDR edge, not a pure `GraphRole::Virtual` DSP island like a BBD, which
3362/// is handled by `all_boundary_nodes`).
3363///
3364/// Only the endpoints of the **Behavioral** edge (the galvanically-isolated
3365/// side that has NO conductive graph edge — the LED) are forced to terminals.
3366/// The conductive side (the LDR `a`/`b`) already gets a stage port from its
3367/// real graph edge, so it MUST NOT be forced: forcing it would fragment the
3368/// passive WDF tree of every existing photocoupler-as-LDR circuit (e.g.
3369/// `photocoupler_t4b.pedal`), which is a regression, not isolation.
3370fn coupler_boundary_nodes(graph: &CircuitGraph) -> Vec<NodeId> {
3371    use super::component::{EdgeKind, GraphRole};
3372    let mut nodes = Vec::new();
3373    for comp in &graph.components {
3374        let role_is_conductive = matches!(
3375            comp.kind.graph_role(),
3376            GraphRole::Edge { .. } | GraphRole::ActiveEdge { .. } | GraphRole::CoupledEdge { .. }
3377        );
3378        if !role_is_conductive {
3379            continue;
3380        }
3381        for edge in comp.kind.edges() {
3382            if edge.kind != EdgeKind::Behavioral {
3383                continue;
3384            }
3385            for pin in [edge.pin_a, edge.pin_b] {
3386                if let Some(&n) = graph.node_names.get(&format!("{}.{}", comp.id, pin)) {
3387                    if !nodes.contains(&n) {
3388                        nodes.push(n);
3389                    }
3390                }
3391            }
3392        }
3393    }
3394    nodes
3395}
3396
3397/// Check if a group is a merged pot pair (aw + wb of same component).
3398fn is_pot_divider_group(group: &super::signal_flow::FlowGroup, graph: &CircuitGraph) -> bool {
3399    let edges = group.all_edges();
3400    if edges.len() != 2 {
3401        return false;
3402    }
3403    let comp0 = &graph.components[graph.edges[edges[0]].comp_idx];
3404    let comp1 = &graph.components[graph.edges[edges[1]].comp_idx];
3405
3406    // Check for synthetic __aw/__wb split (legacy 3-terminal pot encoding)
3407    let is_aw_wb = (comp0.id.ends_with("__aw") && comp1.id.ends_with("__wb"))
3408        || (comp0.id.ends_with("__wb") && comp1.id.ends_with("__aw"));
3409
3410    // Check for 2-edge pot (same component, both edges are the same pot)
3411    let is_same_pot =
3412        graph.edges[edges[0]].comp_idx == graph.edges[edges[1]].comp_idx && comp0.kind.is_pot();
3413
3414    is_aw_wb || is_same_pot
3415}
3416
3417/// Build a pot voltage divider stage: Parallel(R_aw, R_wb) with ShortCircuit root.
3418///
3419/// The output is at the wiper (parallel junction). In WDF, the Parallel
3420/// adaptor's junction voltage IS the voltage divider output.
3421fn build_pot_divider(
3422    group: &super::signal_flow::FlowGroup,
3423    graph: &CircuitGraph,
3424    sample_rate: f64,
3425) -> Result<BuiltStage, String> {
3426    let edges = group.all_edges();
3427
3428    // Build both pot leaf nodes. Use the component's actual name (no __aw/__wb).
3429    // The signal-side half (NOT touching ground) gets marked as complement
3430    // so set_control applies 1-value: R_aw = (1-pos)*max_R.
3431    let mut leaves: Vec<DynNode> = Vec::new();
3432    for &eidx in &edges {
3433        let e = &graph.edges[eidx];
3434        let comp = &graph.components[e.comp_idx];
3435        if let Some(mut leaf) = comp.kind.make_leaf(&comp.id, sample_rate) {
3436            let touches_gnd = e.node_a == graph.gnd_node
3437                || e.node_b == graph.gnd_node
3438                || graph.ac_ground_nodes.contains(&e.node_a)
3439                || graph.ac_ground_nodes.contains(&e.node_b);
3440            if !touches_gnd {
3441                if let DynNode::Leaf(ref mut l) = leaf {
3442                    l.set_complement();
3443                }
3444            }
3445            leaves.push(leaf);
3446        }
3447    }
3448
3449    if leaves.len() != 2 {
3450        return Err("Pot divider: expected 2 leaves".to_string());
3451    }
3452
3453    // Series(R_aw, R_wb) — voltage divider from signal to ground.
3454    // Wiper is the junction between aw and wb.
3455    // ShortCircuit root = ground at the bottom of R_wb.
3456    // Output extracted at the junction (series_junction_voltage).
3457    let divider = DynNode::Series(Box::new(leaves.remove(0)), Box::new(leaves.remove(0)));
3458
3459    // Tree: VS in series with the divider chain. A dedicated pot divider is
3460    // normally driven by a low-impedance previous stage/output source; using
3461    // the generic 10k passive-filter source impedance turns a 100k midpoint
3462    // level pot into a 0.45x divider before control scaling.
3463    let tree = with_voltage_source_rp(divider, 1.0);
3464
3465    let oversampler = Oversampler::new(OversamplingFactor::X1);
3466    Ok(BuiltStage::Wdf(WdfStage::new(
3467        tree,
3468        RootKind::ShortCircuit,
3469        oversampler,
3470    )))
3471}
3472
3473fn root_supports_k_table(root: &RootKind) -> bool {
3474    matches!(
3475        root,
3476        RootKind::DiodePair(_)
3477            | RootKind::SingleDiode(_)
3478            | RootKind::ExplicitDiodePair(_)
3479            | RootKind::ExplicitSingleDiode(_)
3480            | RootKind::Zener(_)
3481            | RootKind::Jfet(_)
3482            | RootKind::JfetVr(_)
3483            | RootKind::Triode(_)
3484            | RootKind::VariMu(_)
3485            | RootKind::Pentode(_)
3486            | RootKind::Mosfet(_)
3487            | RootKind::Bjt(_)
3488            | RootKind::DiffPair(_)
3489            | RootKind::Ota(_)
3490            | RootKind::OpAmp(_)
3491    )
3492}
3493
3494/// Wrap a passive DynNode tree with a voltage source input port.
3495///
3496/// The WDF tree needs a voltage source leaf to receive the input signal.
3497/// Creates `Series(VoltageSource, passive_tree)` — the standard WDF
3498/// topology where VS drives the tree and the root terminates it.
3499/// Default VS source impedance (Ω). Used when no port impedance is declared.
3500/// 10kΩ is a reasonable general-purpose value — high enough for RC filters
3501/// to work but not so high that it dominates the circuit.
3502const DEFAULT_VS_RP: f64 = 10_000.0;
3503
3504pub(super) fn with_voltage_source(passive_tree: DynNode) -> DynNode {
3505    with_voltage_source_rp(passive_tree, DEFAULT_VS_RP)
3506}
3507
3508pub(super) fn with_voltage_source_rp(passive_tree: DynNode, rp: f64) -> DynNode {
3509    let vs = DynNode::Leaf(LeafKind::VoltageSource(WdfVoltageSource {
3510        voltage: 0.0,
3511        rp: rp as crate::Wave,
3512        is_cathode_bias: false,
3513        port_name: None,
3514    }));
3515    DynNode::Series(Box::new(vs), Box::new(passive_tree))
3516}
3517
3518#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3519struct FetWdfTopology {
3520    source_follower: bool,
3521}
3522
3523fn build_fet_amplifier_passive_tree(
3524    _edge_indices: &[usize],
3525    nl_edge_idx: usize,
3526    graph: &CircuitGraph,
3527    sample_rate: f64,
3528) -> Option<(DynNode, FetWdfTopology)> {
3529    let nl_edge = &graph.edges[nl_edge_idx];
3530    let comp = &graph.components[nl_edge.comp_idx];
3531    if !(comp.kind.is_jfet() || comp.kind.is_mosfet()) {
3532        return None;
3533    }
3534
3535    let drain_node = nl_edge.node_a;
3536    let source_node = nl_edge.node_b;
3537    let gate_node = graph.node_names.get(&format!("{}.gate", comp.id)).copied();
3538
3539    let source_feeds_output =
3540        fet_terminal_reaches_output(source_node, drain_node, gate_node, graph);
3541    let drain_feeds_output = fet_terminal_reaches_output(drain_node, source_node, gate_node, graph);
3542    let source_follower = source_feeds_output && !drain_feeds_output;
3543    let passive_edge_indices: Vec<usize> = (0..graph.edges.len()).collect();
3544
3545    let drain_leg = build_fet_ac_ground_leg(
3546        drain_node,
3547        &passive_edge_indices,
3548        nl_edge_idx,
3549        graph,
3550        sample_rate,
3551        &[source_node],
3552        gate_node,
3553    );
3554    let source_leg = build_fet_ac_ground_leg(
3555        source_node,
3556        &passive_edge_indices,
3557        nl_edge_idx,
3558        graph,
3559        sample_rate,
3560        &[drain_node],
3561        gate_node,
3562    );
3563
3564    let tree = if source_follower {
3565        // The source-follower runtime root solves Vs against the source load
3566        // directly; including the AC-grounded drain load would inflate Rp.
3567        source_leg.or(drain_leg)?
3568    } else {
3569        match (drain_leg, source_leg) {
3570            (Some(drain), Some(source)) => DynNode::Series(Box::new(drain), Box::new(source)),
3571            (Some(drain), None) => drain,
3572            (None, Some(source)) => source,
3573            (None, None) => return None,
3574        }
3575    };
3576
3577    Some((tree, FetWdfTopology { source_follower }))
3578}
3579
3580fn build_fet_ac_ground_leg(
3581    start_node: NodeId,
3582    edge_indices: &[usize],
3583    nl_edge_idx: usize,
3584    graph: &CircuitGraph,
3585    sample_rate: f64,
3586    blocked_nodes: &[NodeId],
3587    gate_node: Option<NodeId>,
3588) -> Option<DynNode> {
3589    let mut visited_edges = std::collections::HashSet::new();
3590    build_fet_leg_from_node(
3591        start_node,
3592        edge_indices,
3593        nl_edge_idx,
3594        graph,
3595        sample_rate,
3596        blocked_nodes,
3597        gate_node,
3598        &mut visited_edges,
3599    )
3600}
3601
3602fn build_fet_leg_from_node(
3603    node: NodeId,
3604    edge_indices: &[usize],
3605    nl_edge_idx: usize,
3606    graph: &CircuitGraph,
3607    sample_rate: f64,
3608    blocked_nodes: &[NodeId],
3609    gate_node: Option<NodeId>,
3610    visited_edges: &mut std::collections::HashSet<usize>,
3611) -> Option<DynNode> {
3612    if is_fet_ac_ground(node, graph) {
3613        return None;
3614    }
3615
3616    let mut branches = Vec::new();
3617    for &eidx in edge_indices {
3618        if eidx == nl_edge_idx || visited_edges.contains(&eidx) {
3619            continue;
3620        }
3621        let edge = &graph.edges[eidx];
3622        let Some(next_node) = other_node(edge, node) else {
3623            continue;
3624        };
3625        if Some(next_node) == gate_node || blocked_nodes.contains(&next_node) {
3626            continue;
3627        }
3628        if !matches!(
3629            graph.effective_edge_kind(eidx),
3630            super::component::EdgeKind::Linear | super::component::EdgeKind::Reactive
3631        ) {
3632            continue;
3633        }
3634
3635        let comp = &graph.components[edge.comp_idx];
3636        let leaf = comp.kind.make_leaf(&comp.id, sample_rate)?;
3637        let mut branch_visited = visited_edges.clone();
3638        branch_visited.insert(eidx);
3639
3640        let branch = if is_fet_ac_ground(next_node, graph) {
3641            leaf
3642        } else if let Some(rest) = build_fet_leg_from_node(
3643            next_node,
3644            edge_indices,
3645            nl_edge_idx,
3646            graph,
3647            sample_rate,
3648            blocked_nodes,
3649            gate_node,
3650            &mut branch_visited,
3651        ) {
3652            DynNode::Series(Box::new(leaf), Box::new(rest))
3653        } else {
3654            continue;
3655        };
3656
3657        branches.push(branch);
3658    }
3659
3660    fold_dyn_nodes_parallel(branches)
3661}
3662
3663fn fold_dyn_nodes_parallel(mut nodes: Vec<DynNode>) -> Option<DynNode> {
3664    match nodes.len() {
3665        0 => None,
3666        1 => Some(nodes.remove(0)),
3667        _ => {
3668            let mut tree = nodes.pop().unwrap();
3669            while let Some(left) = nodes.pop() {
3670                tree = DynNode::Parallel(Box::new(left), Box::new(tree));
3671            }
3672            Some(tree)
3673        }
3674    }
3675}
3676
3677fn other_node(edge: &super::graph::GraphEdge, node: NodeId) -> Option<NodeId> {
3678    if edge.node_a == node {
3679        Some(edge.node_b)
3680    } else if edge.node_b == node {
3681        Some(edge.node_a)
3682    } else {
3683        None
3684    }
3685}
3686
3687fn is_fet_ac_ground(node: NodeId, graph: &CircuitGraph) -> bool {
3688    node == graph.gnd_node
3689        || node == graph.vcc_node
3690        || graph.supply_nodes.contains(&node)
3691        || graph.ac_ground_nodes.contains(&node)
3692}
3693
3694fn fet_terminal_reaches_output(
3695    start_node: NodeId,
3696    other_fet_terminal: NodeId,
3697    gate_node: Option<NodeId>,
3698    graph: &CircuitGraph,
3699) -> bool {
3700    let mut seen_nodes = std::collections::HashSet::new();
3701    let mut stack = vec![start_node];
3702    seen_nodes.insert(start_node);
3703
3704    while let Some(node) = stack.pop() {
3705        if node == graph.out_node {
3706            return true;
3707        }
3708        for (eidx, edge) in graph.edges.iter().enumerate() {
3709            let Some(next) = other_node(edge, node) else {
3710                continue;
3711            };
3712            if next == other_fet_terminal
3713                || Some(next) == gate_node
3714                || is_fet_ac_ground(next, graph)
3715            {
3716                continue;
3717            }
3718            if !matches!(
3719                graph.effective_edge_kind(eidx),
3720                super::component::EdgeKind::Linear | super::component::EdgeKind::Reactive
3721            ) {
3722                continue;
3723            }
3724            if seen_nodes.insert(next) {
3725                stack.push(next);
3726            }
3727        }
3728    }
3729
3730    false
3731}
3732
3733fn find_fet_source_probe(
3734    source_node: NodeId,
3735    drain_node: NodeId,
3736    gate_node: Option<NodeId>,
3737    graph: &CircuitGraph,
3738) -> Option<String> {
3739    for (eidx, edge) in graph.edges.iter().enumerate() {
3740        let Some(other) = other_node(edge, source_node) else {
3741            continue;
3742        };
3743        if other == drain_node || Some(other) == gate_node || !is_fet_ac_ground(other, graph) {
3744            continue;
3745        }
3746        if !matches!(
3747            graph.effective_edge_kind(eidx),
3748            super::component::EdgeKind::Linear | super::component::EdgeKind::Reactive
3749        ) {
3750            continue;
3751        }
3752        let comp = &graph.components[edge.comp_idx];
3753        if comp.kind.make_leaf(&comp.id, 48_000.0).is_some() {
3754            return Some(comp.id.clone());
3755        }
3756    }
3757    None
3758}
3759
3760/// Build a runnable `WdfStage` from an `SpqrStage`.
3761///
3762/// - **PassiveWdf**: VS + DynNode tree + Passthrough root
3763/// - **NlWdf**: VS + DynNode tree + NL root from Component::classify_nonlinear()
3764/// - **Rigid**: not yet supported (returns Err — build layer will handle IIR/OpAmpRoot/MNA)
3765///
3766/// `supply_voltage` is the circuit's B+ supply (e.g. 250V for tube stages, 9V for pedals).
3767/// It is used to configure tube roots with the correct `v_max` so the WDF voltage source
3768/// matches the actual plate supply rail.
3769pub(super) fn build_spqr_stage(
3770    stage: SpqrStage,
3771    graph: &CircuitGraph,
3772    _sample_rate: f64,
3773    supply_voltage: f64,
3774) -> Result<BuiltStage, String> {
3775    let bias_node_voltages = std::collections::BTreeMap::new();
3776    build_spqr_stage_with_options(
3777        stage,
3778        graph,
3779        _sample_rate,
3780        false,
3781        &[],
3782        supply_voltage,
3783        &bias_node_voltages,
3784    )
3785}
3786
3787pub(super) fn build_spqr_stage_with_options(
3788    stage: SpqrStage,
3789    graph: &CircuitGraph,
3790    _sample_rate: f64,
3791    disable_iir: bool,
3792    init_hints: &[crate::dsl::InitHint],
3793    supply_voltage: f64,
3794    bias_node_voltages: &std::collections::BTreeMap<NodeId, f64>,
3795) -> Result<BuiltStage, String> {
3796    match stage {
3797        SpqrStage::PassiveWdf {
3798            tree, edge_indices, ..
3799        } => {
3800            if let Some(wdf) =
3801                build_passive_rtype_stage(&edge_indices, graph, _sample_rate, bias_node_voltages)
3802            {
3803                return Ok(BuiltStage::Wdf(wdf));
3804            }
3805
3806            let tree = with_voltage_source(tree);
3807            let oversampler = Oversampler::new(OversamplingFactor::X1);
3808            // Ground-terminated → ShortCircuit root. Floating → Passthrough.
3809            let touches_gnd = edge_indices.iter().any(|&eidx| {
3810                let e = &graph.edges[eidx];
3811                e.node_a == graph.gnd_node
3812                    || e.node_b == graph.gnd_node
3813                    || graph.ac_ground_nodes.contains(&e.node_a)
3814                    || graph.ac_ground_nodes.contains(&e.node_b)
3815            });
3816            let root = if touches_gnd {
3817                RootKind::ShortCircuit
3818            } else {
3819                RootKind::Passthrough
3820            };
3821            let mut wdf = WdfStage::new(tree, root, oversampler);
3822
3823            // Set output_probe: find the GND-side leaf at the output boundary.
3824            // For ShortCircuit stages, short_circuit_junction_voltage fails when
3825            // VS rp << passive rp (gamma ≈ 0 → near-zero junction voltage).
3826            // Instead, probe the leaf directly — leaf_voltage returns (a+b)/2
3827            // which is correct regardless of gamma.
3828            //
3829            // The output boundary is the group's non-input terminal. Find the
3830            // edge at that node whose other end reaches GND.
3831            if touches_gnd {
3832                // Find the group's output boundary node: any terminal that isn't
3833                // in_node and isn't GND.
3834                let group_terminals = compute_group_terminals(
3835                    &edge_indices,
3836                    graph,
3837                    &vec![graph.in_node, graph.out_node],
3838                );
3839                let is_gnd = |n: super::graph::NodeId| -> bool {
3840                    n == graph.gnd_node || graph.ac_ground_nodes.contains(&n)
3841                };
3842                let terminal_has_ground_load = |terminal: super::graph::NodeId| -> bool {
3843                    edge_indices.iter().any(|&eidx| {
3844                        let e = &graph.edges[eidx];
3845                        (e.node_a == terminal && is_gnd(e.node_b))
3846                            || (e.node_b == terminal && is_gnd(e.node_a))
3847                    })
3848                };
3849                // Prefer the local output/load boundary. This matters for
3850                // coupling stages like C_out -> R_out -> gnd where the group
3851                // input is not the global graph.in_node; picking the wrong
3852                // terminal probes the coupling cap instead of the load.
3853                let output_boundary = group_terminals
3854                    .iter()
3855                    .find(|&&t| terminal_has_ground_load(t))
3856                    .or_else(|| group_terminals.iter().find(|&&t| t == graph.out_node))
3857                    .or_else(|| group_terminals.iter().find(|&&t| t != graph.in_node))
3858                    .or_else(|| group_terminals.first())
3859                    .copied()
3860                    .unwrap_or(graph.out_node);
3861                // Find the edge at the output boundary that goes toward GND
3862                for &eidx in &edge_indices {
3863                    let e = &graph.edges[eidx];
3864                    let (touches_out, other) = if e.node_a == output_boundary {
3865                        (true, e.node_b)
3866                    } else if e.node_b == output_boundary {
3867                        (true, e.node_a)
3868                    } else {
3869                        (false, output_boundary)
3870                    };
3871                    if !touches_out {
3872                        continue;
3873                    }
3874                    let other_reaches_gnd = is_gnd(other)
3875                        || edge_indices.iter().any(|&eidx2| {
3876                            let e2 = &graph.edges[eidx2];
3877                            (e2.node_a == other || e2.node_b == other)
3878                                && (is_gnd(e2.node_a) || is_gnd(e2.node_b))
3879                        });
3880                    if other_reaches_gnd {
3881                        let comp = &graph.components[e.comp_idx];
3882                        wdf.output_probe = Some(comp.id.clone());
3883                        break;
3884                    }
3885                }
3886            }
3887
3888            Ok(BuiltStage::Wdf(wdf))
3889        }
3890        SpqrStage::NlWdf {
3891            tree,
3892            nl_edge_idx,
3893            edge_indices,
3894            ..
3895        } => {
3896            let e = &graph.edges[nl_edge_idx];
3897            let comp = &graph.components[e.comp_idx];
3898            let (nl_kind, _junction_nodes) = comp
3899                .kind
3900                .classify_nonlinear(
3901                    &comp.id,
3902                    e.node_a,
3903                    e.node_b,
3904                    graph.gnd_node,
3905                    &graph.node_names,
3906                )
3907                .ok_or_else(|| format!("NL edge {} ({}) didn't classify", nl_edge_idx, comp.id))?;
3908            let (tree, fet_topology) =
3909                build_fet_amplifier_passive_tree(&edge_indices, nl_edge_idx, graph, _sample_rate)
3910                    .map(|(tree, topology)| (tree, Some(topology)))
3911                    .unwrap_or((tree, None));
3912
3913            // BJTs now use BjtRoot (single-port WDF root with external Vbe),
3914            // same as triodes use TriodeRoot. No MultiNL fallback needed.
3915            let (mut root, base_diode_model) = create_root(&nl_kind, false);
3916            eprintln!(
3917                "[NlWdf] comp.id={:?} hints={:?}",
3918                comp.id,
3919                init_hints
3920                    .iter()
3921                    .map(|h| &h.device_label)
3922                    .collect::<Vec<_>>()
3923            );
3924            // Apply init hint to BjtRoot: set asymmetric initial Vce warm-start.
3925            // This is the mechanism for free-running BJT oscillators (e.g. astable
3926            // multivibrators). Without asymmetric initial conditions, the NR solver
3927            // can trap at the symmetric DC fixed point and produce no oscillation.
3928            if let RootKind::Bjt(ref mut bjt) = root {
3929                // Node-voltage seeds key on circuit nodes, not BJT refs — skip
3930                // them here (they seed reactive ports, not this BjtRoot).
3931                if let Some(hint) = init_hints.iter().find(|h| {
3932                    h.device_label == comp.id
3933                        && !matches!(h.state, crate::dsl::InitState::NodeVoltage { .. })
3934                }) {
3935                    let sign = if bjt.is_pnp { -1.0 } else { 1.0 };
3936                    let vce = match &hint.state {
3937                        crate::dsl::InitState::Named(state_name) => {
3938                            bjt_hint_vce(state_name, supply_voltage, bjt.is_pnp)
3939                        }
3940                        // Explicit `op { }` seed: warm-start the single-port WDF
3941                        // BjtRoot at the supplied collector-emitter voltage.
3942                        crate::dsl::InitState::Explicit { vce, .. } => sign * *vce,
3943                        // Filtered out above; unreachable.
3944                        crate::dsl::InitState::NodeVoltage { .. } => 0.0,
3945                    };
3946                    bjt.set_initial_prev_v(vce as crate::Wave);
3947                }
3948            }
3949            // Set the supply voltage on tube roots so the WDF voltage source (VS =
3950            // t.v_max()) matches the actual B+ rail.  Without this the triode/pentode
3951            // would default to 500 V, shifting the plate operating point and gain by
3952            // ~8 dB versus the correct 250 V operating point.
3953            match &mut root {
3954                RootKind::Triode(t) => t.set_v_max(supply_voltage.max(1.0) as crate::Wave),
3955                RootKind::VariMu(t) => t.set_v_max(supply_voltage.max(1.0) as crate::Wave),
3956                RootKind::Pentode(p) => p.set_v_max(supply_voltage.max(1.0) as crate::Wave),
3957                RootKind::Bjt(b) => b.set_v_max(supply_voltage.abs().max(1.0) as crate::Wave),
3958                _ => {}
3959            }
3960            // Seed the TriodeRoot with the DC Q-point from load-line analysis.
3961            //
3962            // bias::solve_wdf_triode_dc_qpoint (ko5g.3 — the unified solver
3963            // that replaced this file's duplicate `compute_wdf_triode_dc_qpoint`)
3964            // runs the shared 1-D load-line relaxation (Vgk = -Ia*Rk,
3965            // Vpk = VCC - Ia*Rp) using edge_indices to locate R_plate and
3966            // R_cathode.  NotApplicable when the triode lacks a grid node
3967            // (strapped triode) or is vari-mu; Undeterminable when the plate/
3968            // cathode resistors are not found.
3969            //
3970            // Two initialization targets:
3971            //   1. TriodeRoot::set_bias(vgk)  — NR warm-start + K-table centre
3972            //   2. Cathode bypass cap seed    — eliminates startup transient
3973            //
3974            // Without (1) the NR solver starts at vgk_bias=-2.0 V (the default)
3975            // which is wrong for high-voltage stages (e.g. 250 V 12AX7).
3976            // Without (2) the cathode cap takes τ=Rk*Ck time-constants to charge;
3977            // SPICE runs a .op before .tran so it starts at steady state.
3978            let dc_qpoint = match super::bias::solve_wdf_triode_dc_qpoint(
3979                &nl_kind,
3980                &edge_indices,
3981                graph,
3982                supply_voltage,
3983            ) {
3984                Ok(dc) => Some(dc),
3985                Err(skip) => {
3986                    // ko5g.3 warn-not-error: an undeterminable triode keeps the
3987                    // legacy -2.0 V TriodeRoot default (loudly); the fail-loud
3988                    // flip is ko5g.8.
3989                    skip.warn_if_undeterminable(
3990                        "Triode stage keeps the -2.0 V default Vgk bias.",
3991                    );
3992                    None
3993                }
3994            };
3995            // (1) Seed bias
3996            if let Some(ref dc) = dc_qpoint {
3997                if let RootKind::Triode(t) = &mut root {
3998                    t.set_bias(dc.vgk as pedalkernel_rt::Wave);
3999                }
4000            }
4001
4002            // Seed the PentodeRoot with the DC Q-point (ko5g.5 — the FIRST
4003            // pentode bias solver; every pentode previously rode the −8.0 V
4004            // `vg1k_bias` default and the model-default `vg2k`, never seeded).
4005            //
4006            // bias::solve_wdf_pentode_dc_qpoint solves the self-bias load line
4007            // (shared-cathode aware, OT-primary-at-DCR plate paths) and
4008            // resolves the screen divider at DC.  Three initialization
4009            // targets:
4010            //   1. PentodeRoot::set_bias(vg1k)   — NR warm-start + K-table centre
4011            //   2. PentodeRoot::set_vg2k(vg2)    — circuit-true screen voltage
4012            //   3. Cathode bypass cap seed       — eliminates startup transient
4013            //
4014            // Undeterminable topologies (incl. the grounded-cathode FIXED-BIAS
4015            // guard — the ko5g.2 la2a-V5 safeguard) keep ALL defaults, loudly.
4016            let pentode_dc = if matches!(root, RootKind::Pentode(_)) {
4017                match super::bias::solve_wdf_pentode_dc_qpoint(
4018                    &nl_kind,
4019                    &edge_indices,
4020                    graph,
4021                    supply_voltage,
4022                ) {
4023                    Ok(dc) => Some(dc),
4024                    Err(skip) => {
4025                        skip.warn_if_undeterminable(
4026                            "Pentode stage keeps the -8.0 V default Vg1k bias and the \
4027                             model-default Vg2.",
4028                        );
4029                        None
4030                    }
4031                }
4032            } else {
4033                None
4034            };
4035            if let Some(ref dc) = pentode_dc {
4036                if let RootKind::Pentode(p) = &mut root {
4037                    p.set_bias(dc.vg1k as pedalkernel_rt::Wave);
4038                    if let Some(vg2) = dc.vg2k {
4039                        p.set_vg2k(vg2 as pedalkernel_rt::Wave);
4040                    }
4041                }
4042            }
4043
4044            // Seed the BjtRoot DC operating point (Q-point).
4045            //
4046            // Without this the BjtRoot keeps its default vbe_bias = 0 V, which
4047            // sits the transistor in cutoff (Ic ≈ 0) so a common-emitter stage
4048            // produces almost no output (≈1% of the SPICE level).  The base-bias
4049            // divider (R1 to a rail, R2 to gnd) sets the DC base voltage; the
4050            // emitter degeneration resistor lowers the actual Vbe.  We seed the
4051            // root with the forward Vbe operating point so its Newton-Raphson
4052            // solve and K-table are centred at conduction, exactly mirroring the
4053            // semantics the blockwise multi-NL path already uses.
4054            //
4055            // We only seed when a Q-point is actually solvable (a determinable
4056            // base-bias network + emitter resistor).  Since pedalkernel-6dof the
4057            // base→GND leg is OPTIONAL, so single-resistor base bias (base tied
4058            // to the rail through R_b alone) IS solvable; what stays unsolvable
4059            // is a base with no DC path to any rail, or a topology we cannot
4060            // resolve.  There we deliberately LEAVE the existing vbe_bias
4061            // untouched rather than forcing a nominal default.  Forcing a
4062            // default there would push such a stage into conduction whose DC
4063            // operating point cannot be characterized (and may not be DC-blocked
4064            // downstream), a strictly larger blast radius than the
4065            // cutoff-amplifier bug this fix targets.
4066            //
4067            // bias::solve_wdf_bjt_dc_qpoint (ko5g.4 — the unified solver that
4068            // replaced this file's duplicate `compute_wdf_bjt_dc_qpoint`) runs
4069            // the shared base-loop Newton with the legacy stage-set direct-only
4070            // finder breadth (`BjtFinderFlavor::WdfStageDirect`).
4071            let bjt_dc = if matches!(root, RootKind::Bjt(_)) {
4072                match super::bias::solve_wdf_bjt_dc_qpoint(
4073                    &nl_kind,
4074                    &edge_indices,
4075                    graph,
4076                    bias_node_voltages,
4077                    supply_voltage,
4078                ) {
4079                    Ok(dc) => Some(dc),
4080                    Err(skip) => {
4081                        // ko5g.4 warn-not-error: an undeterminable BJT keeps the
4082                        // legacy leave-at-cutoff fallback (vbe_bias = 0, no Vce
4083                        // warm-start) — loudly; the fail-loud flip is ko5g.8.
4084                        skip.warn_if_undeterminable(
4085                            "BJT stage keeps its cutoff default (vbe_bias = 0 V, no Vce warm-start).",
4086                        );
4087                        None
4088                    }
4089                }
4090            } else {
4091                None
4092            };
4093            if let RootKind::Bjt(b) = &mut root {
4094                if let Some(ref dc) = bjt_dc {
4095                    b.set_bias(dc.vbe as pedalkernel_rt::Wave);
4096                    // Warm-start the NR/K-table at the Q-point Vce (unless an
4097                    // explicit init { } hint already set an asymmetric state for
4098                    // an oscillator).  This removes the bias-settling startup
4099                    // transient so the steady-state output is reached immediately,
4100                    // matching ngspice's `.op`-then-`.tran` behaviour.
4101                    let has_hint = init_hints.iter().any(|h| h.device_label == comp.id);
4102                    if !has_hint && dc.vce.is_finite() {
4103                        b.set_initial_prev_v(dc.vce as pedalkernel_rt::Wave);
4104                    }
4105                }
4106            }
4107
4108            // Seed FET gate-source DC operating point.  The gate is a high-Z
4109            // control terminal and is intentionally excluded from the WDF tree
4110            // above; this puts the nonlinear root at the same DC bias that the
4111            // omitted gate/source resistor network establishes in SPICE.
4112            let fet_dc = if matches!(root, RootKind::Jfet(_) | RootKind::Mosfet(_)) {
4113                compute_wdf_fet_dc_qpoint(&nl_kind, &edge_indices, graph, supply_voltage)
4114            } else {
4115                None
4116            };
4117            match &mut root {
4118                RootKind::Jfet(j) => {
4119                    if let Some(ref dc) = fet_dc {
4120                        j.set_operating_point(
4121                            dc.vgs as pedalkernel_rt::Wave,
4122                            dc.vds as pedalkernel_rt::Wave,
4123                        );
4124                    }
4125                }
4126                RootKind::Mosfet(m) => {
4127                    if let Some(ref dc) = fet_dc {
4128                        m.set_operating_point(
4129                            dc.vgs as pedalkernel_rt::Wave,
4130                            dc.vds as pedalkernel_rt::Wave,
4131                        );
4132                    }
4133                }
4134                _ => {}
4135            }
4136
4137            let tree = if fet_topology.is_some() {
4138                tree
4139            } else {
4140                with_voltage_source(tree)
4141            };
4142            let oversampler = Oversampler::new(OversamplingFactor::X1);
4143            let mut wdf_stage = WdfStage::new(tree, root, oversampler);
4144            if let (Some(topology), Some(dc)) = (fet_topology, fet_dc.as_ref()) {
4145                wdf_stage.output_bias = if topology.source_follower {
4146                    dc.source_voltage as pedalkernel_rt::Wave
4147                } else {
4148                    dc.drain_voltage as pedalkernel_rt::Wave
4149                };
4150                let gate_node = graph.node_names.get(&format!("{}.gate", comp.id)).copied();
4151                wdf_stage.fet_source_probe =
4152                    find_fet_source_probe(e.node_b, e.node_a, gate_node, graph);
4153            }
4154            if fet_topology.is_some_and(|topology| topology.source_follower)
4155                && matches!(wdf_stage.root, RootKind::Jfet(_))
4156            {
4157                wdf_stage.is_source_follower = true;
4158            }
4159            wdf_stage.base_diode_model = base_diode_model;
4160            // (2) Pre-charge cathode bypass cap
4161            if let Some(dc) = dc_qpoint {
4162                if dc.v_cathode > 0.01 {
4163                    if let super::classify::NonlinearKind::Triode {
4164                        cathode_node: triode_cathode_node,
4165                        ..
4166                    } = &nl_kind
4167                    {
4168                        let v_cat = dc.v_cathode as pedalkernel_rt::Wave;
4169                        for eidx in 0..graph.edges.len() {
4170                            let e = &graph.edges[eidx];
4171                            let is_cathode_gnd = (e.node_a == *triode_cathode_node
4172                                && (e.node_b == graph.gnd_node
4173                                    || graph.ac_ground_nodes.contains(&e.node_b)))
4174                                || (e.node_b == *triode_cathode_node
4175                                    && (e.node_a == graph.gnd_node
4176                                        || graph.ac_ground_nodes.contains(&e.node_a)));
4177                            if !is_cathode_gnd {
4178                                continue;
4179                            }
4180                            let comp = &graph.components[e.comp_idx];
4181                            if comp.kind.capacitance().is_none() {
4182                                continue;
4183                            }
4184                            if let Some(port) =
4185                                wdf_stage.tree.one_port_runtime_binding_mut(&comp.id)
4186                            {
4187                                port.wdf_set_one_port_state(
4188                                    pedalkernel_rt::boundary_math::OnePortState::CapacitorVoltage(
4189                                        v_cat,
4190                                    ),
4191                                    &mut wdf_stage.runtime_state,
4192                                );
4193                            }
4194                        }
4195                    }
4196                }
4197            }
4198
4199            // (2a) Pre-charge the pentode cathode bypass cap (ko5g.5) — the
4200            // exact mirror of the triode block above: the shared/self-bias
4201            // cathode resistor develops n·Ia·Rk and the bypass cap across it
4202            // must start charged or the stage spends τ = Rk·Ck settling.
4203            if let Some(ref dc) = pentode_dc {
4204                if dc.v_cathode > 0.01 {
4205                    if let super::classify::NonlinearKind::Pentode {
4206                        cathode_node: pentode_cathode_node,
4207                        ..
4208                    } = &nl_kind
4209                    {
4210                        let v_cat = dc.v_cathode as pedalkernel_rt::Wave;
4211                        for eidx in 0..graph.edges.len() {
4212                            let e = &graph.edges[eidx];
4213                            let is_cathode_gnd = (e.node_a == *pentode_cathode_node
4214                                && (e.node_b == graph.gnd_node
4215                                    || graph.ac_ground_nodes.contains(&e.node_b)))
4216                                || (e.node_b == *pentode_cathode_node
4217                                    && (e.node_a == graph.gnd_node
4218                                        || graph.ac_ground_nodes.contains(&e.node_a)));
4219                            if !is_cathode_gnd {
4220                                continue;
4221                            }
4222                            let comp = &graph.components[e.comp_idx];
4223                            if comp.kind.capacitance().is_none() {
4224                                continue;
4225                            }
4226                            if let Some(port) =
4227                                wdf_stage.tree.one_port_runtime_binding_mut(&comp.id)
4228                            {
4229                                port.wdf_set_one_port_state(
4230                                    pedalkernel_rt::boundary_math::OnePortState::CapacitorVoltage(
4231                                        v_cat,
4232                                    ),
4233                                    &mut wdf_stage.runtime_state,
4234                                );
4235                            }
4236                        }
4237                    }
4238                }
4239            }
4240
4241            // (2b) Pre-charge the BJT emitter bypass cap to its DC drop.
4242            //
4243            // Mirrors the triode cathode-bypass-cap pre-charge: the emitter
4244            // resistor RE develops a DC drop (Ie·RE) and the bypass cap (CE)
4245            // across it charges to that voltage with τ = RE·CE.  Starting it at
4246            // 0 V produces a large bias-settling transient that drives the
4247            // collector to the rail for several τ.  Seeding the cap state to the
4248            // Q-point drop makes the stage start at steady state.
4249            if let Some(ref dc) = bjt_dc {
4250                if dc.v_emitter > 0.01 {
4251                    let emitter_node = match &nl_kind {
4252                        super::classify::NonlinearKind::BjtNpn { emitter_node, .. }
4253                        | super::classify::NonlinearKind::BjtPnp { emitter_node, .. } => {
4254                            Some(*emitter_node)
4255                        }
4256                        _ => None,
4257                    };
4258                    if let Some(emitter_node) = emitter_node {
4259                        let v_e = dc.v_emitter as pedalkernel_rt::Wave;
4260                        for eidx in 0..graph.edges.len() {
4261                            let e = &graph.edges[eidx];
4262                            let is_emitter_gnd = (e.node_a == emitter_node
4263                                && (e.node_b == graph.gnd_node
4264                                    || graph.ac_ground_nodes.contains(&e.node_b)))
4265                                || (e.node_b == emitter_node
4266                                    && (e.node_a == graph.gnd_node
4267                                        || graph.ac_ground_nodes.contains(&e.node_a)));
4268                            if !is_emitter_gnd {
4269                                continue;
4270                            }
4271                            let comp = &graph.components[e.comp_idx];
4272                            if comp.kind.capacitance().is_none() {
4273                                continue;
4274                            }
4275                            if let Some(port) =
4276                                wdf_stage.tree.one_port_runtime_binding_mut(&comp.id)
4277                            {
4278                                port.wdf_set_one_port_state(
4279                                    pedalkernel_rt::boundary_math::OnePortState::CapacitorVoltage(
4280                                        v_e,
4281                                    ),
4282                                    &mut wdf_stage.runtime_state,
4283                                );
4284                            }
4285                        }
4286                    }
4287                }
4288            }
4289
4290            // (2c) Restore series emitter feedback for UNBYPASSED emitter
4291            // degeneration (pedalkernel-2alk).
4292            //
4293            // The one-port BjtRoot's control clamp
4294            // (`RootKind::set_control_voltage`: Vbe = vbe_bias + input·comp)
4295            // never sees the emitter-node swing, so the series feedback an
4296            // unbypassed R_E provides in the real circuit (v_be = v_b − i_e·R_E)
4297            // is structurally severed — the stage runs at the UNDEGENERATED
4298            // gain gm·R_loop (APB: 217× measured vs 20-22× theory/ngspice;
4299            // R_E contributes only its ohmic loop drop).
4300            //
4301            // A bypass cap across R_E shorts the emitter swing at audio, so
4302            // BYPASSED stages are already correct as compiled and must stay
4303            // bit-identical (flip-proof: 100u across APB's R5 moved the WDF
4304            // output only +0.15 dB) — the divider only engages when NO cap
4305            // sits across the emitter leg.
4306            //
4307            // Compile-time divider at the solved Q-point.  The stage output
4308            // is the ROOT PORT voltage, so the engine's raw Vbe→out transfer
4309            // is gm·rp_root where rp_root = tree.port_resistance() — which
4310            // includes the synthetic voltage-source rp (DEFAULT_VS_RP) and
4311            // the folded base-bias network, NOT the physical collector load
4312            // (APB: rp_root ≈ 20.5 kΩ vs R_C = 10 kΩ).  The divider therefore
4313            // normalizes the loop to the physical load AND applies the series
4314            // feedback factor F = 1 + gm·R_E·(β+1)/β:
4315            //     divider = R_C / (rp_root · F)
4316            //     ⇒ stage gain = gm·rp_root·divider = gm·R_C / F
4317            // which is exactly the degenerated common-emitter small-signal
4318            // gain (→ R_C/R_E as gm·R_E ≫ 1).  When R_C could not be located
4319            // (half-rail Vce fallback upstream) the loop normalization is
4320            // skipped and only the feedback factor 1/F applies.  K-tables are
4321            // unaffected: their control axis is the Vbe OFFSET and the
4322            // divider applies upstream of it.
4323            let bjt_beta = match &wdf_stage.root {
4324                RootKind::Bjt(b) => Some((b.model.bf as f64).max(1.0)),
4325                _ => None,
4326            };
4327            if let (Some(beta), Some(dc)) = (bjt_beta, bjt_dc.as_ref()) {
4328                if dc.r_degeneration > 0.0 && dc.gm > 0.0 {
4329                    let emitter_node = match &nl_kind {
4330                        super::classify::NonlinearKind::BjtNpn { emitter_node, .. }
4331                        | super::classify::NonlinearKind::BjtPnp { emitter_node, .. } => {
4332                            Some(*emitter_node)
4333                        }
4334                        _ => None,
4335                    };
4336                    // Bypass detection mirrors the (2b) pre-charge scan: any
4337                    // capacitor from the emitter node to gnd/ac-ground.
4338                    let emitter_bypassed = emitter_node.is_some_and(|emitter_node| {
4339                        graph.edges.iter().any(|e| {
4340                            let is_emitter_gnd = (e.node_a == emitter_node
4341                                && (e.node_b == graph.gnd_node
4342                                    || graph.ac_ground_nodes.contains(&e.node_b)))
4343                                || (e.node_b == emitter_node
4344                                    && (e.node_a == graph.gnd_node
4345                                        || graph.ac_ground_nodes.contains(&e.node_a)));
4346                            is_emitter_gnd
4347                                && graph.components[e.comp_idx].kind.capacitance().is_some()
4348                        })
4349                    });
4350                    if emitter_node.is_some() && !emitter_bypassed {
4351                        // Series-feedback factor F = 1 + gm·R_E·(β+1)/β
4352                        // (the (β+1)/β term carries the emitter current's
4353                        // base-current component; equals rπ/(rπ+(β+1)·R_E)
4354                        // as a divider, rπ = β/gm).
4355                        let f = 1.0 + dc.gm * dc.r_degeneration * (beta + 1.0) / beta;
4356                        let rp_root = wdf_stage.tree.port_resistance();
4357                        let divider = if dc.r_load > 0.0 && rp_root.is_finite() && rp_root > 0.0
4358                        {
4359                            dc.r_load / (rp_root as f64 * f)
4360                        } else {
4361                            1.0 / f
4362                        };
4363                        if std::env::var("PK_BIAS_QPOINT_DEBUG").is_ok() {
4364                            eprintln!(
4365                                "[degen-divider] {}: gm={:.6} S beta={beta:.1} \
4366                                 r_e={:.1} Ω r_load={:.1} Ω rp_root={rp_root:.1} Ω \
4367                                 F={f:.4} divider={divider:.6}",
4368                                comp.id, dc.gm, dc.r_degeneration, dc.r_load
4369                            );
4370                        }
4371                        if divider.is_finite() && divider > 0.0 && divider < 1.0 {
4372                            wdf_stage.control_divider = divider as pedalkernel_rt::Wave;
4373                        }
4374                    }
4375                }
4376            }
4377
4378            // Series-diode rectifier output extraction.
4379            //
4380            // For a diode root the stage normally reports the *junction* voltage
4381            // −(a_root + b_tree)/2, i.e. the voltage across the diode itself.
4382            // That is correct for the canonical cases the diode root was built
4383            // for: a shunt clipper (diode to ground — junction voltage IS the
4384            // output) and an op-amp feedback clipper (diode across the feedback
4385            // path). But in a *series* rectifier `in -> R1 -> D1 -> RL -> gnd`,
4386            // the circuit output is the load voltage at the diode's cathode
4387            // (D1.b / RL.a), NOT the diode junction voltage. Reporting the
4388            // junction voltage there yields an inverted, non-rectifying signal.
4389            //
4390            // Detect the series-rectifier shape: a single-diode whose cathode
4391            // (output terminal "b") drives exactly one resistor to ground (the
4392            // load RL), with one resistor between the anode and the source (R1).
4393            // The output load voltage follows from KVL: V_RL = (Vin - V_diode) *
4394            // RL/(R1+RL). Record that divider so the runtime computes the load
4395            // voltage. Shunt clippers (cathode == gnd) leave it unset and keep the
4396            // junction-voltage behaviour.
4397            if matches!(nl_kind, super::classify::NonlinearKind::SingleDiode(_)) {
4398                let is_gnd = |n: super::graph::NodeId| -> bool {
4399                    n == graph.gnd_node || graph.ac_ground_nodes.contains(&n)
4400                };
4401                // Diode terminals: anode = input "a" = e.node_a, cathode = "b".
4402                let anode = e.node_a;
4403                let cathode = e.node_b;
4404                // Total series resistance touching a node (excluding the diode).
4405                let series_r_at = |node: super::graph::NodeId| -> f64 {
4406                    edge_indices
4407                        .iter()
4408                        .filter(|&&eidx| eidx != nl_edge_idx)
4409                        .filter_map(|&eidx| {
4410                            let le = &graph.edges[eidx];
4411                            if le.node_a == node || le.node_b == node {
4412                                graph.components[le.comp_idx].kind.resistance()
4413                            } else {
4414                                None
4415                            }
4416                        })
4417                        .sum()
4418                };
4419                if !is_gnd(cathode) {
4420                    // Load resistance: cathode → gnd resistors (RL).
4421                    let r_load: f64 = edge_indices
4422                        .iter()
4423                        .filter(|&&eidx| eidx != nl_edge_idx)
4424                        .filter_map(|&eidx| {
4425                            let le = &graph.edges[eidx];
4426                            let touches_cathode = le.node_a == cathode || le.node_b == cathode;
4427                            let other = if le.node_a == cathode {
4428                                le.node_b
4429                            } else {
4430                                le.node_a
4431                            };
4432                            if touches_cathode && is_gnd(other) {
4433                                graph.components[le.comp_idx].kind.resistance()
4434                            } else {
4435                                None
4436                            }
4437                        })
4438                        .sum();
4439                    // Source-side series resistance feeding the anode (R1).
4440                    let r_series = series_r_at(anode);
4441                    if r_load > 0.0 && (r_series + r_load).is_finite() {
4442                        wdf_stage.series_rectifier_divider = Some((r_load / (r_series + r_load)) as crate::Wave);
4443                    }
4444                }
4445            }
4446
4447            Ok(BuiltStage::Wdf(wdf_stage))
4448        }
4449        SpqrStage::Rigid {
4450            edge_indices,
4451            boundary_nodes,
4452            pendant_trees,
4453            ..
4454        } => {
4455            let all_passive = edge_indices.iter().all(|&eidx| {
4456                let comp = &graph.components[graph.edges[eidx].comp_idx];
4457                comp.kind.is_passive()
4458            });
4459            if all_passive {
4460                if let Some(wdf) = build_passive_rtype_stage(
4461                    &edge_indices,
4462                    graph,
4463                    _sample_rate,
4464                    bias_node_voltages,
4465                ) {
4466                    return Ok(BuiltStage::Wdf(wdf));
4467                }
4468            }
4469
4470            // Use build_rigid_from_group_with_hints so init_hints flow through
4471            // when this Rigid stage is reached via the SPQR or blockwise path.
4472            // boundary_nodes and pendant_trees are not used by build_rigid_from_group*
4473            // (they were only meaningful for the legacy MNA pendant tree path).
4474            let _ = (boundary_nodes, pendant_trees);
4475            super::rigid::build_rigid_from_group_with_hints(
4476                edge_indices,
4477                graph,
4478                _sample_rate,
4479                None,
4480                supply_voltage,
4481                None,
4482                !disable_iir,
4483                init_hints,
4484            )
4485        }
4486    }
4487}
4488
4489fn build_passive_rtype_stage(
4490    edge_indices: &[usize],
4491    graph: &CircuitGraph,
4492    sample_rate: f64,
4493    bias_node_voltages: &std::collections::BTreeMap<NodeId, f64>,
4494) -> Option<WdfStage> {
4495    let is_ground = |n: NodeId| n == graph.gnd_node || graph.ac_ground_nodes.contains(&n);
4496
4497    let terminals =
4498        compute_group_terminals(edge_indices, graph, &vec![graph.in_node, graph.out_node]);
4499    let mut output_node = terminals
4500        .iter()
4501        .copied()
4502        .find(|&t| t == graph.out_node)
4503        .or_else(|| {
4504            terminals
4505                .iter()
4506                .copied()
4507                .find(|&t| !is_ground(t) && t != graph.in_node)
4508        })?;
4509    let mut input_node = terminals
4510        .iter()
4511        .copied()
4512        .find(|&t| t == graph.in_node)
4513        .or_else(|| {
4514            terminals
4515                .iter()
4516                .copied()
4517                .find(|&t| !is_ground(t) && t != output_node)
4518        })?;
4519    // Interior groups (neither terminal is the global in/out) picked their
4520    // input/output above by terminal ITERATION order — i.e. by NodeId —
4521    // which flips with incidental node renumbering (e.g. a virtual
4522    // `EF.in` tap pin unioning into a nearby node shifts union-find roots).
4523    // An inverted orientation stamps the MNA voltage source on the
4524    // DOWNSTREAM terminal and extracts at the upstream one, erasing the
4525    // group's transfer (a JFET-shunt GR divider measured vs=1.0 — flat —
4526    // in exactly that case). Orient by rail-blocked signal-flow distance
4527    // from `in`: the upstream terminal drives, the downstream one extracts.
4528    // Swap only on PROVEN inversion (both distances known and ordered) so
4529    // unreachable/tied terminals keep the legacy order.
4530    if output_node != graph.out_node && input_node != graph.in_node {
4531        let d_in = super::signal_flow::bfs_distances_from_in_node(graph);
4532        if let (Some(&d_input), Some(&d_output)) = (d_in.get(&input_node), d_in.get(&output_node)) {
4533            if d_output < d_input {
4534                core::mem::swap(&mut input_node, &mut output_node);
4535            }
4536        }
4537    }
4538
4539    let mut nodes: Vec<NodeId> = edge_indices
4540        .iter()
4541        .flat_map(|&eidx| {
4542            let e = &graph.edges[eidx];
4543            [e.node_a, e.node_b]
4544        })
4545        .filter(|&n| !is_ground(n))
4546        .collect();
4547    nodes.sort_unstable();
4548    nodes.dedup();
4549
4550    let node_to_mna =
4551        |node: NodeId, nodes: &[NodeId]| -> Option<usize> { nodes.iter().position(|&n| n == node) };
4552
4553    let transformer_comp_indices: Vec<usize> = {
4554        let mut seen = std::collections::HashSet::new();
4555        edge_indices
4556            .iter()
4557            .filter_map(|&eidx| {
4558                let comp = &graph.components[graph.edges[eidx].comp_idx];
4559                let cfg = comp.kind.transformer_config()?;
4560                if cfg.has_tertiary() || !seen.insert(graph.edges[eidx].comp_idx) {
4561                    return None;
4562                }
4563                Some(graph.edges[eidx].comp_idx)
4564            })
4565            .collect()
4566    };
4567    let transformer_internal_base = nodes.len();
4568    let transformer_internals: std::collections::HashMap<usize, [usize; 4]> =
4569        transformer_comp_indices
4570            .iter()
4571            .enumerate()
4572            .map(|(i, &comp_idx)| {
4573                let base = transformer_internal_base + i * 4;
4574                (comp_idx, [base, base + 1, base + 2, base + 3])
4575            })
4576            .collect();
4577    let mut mna = MnaSystem::new(
4578        nodes.len() + transformer_comp_indices.len() * 4,
4579        1 + transformer_comp_indices.len() * 2,
4580    );
4581    let vs_node = node_to_mna(input_node, &nodes);
4582    mna.stamp_voltage_source(vs_node, None, 0);
4583
4584    let mut children = Vec::new();
4585    let mut ports = Vec::new();
4586    let mut variable_resistors = Vec::new();
4587    let mut seen_pots = std::collections::HashSet::new();
4588    let mut stamped_transformers = std::collections::HashSet::new();
4589
4590    let add_dynamic_port = |ports: &mut Vec<WdfPort>,
4591                            children: &mut Vec<DynNode>,
4592                            variable_resistors: &mut Vec<MnaVariableResistorBinding>,
4593                            port: WdfPort,
4594                            child: DynNode| {
4595        ports.push(port);
4596        let child_idx = ports.len() - 1;
4597        children.insert(child_idx, child);
4598        for binding in variable_resistors {
4599            if binding.child_idx >= child_idx {
4600                binding.child_idx += 1;
4601            }
4602        }
4603    };
4604
4605    for &eidx in edge_indices {
4606        let edge = &graph.edges[eidx];
4607        let comp = &graph.components[edge.comp_idx];
4608        let n1 = node_to_mna(edge.node_a, &nodes);
4609        let n2 = node_to_mna(edge.node_b, &nodes);
4610
4611        if comp.kind.is_pot() {
4612            if let Some(pot) = comp
4613                .kind
4614                .as_any()
4615                .downcast_ref::<super::components::Potentiometer>()
4616            {
4617                let mut child = DynNode::Pot(comp.id.clone(), pot.max_r as crate::Wave, 0.5, pot.taper);
4618                if pot_edge_is_aw_half_for_build(graph, &comp.id, edge.node_a, edge.node_b) {
4619                    if let DynNode::Leaf(ref mut leaf) = child {
4620                        leaf.set_complement();
4621                    }
4622                }
4623                let r = child.port_resistance();
4624                mna.stamp_resistor(n1, n2, r);
4625                variable_resistors.push(MnaVariableResistorBinding {
4626                    child_idx: children.len(),
4627                    terminals: MnaPortTerminals::maybe_differential(
4628                        n1.map(MnaNodeId::new),
4629                        n2.map(MnaNodeId::new),
4630                    ),
4631                    conductance: 1.0 / r,
4632                });
4633                children.push(child);
4634                seen_pots.insert(edge.comp_idx);
4635            }
4636            continue;
4637        }
4638
4639        if let Some(cfg) = comp.kind.transformer_config() {
4640            if !cfg.has_tertiary() && stamped_transformers.insert(edge.comp_idx) {
4641                let Some(&internals) = transformer_internals.get(&edge.comp_idx) else {
4642                    continue;
4643                };
4644                let vsrc_p = 1 + transformer_comp_indices
4645                    .iter()
4646                    .position(|&idx| idx == edge.comp_idx)
4647                    .unwrap()
4648                    * 2;
4649                let Some(sec_pos_node) = graph.node_names.get(&format!("{}.c", comp.id)).copied()
4650                else {
4651                    continue;
4652                };
4653                let Some(sec_neg_node) = graph.node_names.get(&format!("{}.d", comp.id)).copied()
4654                else {
4655                    continue;
4656                };
4657                let primary_pos_node = graph.node_names.get(&format!("{}.a", comp.id)).copied();
4658                let primary_neg_node = graph.node_names.get(&format!("{}.b", comp.id)).copied();
4659                let s1 = node_to_mna(sec_pos_node, &nodes);
4660                let s2 = node_to_mna(sec_neg_node, &nodes);
4661                let resolved_cfg = crate::model_lookup::transformer_config_from_dsl(cfg);
4662                let dc_bias_current = resolved_cfg.dc_bias_current.or_else(|| {
4663                    match (primary_pos_node, primary_neg_node) {
4664                        (Some(a), Some(b)) => infer_transformer_dc_bias_current(
4665                            &resolved_cfg,
4666                            a,
4667                            b,
4668                            bias_node_voltages,
4669                            graph,
4670                        ),
4671                        _ => None,
4672                    }
4673                });
4674                let dynamic = stamp_linear_transformer_skeleton(
4675                    &mut mna,
4676                    &comp.id,
4677                    &resolved_cfg,
4678                    n1,
4679                    n2,
4680                    s1,
4681                    s2,
4682                    internals,
4683                    vsrc_p,
4684                    sample_rate,
4685                    dc_bias_current,
4686                );
4687                for (port, child) in dynamic {
4688                    add_dynamic_port(
4689                        &mut ports,
4690                        &mut children,
4691                        &mut variable_resistors,
4692                        port,
4693                        child,
4694                    );
4695                }
4696            }
4697            continue;
4698        }
4699
4700        // Gate-modulated JFETs reach this builder as Linear edges (variable
4701        // resistors). Stamp the current Rds and register the `jfet_vr` child
4702        // so runtime Vgs modulation (LFO/envelope) can update the G matrix
4703        // and re-derive scattering — same mechanism as pots. Without this
4704        // the JFET edge was silently dropped from the MNA (audit gap G2).
4705        // Photocoupler LDR cells are the same shape of controlled resistance
4706        // (LED drive instead of Vgs) and were dropped identically (gap G3).
4707        if comp.kind.is_jfet() || comp.kind.type_tag() == "photocoupler" {
4708            if let Some(child) = comp.kind.make_leaf(&comp.id, sample_rate) {
4709                let r = child.port_resistance();
4710                mna.stamp_resistor(n1, n2, r);
4711                variable_resistors.push(MnaVariableResistorBinding {
4712                    child_idx: children.len(),
4713                    terminals: MnaPortTerminals::maybe_differential(
4714                        n1.map(MnaNodeId::new),
4715                        n2.map(MnaNodeId::new),
4716                    ),
4717                    conductance: 1.0 / r,
4718                });
4719                children.push(child);
4720            }
4721            continue;
4722        }
4723
4724        if let Some(r) = comp.kind.resistance() {
4725            mna.stamp_resistor(n1, n2, r as crate::Wave);
4726        } else if comp.kind.capacitance().is_some() || comp.kind.inductance().is_some() {
4727            let child = comp.kind.make_leaf(&comp.id, sample_rate)?;
4728            let rp = child.port_resistance();
4729            let (port_pos, port_neg) = if edge.node_a == input_node {
4730                (n2, n1)
4731            } else if edge.node_b == input_node {
4732                (n1, n2)
4733            } else {
4734                (n1, n2)
4735            };
4736            ports.push(WdfPort {
4737                node_pos: port_pos,
4738                node_neg: port_neg,
4739                resistance: rp,
4740            });
4741            children.insert(ports.len() - 1, child);
4742            for binding in &mut variable_resistors {
4743                if binding.child_idx >= ports.len() - 1 {
4744                    binding.child_idx += 1;
4745                }
4746            }
4747        }
4748    }
4749
4750    let transformer_voltage_gain = if stamped_transformers.is_empty() {
4751        passive_transformer_voltage_gain(input_node, output_node, graph)
4752    } else {
4753        None
4754    };
4755    let output_mna = node_to_mna(output_node, &nodes);
4756    let (scattering, vs_injection) = mna.derive_scattering_and_vs_injection(&ports, 0);
4757    let (extraction_coeffs, mut extraction_vs) =
4758        mna.derive_extraction_coeffs(&ports, 0, output_mna, None);
4759    if let Some(gain) = transformer_voltage_gain {
4760        extraction_vs = gain as crate::Wave;
4761    }
4762
4763    if scattering.iter().any(|v| !v.is_finite())
4764        || vs_injection.iter().any(|v| !v.is_finite())
4765        || extraction_coeffs.iter().any(|v| !v.is_finite())
4766        || !extraction_vs.is_finite()
4767    {
4768        return None;
4769    }
4770
4771    let mut child_runtime_states = Vec::with_capacity(children.len());
4772    for child in &mut children {
4773        child_runtime_states.push(child.bind_runtime_state());
4774    }
4775
4776    // `nodes` maps MNA index → circuit NodeId; needed for secondary port injection.
4777    let mna_node_map: Vec<usize> = nodes.clone();
4778
4779    let mut wdf = WdfStage::new(
4780        DynNode::Resistor(Some("__passive_rtype_dummy".to_string()), 1.0),
4781        RootKind::PassiveRType {
4782            scattering,
4783            vs_injection,
4784            n_ports: ports.len(),
4785            children,
4786            child_runtime_states,
4787            output_port: 0,
4788            extraction_coeffs,
4789            extraction_vs,
4790            extraction_output_pos: output_mna,
4791            extraction_output_neg: None,
4792            recompute_mna: Some(mna),
4793            recompute_ports: Some(ports),
4794            variable_resistors,
4795            needs_recompute: false,
4796            interp_table: None,
4797            mna_node_map,
4798            port_vs_injections: Vec::new(),
4799        },
4800        Oversampler::new(OversamplingFactor::X1),
4801    );
4802    wdf.output_probe = None;
4803
4804    // F13b: detect a parallel-branch convergence mixer and attach a superposition
4805    // summation. NARROW by construction — only resistive groups whose boundary
4806    // includes >=2 distinct op-amp-output nodes AND the global `out` qualify, so
4807    // every single-source passive group is completely unaffected (zero-diff).
4808    if let Some(cs) = try_build_convergence_sum(edge_indices, graph) {
4809        wdf.convergence = Some(cs);
4810    }
4811
4812    Some(wdf)
4813}
4814
4815/// Detect and build a parallel-branch convergence summation (F13b).
4816///
4817/// Predicate (intentionally narrow): the group's boundary nodes must include
4818/// **two or more distinct op-amp output nodes** (driving ideal voltage sources)
4819/// AND the group must touch the global `out` node. This is exactly the
4820/// "several active branches mixed to the output" topology (the 808 snare's two
4821/// bridged-T resonators into the blend pot). Single-source groups, bias
4822/// dividers, tone stacks, and ordinary serial passives all fail the predicate
4823/// and are untouched.
4824///
4825/// When it matches, the gains are solved by DC superposition (see
4826/// [`ConvergenceSum::recompute_gains`]).
4827fn try_build_convergence_sum(
4828    edge_indices: &[usize],
4829    graph: &CircuitGraph,
4830) -> Option<pedalkernel_rt::convergence::ConvergenceSum> {
4831    use pedalkernel_rt::convergence::{ConvergenceBranch, ConvergenceSum, FixedBranch, PotBranch};
4832
4833    let is_ground = |n: NodeId| n == graph.gnd_node || graph.ac_ground_nodes.contains(&n);
4834
4835    let terminals = compute_group_terminals(edge_indices, graph, &[graph.in_node, graph.out_node]);
4836
4837    // Must reach the global output.
4838    if !terminals.contains(&graph.out_node) {
4839        return None;
4840    }
4841
4842    // Driver nodes = boundary terminals that are an op-amp `out` node of an
4843    // op-amp NOT contained in this group (a genuine upstream voltage source).
4844    let group_comps: std::collections::HashSet<usize> = edge_indices
4845        .iter()
4846        .map(|&e| graph.edges[e].comp_idx)
4847        .collect();
4848    let mut driver_nodes: Vec<NodeId> = Vec::new();
4849    for &t in &terminals {
4850        if t == graph.out_node || is_ground(t) {
4851            continue;
4852        }
4853        let is_upstream_opamp_out = graph
4854            .nullor_pins
4855            .iter()
4856            .any(|p| p.out_node == t && !group_comps.contains(&p.comp_idx));
4857        if is_upstream_opamp_out && !driver_nodes.contains(&t) {
4858            driver_nodes.push(t);
4859        }
4860    }
4861    if driver_nodes.len() < 2 {
4862        return None;
4863    }
4864
4865    // Build the resistive network over this group's local nodes (excl. ground).
4866    let mut local: Vec<NodeId> = Vec::new();
4867    let mut local_index = |node: NodeId, local: &mut Vec<NodeId>| -> Option<usize> {
4868        if is_ground(node) {
4869            return None;
4870        }
4871        if let Some(p) = local.iter().position(|&n| n == node) {
4872            Some(p)
4873        } else {
4874            local.push(node);
4875            Some(local.len() - 1)
4876        }
4877    };
4878
4879    let mut fixed_branches: Vec<FixedBranch> = Vec::new();
4880    let mut pot_branches: Vec<PotBranch> = Vec::new();
4881    for &eidx in edge_indices {
4882        let edge = &graph.edges[eidx];
4883        let comp = &graph.components[edge.comp_idx];
4884        let na = local_index(edge.node_a, &mut local);
4885        let nb = local_index(edge.node_b, &mut local);
4886        if na.is_none() && nb.is_none() {
4887            continue;
4888        }
4889        if comp.kind.is_pot() {
4890            if let Some(pot) = comp
4891                .kind
4892                .as_any()
4893                .downcast_ref::<super::components::Potentiometer>()
4894            {
4895                let is_aw =
4896                    pot_edge_is_aw_half_for_build(graph, &comp.id, edge.node_a, edge.node_b);
4897                pot_branches.push(PotBranch {
4898                    comp_id: comp.id.clone(),
4899                    na,
4900                    nb,
4901                    max_r: pot.max_r as crate::Wave,
4902                    taper: pot.taper,
4903                    is_aw,
4904                    position: 0.5,
4905                });
4906            }
4907        } else if let Some(r) = comp.kind.resistance() {
4908            if r > 0.0 {
4909                fixed_branches.push(FixedBranch {
4910                    na,
4911                    nb,
4912                    conductance: (1.0 / r) as crate::Wave,
4913                });
4914            }
4915        }
4916        // Reactive/NL elements are intentionally ignored: convergence groups are
4917        // resistive by the predicate above (a group containing an NL device would
4918        // not have been routed through `build_passive_rtype_stage`).
4919    }
4920
4921    let branches: Vec<ConvergenceBranch> = driver_nodes
4922        .iter()
4923        .filter_map(|&node| {
4924            local
4925                .iter()
4926                .position(|&n| n == node)
4927                .map(|source_local| ConvergenceBranch {
4928                    source_node_id: node,
4929                    source_local,
4930                    gain: 0.0,
4931                })
4932        })
4933        .collect();
4934    if branches.len() < 2 {
4935        return None;
4936    }
4937
4938    let output_local = local.iter().position(|&n| n == graph.out_node);
4939
4940    let mut cs = ConvergenceSum {
4941        branches,
4942        output_node_id: graph.out_node,
4943        num_nodes: local.len(),
4944        output_local,
4945        fixed_branches,
4946        pot_branches,
4947    };
4948    cs.recompute_gains();
4949    Some(cs)
4950}
4951
4952/// Edge-guard (pedalkernel-ffkl): what a builder does when a circuit edge
4953/// falls through every stamping branch — the failure mode that silently
4954/// dropped the LA-2A `T_out` primary from the triode-context MNA.
4955///
4956/// * `error`: the compile FAILS with the offending component list.
4957///   A circuit must not compile with a component missing and no trace.
4958/// * `warn`: loud eprintln diagnostic, compile proceeds.
4959/// * `off`: legacy silent behavior.
4960///
4961/// DEFAULTS are per-layer (each call site passes its own), overridable for
4962/// both layers at once via `PK_EDGE_GUARD=error|warn|off`:
4963/// * builder-level (`rigid::general` — an edge handed to a builder was not
4964///   stamped): **error**. Corpus-measured clean; any trip is a formation bug.
4965/// * assembly-level (a graph edge landed in NO stage and NO consumption
4966///   mechanism): **error** (pedalkernel-x5ac, promoted in the
4967///   pedalkernel-y9hz batch). The one real corpus drop that blocked
4968///   promotion — `try_build_blockwise` returning `Some(empty)` and
4969///   vaporizing the PNP fuzz family's BJT core — is FIXED (empty build
4970///   declines to the monolithic path), and the full lib corpus runs
4971///   guard-quiet (2 trips -> 0, measured). Any trip is now a compile error:
4972///   a circuit must not compile with a component missing and no trace.
4973#[derive(Clone, Copy, PartialEq, Eq, Debug)]
4974pub(in crate::compiler) enum EdgeGuardMode {
4975    Off,
4976    Warn,
4977    Error,
4978}
4979
4980pub(in crate::compiler) fn edge_guard_mode(default_mode: EdgeGuardMode) -> EdgeGuardMode {
4981    match std::env::var("PK_EDGE_GUARD").as_deref() {
4982        Ok("off") => EdgeGuardMode::Off,
4983        Ok("warn") => EdgeGuardMode::Warn,
4984        Ok("error") => EdgeGuardMode::Error,
4985        _ => default_mode,
4986    }
4987}
4988
4989/// Report edges that a builder received but did not stamp, port, or
4990/// explicitly consume. Returns `Err` in [`EdgeGuardMode::Error`].
4991pub(in crate::compiler) fn report_dropped_edges(
4992    context: &str,
4993    dropped: &[usize],
4994    graph: &super::graph::CircuitGraph,
4995    default_mode: EdgeGuardMode,
4996) -> Result<(), String> {
4997    if dropped.is_empty() {
4998        return Ok(());
4999    }
5000    let mode = edge_guard_mode(default_mode);
5001    if mode == EdgeGuardMode::Off {
5002        return Ok(());
5003    }
5004    let mut descriptions: Vec<String> = dropped
5005        .iter()
5006        .map(|&eidx| {
5007            let e = &graph.edges[eidx];
5008            let comp = &graph.components[e.comp_idx];
5009            format!(
5010                "{} ({}, edge {eidx}, kind {:?})",
5011                comp.id,
5012                comp.kind.type_tag(),
5013                graph.effective_edge_kind(eidx)
5014            )
5015        })
5016        .collect();
5017    descriptions.sort();
5018    descriptions.dedup();
5019    let msg = format!(
5020        "edge guard [{context}]: {} edge(s) fell through the build without a \
5021         stamp, port, or explicit consumption — the compiled circuit would be \
5022         missing these components with no trace: {}. \
5023         (Set PK_EDGE_GUARD=warn to compile anyway, PK_EDGE_GUARD=off to \
5024         silence.)",
5025        descriptions.len(),
5026        descriptions.join(", ")
5027    );
5028    match mode {
5029        EdgeGuardMode::Error => Err(msg),
5030        _ => {
5031            eprintln!("[pedalkernel] WARNING: {msg}");
5032            Ok(())
5033        }
5034    }
5035}
5036
5037pub(in crate::compiler) fn stamp_linear_transformer_skeleton(
5038    mna: &mut MnaSystem,
5039    comp_id: &str,
5040    cfg: &TransformerConfig,
5041    p_pos: Option<usize>,
5042    p_neg: Option<usize>,
5043    s_pos: Option<usize>,
5044    s_neg: Option<usize>,
5045    internals: [usize; 4],
5046    vsrc_p: usize,
5047    sample_rate: f64,
5048    dc_bias_current: Option<f64>,
5049) -> Vec<(WdfPort, DynNode)> {
5050    const SHORT_R: f64 = 1.0e-6;
5051    const MIN_L: f64 = 1.0e-12;
5052    const MIN_C: f64 = 1.0e-15;
5053
5054    let [p_series, p_core, s_core, s_series] = internals;
5055    let n = cfg.turns_ratio;
5056    let l_primary = cfg.primary_inductance.max(MIN_L);
5057    let l_secondary = l_primary / (n * n);
5058    let k = cfg.coupling.clamp(0.0, 1.0);
5059    let l_leak_p = cfg.primary_leakage.unwrap_or(l_primary * (1.0 - k));
5060    let l_leak_s = cfg.secondary_leakage.unwrap_or(l_secondary * (1.0 - k));
5061    let l_mag = cfg
5062        .magnetizing_inductance
5063        .unwrap_or(l_primary * k.max(1.0e-6));
5064    let ja_core = transformer_ja_core_model(cfg);
5065    let dc_bias_current = dc_bias_current.unwrap_or(0.0);
5066
5067    fn add_inductor_or_short(
5068        mna: &mut MnaSystem,
5069        dynamic: &mut Vec<(WdfPort, DynNode)>,
5070        comp_id: &str,
5071        name: &str,
5072        a: Option<usize>,
5073        b: Option<usize>,
5074        l: f64,
5075        sample_rate: f64,
5076    ) {
5077        const SHORT_R: f64 = 1.0e-6;
5078        const MIN_L: f64 = 1.0e-12;
5079        if l.is_finite() && l > MIN_L {
5080            let rp = 2.0 * sample_rate * l;
5081            dynamic.push((
5082                WdfPort {
5083                    node_pos: a,
5084                    node_neg: b,
5085                    resistance: rp as crate::Wave,
5086                },
5087                DynNode::Inductor(Some(format!("{comp_id}.{name}")), l as crate::Wave, rp as crate::Wave),
5088            ));
5089        } else {
5090            mna.stamp_resistor(a, b, SHORT_R as crate::Wave);
5091        }
5092    }
5093
5094    fn add_magnetizing_branch(
5095        mna: &mut MnaSystem,
5096        dynamic: &mut Vec<(WdfPort, DynNode)>,
5097        comp_id: &str,
5098        a: Option<usize>,
5099        b: Option<usize>,
5100        l: f64,
5101        sample_rate: f64,
5102        ja_core: Option<JaCoreModel>,
5103        dc_bias_current: f64,
5104    ) {
5105        const SHORT_R: f64 = 1.0e-6;
5106        const MIN_L: f64 = 1.0e-12;
5107        if !(l.is_finite() && l > MIN_L) {
5108            mna.stamp_resistor(a, b, SHORT_R as crate::Wave);
5109            return;
5110        }
5111
5112        let rp = 2.0 * sample_rate * l;
5113        let node = if let Some(model) = ja_core {
5114            DynNode::JaMagnetizingWithDcBias(
5115                Some(format!("{comp_id}.Lm")),
5116                model,
5117                sample_rate as crate::Wave,
5118                rp as crate::Wave,
5119                dc_bias_current as pedalkernel_rt::Wave,
5120            )
5121        } else {
5122            DynNode::Inductor(Some(format!("{comp_id}.Lm")), l as crate::Wave, rp as crate::Wave)
5123        };
5124        dynamic.push((
5125            WdfPort {
5126                node_pos: a,
5127                node_neg: b,
5128                resistance: rp as crate::Wave,
5129            },
5130            node,
5131        ));
5132    }
5133
5134    let mut dynamic = Vec::new();
5135
5136    mna.stamp_resistor(p_pos, Some(p_series), cfg.primary_dcr.max(SHORT_R) as crate::Wave);
5137    add_inductor_or_short(
5138        mna,
5139        &mut dynamic,
5140        comp_id,
5141        "Llp",
5142        Some(p_series),
5143        Some(p_core),
5144        l_leak_p,
5145        sample_rate,
5146    );
5147
5148    add_inductor_or_short(
5149        mna,
5150        &mut dynamic,
5151        comp_id,
5152        "Lls",
5153        Some(s_core),
5154        Some(s_series),
5155        l_leak_s,
5156        sample_rate,
5157    );
5158    mna.stamp_resistor(Some(s_series), s_pos, cfg.secondary_dcr.max(SHORT_R) as crate::Wave);
5159
5160    add_magnetizing_branch(
5161        mna,
5162        &mut dynamic,
5163        comp_id,
5164        Some(p_core),
5165        p_neg,
5166        l_mag,
5167        sample_rate,
5168        ja_core,
5169        dc_bias_current,
5170    );
5171    if let Some(rc) = cfg
5172        .core_loss_resistance
5173        .filter(|r| r.is_finite() && *r > 0.0)
5174    {
5175        mna.stamp_resistor(Some(p_core), p_neg, rc as crate::Wave);
5176    }
5177
5178    if cfg.capacitance.is_finite() && cfg.capacitance > MIN_C {
5179        let rp = 1.0 / (2.0 * sample_rate * cfg.capacitance);
5180        dynamic.push((
5181            WdfPort {
5182                node_pos: Some(p_core),
5183                node_neg: Some(s_core),
5184                resistance: rp as crate::Wave,
5185            },
5186            DynNode::Capacitor(Some(format!("{comp_id}.Cp")), cfg.capacitance as crate::Wave, rp as crate::Wave),
5187        ));
5188    }
5189
5190    mna.stamp_transformer(
5191        Some(p_core),
5192        p_neg,
5193        Some(s_core),
5194        s_neg,
5195        vsrc_p,
5196        vsrc_p + 1,
5197        n as crate::Wave,
5198    );
5199
5200    dynamic
5201}
5202
5203fn transformer_ja_core_model(cfg: &TransformerConfig) -> Option<JaCoreModel> {
5204    let model = JaCoreModel {
5205        ms: cfg.ja_ms? as pedalkernel_rt::Wave,
5206        a: cfg.ja_a? as pedalkernel_rt::Wave,
5207        alpha: cfg.ja_alpha? as pedalkernel_rt::Wave,
5208        k: cfg.ja_k? as pedalkernel_rt::Wave,
5209        c: cfg.ja_c? as pedalkernel_rt::Wave,
5210        n_turns: cfg.core_primary_turns? as pedalkernel_rt::Wave,
5211        area: cfg.core_area? as pedalkernel_rt::Wave,
5212        path_len: cfg.core_path_length? as pedalkernel_rt::Wave,
5213        gap: cfg.core_gap.unwrap_or(0.0) as pedalkernel_rt::Wave,
5214    };
5215    model.is_complete().then_some(model)
5216}
5217
5218fn infer_transformer_dc_bias_current(
5219    cfg: &TransformerConfig,
5220    primary_pos: NodeId,
5221    primary_neg: NodeId,
5222    bias_node_voltages: &std::collections::BTreeMap<NodeId, f64>,
5223    graph: &CircuitGraph,
5224) -> Option<f64> {
5225    let r_primary = cfg.primary_dcr;
5226    if !(r_primary.is_finite() && r_primary > 1.0e-9) {
5227        return None;
5228    }
5229
5230    // Moved to bias.rs (ko5g.4) — the single legacy-arm-order node-voltage read.
5231    let vp = super::bias::node_dc_voltage(primary_pos, bias_node_voltages, graph)?;
5232    let vn = super::bias::node_dc_voltage(primary_neg, bias_node_voltages, graph)?;
5233    let current = (vp - vn) / r_primary;
5234    (current.is_finite() && current.abs() > 1.0e-12).then_some(current)
5235}
5236
5237fn passive_transformer_voltage_gain(
5238    input_node: NodeId,
5239    output_node: NodeId,
5240    graph: &CircuitGraph,
5241) -> Option<f64> {
5242    let input = graph.transformer_info.get(&input_node)?;
5243    let output = graph.transformer_info.get(&output_node)?;
5244    if input.comp_idx != output.comp_idx || input.is_secondary == output.is_secondary {
5245        return None;
5246    }
5247
5248    let n = input.turns_ratio;
5249    if !(n.is_finite() && n > 0.0) {
5250        return None;
5251    }
5252
5253    if input.is_secondary && !output.is_secondary {
5254        Some(n)
5255    } else {
5256        Some(1.0 / n)
5257    }
5258}
5259
5260fn group_has_signal_transformer_boundary(
5261    group: &super::signal_flow::FlowGroup,
5262    graph: &CircuitGraph,
5263    input_node: NodeId,
5264    output_node: NodeId,
5265) -> bool {
5266    if passive_transformer_voltage_gain(input_node, output_node, graph).is_none() {
5267        return false;
5268    }
5269
5270    group.all_edges().iter().any(|&eidx| {
5271        graph.components[graph.edges[eidx].comp_idx]
5272            .kind
5273            .is_transformer()
5274    })
5275}
5276
5277fn pot_edge_is_aw_half_for_build(
5278    graph: &CircuitGraph,
5279    comp_id: &str,
5280    a: NodeId,
5281    b: NodeId,
5282) -> bool {
5283    let Some(&w_node) = graph
5284        .node_names
5285        .get(&format!("{comp_id}.w"))
5286        .or_else(|| graph.node_names.get(&format!("{comp_id}.wiper")))
5287    else {
5288        return false;
5289    };
5290    let Some(&a_node) = graph.node_names.get(&format!("{comp_id}.a")) else {
5291        return false;
5292    };
5293    (a == w_node && b == a_node) || (a == a_node && b == w_node)
5294}
5295
5296// NOTE: stage-ordering helper. Walks ALL edges (including through rails),
5297// unlike `signal_flow::bfs_distances_from_in_node`, which measures
5298// signal-path distance (rail-blocked) for the F10 claiming bound.
5299fn bfs_dist_from_in_node(
5300    target: super::graph::NodeId,
5301    graph: &super::graph::CircuitGraph,
5302) -> Option<usize> {
5303    use std::collections::VecDeque;
5304    let mut visited: hashbrown::HashMap<super::graph::NodeId, usize> = hashbrown::HashMap::new();
5305    let mut queue: VecDeque<super::graph::NodeId> = VecDeque::new();
5306    visited.insert(graph.in_node, 0);
5307    queue.push_back(graph.in_node);
5308    while let Some(node) = queue.pop_front() {
5309        let dist = visited[&node];
5310        if node == target {
5311            return Some(dist);
5312        }
5313        for e in &graph.edges {
5314            let (touches, other) = if e.node_a == node {
5315                (true, e.node_b)
5316            } else if e.node_b == node {
5317                (true, e.node_a)
5318            } else {
5319                (false, node)
5320            };
5321            if touches && !visited.contains_key(&other) {
5322                visited.insert(other, dist + 1);
5323                queue.push_back(other);
5324            }
5325        }
5326    }
5327    None
5328}
5329
5330/// Collect all edges that belong to a triode's local subcircuit.
5331///
5332/// Starting from the triode's plate, cathode, and grid nodes, performs a BFS
5333/// through the graph collecting all passive (Linear/Reactive) edges. The BFS
5334/// stops at any node that is:
5335/// - `graph.gnd_node` or a supply node (VCC, global rail)
5336/// - `graph.in_node` or `graph.out_node` (global signal boundary)
5337///
5338/// This lets a standalone common-cathode triode (which gets no passive claiming
5339/// from signal_flow because it has no feedback path) gather its plate load,
5340/// cathode bias resistor, and cathode bypass capacitor into the same MNA stage.
5341///
5342/// Returns the collected edge indices (including the triode's own NL edge).
5343fn collect_triode_context_edges(
5344    triode_nl_edge_idx: usize,
5345    graph: &super::graph::CircuitGraph,
5346    all_graph_edges: &[usize],
5347) -> Vec<usize> {
5348    use super::component::EdgeKind;
5349
5350    let e = &graph.edges[triode_nl_edge_idx];
5351    let comp = &graph.components[e.comp_idx];
5352
5353    // Collect the triode's terminal nodes from its component pins.
5354    let mut frontier_nodes: std::collections::HashSet<NodeId> = std::collections::HashSet::new();
5355    for pin in ["plate", "cathode", "grid"] {
5356        let key = format!("{}.{pin}", comp.id);
5357        if let Some(&node) = graph.node_names.get(&key) {
5358            frontier_nodes.insert(node);
5359        }
5360    }
5361    // Also include the NL edge endpoints directly.
5362    frontier_nodes.insert(e.node_a);
5363    frontier_nodes.insert(e.node_b);
5364
5365    // Boundary nodes: stop BFS here (don't pull in coupling caps / grid leak
5366    // that connects to the circuit's global input or output).
5367    let is_global = |n: NodeId| -> bool {
5368        n == graph.gnd_node
5369            || graph.supply_nodes.contains(&n)
5370            || n == graph.in_node
5371            || n == graph.out_node
5372    };
5373
5374    // F10 upstream bound: never collect toward a non-global node strictly
5375    // closer to `in_node` than the triode's grid pin. Such a node belongs to
5376    // the UPSTREAM network (e.g. a passive EQ feeding this makeup stage);
5377    // collecting it would swallow the network into the triode's MNA stage,
5378    // flattening its response and freezing its pots. Rail-terminated bias
5379    // edges (vcc→plate, cathode→gnd, grid→gnd) are handled by the is_global
5380    // branch and stay collected. Unreachable nodes (disconnected
5381    // subcircuits) have no distance and are never bounded.
5382    let d_in = super::signal_flow::bfs_distances_from_in_node(graph);
5383    let grid_dist: Option<usize> = graph
5384        .node_names
5385        .get(&format!("{}.grid", comp.id))
5386        .and_then(|node| d_in.get(node))
5387        .copied();
5388    let upstream_of_grid = |n: NodeId| -> bool {
5389        match (d_in.get(&n), grid_dist) {
5390            (Some(&d), Some(d_grid)) => d < d_grid,
5391            _ => false,
5392        }
5393    };
5394
5395    let mut collected_edges: std::collections::HashSet<usize> = std::collections::HashSet::new();
5396    collected_edges.insert(triode_nl_edge_idx);
5397
5398    let mut visited_nodes: std::collections::HashSet<NodeId> = frontier_nodes.clone();
5399    let mut queue: std::collections::VecDeque<NodeId> = frontier_nodes.into_iter().collect();
5400
5401    while let Some(node) = queue.pop_front() {
5402        if is_global(node) {
5403            continue;
5404        }
5405        for &eidx in all_graph_edges {
5406            if collected_edges.contains(&eidx) {
5407                continue;
5408            }
5409            let edge = &graph.edges[eidx];
5410            let kind = graph.effective_edge_kind(eidx);
5411            // Only follow passive (Linear/Reactive) edges — don't cross into
5412            // other NL devices.
5413            if kind == EdgeKind::Nonlinear {
5414                continue;
5415            }
5416            let (touches, other) = if edge.node_a == node {
5417                (true, edge.node_b)
5418            } else if edge.node_b == node {
5419                (true, edge.node_a)
5420            } else {
5421                (false, node)
5422            };
5423            if !touches {
5424                continue;
5425            }
5426
5427            let comp = &graph.components[edge.comp_idx];
5428
5429            // Coupling capacitors (reactive elements connecting to in/out ports)
5430            // are NOT part of the triode's local bias network. Exclude them.
5431            // R_plate (plate→VCC) and R_cathode/C_cathode (cathode→GND) ARE
5432            // included because they connect triode pins to supply rails.
5433            if is_global(other) {
5434                // other is a global node (VCC, GND, in_node, out_node).
5435                // Include the edge only if it is a resistor (bias/load resistor
5436                // connecting triode pin to a supply) OR if it is a reactive
5437                // element whose global end is VCC/GND (bypass capacitor to GND
5438                // is fine; coupling cap to in/out is not).
5439                let other_is_signal_port = other == graph.in_node || other == graph.out_node;
5440                if other_is_signal_port {
5441                    // This is a coupling element (C_in: in→grid, C_out: plate→out).
5442                    // Do NOT include — it would drag in/out signal domain into the MNA.
5443                    continue;
5444                }
5445                // other is VCC or GND — include (plate load, cathode bias, bypass cap)
5446                collected_edges.insert(eidx);
5447                continue;
5448            }
5449
5450            // other is a non-global internal node.
5451            // F10: refuse to collect toward the upstream network (see the
5452            // upstream_of_grid doc above).
5453            if upstream_of_grid(other) {
5454                continue;
5455            }
5456            // Exclude coupling caps that lead toward the signal ports: if the
5457            // component is reactive and the other node eventually only connects
5458            // to in_node / out_node without passing through any resistor to a
5459            // supply, it's a coupling cap. Use a simple one-hop check: if the
5460            // only connections at 'other' (besides this edge) lead to in_node
5461            // or out_node, it's an isolated node that is the output of a
5462            // coupling cap chain — don't include.
5463            let other_only_connects_to_signal_port = comp.kind.capacitance().is_some()
5464                && graph.edges.iter().enumerate().all(|(other_eidx, other_e)| {
5465                    if other_eidx == eidx {
5466                        return true; // skip self
5467                    }
5468                    if other_e.node_a != other && other_e.node_b != other {
5469                        return true; // doesn't touch other node
5470                    }
5471                    let far = if other_e.node_a == other {
5472                        other_e.node_b
5473                    } else {
5474                        other_e.node_a
5475                    };
5476                    far == graph.in_node || far == graph.out_node || far == graph.gnd_node
5477                });
5478            if other_only_connects_to_signal_port {
5479                continue;
5480            }
5481
5482            collected_edges.insert(eidx);
5483            if !visited_nodes.contains(&other) {
5484                visited_nodes.insert(other);
5485                queue.push_back(other);
5486            }
5487        }
5488    }
5489
5490    collected_edges.into_iter().collect()
5491}
5492
5493/// Determine which groups are on the audio signal path (in_node → ... → out_node).
5494///
5495/// Check if a group is a ground-clip pattern: diode edges connecting signal to GND.
5496/// Only matches diode-family components (diode, diode_pair, zener), not BJTs/JFETs
5497/// which also have NL edges to ground but serve a different purpose.
5498fn is_ground_clip_group(
5499    group: &super::signal_flow::FlowGroup,
5500    graph: &super::graph::CircuitGraph,
5501) -> bool {
5502    use super::component::EdgeKind;
5503    let nl_edges: Vec<usize> = group
5504        .all_edges()
5505        .iter()
5506        .filter(|&&eidx| graph.effective_edge_kind(eidx) == EdgeKind::Nonlinear)
5507        .copied()
5508        .collect();
5509    if nl_edges.is_empty() {
5510        return false;
5511    }
5512    let mut pot_edge_counts = std::collections::HashMap::new();
5513    for eidx in group.all_edges() {
5514        let comp = &graph.components[graph.edges[eidx].comp_idx];
5515        if comp.kind.is_pot() {
5516            *pot_edge_counts
5517                .entry(graph.edges[eidx].comp_idx)
5518                .or_insert(0usize) += 1;
5519        }
5520    }
5521    if pot_edge_counts.values().any(|&count| count > 1) {
5522        return false;
5523    }
5524
5525    nl_edges.iter().all(|&eidx| {
5526        let e = &graph.edges[eidx];
5527        let comp = &graph.components[e.comp_idx];
5528        let is_diode = comp.kind.is_diode_family();
5529        let to_gnd = e.node_a == graph.gnd_node
5530            || e.node_b == graph.gnd_node
5531            || graph.ac_ground_nodes.contains(&e.node_a)
5532            || graph.ac_ground_nodes.contains(&e.node_b);
5533        is_diode && to_gnd
5534    })
5535}
5536
5537/// Merge purely-linear passive groups (series input resistors) into adjacent
5538/// active groups so the active group's MNA captures the source impedance.
5539///
5540/// Motivation: Sallen-Key and other active filter topologies have a series
5541/// input resistor R1 in its own FlowGroup (no feedback, no reactive elements).
5542/// Without merging, the active group's MNA treats its first node (junction J)
5543/// as an ideal voltage source, losing R1's contribution and producing a flat
5544/// frequency response instead of the correct filter shape.
5545///
5546/// Merge condition (all must hold):
5547/// 1. Source group is passive (no active edges).
5548/// 2. Source group's edges are ALL linear (no reactive, NL, or VCVS).
5549/// 3. One endpoint of every source edge connects the circuit `in_node` or the
5550///    output node of a preceding active group (i.e. an already-committed
5551///    signal source) to a node that is also an internal node of exactly one
5552///    active group.
5553/// 4. The target active group contains a VCVS (needs source impedance for
5554///    correct filter computation).
5555///
5556/// Only source groups with a single linear resistor touching the active
5557/// group's node-set qualify — broader merges are too risky.
5558pub(super) fn merge_input_coupling_into_active_groups(
5559    groups: &mut Vec<super::signal_flow::FlowGroup>,
5560    graph: &super::graph::CircuitGraph,
5561    cut_edges: &super::boundary_rules::DelayedCutSet,
5562) {
5563    // Build node sets for each active group (include all edges — active,
5564    // feedback, and pendant — so we can find the coupling junction J even
5565    // when the VCVS is a self-loop at node_out and the filter passives are
5566    // in pendant_edges rather than active_edges).
5567    let active_group_nodes: Vec<std::collections::HashSet<super::graph::NodeId>> = groups
5568        .iter()
5569        .map(|g| {
5570            let mut nodes = std::collections::HashSet::new();
5571            for &eidx in &g.all_edges() {
5572                let e = &graph.edges[eidx];
5573                nodes.insert(e.node_a);
5574                nodes.insert(e.node_b);
5575            }
5576            nodes
5577        })
5578        .collect();
5579
5580    // Identify which active groups contain a VCVS AND are purely linear
5581    // (no NL/ControlledConductance). We must not merge input resistors into
5582    // General-class groups (those with pots, JFETs, etc.) — the General
5583    // builder handles source impedance differently and merging pendant edges
5584    // into it disrupts signal flow routing.
5585    let has_vcvs: Vec<bool> = groups
5586        .iter()
5587        .map(|g| {
5588            let all = g.all_edges();
5589            let has_v = g
5590                .active_edges
5591                .iter()
5592                .any(|&eidx| graph.effective_edge_kind(eidx) == super::component::EdgeKind::Vcvs);
5593            let is_linear = all.iter().all(|&eidx| {
5594                matches!(
5595                    graph.effective_edge_kind(eidx),
5596                    super::component::EdgeKind::Linear
5597                        | super::component::EdgeKind::Reactive
5598                        | super::component::EdgeKind::Vcvs
5599                )
5600            });
5601            has_v && is_linear
5602        })
5603        .collect();
5604
5605    let mut merge_into: Vec<Option<usize>> = vec![None; groups.len()];
5606
5607    for (source_idx, source) in groups.iter().enumerate() {
5608        // Must be a passive group.
5609        if !source.active_edges.is_empty() {
5610            continue;
5611        }
5612
5613        let edges = source.all_edges();
5614        if edges.is_empty() {
5615            continue;
5616        }
5617
5618        // All edges must be strictly linear (resistors/pots only).
5619        let all_linear = edges
5620            .iter()
5621            .all(|&eidx| graph.effective_edge_kind(eidx) == super::component::EdgeKind::Linear);
5622        if !all_linear {
5623            continue;
5624        }
5625
5626        // For each edge, one endpoint must be `in_node` (or another "source"
5627        // node) and the other must be an internal node of exactly one VCVS
5628        // active group.
5629        let is_source_node = |n: super::graph::NodeId| -> bool {
5630            n == graph.in_node
5631                || n == graph.gnd_node
5632                || graph.supply_nodes.contains(&n)
5633                || graph.ac_ground_nodes.contains(&n)
5634        };
5635
5636        let mut target: Option<usize> = None;
5637        for &eidx in &edges {
5638            // Phase 2a: a broker-cut tap-mouth edge can't be a merge bridge.
5639            if cut_edges.cuts.contains_key(&eidx) {
5640                continue;
5641            }
5642            let e = &graph.edges[eidx];
5643            // One side must be a "source" node.
5644            let (src_side, other_side) = if is_source_node(e.node_a) {
5645                (e.node_a, e.node_b)
5646            } else if is_source_node(e.node_b) {
5647                (e.node_b, e.node_a)
5648            } else {
5649                continue;
5650            };
5651            let _ = src_side;
5652
5653            // The other side must be in exactly one VCVS active group's
5654            // node set (not the output node — we want an internal coupling node).
5655            let candidate = active_group_nodes
5656                .iter()
5657                .enumerate()
5658                .filter(|(gi, nodes)| has_vcvs[*gi] && nodes.contains(&other_side))
5659                .map(|(gi, _)| gi)
5660                .collect::<Vec<_>>();
5661            if candidate.len() == 1 {
5662                let t = candidate[0];
5663                match target {
5664                    None => target = Some(t),
5665                    Some(prev) if prev == t => {} // Same target
5666                    Some(_) => {
5667                        // Ambiguous — don't merge.
5668                        target = None;
5669                        break;
5670                    }
5671                }
5672            }
5673        }
5674
5675        if let Some(t) = target {
5676            // Don't create a cycle (source must not be the target).
5677            if t != source_idx {
5678                merge_into[source_idx] = Some(t);
5679            }
5680        }
5681    }
5682
5683    // Apply merges: move source edges into target's pendant_edges.
5684    for source_idx in 0..groups.len() {
5685        let Some(target_idx) = merge_into[source_idx] else {
5686            continue;
5687        };
5688        let edges = groups[source_idx].all_edges();
5689        if edges.is_empty() {
5690            continue;
5691        }
5692        #[cfg(test)]
5693        {
5694            let names: Vec<&str> = edges
5695                .iter()
5696                .map(|&eidx| graph.components[graph.edges[eidx].comp_idx].id.as_str())
5697                .collect();
5698            eprintln!(
5699                "  [compile] merged input-coupling group {source_idx} into active group {target_idx}: {names:?}"
5700            );
5701        }
5702        for eidx in edges {
5703            if !groups[target_idx].pendant_edges.contains(&eidx) {
5704                groups[target_idx].pendant_edges.push(eidx);
5705            }
5706        }
5707    }
5708
5709    // Remove merged-away groups.
5710    let mut idx = 0;
5711    groups.retain(|_| {
5712        let keep = merge_into.get(idx).and_then(|target| *target).is_none();
5713        idx += 1;
5714        keep
5715    });
5716}
5717
5718pub(super) fn merge_cross_reactive_groups_into_active_groups(
5719    groups: &mut Vec<super::signal_flow::FlowGroup>,
5720    graph: &super::graph::CircuitGraph,
5721    cut_edges: &super::boundary_rules::DelayedCutSet,
5722) {
5723    let rails = super::signal_flow::rail_nodes(graph);
5724    let is_boundary_node = |node: super::graph::NodeId| -> bool {
5725        rails.contains(&node) || node == graph.in_node || node == graph.out_node
5726    };
5727
5728    let mut active_terminal_nodes: Vec<std::collections::HashSet<super::graph::NodeId>> =
5729        Vec::with_capacity(groups.len());
5730    let mut active_reactive_nodes: Vec<std::collections::HashSet<super::graph::NodeId>> =
5731        Vec::with_capacity(groups.len());
5732    for group in groups.iter() {
5733        let mut terminal_nodes = std::collections::HashSet::new();
5734        let mut reactive_nodes = std::collections::HashSet::new();
5735        if !can_absorb_cross_reactive_passives(group, graph) {
5736            active_terminal_nodes.push(terminal_nodes);
5737            active_reactive_nodes.push(reactive_nodes);
5738            continue;
5739        }
5740        for &eidx in &group.active_edges {
5741            let edge = &graph.edges[eidx];
5742            terminal_nodes.insert(edge.node_a);
5743            terminal_nodes.insert(edge.node_b);
5744        }
5745        for eidx in group.all_edges() {
5746            if graph.effective_edge_kind(eidx) != super::component::EdgeKind::Reactive {
5747                continue;
5748            }
5749            let edge = &graph.edges[eidx];
5750            if !is_boundary_node(edge.node_a) {
5751                reactive_nodes.insert(edge.node_a);
5752            }
5753            if !is_boundary_node(edge.node_b) {
5754                reactive_nodes.insert(edge.node_b);
5755            }
5756        }
5757        active_terminal_nodes.push(terminal_nodes);
5758        active_reactive_nodes.push(reactive_nodes);
5759    }
5760
5761    let mut merge_into: Vec<Option<usize>> = vec![None; groups.len()];
5762    for (source_idx, group) in groups.iter().enumerate() {
5763        if !group.active_edges.is_empty() {
5764            continue;
5765        }
5766        let reactive_edges: Vec<usize> = group
5767            .all_edges()
5768            .into_iter()
5769            // Phase 2a: a broker-cut tap-mouth edge can't be a merge bridge.
5770            .filter(|eidx| !cut_edges.cuts.contains_key(eidx))
5771            .filter(|&eidx| graph.effective_edge_kind(eidx) == super::component::EdgeKind::Reactive)
5772            .collect();
5773        if reactive_edges.is_empty() {
5774            continue;
5775        }
5776
5777        let mut reactive_group_nodes = std::collections::HashSet::new();
5778        for &eidx in &reactive_edges {
5779            let edge = &graph.edges[eidx];
5780            if !is_boundary_node(edge.node_a) {
5781                reactive_group_nodes.insert(edge.node_a);
5782            }
5783            if !is_boundary_node(edge.node_b) {
5784                reactive_group_nodes.insert(edge.node_b);
5785            }
5786        }
5787
5788        for (target_idx, terminal_nodes) in active_terminal_nodes.iter().enumerate() {
5789            if target_idx == source_idx || groups[target_idx].active_edges.is_empty() {
5790                continue;
5791            }
5792            let bridges_active_terminals = reactive_edges.iter().any(|&eidx| {
5793                let edge = &graph.edges[eidx];
5794                terminal_nodes.contains(&edge.node_a) && terminal_nodes.contains(&edge.node_b)
5795            });
5796            let shares_reactive_internal_node = reactive_group_nodes
5797                .iter()
5798                .any(|node| active_reactive_nodes[target_idx].contains(node));
5799            if bridges_active_terminals || shares_reactive_internal_node {
5800                merge_into[source_idx] = Some(target_idx);
5801                break;
5802            }
5803        }
5804    }
5805
5806    for source_idx in 0..groups.len() {
5807        let Some(target_idx) = merge_into[source_idx] else {
5808            continue;
5809        };
5810        let edges = groups[source_idx].all_edges();
5811        if edges.is_empty() {
5812            continue;
5813        }
5814        #[cfg(test)]
5815        {
5816            let names: Vec<&str> = edges
5817                .iter()
5818                .map(|&eidx| graph.components[graph.edges[eidx].comp_idx].id.as_str())
5819                .collect();
5820            eprintln!(
5821                "  [compile] merged cross-reactive passive group {source_idx} into active group {target_idx}: {names:?}"
5822            );
5823        }
5824        for eidx in edges {
5825            if !groups[target_idx].feedback_edges.contains(&eidx) {
5826                groups[target_idx].feedback_edges.push(eidx);
5827            }
5828        }
5829    }
5830
5831    let mut idx = 0;
5832    groups.retain(|_| {
5833        let keep = merge_into.get(idx).and_then(|target| *target).is_none();
5834        idx += 1;
5835        keep
5836    });
5837}
5838
5839fn can_absorb_cross_reactive_passives(
5840    group: &super::signal_flow::FlowGroup,
5841    graph: &super::graph::CircuitGraph,
5842) -> bool {
5843    if group.active_edges.is_empty() {
5844        return false;
5845    }
5846    let rails = super::signal_flow::rail_nodes(graph);
5847    group.active_edges.iter().any(|&eidx| {
5848        let edge = &graph.edges[eidx];
5849        let comp = &graph.components[edge.comp_idx];
5850        !(comp.kind.is_diode_family()
5851            && (rails.contains(&edge.node_a) || rails.contains(&edge.node_b)))
5852    })
5853}
5854
5855/// Phase 2a: true if `group` is the de-fused DELAYED detector group — it holds a
5856/// DelayedSense control electrode (a node in `detector_seeds`) AND, after the
5857/// broker tap-mouth cut, has NO edge touching the global `in`/`out` nodes (no
5858/// non-cut path to the forward chain). Such a group must compute (state/metering)
5859/// but NOT overwrite the forward serial signal, so it is marked `bypass_serial`.
5860fn is_delayed_detector_group(
5861    group: &super::signal_flow::FlowGroup,
5862    graph: &super::graph::CircuitGraph,
5863    detector_seeds: &std::collections::HashSet<super::graph::NodeId>,
5864) -> bool {
5865    if detector_seeds.is_empty() {
5866        return false;
5867    }
5868    let mut holds_seed = false;
5869    let mut touches_io = false;
5870    for &eidx in group.all_edges().iter() {
5871        let e = &graph.edges[eidx];
5872        for n in [e.node_a, e.node_b] {
5873            if detector_seeds.contains(&n) {
5874                holds_seed = true;
5875            }
5876            if n == graph.in_node || n == graph.out_node {
5877                touches_io = true;
5878            }
5879        }
5880    }
5881    holds_seed && !touches_io
5882}
5883
5884fn is_nonlinear_modulator_group(
5885    group: &super::signal_flow::FlowGroup,
5886    graph: &super::graph::CircuitGraph,
5887) -> bool {
5888    let mut has_transistor_nl = false;
5889    let mut touches_output = false;
5890    let mut has_reactive = false;
5891    let mut has_vcvs = false;
5892    for &eidx in group.all_edges().iter() {
5893        let e = &graph.edges[eidx];
5894        touches_output |= e.node_a == graph.out_node || e.node_b == graph.out_node;
5895        match graph.effective_edge_kind(eidx) {
5896            super::component::EdgeKind::Reactive => {
5897                has_reactive = true;
5898                continue;
5899            }
5900            super::component::EdgeKind::Vcvs => {
5901                has_vcvs = true;
5902                continue;
5903            }
5904            super::component::EdgeKind::Nonlinear => {}
5905            _ => continue,
5906        }
5907        if graph.effective_edge_kind(eidx) != super::component::EdgeKind::Nonlinear {
5908            continue;
5909        }
5910        let comp = &graph.components[e.comp_idx];
5911        if comp.kind.is_bjt() || comp.kind.is_jfet() || comp.kind.is_mosfet() {
5912            has_transistor_nl = true;
5913        } else {
5914            return false;
5915        }
5916    }
5917
5918    if !(has_transistor_nl && !touches_output && !has_reactive && !has_vcvs) {
5919        return false;
5920    }
5921
5922    // pedalkernel-y9hz GHOST-DROP FIX (the pedalkernel-ffkl guard finding):
5923    // "doesn't touch `out` directly" is NOT "off the audio path" — the
5924    // fuzz-face family's BJT core reaches `out` THROUGH its output coupling
5925    // cap (a cut edge in a different group), so this classifier consumed the
5926    // whole audio core as a "modulator" and the pedal compiled as a unity
5927    // passthrough. A group that signal FLOWS THROUGH (reachable from `in`,
5928    // device outputs reach `out` over the directed signal graph) is an
5929    // audio-path core and must compile as real stages. True modulators
5930    // (envelope followers, LFO cores) fail the directed through-path test —
5931    // their device outputs dead-end at control electrodes.
5932    if super::signal_flow::group_is_on_forward_audio_path(graph, &group.all_edges()) {
5933        return false;
5934    }
5935
5936    true
5937}
5938
5939/// Get the non-GND signal node from a ground-clip group.
5940fn get_ground_clip_signal_node(
5941    group: &super::signal_flow::FlowGroup,
5942    graph: &super::graph::CircuitGraph,
5943) -> super::graph::NodeId {
5944    for &eidx in group.all_edges().iter() {
5945        let e = &graph.edges[eidx];
5946        if e.node_a != graph.gnd_node && !graph.ac_ground_nodes.contains(&e.node_a) {
5947            return e.node_a;
5948        }
5949        if e.node_b != graph.gnd_node && !graph.ac_ground_nodes.contains(&e.node_b) {
5950            return e.node_b;
5951        }
5952    }
5953    graph.gnd_node // fallback
5954}
5955
5956/// Build a ground-clip WdfStage from merged NL edges (all to GND).
5957///
5958/// If two SingleDiode edges exist, synthesizes a DiodePair for antiparallel
5959/// clipping. Otherwise uses a single diode root.
5960fn build_ground_clip_stage(
5961    nl_edge_indices: &[usize],
5962    graph: &super::graph::CircuitGraph,
5963    _sample_rate: f64,
5964) -> Option<BuiltStage> {
5965    // Classify all NL edges
5966    let mut nl_kinds = Vec::new();
5967    for &eidx in nl_edge_indices {
5968        let e = &graph.edges[eidx];
5969        let comp = &graph.components[e.comp_idx];
5970        if let Some((kind, _)) = comp.kind.classify_nonlinear(
5971            &comp.id,
5972            e.node_a,
5973            e.node_b,
5974            graph.gnd_node,
5975            &graph.node_names,
5976        ) {
5977            nl_kinds.push(kind);
5978        }
5979    }
5980    if nl_kinds.is_empty() {
5981        return None;
5982    }
5983
5984    // Synthesize pair roots from two antiparallel ground-clip edges. Otherwise
5985    // two opposite zeners collapse to one polarity and clip asymmetrically.
5986    let (root, base_diode_model) = if nl_kinds.len() >= 2 {
5987        let mut pair_dt = None;
5988        let mut zener_voltage = None;
5989        for i in 0..nl_kinds.len() {
5990            for j in (i + 1)..nl_kinds.len() {
5991                match (&nl_kinds[i], &nl_kinds[j]) {
5992                    (
5993                        super::classify::NonlinearKind::SingleDiode(dt_a),
5994                        super::classify::NonlinearKind::SingleDiode(_),
5995                    ) => {
5996                        pair_dt = Some(*dt_a);
5997                        break;
5998                    }
5999                    (
6000                        super::classify::NonlinearKind::Zener { voltage },
6001                        super::classify::NonlinearKind::Zener { .. },
6002                    ) => {
6003                        zener_voltage = Some(*voltage);
6004                        break;
6005                    }
6006                    _ => {}
6007                }
6008            }
6009            if pair_dt.is_some() || zener_voltage.is_some() {
6010                break;
6011            }
6012        }
6013        if let Some(voltage) = zener_voltage {
6014            let model = crate::elements::ZenerModel::new(voltage as crate::Wave);
6015            (
6016                super::stage::RootKind::ZenerPair(crate::elements::ZenerPairRoot::new(model)),
6017                None,
6018            )
6019        } else if let Some(dt) = pair_dt {
6020            let model = super::helpers::diode_model(dt);
6021            (
6022                super::stage::RootKind::ExplicitDiodePair(
6023                    crate::elements::ExplicitDiodePairRoot::new(model),
6024                ),
6025                Some(model),
6026            )
6027        } else {
6028            super::build::create_root(&nl_kinds[0], false)
6029        }
6030    } else {
6031        super::build::create_root(&nl_kinds[0], false)
6032    };
6033
6034    // VS with op-amp output impedance as source resistance.
6035    // In the real circuit, the diode sees the driving stage's output Z.
6036    // Typical op-amp output impedance: 50-200Ω. Using 100Ω as default.
6037    // This gives the WDF diode root the correct source impedance for
6038    // computing junction voltage and clipping behavior.
6039    let tree = DynNode::Leaf(super::wdf_leaf::LeafKind::VoltageSource(
6040        super::wdf_leaf::WdfVoltageSource {
6041            voltage: 0.0,
6042            rp: 100.0,
6043            is_cathode_bias: false,
6044            port_name: None,
6045        },
6046    ));
6047
6048    let oversampler =
6049        crate::oversampling::Oversampler::new(crate::oversampling::OversamplingFactor::X2);
6050    let mut stage = super::stage::WdfStage::new(tree, root, oversampler);
6051    stage.base_diode_model = base_diode_model;
6052
6053    Some(BuiltStage::Wdf(stage))
6054}
6055
6056/// Compute SPQR terminals for a passive sub-group.
6057///
6058/// Compute signal flow distance for each group via BFS from in_node.
6059///
6060/// Each group's distance = minimum BFS hop count from `graph.in_node` to any
6061/// node touched by the group's edges. Groups closer to the circuit input get
6062/// Compute bias-derived v_max for an op-amp in a flow group.
6063///
6064/// Looks up the VCVS component's pos/neg pin nodes in the bias voltage
6065/// map, then calls `Component::apply_bias()` to get rail limits.
6066/// Returns the symmetric v_max (min of positive and negative rail).
6067/// Returns (v_rail_pos, v_rail_neg) for asymmetric rail clipping.
6068fn compute_bias_v_max_for_group(
6069    group: &super::signal_flow::FlowGroup,
6070    graph: &super::graph::CircuitGraph,
6071    bias_node_voltages: &std::collections::BTreeMap<super::graph::NodeId, f64>,
6072    supply_voltage: f64,
6073) -> Option<(f64, f64)> {
6074    use super::component::BiasResult;
6075
6076    // Find the VCVS component in this group
6077    for &eidx in group.active_edges.iter() {
6078        let e = &graph.edges[eidx];
6079        let comp = &graph.components[e.comp_idx];
6080        if comp.kind.op_amp_type().is_none() {
6081            continue;
6082        }
6083
6084        // Build bias_voltages map from pin names to DC voltages.
6085        let mut pin_voltages: hashbrown::HashMap<String, f64> = hashbrown::HashMap::new();
6086
6087        if let Some(pins) = graph.nullor_pins.iter().find(|p| p.comp_idx == e.comp_idx) {
6088            if let Some(&v) = bias_node_voltages.get(&pins.pos_node) {
6089                pin_voltages.insert("pos".to_string(), v);
6090            }
6091            if let Some(&v) = bias_node_voltages.get(&pins.neg_node) {
6092                pin_voltages.insert("neg".to_string(), v);
6093            }
6094        }
6095
6096        let result = comp.kind.apply_bias(&pin_voltages, supply_voltage);
6097        if let BiasResult::Applied {
6098            v_rail_pos,
6099            v_rail_neg,
6100        } = result
6101        {
6102            return Some((v_rail_pos, v_rail_neg));
6103        }
6104    }
6105
6106    None
6107}
6108
6109/// Reorder `v` in place so that the new element `i` is the old element
6110/// `perm[i]`. `perm` must be a permutation of `0..v.len()`.
6111fn apply_permutation<T>(v: &mut Vec<T>, perm: &[usize]) {
6112    debug_assert_eq!(v.len(), perm.len());
6113    let mut taken: Vec<Option<T>> = v.drain(..).map(Some).collect();
6114    for &i in perm {
6115        v.push(taken[i].take().expect("permutation index reused"));
6116    }
6117}
6118
6119/// lower distance values, giving the correct processing order regardless of
6120/// the order `find_flow_groups` returned them.
6121fn compute_group_flow_distances(
6122    groups: &[super::signal_flow::FlowGroup],
6123    graph: &super::graph::CircuitGraph,
6124) -> Vec<usize> {
6125    use super::component::EdgeKind;
6126    use super::graph::NodeId;
6127    use hashbrown::HashMap;
6128    use std::collections::{HashSet, VecDeque};
6129
6130    fn bfs_distances(start: NodeId, graph: &super::graph::CircuitGraph) -> HashMap<NodeId, usize> {
6131        let mut node_dist: HashMap<NodeId, usize> = HashMap::new();
6132        let mut queue: VecDeque<NodeId> = VecDeque::new();
6133        node_dist.insert(start, 0);
6134        queue.push_back(start);
6135
6136        let mut adj: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
6137        for e in &graph.edges {
6138            adj.entry(e.node_a).or_default().push(e.node_b);
6139            adj.entry(e.node_b).or_default().push(e.node_a);
6140        }
6141
6142        while let Some(node) = queue.pop_front() {
6143            let dist = node_dist[&node];
6144            if let Some(neighbors) = adj.get(&node) {
6145                for &next in neighbors {
6146                    if !node_dist.contains_key(&next) {
6147                        node_dist.insert(next, dist + 1);
6148                        queue.push_back(next);
6149                    }
6150                }
6151            }
6152        }
6153
6154        node_dist
6155    }
6156
6157    fn passive_path_exists(
6158        start: NodeId,
6159        target: NodeId,
6160        graph: &super::graph::CircuitGraph,
6161    ) -> bool {
6162        if start == target {
6163            return true;
6164        }
6165
6166        let mut queue: VecDeque<NodeId> = VecDeque::new();
6167        let mut visited: HashSet<NodeId> = HashSet::new();
6168        visited.insert(start);
6169        queue.push_back(start);
6170
6171        while let Some(node) = queue.pop_front() {
6172            for (eidx, e) in graph.edges.iter().enumerate() {
6173                let next = if e.node_a == node {
6174                    e.node_b
6175                } else if e.node_b == node {
6176                    e.node_a
6177                } else {
6178                    continue;
6179                };
6180
6181                match graph.effective_edge_kind(eidx) {
6182                    EdgeKind::Linear | EdgeKind::Reactive => {}
6183                    EdgeKind::Vcvs
6184                    | EdgeKind::Vccs
6185                    | EdgeKind::Behavioral
6186                    | EdgeKind::Nonlinear => continue,
6187                }
6188
6189                if next == target {
6190                    return true;
6191                }
6192                if next == graph.gnd_node
6193                    || graph.supply_nodes.contains(&next)
6194                    || graph.ac_ground_nodes.contains(&next)
6195                {
6196                    continue;
6197                }
6198                if visited.insert(next) {
6199                    queue.push_back(next);
6200                }
6201            }
6202        }
6203
6204        false
6205    }
6206
6207    // Defect B (stage ordering): the base in->node distance is now a
6208    // RAIL-BLOCKED, signal-DIRECTED traversal. The old `bfs_distances` was
6209    // undirected and never blocked B+/gnd/ac-ground rails, so a plate-/
6210    // collector-load resistor tied to B+ let the walk reach a tube's downstream
6211    // plate node in a few hops THROUGH the supply rail — giving load nodes tiny
6212    // distances and scrambling serial stage order. Crossing active devices only
6213    // input->output and dead-ending rails fixes the order. The `out`-side
6214    // distance keeps the old undirected walk: it only ranks active-output->output
6215    // proximity for the feedback ordering special case, where reachability
6216    // (not signal direction) is what matters.
6217    let mut node_dist: HashMap<NodeId, usize> = HashMap::new();
6218    node_dist.extend(super::signal_flow::directed_signal_distances_from_in(graph));
6219    let out_dist = bfs_distances(graph.out_node, graph);
6220    let span = graph.edges.len() + graph.components.len() + groups.len() + 1;
6221
6222    // GAP 4c (pedalkernel-0lsv) — MAX-band ordering retry. The base metric above
6223    // (`directed_signal_distances_from_in`) does NOT cross an op-amp control pin
6224    // (`pos`), so a non-inverting IC is a directed dead-end: a downstream output
6225    // FOLLOWER (uberdrive/lgsm Q2 emitter follower, fed IC2.out -> C7 -> volume
6226    // divider -> Q2.base) gets `min_dist == usize::MAX` and lands in the flat MAX
6227    // band, so it is never serially chained after the tone stage and runs with a
6228    // serial input of 0.0. The control-crossing metric
6229    // `boundary_reachable_distances_from_in` DOES reach it. We consult it ONLY as
6230    // a per-group RETRY for groups already stuck at MAX — the GLOBAL ordering
6231    // metric (`node_dist`) that screamer/sd1/muff depend on is untouched, so their
6232    // ordering is byte-identical.
6233    let boundary_dist: HashMap<NodeId, usize> =
6234        super::signal_flow::boundary_reachable_distances_from_in(graph);
6235
6236    let min_dists: Vec<usize> = groups
6237        .iter()
6238        .map(|group| {
6239            let mut min_dist = usize::MAX;
6240            for &eidx in group.all_edges().iter() {
6241                let e = &graph.edges[eidx];
6242                if let Some(&d) = node_dist.get(&e.node_a) {
6243                    min_dist = min_dist.min(d);
6244                }
6245                if let Some(&d) = node_dist.get(&e.node_b) {
6246                    min_dist = min_dist.min(d);
6247                }
6248            }
6249            min_dist
6250        })
6251        .collect();
6252
6253    // Control-crossing distance for a group stuck at `usize::MAX` under the
6254    // directed metric. Finite only when a boundary node of the group is reachable
6255    // once op-amp control pins are crossed (uberdrive/lgsm followers). Returns
6256    // `None` for a group with no such node — keeping it in the flat MAX band.
6257    let boundary_retry_dist = |group: &super::signal_flow::FlowGroup| -> Option<usize> {
6258        let mut best = usize::MAX;
6259        for &eidx in group.all_edges().iter() {
6260            let e = &graph.edges[eidx];
6261            if let Some(&d) = boundary_dist.get(&e.node_a) {
6262                best = best.min(d);
6263            }
6264            if let Some(&d) = boundary_dist.get(&e.node_b) {
6265                best = best.min(d);
6266            }
6267        }
6268        (best != usize::MAX).then_some(best)
6269    };
6270
6271    let mut distances: Vec<usize> = groups
6272        .iter()
6273        .enumerate()
6274        .map(|(gi, group)| {
6275            let min_dist = min_dists[gi];
6276            if min_dist == usize::MAX {
6277                // GAP 4c retry: an output follower behind a non-inverting op-amp
6278                // is a directed dead-end under the base metric but reachable once
6279                // control pins are crossed. Place it at the MAX-band base plus its
6280                // control-crossing distance (same band form as the touches-output
6281                // sink below) so it orders AFTER the reachable stages that feed it
6282                // and among MAX-band peers by real distance. Falls back to the flat
6283                // MAX band for groups the control-crossing metric still can't reach.
6284                if let Some(bdist) = boundary_retry_dist(group) {
6285                    return groups.len() * span + bdist;
6286                }
6287                return groups.len() * span;
6288            }
6289            let touches_output = group.all_edges().iter().any(|&eidx| {
6290                let e = &graph.edges[eidx];
6291                e.node_a == graph.out_node || e.node_b == graph.out_node
6292            });
6293
6294            if touches_output && !group.has_feedback() {
6295                // A non-feedback group touching the circuit output is a sink
6296                // network: output pot, coupling cap, load, or final pad. Its
6297                // nearest node may be an upstream op-amp output, so min-hop
6298                // BFS can place it before the active stages that feed it.
6299                // Keep it after upstream feedback/active groups while still
6300                // preserving deterministic ordering among multiple sinks.
6301                groups.len() * span + min_dist
6302            } else if group.has_feedback() {
6303                let mut max_active_out_to_output = 0usize;
6304                for &eidx in group.active_edges.iter() {
6305                    let comp_idx = graph.edges[eidx].comp_idx;
6306                    if let Some(pins) = graph.nullor_pins.iter().find(|p| p.comp_idx == comp_idx) {
6307                        if let Some(&d) = out_dist.get(&pins.out_node) {
6308                            max_active_out_to_output = max_active_out_to_output.max(d);
6309                        }
6310                    }
6311                }
6312                min_dist * span + (span - max_active_out_to_output.min(span))
6313            } else {
6314                min_dist
6315            }
6316        })
6317        .collect();
6318
6319    for (gi, group) in groups.iter().enumerate() {
6320        if !is_ground_clip_group(group, graph) {
6321            continue;
6322        }
6323
6324        let clip_node = get_ground_clip_signal_node(group, graph);
6325        let mut driven_by_active = None;
6326        for (driver_gi, driver_group) in groups.iter().enumerate() {
6327            if !driver_group.has_feedback() {
6328                continue;
6329            }
6330            for &active_eidx in &driver_group.active_edges {
6331                let comp_idx = graph.edges[active_eidx].comp_idx;
6332                let Some(pins) = graph.nullor_pins.iter().find(|p| p.comp_idx == comp_idx) else {
6333                    continue;
6334                };
6335                if passive_path_exists(pins.out_node, clip_node, graph) {
6336                    driven_by_active = Some(
6337                        driven_by_active
6338                            .map_or(distances[driver_gi], |d: usize| d.min(distances[driver_gi])),
6339                    );
6340                }
6341            }
6342        }
6343
6344        if let Some(driver_dist) = driven_by_active {
6345            distances[gi] = distances[gi].max(driver_dist.saturating_add(1));
6346        }
6347    }
6348
6349    distances
6350}
6351
6352/// Instead of using the global [in_node, out_node], find the group's actual
6353/// boundary nodes — nodes that connect to edges outside the group or to the
6354/// global input/output. Includes GND if any edge touches ground, so SPQR
6355/// can recognize ground-shunt components in the SP structure.
6356pub(super) fn compute_group_terminals(
6357    group_edges: &[usize],
6358    graph: &CircuitGraph,
6359    global_terminals: &[NodeId],
6360) -> Vec<NodeId> {
6361    use std::collections::HashSet;
6362
6363    // Collect all nodes touched by this group's edges. BTreeSet, not HashSet:
6364    // the iteration below fixes the terminal order, and downstream input-node
6365    // selection and MNA source stamping depend on that order — it must be
6366    // deterministic across compiles and processes.
6367    let mut group_nodes: std::collections::BTreeSet<NodeId> = std::collections::BTreeSet::new();
6368    for &eidx in group_edges {
6369        let e = &graph.edges[eidx];
6370        group_nodes.insert(e.node_a);
6371        group_nodes.insert(e.node_b);
6372    }
6373    // Sort to a deterministic iteration order before building terminals.
6374    // HashSet iteration is non-deterministic (random seed per HashMap instance).
6375    // The terminal ordering directly feeds spqr_decompose(), so any variation
6376    // here produces different SPQR trees and different compiled topologies.
6377    let mut group_nodes_sorted: Vec<NodeId> = group_nodes.into_iter().collect();
6378    group_nodes_sorted.sort_unstable();
6379
6380    // Terminals = nodes in this group that are also global terminals
6381    // or that connect to edges NOT in this group (boundary nodes).
6382    let group_edge_set: HashSet<usize> = group_edges.iter().copied().collect();
6383    let mut terminals: Vec<NodeId> = Vec::new();
6384
6385    for &node in &group_nodes_sorted {
6386        // Skip rail and AC ground nodes — they're DC references, not
6387        // signal boundaries. AC ground nodes (VB+ bias junctions) are
6388        // detected by the graph builder from capacitor-bypassed dividers.
6389        if node == graph.gnd_node
6390            || node == graph.vcc_node
6391            || graph.supply_nodes.contains(&node)
6392            || graph.ac_ground_nodes.contains(&node)
6393        {
6394            continue;
6395        }
6396        // Global terminals (in_node, out_node): always add if the group
6397        // touches them. They're the circuit's signal ports. For mid-chain
6398        // groups that don't touch in_node/out_node, this is a no-op.
6399        if global_terminals.contains(&node) {
6400            if !terminals.contains(&node) {
6401                terminals.push(node);
6402            }
6403            continue;
6404        }
6405        // Does this node connect to edges outside the group?
6406        let is_boundary = graph.edges.iter().enumerate().any(|(eidx, e)| {
6407            !group_edge_set.contains(&eidx) && (e.node_a == node || e.node_b == node)
6408        });
6409        if is_boundary && !terminals.contains(&node) {
6410            terminals.push(node);
6411        }
6412    }
6413
6414    // Merge terminals that converge at the same downstream node.
6415    // If two boundary nodes both connect (via external edges) to the same
6416    // destination node, they're effectively one output port — keep only one.
6417    // Example: nodes 49 and 51 both connect to U3.neg → merge to one terminal.
6418    if terminals.len() > 2 {
6419        // For each terminal, find the set of downstream nodes it connects to
6420        let downstream: Vec<(NodeId, std::collections::HashSet<NodeId>)> = terminals
6421            .iter()
6422            .map(|&t| {
6423                let dests: std::collections::HashSet<NodeId> = graph
6424                    .edges
6425                    .iter()
6426                    .enumerate()
6427                    .filter(|(eidx, e)| {
6428                        !group_edge_set.contains(eidx) && (e.node_a == t || e.node_b == t)
6429                    })
6430                    .map(|(_, e)| if e.node_a == t { e.node_b } else { e.node_a })
6431                    .collect();
6432                (t, dests)
6433            })
6434            .collect();
6435
6436        // Find terminals that share downstream destinations and merge them
6437        let mut merged = vec![false; terminals.len()];
6438        for i in 0..downstream.len() {
6439            if merged[i] {
6440                continue;
6441            }
6442            for j in (i + 1)..downstream.len() {
6443                if merged[j] {
6444                    continue;
6445                }
6446                // If they share ANY downstream node, merge (keep i, drop j)
6447                if !downstream[i].1.is_disjoint(&downstream[j].1) {
6448                    merged[j] = true;
6449                }
6450            }
6451        }
6452
6453        terminals = terminals
6454            .into_iter()
6455            .enumerate()
6456            .filter(|(i, _)| !merged[*i])
6457            .map(|(_, t)| t)
6458            .collect();
6459    }
6460
6461    // Fallback: if no terminals found, use global
6462    if terminals.is_empty() {
6463        return global_terminals.to_vec();
6464    }
6465
6466    terminals
6467}
6468
6469// NOTE (ko5g.3): the duplicate `compute_wdf_triode_dc_qpoint` that lived here
6470// was deleted — the single-port WDF triode call-site now rides
6471// `super::bias::solve_wdf_triode_dc_qpoint` (same load-line core as the
6472// grouped MNA path, legacy `TriodeFinderFlavor::DirectOnly` finder breadth).
6473
6474struct FetDcQpoint {
6475    vgs: f64,
6476    vds: f64,
6477    drain_voltage: f64,
6478    source_voltage: f64,
6479}
6480
6481fn compute_wdf_fet_dc_qpoint(
6482    nl_kind: &NonlinearKind,
6483    edge_indices: &[usize],
6484    graph: &CircuitGraph,
6485    supply_voltage: f64,
6486) -> Option<FetDcQpoint> {
6487    let (gate_node, drain_node, source_node) = match nl_kind {
6488        NonlinearKind::Jfet { .. } | NonlinearKind::Mosfet { .. } => {
6489            let nl_edge_idx = edge_indices.iter().copied().find(|&eidx| {
6490                matches!(
6491                    graph.effective_edge_kind(eidx),
6492                    super::component::EdgeKind::Nonlinear
6493                ) && {
6494                    let comp = &graph.components[graph.edges[eidx].comp_idx];
6495                    comp.kind.is_jfet() || comp.kind.is_mosfet()
6496                }
6497            })?;
6498            let edge = &graph.edges[nl_edge_idx];
6499            let comp = &graph.components[edge.comp_idx];
6500            let gate = graph
6501                .node_names
6502                .get(&format!("{}.gate", comp.id))
6503                .copied()?;
6504            (gate, edge.node_a, edge.node_b)
6505        }
6506        _ => return None,
6507    };
6508
6509    let all_edges: Vec<usize> = (0..graph.edges.len()).collect();
6510    let gate_voltage = dc_thevenin_voltage(gate_node, &all_edges, graph, supply_voltage)?;
6511    let (drain_rail_voltage, drain_resistance) =
6512        dc_single_rail_resistance(drain_node, &all_edges, graph, supply_voltage)?;
6513    let (source_rail_voltage, source_resistance) =
6514        dc_single_rail_resistance(source_node, &all_edges, graph, supply_voltage)?;
6515
6516    let model_current = |vgs: f64, vds: f64| -> f64 {
6517        match nl_kind {
6518            NonlinearKind::Jfet {
6519                model_name,
6520                is_n_channel,
6521            } => {
6522                let model = super::helpers::jfet_model(model_name, *is_n_channel);
6523                let mut root = pedalkernel_rt::elements::nonlinear::JfetRoot::new(model);
6524                root.set_vgs(vgs as pedalkernel_rt::Wave);
6525                root.drain_current(vds as pedalkernel_rt::Wave) as f64
6526            }
6527            NonlinearKind::Mosfet {
6528                mosfet_type,
6529                is_n_channel,
6530            } => {
6531                let model = super::helpers::mosfet_model(*mosfet_type, *is_n_channel);
6532                model.ids(vgs as pedalkernel_rt::Wave, vds as pedalkernel_rt::Wave) as f64
6533            }
6534            _ => 0.0,
6535        }
6536    };
6537
6538    let point_for_ids = |ids: f64| -> (f64, f64, f64, f64, f64) {
6539        let drain_voltage = drain_rail_voltage - ids * drain_resistance;
6540        let source_voltage = source_rail_voltage + ids * source_resistance;
6541        let vgs = gate_voltage - source_voltage;
6542        let vds = drain_voltage - source_voltage;
6543        (
6544            vgs,
6545            vds,
6546            drain_voltage,
6547            source_voltage,
6548            model_current(vgs, vds),
6549        )
6550    };
6551
6552    let residual = |ids: f64| -> f64 {
6553        let (_vgs, _vds, _vd, _vs, device_ids) = point_for_ids(ids);
6554        device_ids - ids
6555    };
6556
6557    let resistance_sum = (drain_resistance + source_resistance).max(1.0);
6558    let search = (supply_voltage.abs().max(1.0) / resistance_sum * 4.0).max(1e-6);
6559    let mut best_ids = None;
6560    let mut best_abs = f64::INFINITY;
6561    let mut prev_i = -search;
6562    let mut prev_f = residual(prev_i);
6563
6564    for i in 1..=480 {
6565        let ids = -search + (2.0 * search) * (i as f64 / 480.0);
6566        let f = residual(ids);
6567        if f.is_finite() && f.abs() < best_abs {
6568            best_abs = f.abs();
6569            best_ids = Some(ids);
6570        }
6571        if prev_f.is_finite() && f.is_finite() && prev_f.signum() != f.signum() {
6572            let mut lo = prev_i;
6573            let mut hi = ids;
6574            let mut flo = prev_f;
6575            for _ in 0..80 {
6576                let mid = 0.5 * (lo + hi);
6577                let fmid = residual(mid);
6578                if !fmid.is_finite() {
6579                    break;
6580                }
6581                if fmid.abs() < 1e-10 {
6582                    let (vgs, vds, drain_voltage, source_voltage, _ids) = point_for_ids(mid);
6583                    return Some(FetDcQpoint {
6584                        vgs,
6585                        vds,
6586                        drain_voltage,
6587                        source_voltage,
6588                    });
6589                }
6590                if flo.signum() == fmid.signum() {
6591                    lo = mid;
6592                    flo = fmid;
6593                } else {
6594                    hi = mid;
6595                }
6596            }
6597            let ids = 0.5 * (lo + hi);
6598            let (vgs, vds, drain_voltage, source_voltage, _ids) = point_for_ids(ids);
6599            return Some(FetDcQpoint {
6600                vgs,
6601                vds,
6602                drain_voltage,
6603                source_voltage,
6604            });
6605        }
6606        prev_i = ids;
6607        prev_f = f;
6608    }
6609
6610    best_ids.filter(|_| best_abs.is_finite()).map(|ids| {
6611        let (vgs, vds, drain_voltage, source_voltage, _ids) = point_for_ids(ids);
6612        FetDcQpoint {
6613            vgs,
6614            vds,
6615            drain_voltage,
6616            source_voltage,
6617        }
6618    })
6619}
6620
6621fn dc_thevenin_voltage(
6622    node: NodeId,
6623    edge_indices: &[usize],
6624    graph: &CircuitGraph,
6625    supply_voltage: f64,
6626) -> Option<f64> {
6627    let mut conductance_sum = 0.0;
6628    let mut weighted_voltage_sum = 0.0;
6629    for &eidx in edge_indices {
6630        if graph.effective_edge_kind(eidx) != super::component::EdgeKind::Linear {
6631            continue;
6632        }
6633        let edge = &graph.edges[eidx];
6634        let Some(other) = other_node(edge, node) else {
6635            continue;
6636        };
6637        let Some(rail_voltage) = dc_rail_voltage(other, graph, supply_voltage) else {
6638            continue;
6639        };
6640        let Some(r) = graph.components[edge.comp_idx].kind.resistance() else {
6641            continue;
6642        };
6643        if r <= 0.0 || !r.is_finite() {
6644            continue;
6645        }
6646        let g = 1.0 / r;
6647        conductance_sum += g;
6648        weighted_voltage_sum += rail_voltage * g;
6649    }
6650    (conductance_sum > 0.0).then_some(weighted_voltage_sum / conductance_sum)
6651}
6652
6653fn dc_single_rail_resistance(
6654    node: NodeId,
6655    edge_indices: &[usize],
6656    graph: &CircuitGraph,
6657    supply_voltage: f64,
6658) -> Option<(f64, f64)> {
6659    edge_indices.iter().find_map(|&eidx| {
6660        if graph.effective_edge_kind(eidx) != super::component::EdgeKind::Linear {
6661            return None;
6662        }
6663        let edge = &graph.edges[eidx];
6664        let other = other_node(edge, node)?;
6665        let rail_voltage = dc_rail_voltage(other, graph, supply_voltage)?;
6666        let r = graph.components[edge.comp_idx].kind.resistance()?;
6667        (r > 0.0 && r.is_finite()).then_some((rail_voltage, r))
6668    })
6669}
6670
6671fn dc_rail_voltage(node: NodeId, graph: &CircuitGraph, supply_voltage: f64) -> Option<f64> {
6672    // Delegates to THE shared rail resolver (pedalkernel-0stg): named rails
6673    // now resolve to their ACTUAL declared voltage (graph.supply_voltages)
6674    // instead of blanket `supply_voltage`. Single-supply pedals are
6675    // unchanged (their one rail's voltage IS supply_voltage).
6676    super::bias::rail_dc_voltage(node, graph, supply_voltage)
6677}
6678
6679/// Resolve a BJT init state name to the initial Vce warm-start for BjtRoot.
6680///
6681/// Mirrors the table in `rigid/general.rs:resolve_bjt_init_state` but returns
6682/// only Vce (the scalar that BjtRoot uses as `prev_v`). Sign is applied for PNP.
6683fn bjt_hint_vce(state_name: &str, supply_voltage: f64, is_pnp: bool) -> f64 {
6684    let vce = match state_name {
6685        "saturated" => 0.1,
6686        "cutoff" => supply_voltage,
6687        "active" | "forward" => supply_voltage * 0.5,
6688        "reverse" => supply_voltage,
6689        _ => supply_voltage * 0.5,
6690    };
6691    if is_pnp {
6692        -vce
6693    } else {
6694        vce
6695    }
6696}