Skip to main content

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}