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}