pedalkernel/compiler/neighbor_roles.rs
1//! Connectivity-based neighbour-role inference and compile-time completeness
2//! validation for active devices.
3//!
4//! # What this is (and is NOT)
5//!
6//! Stage boundaries are currently set by a role-blind topology heuristic that
7//! can, for example, evict a tube's plate-load resistor and collapse a
8//! multi-stage amp. The durable fix is to let components declare — per terminal
9//! — what *neighbours* they need (see [`Component::terminal_requirements`] and
10//! [`NeighborRole`]), so a later "arbitration" phase can set boundaries by
11//! circuit meaning rather than topology shape.
12//!
13//! **This module builds only the declaration-driven inference + a completeness
14//! diagnostic.** It does NOT change boundary formation, stage ordering, or
15//! arbitration — those are a separate later phase that will *consume* these
16//! roles. The only runtime-visible behaviour added here is a compile-time
17//! completeness error, which is gated to be false-positive-free on the entire
18//! working `.pedal` corpus.
19//!
20//! # Role inference rules (per active terminal, on the compiled netlist)
21//!
22//! For an active device terminal node, each incident passive (or transformer /
23//! active) neighbour edge is classified by a directed, rail-aware walk:
24//!
25//! - **[`NeighborRole::Load`]** — the neighbour develops the device's output:
26//! a passive path from the far side reaches a supply rail (vcc / named
27//! supply), OR the neighbour edge is a transformer winding, OR the far side
28//! is another active device's terminal acting as the load. Recognised on an
29//! *output* terminal (plate/cathode, collector/emitter, drain/source).
30//! - **[`NeighborRole::Signal`]** — a series element whose far side reaches
31//! ANOTHER active device's INPUT terminal (grid / base / gate) by a passive
32//! path. This is the future stage boundary / ordering edge.
33//! - **[`NeighborRole::Ref`]** — DC operating-point / AC-grounded support: the
34//! far side reaches ground or an AC-ground (supply rail or large-cap bypass).
35//!
36//! Inference operates on the netlist and is INDEPENDENT of stage formation.
37
38use std::collections::{HashSet, VecDeque};
39
40use super::component::{Cardinality, Component, EdgeKind, NeighborRole};
41use super::graph::{CircuitGraph, NodeId};
42
43/// A completeness error: an active device terminal is missing a `Required`
44/// neighbour role.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub(super) struct CompletenessError {
47 /// Component id (e.g. "V1").
48 pub comp_id: String,
49 /// Human-readable device type tag (e.g. "triode").
50 pub type_tag: &'static str,
51 /// The missing role.
52 pub role: NeighborRole,
53 /// One-line fix hint, already device-specialised where useful.
54 pub hint: String,
55}
56
57impl CompletenessError {
58 /// Render as a nice, single-line compile error message.
59 ///
60 /// DSL `PedalDef` carries no source spans (Pin / ComponentDef / NetDef
61 /// have no line/column), so the message names the component + terminal/role
62 /// instead of a `file:line` location.
63 pub(super) fn message(&self) -> String {
64 format!(
65 "{} ({}): no {} found — {}",
66 self.comp_id,
67 self.type_tag,
68 self.role.label(),
69 self.hint
70 )
71 }
72}
73
74/// Set of NodeIds that are AC-equivalent to ground (gnd + supplies + bypasses).
75fn is_ac_ground(graph: &CircuitGraph, node: NodeId) -> bool {
76 node == graph.gnd_node || graph.ac_ground_nodes.contains(&node)
77}
78
79/// True if `node` is a supply rail (vcc or a named secondary supply).
80fn is_supply_rail(graph: &CircuitGraph, node: NodeId) -> bool {
81 node == graph.vcc_node || graph.supply_nodes.contains(&node)
82}
83
84/// All nodes that are a terminal pin of SOME active (gain / requirement-
85/// declaring) device. These are barriers for the rail-aware load walk and the
86/// active-input signal walk: a load develops directly off a terminal through
87/// passives to a rail — it must not route *through another active device's
88/// terminal* (e.g. through a neighbouring tube's plate) to find the rail.
89fn active_terminal_nodes(graph: &CircuitGraph) -> HashSet<NodeId> {
90 let mut set = HashSet::new();
91 for comp in graph.components.iter() {
92 if !comp.kind.is_gain_device() && comp.kind.terminal_requirements().is_empty() {
93 continue;
94 }
95 for pin in comp.kind.pin_config().valid_pins {
96 let key = format!("{}.{}", comp.id, pin);
97 if let Some(&n) = graph.node_names.get(&key) {
98 set.insert(n);
99 }
100 }
101 }
102 set
103}
104
105/// Passive (Linear/Reactive) rail-blocked walk: starting from `start`, can we
106/// reach a supply rail without passing through ground / AC-ground and without
107/// crossing a nonlinear / VCVS / VCCS / behavioural element OR routing through
108/// another active device's terminal node?
109///
110/// This mirrors the `passive_path_exists` traversal in `spqr_build.rs` but
111/// targets the rail *set* rather than a single node, treats ground / AC-ground
112/// as dead ends (a resistor to ground is a Ref, not a Load), and treats other
113/// active terminals as barriers (`active_terms`).
114fn passive_reaches_supply_rail(
115 graph: &CircuitGraph,
116 start: NodeId,
117 active_terms: &HashSet<NodeId>,
118) -> bool {
119 if is_supply_rail(graph, start) {
120 return true;
121 }
122 let mut queue: VecDeque<NodeId> = VecDeque::new();
123 let mut visited: HashSet<NodeId> = HashSet::new();
124 visited.insert(start);
125 queue.push_back(start);
126
127 while let Some(node) = queue.pop_front() {
128 for (eidx, e) in graph.edges.iter().enumerate() {
129 let next = if e.node_a == node {
130 e.node_b
131 } else if e.node_b == node {
132 e.node_a
133 } else {
134 continue;
135 };
136 // Only walk passive series elements.
137 match graph.effective_edge_kind(eidx) {
138 EdgeKind::Linear | EdgeKind::Reactive => {}
139 EdgeKind::Vcvs
140 | EdgeKind::Vccs
141 | EdgeKind::Behavioral
142 | EdgeKind::Nonlinear => continue,
143 }
144 if is_supply_rail(graph, next) {
145 return true;
146 }
147 // Ground / AC-ground is a dead end for a load search.
148 if next == graph.gnd_node || is_ac_ground(graph, next) {
149 continue;
150 }
151 // Do not route a load through another active device's terminal.
152 if next != start && active_terms.contains(&next) {
153 continue;
154 }
155 if visited.insert(next) {
156 queue.push_back(next);
157 }
158 }
159 }
160 false
161}
162
163/// True if `node` is the terminal of *some other* active (gain) device — i.e.
164/// the far side of a neighbour is another active stage acting as the load
165/// (direct-coupled / active-load topologies).
166fn node_is_other_active_terminal(
167 graph: &CircuitGraph,
168 node: NodeId,
169 self_comp_idx: usize,
170) -> bool {
171 for (idx, comp) in graph.components.iter().enumerate() {
172 if idx == self_comp_idx {
173 continue;
174 }
175 if !comp.kind.is_gain_device() && comp.kind.terminal_requirements().is_empty() {
176 continue;
177 }
178 for pin in comp.kind.pin_config().valid_pins {
179 let key = format!("{}.{}", comp.id, pin);
180 if let Some(&pin_node) = graph.node_names.get(&key) {
181 if pin_node == node {
182 return true;
183 }
184 }
185 }
186 }
187 false
188}
189
190/// True if `edge_idx` is a transformer winding edge.
191fn edge_is_transformer(graph: &CircuitGraph, edge_idx: usize) -> bool {
192 let comp = &graph.components[graph.edges[edge_idx].comp_idx];
193 comp.kind.is_transformer()
194}
195
196/// Does `term_node` (an output terminal of the active device at `self_comp_idx`)
197/// have a Load neighbour?
198///
199/// A Load is recognised when an incident neighbour:
200/// 1. is a transformer winding (transformer-coupled / output-transformer load), OR
201/// 2. directly connects to a supply rail, OR
202/// 3. is a passive series element whose far side reaches a supply rail
203/// (resistor/inductor/choke plate-load to B+), OR
204/// 4. its far side is another active device's terminal (active / direct-coupled load).
205fn terminal_has_load(
206 graph: &CircuitGraph,
207 self_comp_idx: usize,
208 term_node: NodeId,
209 active_terms: &HashSet<NodeId>,
210) -> bool {
211 // The output terminal sitting directly on a supply rail is itself a valid
212 // "load on an output terminal" arrangement only for the *other* output
213 // (e.g. cathode-follower: plate straight to B+). We treat the plate-on-rail
214 // as a degenerate case handled by the sibling terminal carrying the load,
215 // so we do not early-return here.
216 for (eidx, e) in graph.edges.iter().enumerate() {
217 let far = if e.node_a == term_node {
218 e.node_b
219 } else if e.node_b == term_node {
220 e.node_a
221 } else {
222 continue;
223 };
224 // Skip the device's own active/nonlinear edge.
225 if e.comp_idx == self_comp_idx {
226 continue;
227 }
228 // 1. Transformer winding directly on this terminal → Load.
229 if edge_is_transformer(graph, eidx) {
230 return true;
231 }
232 // Only passive series elements develop a resistive/reactive load.
233 let kind = graph.effective_edge_kind(eidx);
234 let is_passive_edge = matches!(kind, EdgeKind::Linear | EdgeKind::Reactive);
235
236 // 2 + 3. Passive element whose far side reaches a supply rail.
237 if is_passive_edge
238 && (is_supply_rail(graph, far)
239 || passive_reaches_supply_rail(graph, far, active_terms))
240 {
241 return true;
242 }
243 // 4. Far side is another active device's terminal → active/direct load.
244 if node_is_other_active_terminal(graph, far, self_comp_idx) {
245 return true;
246 }
247 }
248 false
249}
250
251/// True if this device is a FET (JFET/MOSFET) used as a controlled variable
252/// resistor rather than a common-source gain stage. Recognised two ways:
253/// 1. its own drain–source edge has been RESOLVED to Linear/Reactive
254/// (envelope-driven `vgs`, handled by `resolve_components_by`), OR
255/// 2. its `gate` / `vgs` node is driven by a modulation source's `.out`
256/// (LFO or envelope follower) — the netlist unions those pins to one node.
257///
258/// Such a device is a passive controlled R (e.g. Phase 90 allpass JFETs whose
259/// drain feeds an op-amp virtual ground with no drain-load to a rail), so it
260/// carries no Load requirement. Key config-tolerance carve-out surfaced by the
261/// corpus gate.
262fn is_resolved_variable_resistor(graph: &CircuitGraph, comp_idx: usize) -> bool {
263 let comp = &graph.components[comp_idx];
264 if !(comp.kind.is_jfet() || comp.kind.is_mosfet()) {
265 return false;
266 }
267 // 1. Own edge already resolved to a passive kind.
268 for (eidx, e) in graph.edges.iter().enumerate() {
269 if e.comp_idx == comp_idx
270 && matches!(
271 graph.effective_edge_kind(eidx),
272 EdgeKind::Linear | EdgeKind::Reactive
273 )
274 {
275 return true;
276 }
277 }
278 // 2. Gate / vgs driven by a modulation source (LFO / envelope follower).
279 let mut control_nodes: HashSet<NodeId> = HashSet::new();
280 for &pin in &["gate", "vgs"] {
281 let key = format!("{}.{}", comp.id, pin);
282 if let Some(&n) = graph.node_names.get(&key) {
283 control_nodes.insert(n);
284 }
285 }
286 if control_nodes.is_empty() {
287 return false;
288 }
289 for other in graph.components.iter() {
290 if !other.kind.is_modulation_source() {
291 continue;
292 }
293 let out_key = format!("{}.out", other.id);
294 if let Some(&out_node) = graph.node_names.get(&out_key) {
295 if control_nodes.contains(&out_node) {
296 return true;
297 }
298 }
299 }
300 false
301}
302
303/// True if `term_node` is directly connected (one edge) to a supply rail.
304fn terminal_on_rail(graph: &CircuitGraph, term_node: NodeId) -> bool {
305 if is_supply_rail(graph, term_node) {
306 return true;
307 }
308 for e in graph.edges.iter() {
309 let far = if e.node_a == term_node {
310 e.node_b
311 } else if e.node_b == term_node {
312 e.node_a
313 } else {
314 continue;
315 };
316 if is_supply_rail(graph, far) {
317 return true;
318 }
319 }
320 false
321}
322
323/// True if `term_node` develops a follower-style load: a passive series element
324/// (resistor / inductor / cap) whose far side is ground / AC-ground. In a
325/// cathode / emitter / source follower the output is developed across this
326/// element even though it returns to ground.
327fn terminal_has_passive_to_ground(
328 graph: &CircuitGraph,
329 self_comp_idx: usize,
330 term_node: NodeId,
331) -> bool {
332 for (eidx, e) in graph.edges.iter().enumerate() {
333 if e.comp_idx == self_comp_idx {
334 continue;
335 }
336 let far = if e.node_a == term_node {
337 e.node_b
338 } else if e.node_b == term_node {
339 e.node_a
340 } else {
341 continue;
342 };
343 let is_passive_edge = matches!(
344 graph.effective_edge_kind(eidx),
345 EdgeKind::Linear | EdgeKind::Reactive
346 );
347 if is_passive_edge && (far == graph.gnd_node || is_ac_ground(graph, far)) {
348 return true;
349 }
350 }
351 false
352}
353
354/// Config-tolerance: recognise a follower (cathode / emitter / source) where one
355/// output terminal sits on a supply rail and a sibling output terminal develops
356/// its load to ground. `load_terms` are the Required-Load terminals (output
357/// candidates) that are present in the netlist with their resolved node ids.
358fn is_follower_configuration(
359 graph: &CircuitGraph,
360 self_comp_idx: usize,
361 load_terms: &[(&'static str, NodeId)],
362) -> bool {
363 let any_on_rail = load_terms
364 .iter()
365 .any(|&(_, n)| terminal_on_rail(graph, n));
366 if !any_on_rail {
367 return false;
368 }
369 load_terms
370 .iter()
371 .any(|&(_, n)| terminal_has_passive_to_ground(graph, self_comp_idx, n))
372}
373
374/// Build a device-specialised fix hint for a missing role.
375fn load_hint(type_tag: &str) -> String {
376 match type_tag {
377 "triode" | "variable-mu triode" => {
378 "a common-cathode stage needs a plate-load resistor to a supply rail, \
379 or wire it as a cathode follower (load on the cathode)."
380 .to_string()
381 }
382 "pentode" => {
383 "a pentode stage needs a plate-load (resistor, choke, or output \
384 transformer) to a supply rail, or a cathode-follower load."
385 .to_string()
386 }
387 "NPN transistor" | "PNP transistor" => {
388 "a common-emitter stage needs a collector-load resistor to a supply \
389 rail, or wire it as an emitter follower (load on the emitter)."
390 .to_string()
391 }
392 _ => {
393 // JFET / MOSFET and any future gain device.
394 "a common-source stage needs a drain-load resistor to a supply rail, \
395 or wire it as a source follower (load on the source)."
396 .to_string()
397 }
398 }
399}
400
401/// Infer neighbour roles for every active device and report completeness errors
402/// for any `Required` role that is missing.
403///
404/// Config tolerance: a `Load` declared `Required` on more than one terminal of
405/// the same device is satisfied if ANY of those terminals carries a Load. This
406/// is what lets common-cathode (plate-load) AND cathode-follower (cathode-load)
407/// both validate without a false positive.
408pub(super) fn check_completeness(graph: &CircuitGraph) -> Vec<CompletenessError> {
409 let mut errors = Vec::new();
410 let active_terms = active_terminal_nodes(graph);
411
412 for (comp_idx, comp) in graph.components.iter().enumerate() {
413 let reqs = comp.kind.terminal_requirements();
414 if reqs.is_empty() {
415 continue;
416 }
417 // Config tolerance: a FET resolved to a variable resistor (modulated
418 // gate, e.g. phaser allpass JFETs) is a passive controlled R, not a
419 // gain stage — it has no Load requirement.
420 if is_resolved_variable_resistor(graph, comp_idx) {
421 continue;
422 }
423 let type_tag = comp.kind.type_tag();
424
425 // ── Required Load: satisfied if ANY declared output terminal has one ──
426 // Collect the terminals that declare a Required Load, and whether any of
427 // them is actually present in the netlist + carries a load.
428 let mut load_required = false;
429 let mut load_satisfied = false;
430 let mut load_terms: Vec<(&'static str, NodeId)> = Vec::new();
431 for (term, term_reqs) in &reqs {
432 let wants_required_load = term_reqs.iter().any(|r| {
433 r.role == NeighborRole::Load && r.card == Cardinality::Required
434 });
435 if !wants_required_load {
436 continue;
437 }
438 load_required = true;
439 let key = format!("{}.{}", comp.id, term);
440 if let Some(&node) = graph.node_names.get(&key) {
441 load_terms.push((term, node));
442 if terminal_has_load(graph, comp_idx, node, &active_terms) {
443 load_satisfied = true;
444 break;
445 }
446 }
447 }
448 // Config tolerance: cathode / emitter / source follower — one output
449 // terminal on a rail, the sibling output develops its load to ground.
450 if load_required && !load_satisfied && is_follower_configuration(graph, comp_idx, &load_terms) {
451 load_satisfied = true;
452 }
453 if load_required && !load_satisfied {
454 errors.push(CompletenessError {
455 comp_id: comp.id.clone(),
456 type_tag,
457 role: NeighborRole::Load,
458 hint: load_hint(type_tag),
459 });
460 }
461
462 // Ref / Signal requirements are declared Optional today (config
463 // tolerance: valid circuits vary too much to hard-require them yet), so
464 // they never produce a completeness error. They are documented in
465 // `terminal_requirements()` and consumed by the future arbitration
466 // phase. No other Required roles exist at present.
467 }
468
469 errors
470}
471
472/// Run the completeness pass and return a combined compile error string if any
473/// device is incomplete. Returns `Ok(())` when the circuit is complete.
474pub(super) fn validate_completeness(graph: &CircuitGraph) -> Result<(), String> {
475 let errors = check_completeness(graph);
476 if errors.is_empty() {
477 return Ok(());
478 }
479 let mut msg = String::from("circuit completeness error(s):");
480 for e in &errors {
481 msg.push_str("\n ");
482 msg.push_str(&e.message());
483 }
484 Err(msg)
485}
486
487// ═══════════════════════════════════════════════════════════════════════════
488// Public introspection API (for tests + the future arbitration phase)
489// ═══════════════════════════════════════════════════════════════════════════
490
491/// An inferred role for one neighbour of one active-device terminal.
492#[derive(Debug, Clone, PartialEq, Eq)]
493pub struct InferredNeighbor {
494 /// Owning active device id (e.g. "V1").
495 pub comp_id: String,
496 /// Terminal name (e.g. "plate").
497 pub terminal: &'static str,
498 /// Neighbour component id across the incident edge (e.g. "R_plate"),
499 /// or `None` for a connection straight to a reserved net / rail.
500 pub neighbor_id: Option<String>,
501 /// Inferred role of the neighbour.
502 pub role: NeighborRole,
503}
504
505/// Classify the neighbour role of a single incident edge from a terminal node.
506fn classify_neighbor(
507 graph: &CircuitGraph,
508 self_comp_idx: usize,
509 far: NodeId,
510 edge_idx: usize,
511 active_terms: &HashSet<NodeId>,
512) -> NeighborRole {
513 // Transformer winding or another active device's terminal across the edge
514 // is a Load (transformer-coupled / active / direct-coupled load).
515 if edge_is_transformer(graph, edge_idx)
516 || node_is_other_active_terminal(graph, far, self_comp_idx)
517 {
518 return NeighborRole::Load;
519 }
520 // Far side reaches a supply rail through passives → Load.
521 if is_supply_rail(graph, far) || passive_reaches_supply_rail(graph, far, active_terms) {
522 return NeighborRole::Load;
523 }
524 // Far side reaches another active device's INPUT terminal → Signal.
525 if passive_reaches_active_input(graph, far, self_comp_idx) {
526 return NeighborRole::Signal;
527 }
528 // Otherwise it is an AC-grounded / DC-bias reference.
529 NeighborRole::Ref
530}
531
532/// Passive walk: does `start` reach the INPUT terminal (grid / base / gate) of
533/// some other active device, without crossing a rail or another active edge?
534fn passive_reaches_active_input(
535 graph: &CircuitGraph,
536 start: NodeId,
537 self_comp_idx: usize,
538) -> bool {
539 // Collect input-terminal nodes of every OTHER active device.
540 let mut input_nodes: HashSet<NodeId> = HashSet::new();
541 for (idx, comp) in graph.components.iter().enumerate() {
542 if idx == self_comp_idx {
543 continue;
544 }
545 if !comp.kind.is_gain_device() && comp.kind.terminal_requirements().is_empty() {
546 continue;
547 }
548 for &pin in &["grid", "base", "gate"] {
549 let key = format!("{}.{}", comp.id, pin);
550 if let Some(&n) = graph.node_names.get(&key) {
551 input_nodes.insert(n);
552 }
553 }
554 }
555 if input_nodes.is_empty() {
556 return false;
557 }
558 if input_nodes.contains(&start) {
559 return true;
560 }
561
562 let mut queue: VecDeque<NodeId> = VecDeque::new();
563 let mut visited: HashSet<NodeId> = HashSet::new();
564 visited.insert(start);
565 queue.push_back(start);
566 while let Some(node) = queue.pop_front() {
567 for (eidx, e) in graph.edges.iter().enumerate() {
568 let next = if e.node_a == node {
569 e.node_b
570 } else if e.node_b == node {
571 e.node_a
572 } else {
573 continue;
574 };
575 match graph.effective_edge_kind(eidx) {
576 EdgeKind::Linear | EdgeKind::Reactive => {}
577 _ => continue,
578 }
579 if input_nodes.contains(&next) {
580 return true;
581 }
582 if next == graph.gnd_node
583 || is_supply_rail(graph, next)
584 || is_ac_ground(graph, next)
585 {
586 continue;
587 }
588 if visited.insert(next) {
589 queue.push_back(next);
590 }
591 }
592 }
593 false
594}
595
596/// Component id owning an edge's far terminal across the given edge.
597fn neighbor_comp_id(graph: &CircuitGraph, edge_idx: usize) -> Option<String> {
598 let comp = &graph.components[graph.edges[edge_idx].comp_idx];
599 Some(comp.id.clone())
600}
601
602/// Infer neighbour roles for every active device terminal in `pedal`.
603///
604/// This is the public, behaviour-neutral introspection entry point. It compiles
605/// the netlist into a circuit graph and classifies each active-device terminal's
606/// immediate neighbours as [`NeighborRole::Load`] / [`NeighborRole::Ref`] /
607/// [`NeighborRole::Signal`]. Used by tests today and by the future boundary
608/// arbitration phase.
609pub fn infer_neighbor_roles(pedal: &crate::dsl::PedalDef) -> Vec<InferredNeighbor> {
610 let graph = CircuitGraph::from_pedal(pedal);
611 let active_terms = active_terminal_nodes(&graph);
612 let mut out = Vec::new();
613 for (comp_idx, comp) in graph.components.iter().enumerate() {
614 let reqs = comp.kind.terminal_requirements();
615 if reqs.is_empty() {
616 continue;
617 }
618 for (term, _) in &reqs {
619 let key = format!("{}.{}", comp.id, term);
620 let Some(&term_node) = graph.node_names.get(&key) else {
621 continue;
622 };
623 for (eidx, e) in graph.edges.iter().enumerate() {
624 let far = if e.node_a == term_node {
625 e.node_b
626 } else if e.node_b == term_node {
627 e.node_a
628 } else {
629 continue;
630 };
631 if e.comp_idx == comp_idx {
632 continue; // skip the device's own edge
633 }
634 let role = classify_neighbor(&graph, comp_idx, far, eidx, &active_terms);
635 out.push(InferredNeighbor {
636 comp_id: comp.id.clone(),
637 terminal: term,
638 neighbor_id: neighbor_comp_id(&graph, eidx),
639 role,
640 });
641 }
642 }
643 }
644 out
645}
646
647/// Public completeness errors for a parsed pedal (returns the rendered
648/// messages). `Ok` with an empty vec means the circuit is complete.
649pub fn completeness_errors(pedal: &crate::dsl::PedalDef) -> Vec<String> {
650 let graph = CircuitGraph::from_pedal(pedal);
651 check_completeness(&graph)
652 .iter()
653 .map(|e| e.message())
654 .collect()
655}