Skip to main content

quiver/
graph.rs

1//! Layer 3: Patch Graph
2//!
3//! This module provides the runtime graph-based patching system that allows
4//! arbitrary signal routing between modules. It handles topological sorting,
5//! execution ordering, and signal propagation.
6
7use crate::modules::common::flush_denorm;
8use crate::port::{GraphModule, ParamId, PortId, PortSpec, PortValues, SignalKind};
9use crate::StdMap;
10use alloc::boxed::Box;
11use alloc::collections::VecDeque;
12use alloc::format;
13use alloc::string::{String, ToString};
14use alloc::vec::Vec;
15use serde::{Deserialize, Serialize};
16use slotmap::{DefaultKey, SlotMap};
17
18/// Signal validation strictness level
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20pub enum ValidationMode {
21    /// No validation - allow any connections
22    None,
23    /// Warn on incompatible connections but allow them.
24    ///
25    /// This is the default: it surfaces likely mistakes (e.g. patching Audio into a Gate
26    /// input) as collectable [`Patch::warnings`] without blocking experimentation.
27    #[default]
28    Warn,
29    /// Error on incompatible connections
30    Strict,
31}
32
33/// Result of signal kind compatibility check
34#[derive(Debug, Clone)]
35pub struct CompatibilityResult {
36    pub compatible: bool,
37    pub warning: Option<String>,
38}
39
40impl SignalKind {
41    /// Check if this signal kind is compatible with another for connection
42    /// Returns a compatibility result with optional warning message
43    pub fn is_compatible_with(&self, other: &SignalKind) -> CompatibilityResult {
44        use SignalKind::*;
45
46        // Same types are always compatible
47        if self == other {
48            return CompatibilityResult {
49                compatible: true,
50                warning: None,
51            };
52        }
53
54        // Define compatibility rules
55        match (self, other) {
56            // Audio can connect to any CV for AM/ring mod effects
57            (Audio, CvBipolar) | (CvBipolar, Audio) => CompatibilityResult {
58                compatible: true,
59                warning: Some("Audio/CV connection - ensure this is intentional".to_string()),
60            },
61
62            // Bipolar and unipolar CV are generally compatible with a warning
63            (CvBipolar, CvUnipolar) | (CvUnipolar, CvBipolar) => CompatibilityResult {
64                compatible: true,
65                warning: Some(
66                    "Bipolar/Unipolar CV mismatch - signal may be clipped or offset".to_string(),
67                ),
68            },
69
70            // V/Oct can receive from bipolar CV (for pitch modulation)
71            (CvBipolar, VoltPerOctave) => CompatibilityResult {
72                compatible: true,
73                warning: None,
74            },
75
76            // V/Oct to bipolar CV (extracting pitch as modulation)
77            (VoltPerOctave, CvBipolar) => CompatibilityResult {
78                compatible: true,
79                warning: None,
80            },
81
82            // Gate/Trigger/Clock are interchangeable with warnings
83            (Gate, Trigger) | (Trigger, Gate) => CompatibilityResult {
84                compatible: true,
85                warning: Some("Gate/Trigger connection - timing behavior may differ".to_string()),
86            },
87
88            (Clock, Trigger) | (Trigger, Clock) => CompatibilityResult {
89                compatible: true,
90                warning: None,
91            },
92
93            (Clock, Gate) | (Gate, Clock) => CompatibilityResult {
94                compatible: true,
95                warning: Some("Clock/Gate connection - duty cycle may affect behavior".to_string()),
96            },
97
98            // Audio to V/Oct is unusual but can be used for audio-rate FM
99            (Audio, VoltPerOctave) => CompatibilityResult {
100                compatible: true,
101                warning: Some(
102                    "Audio-rate pitch modulation - ensure this is intentional".to_string(),
103                ),
104            },
105
106            // CV Unipolar can modulate V/Oct (for portamento, etc.)
107            (CvUnipolar, VoltPerOctave) => CompatibilityResult {
108                compatible: true,
109                warning: Some("Unipolar CV to V/Oct - may need offset adjustment".to_string()),
110            },
111
112            // V/Oct to unipolar (unusual)
113            (VoltPerOctave, CvUnipolar) => CompatibilityResult {
114                compatible: true,
115                warning: Some("V/Oct to Unipolar - negative voltages will be clipped".to_string()),
116            },
117
118            // Audio can be used as gate (for envelope followers, etc.)
119            (Audio, Gate) | (Audio, Trigger) => CompatibilityResult {
120                compatible: true,
121                warning: Some("Audio to Gate/Trigger - signal will be thresholded".to_string()),
122            },
123
124            // All other combinations are allowed but with strong warning
125            _ => CompatibilityResult {
126                compatible: true,
127                warning: Some(format!("Unusual connection: {:?} -> {:?}", self, other)),
128            },
129        }
130    }
131}
132
133/// Unique identifier for a node in the patch graph
134pub type NodeId = DefaultKey;
135
136/// Stable, unique identifier for a cable connection.
137///
138/// Assigned by [`Patch::connect`] (and its variants) from a monotonically increasing
139/// counter and stored inside the [`Cable`]. Unlike a positional index into the cable list,
140/// a `CableId` remains valid after other cables are disconnected/removed, so it is safe to
141/// hold and later pass to [`Patch::disconnect`].
142pub type CableId = usize;
143
144/// Reference to a specific port on a specific node
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
146pub struct PortRef {
147    pub node: NodeId,
148    pub port: PortId,
149}
150
151/// A cable connecting two ports
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct Cable {
154    /// Stable identifier assigned at connect time (see [`CableId`]).
155    #[serde(default)]
156    pub id: CableId,
157    pub from: PortRef,
158    pub to: PortRef,
159    /// Optional attenuation/gain (-2.0 to 2.0, where 1.0 = unity)
160    /// Negative values invert the signal (attenuverter behavior)
161    pub attenuation: Option<f64>,
162    /// Optional DC offset added after attenuation (-10.0 to 10.0V)
163    pub offset: Option<f64>,
164}
165
166/// Internal node representation
167struct Node {
168    module: Box<dyn GraphModule>,
169    name: String,
170    position: Option<(f32, f32)>,
171    /// Per-node overrides for the base (unpatched) value of control-input ports, keyed by
172    /// port name. Set through [`Patch::set_param_by_id`] and applied at [`Patch::compile`]
173    /// as the [`InputPlan`] default, so an unpatched knob-style input takes this value.
174    /// Empty for a freshly added node.
175    param_overrides: StdMap<String, f64>,
176}
177
178/// Editable, human-facing metadata for a [`Patch`].
179///
180/// Held on the live patch so it survives a `to_def`/`from_def` round-trip (the graph itself
181/// carries no name/author/tags). All fields are optional; a default `PatchMeta` is empty.
182#[derive(Debug, Clone, Default)]
183pub struct PatchMeta {
184    /// Patch name (falls back to the argument passed to [`Patch::to_def`] when `None`).
185    pub name: Option<String>,
186    /// Author / credit.
187    pub author: Option<String>,
188    /// Free-form description.
189    pub description: Option<String>,
190    /// Search / filter tags.
191    pub tags: Vec<String>,
192}
193
194/// Error types for patch operations.
195///
196/// Marked `#[non_exhaustive]`: downstream `match` expressions must include a wildcard arm,
197/// so new variants can be added in future without breaking callers.
198#[derive(Debug, Clone)]
199#[non_exhaustive]
200pub enum PatchError {
201    /// A referenced node does not exist in the patch.
202    InvalidNode {
203        node: NodeId,
204    },
205    /// A referenced port does not exist on the given node.
206    ///
207    /// Carries enough context to be actionable: the offending node, the requested port by
208    /// name and/or id (whichever the caller supplied), and the list of valid port names on
209    /// that node so the message can suggest the intended target.
210    InvalidPort {
211        node: NodeId,
212        name: Option<String>,
213        port: Option<PortId>,
214        available: Vec<String>,
215    },
216    InvalidCable,
217    /// A feedback cycle with no cycle-breaker (delay) was detected.
218    ///
219    /// `nodes` are the [`NodeId`]s stuck in the cycle; `names` are their resolved module
220    /// names captured at error-construction time so [`Display`](core::fmt::Display) can
221    /// print the actual path without a back-reference to the [`Patch`].
222    CycleDetected {
223        nodes: Vec<NodeId>,
224        names: Vec<String>,
225    },
226    CompilationFailed(String),
227    /// Signal type mismatch (only in Strict validation mode)
228    SignalMismatch {
229        from_kind: SignalKind,
230        to_kind: SignalKind,
231        message: String,
232    },
233}
234
235impl core::fmt::Display for PatchError {
236    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
237        match self {
238            PatchError::InvalidNode { node } => write!(f, "Invalid node: {:?}", node),
239            PatchError::InvalidPort {
240                node,
241                name,
242                port,
243                available,
244            } => {
245                write!(f, "Invalid port")?;
246                match (name, port) {
247                    (Some(n), _) => write!(f, " '{}'", n)?,
248                    (None, Some(p)) => write!(f, " #{}", p)?,
249                    (None, None) => {}
250                }
251                write!(f, " on node {:?}", node)?;
252                if available.is_empty() {
253                    write!(f, " (module exposes no matching ports)")
254                } else {
255                    write!(f, " (available ports: {})", available.join(", "))
256                }
257            }
258            PatchError::InvalidCable => write!(f, "Invalid cable"),
259            PatchError::CycleDetected { nodes, names } => {
260                if names.is_empty() {
261                    write!(f, "Cycle detected involving {} nodes", nodes.len())
262                } else {
263                    write!(f, "Cycle detected: {}", names.join(" -> "))
264                }
265            }
266            PatchError::CompilationFailed(msg) => write!(f, "Compilation failed: {}", msg),
267            PatchError::SignalMismatch {
268                from_kind,
269                to_kind,
270                message,
271            } => write!(
272                f,
273                "Signal mismatch: {:?} -> {:?}: {}",
274                from_kind, to_kind, message
275            ),
276        }
277    }
278}
279
280#[cfg(feature = "std")]
281impl std::error::Error for PatchError {}
282
283/// Handle to a node for ergonomic port references
284#[derive(Clone)]
285pub struct NodeHandle {
286    id: NodeId,
287    spec: PortSpec,
288}
289
290impl NodeHandle {
291    pub fn id(&self) -> NodeId {
292        self.id
293    }
294
295    /// Create a NodeHandle from a NodeId and module reference
296    pub fn from_module(id: NodeId, module: &dyn GraphModule) -> Self {
297        Self {
298            id,
299            spec: module.port_spec().clone(),
300        }
301    }
302
303    /// Reference an output port by name, returning an error if it does not exist.
304    ///
305    /// Prefer this over the panicking [`out`](Self::out) when the port name comes from
306    /// untrusted or dynamic input (e.g. deserializing a patch). The error lists the valid
307    /// output ports for this module.
308    pub fn output(&self, name: &str) -> Result<PortRef, PatchError> {
309        match self.spec.output_by_name(name) {
310            Some(port) => Ok(PortRef {
311                node: self.id,
312                port: port.id,
313            }),
314            None => Err(PatchError::InvalidPort {
315                node: self.id,
316                name: Some(name.to_string()),
317                port: None,
318                available: self.output_names().iter().map(|s| s.to_string()).collect(),
319            }),
320        }
321    }
322
323    /// Reference an input port by name, returning an error if it does not exist.
324    ///
325    /// The fallible companion to [`in_`](Self::in_); see [`output`](Self::output).
326    pub fn input(&self, name: &str) -> Result<PortRef, PatchError> {
327        match self.spec.input_by_name(name) {
328            Some(port) => Ok(PortRef {
329                node: self.id,
330                port: port.id,
331            }),
332            None => Err(PatchError::InvalidPort {
333                node: self.id,
334                name: Some(name.to_string()),
335                port: None,
336                available: self.input_names().iter().map(|s| s.to_string()).collect(),
337            }),
338        }
339    }
340
341    /// Reference an output port by name (panicking convenience).
342    ///
343    /// Panics with a message listing the valid output ports if `name` is unknown. For a
344    /// non-panicking version use [`output`](Self::output).
345    pub fn out(&self, name: &str) -> PortRef {
346        self.output(name).unwrap_or_else(|_| {
347            panic!(
348                "Unknown output port: '{}'. Valid output ports: [{}]",
349                name,
350                self.output_names().join(", ")
351            )
352        })
353    }
354
355    /// Reference an input port by name (panicking convenience).
356    ///
357    /// Panics with a message listing the valid input ports if `name` is unknown. For a
358    /// non-panicking version use [`input`](Self::input).
359    pub fn in_(&self, name: &str) -> PortRef {
360        self.input(name).unwrap_or_else(|_| {
361            panic!(
362                "Unknown input port: '{}'. Valid input ports: [{}]",
363                name,
364                self.input_names().join(", ")
365            )
366        })
367    }
368
369    /// List the names of this module's input ports (in spec order).
370    pub fn input_names(&self) -> Vec<&str> {
371        self.spec.inputs.iter().map(|p| p.name.as_str()).collect()
372    }
373
374    /// List the names of this module's output ports (in spec order).
375    pub fn output_names(&self) -> Vec<&str> {
376        self.spec.outputs.iter().map(|p| p.name.as_str()).collect()
377    }
378
379    /// Get the port specification
380    pub fn spec(&self) -> &PortSpec {
381        &self.spec
382    }
383}
384
385/// One precomputed incoming edge feeding an input port.
386///
387/// Built at [`Patch::compile`] from a [`Cable`]. `src_slot` is a dense index into
388/// [`Routing::out_buf`] identifying the source output value, so gathering an input is a
389/// direct array read rather than a scan of every cable in the patch.
390struct InEdge {
391    /// Dense index of the source output value in [`Routing::out_buf`].
392    src_slot: usize,
393    /// Optional attenuation/gain applied to the source value (see [`Cable::attenuation`]).
394    attenuation: Option<f64>,
395    /// Optional DC offset applied after attenuation (see [`Cable::offset`]).
396    offset: Option<f64>,
397}
398
399/// Compiled routing plan for a single input port (in [`PortSpec`] input order).
400struct InputPlan {
401    /// The input port's id (used as the [`PortValues`] key the module reads).
402    port_id: PortId,
403    /// Value used when the input is unpatched and not normalled.
404    default: f64,
405    /// Sibling input port this normals to when unpatched (see two-pass gather).
406    normalled_to: Option<PortId>,
407    /// Whether at least one cable targets this input. Mirrors the pre-compiled engine's
408    /// `has_connection`: when true the input is the sum of its `edges` (even if that sum is
409    /// 0.0), taking precedence over the default and any normalled fallback.
410    has_connection: bool,
411    /// Cables feeding this input, summed at runtime (hardware-style input mixing).
412    edges: Vec<InEdge>,
413}
414
415/// Compiled per-node execution record (parallel to [`Patch::execution_order`]).
416struct NodeExec {
417    /// Identity of the node in the [`SlotMap`].
418    node_id: NodeId,
419    /// Base index of this node's output values in [`Routing::out_buf`]; output port `k`
420    /// (in [`PortSpec`] order) lives at `out_base + k`.
421    out_base: usize,
422    /// Output port ids in [`PortSpec`] order (slot of `out_ids[k]` is `out_base + k`).
423    out_ids: Vec<PortId>,
424    /// Input plans in [`PortSpec`] input order.
425    inputs: Vec<InputPlan>,
426}
427
428impl NodeExec {
429    /// Resolve this node's input values into `dst` using the two-pass normalled rule,
430    /// reading source outputs from the dense `out_buf`. Semantically identical to the
431    /// previous cable-scanning `gather_inputs`, but does a single pass over exactly the
432    /// cables feeding each input.
433    fn gather(&self, out_buf: &[f64], dst: &mut PortValues) {
434        dst.clear();
435        // Pass 1: patched inputs (summed) and plain (non-normalled) defaults.
436        for plan in &self.inputs {
437            if plan.has_connection {
438                let mut sum = 0.0;
439                for e in &plan.edges {
440                    let value = out_buf[e.src_slot];
441                    let attenuated = e.attenuation.map(|a| value * a).unwrap_or(value);
442                    let with_offset = e.offset.map(|o| attenuated + o).unwrap_or(attenuated);
443                    sum += with_offset;
444                }
445                dst.set(plan.port_id, sum);
446            } else if plan.normalled_to.is_none() {
447                dst.set(plan.port_id, plan.default);
448            }
449            // else: normalled + unpatched -> resolved in pass 2 below.
450        }
451        // Pass 2: normalled-but-unpatched inputs read the *current-tick* value of the
452        // terminal sibling INPUT they normal to. `resolve_normalled_chains` (at
453        // compile time) collapsed each chain so `normalled_to` points at a sibling
454        // pass 1 already resolved, making this pass order-independent; a cycle or
455        // dangling reference was collapsed to `None`, falling back to default.
456        for plan in &self.inputs {
457            if dst.has(plan.port_id) {
458                continue;
459            }
460            let value = match plan.normalled_to {
461                Some(norm) => dst.get(norm).unwrap_or(plan.default),
462                None => plan.default,
463            };
464            dst.set(plan.port_id, value);
465        }
466    }
467
468    /// Scatter the module's outputs from `src` into the dense `out_buf`, flushing denormals
469    /// so inter-module feedback loops cannot circulate subnormal values. Only ports the
470    /// module actually wrote are updated; unwritten output slots retain their prior value
471    /// (matching the previous map-based scatter).
472    fn scatter(&self, src: &PortValues, out_buf: &mut [f64]) {
473        for (k, &port_id) in self.out_ids.iter().enumerate() {
474            if let Some(value) = src.get(port_id) {
475                out_buf[self.out_base + k] = flush_denorm(value);
476            }
477        }
478    }
479}
480
481/// Collapse each normalled chain to a compile-time terminal so the runtime
482/// [`NodeExec::gather`] pass 2 is **order-independent**.
483///
484/// `gather` pass 1 resolves every patched input (its edge sum) and every
485/// unpatched **non-normalled** input (its default); pass 2 then resolves each
486/// unpatched **normalled** input by reading its `normalled_to` sibling. The
487/// single-pass, fixed-spec-order pass 2 only works when that sibling is already
488/// resolved — which fails for a forward-ordered or transitive chain (input A
489/// normals to B, B is itself unpatched-and-normalled to a resolved C, with A
490/// before B in spec order): when A is processed B is not yet set.
491///
492/// Rewriting each unpatched normalled input's `normalled_to` to point directly
493/// at its **terminal** sibling — the first one along the chain that pass 1 will
494/// resolve (a patched input, or an unpatched non-normalled input) — makes pass 2
495/// read an always-resolved value regardless of PortSpec order. A cycle or a
496/// dangling reference collapses to `None`, so the input falls back to its own
497/// default. Done once at compile time; the zero-alloc `tick` path is untouched.
498fn resolve_normalled_chains(inputs: &mut [InputPlan]) {
499    let n = inputs.len();
500    // Snapshot the original chain targets so each input resolves against a
501    // consistent view — the walk is independent of the order in which inputs are
502    // rewritten (so, e.g., a cycle collapses every member to its own default
503    // rather than to whichever member happened to be rewritten first).
504    let original: Vec<Option<PortId>> = inputs.iter().map(|p| p.normalled_to).collect();
505    for i in 0..n {
506        // Only unpatched, normalled inputs are resolved in pass 2.
507        if inputs[i].has_connection || original[i].is_none() {
508            continue;
509        }
510        let mut terminal = None;
511        let mut cursor = original[i];
512        // Bounded by input count: guarantees termination even for a cycle.
513        for _ in 0..n {
514            let Some(pid) = cursor else { break };
515            match inputs.iter().position(|p| p.port_id == pid) {
516                // Dangling sibling reference -> fall back to own default.
517                None => break,
518                Some(idx) => {
519                    if inputs[idx].has_connection || original[idx].is_none() {
520                        // Pass-1-resolvable terminal reached.
521                        terminal = Some(pid);
522                        break;
523                    }
524                    // Sibling is itself unpatched + normalled: keep walking.
525                    cursor = original[idx];
526                }
527            }
528        }
529        inputs[i].normalled_to = terminal;
530    }
531}
532
533/// Compiled, allocation-free routing state produced by [`Patch::compile`].
534///
535/// All buffers are preallocated at compile time so [`Patch::tick`] performs no heap
536/// allocation. `out_buf` persists across ticks, which is what gives cycle-breaker
537/// (delay-style) modules their one-sample feedback: a downstream node not yet executed this
538/// tick still holds last tick's value.
539#[derive(Default)]
540struct Routing {
541    /// Per-node execution records, in topological (execution) order.
542    nodes: Vec<NodeExec>,
543    /// Dense storage for every node's output values (one slot per output port).
544    out_buf: Vec<f64>,
545    /// Maps an output [`PortRef`] to its dense slot in `out_buf` (for `get_output_value`
546    /// and compile-time edge resolution). Not touched on the hot path.
547    out_slot_index: StdMap<PortRef, usize>,
548    /// Precomputed `(left, right)` output slots for `read_output` (right = left when mono).
549    output_slots: Option<(usize, usize)>,
550    /// Reusable per-node input buffers (parallel to `nodes`), cleared and refilled per tick.
551    scratch_in: Vec<PortValues>,
552    /// Reusable per-node output buffers (parallel to `nodes`), cleared and written per tick.
553    scratch_out: Vec<PortValues>,
554}
555
556impl Routing {
557    /// Read the stereo output from the precomputed output slots (silence if uncompiled or
558    /// the patch has no output node).
559    fn read_output(&self) -> (f64, f64) {
560        match self.output_slots {
561            Some((left, right)) => (self.out_buf[left], self.out_buf[right]),
562            None => (0.0, 0.0),
563        }
564    }
565}
566
567/// The main patch graph containing modules and connections
568pub struct Patch {
569    nodes: SlotMap<NodeId, Node>,
570    cables: Vec<Cable>,
571
572    // Monotonic source of stable CableIds (never reused)
573    next_cable_id: CableId,
574
575    // Execution state
576    execution_order: Vec<NodeId>,
577    // Compiled, preallocated routing (dense buffers + adjacency). Rebuilt by compile().
578    routing: Routing,
579
580    // True when the graph has been mutated since the last successful compile().
581    // tick() checks this and recompiles lazily.
582    dirty: bool,
583    // Error from the most recent failed compile (auto or explicit), if any.
584    last_compile_error: Option<PatchError>,
585
586    // Configuration
587    sample_rate: f64,
588
589    // Output node
590    output_node: Option<NodeId>,
591
592    // Validation
593    validation_mode: ValidationMode,
594    warnings: Vec<String>,
595
596    // Human-facing metadata, preserved across to_def/from_def.
597    meta: PatchMeta,
598}
599
600impl Patch {
601    /// Create a new empty patch
602    pub fn new(sample_rate: f64) -> Self {
603        Self {
604            nodes: SlotMap::new(),
605            cables: Vec::new(),
606            next_cable_id: 0,
607            execution_order: Vec::new(),
608            routing: Routing::default(),
609            // A fresh patch is "dirty" so the first tick() compiles automatically even if
610            // the caller forgets to call compile().
611            dirty: true,
612            last_compile_error: None,
613            sample_rate,
614            output_node: None,
615            // Default is Warn (see ValidationMode): mismatched connections are flagged as
616            // warnings without blocking, matching the documented behavior.
617            validation_mode: ValidationMode::Warn,
618            warnings: Vec::new(),
619            meta: PatchMeta::default(),
620        }
621    }
622
623    /// Read the patch's editable metadata (name, author, description, tags).
624    pub fn meta(&self) -> &PatchMeta {
625        &self.meta
626    }
627
628    /// Mutable access to the patch metadata (see [`PatchMeta`]).
629    pub fn meta_mut(&mut self) -> &mut PatchMeta {
630        &mut self.meta
631    }
632
633    /// Replace the patch metadata wholesale.
634    pub fn set_meta(&mut self, meta: PatchMeta) {
635        self.meta = meta;
636    }
637
638    /// Set the signal validation mode
639    pub fn set_validation_mode(&mut self, mode: ValidationMode) {
640        self.validation_mode = mode;
641    }
642
643    /// Get the current validation mode
644    pub fn validation_mode(&self) -> ValidationMode {
645        self.validation_mode
646    }
647
648    /// Get all warnings generated during patching
649    pub fn warnings(&self) -> &[String] {
650        &self.warnings
651    }
652
653    /// Clear all warnings
654    pub fn clear_warnings(&mut self) {
655        self.warnings.clear();
656    }
657
658    /// Get the sample rate
659    pub fn sample_rate(&self) -> f64 {
660        self.sample_rate
661    }
662
663    /// Add a module to the patch
664    pub fn add<M: GraphModule + 'static>(
665        &mut self,
666        name: impl Into<String>,
667        mut module: M,
668    ) -> NodeHandle {
669        module.set_sample_rate(self.sample_rate);
670        let spec = module.port_spec().clone();
671        let id = self.nodes.insert(Node {
672            module: Box::new(module),
673            name: name.into(),
674            position: None,
675            param_overrides: StdMap::new(),
676        });
677        self.invalidate();
678        NodeHandle { id, spec }
679    }
680
681    /// Add a boxed module to the patch
682    pub fn add_boxed(
683        &mut self,
684        name: impl Into<String>,
685        mut module: Box<dyn GraphModule>,
686    ) -> NodeHandle {
687        module.set_sample_rate(self.sample_rate);
688        let spec = module.port_spec().clone();
689        let id = self.nodes.insert(Node {
690            module,
691            name: name.into(),
692            position: None,
693            param_overrides: StdMap::new(),
694        });
695        self.invalidate();
696        NodeHandle { id, spec }
697    }
698
699    /// Remove a module from the patch
700    pub fn remove(&mut self, node: NodeId) -> Result<(), PatchError> {
701        if self.nodes.remove(node).is_none() {
702            return Err(PatchError::InvalidNode { node });
703        }
704
705        // Remove all cables connected to this node
706        self.cables
707            .retain(|cable| cable.from.node != node && cable.to.node != node);
708
709        if self.output_node == Some(node) {
710            self.output_node = None;
711        }
712
713        self.invalidate();
714        Ok(())
715    }
716
717    /// Allocate the next stable cable id.
718    fn alloc_cable_id(&mut self) -> CableId {
719        let id = self.next_cable_id;
720        self.next_cable_id += 1;
721        id
722    }
723
724    /// Connect an output port to an input port.
725    ///
726    /// Returns a stable [`CableId`] that remains valid for [`disconnect`](Self::disconnect)
727    /// even after other cables are removed.
728    pub fn connect(&mut self, from: PortRef, to: PortRef) -> Result<CableId, PatchError> {
729        self.validate_output_port(from)?;
730        self.validate_input_port(to)?;
731        self.validate_signal_compatibility(from, to)?;
732
733        let id = self.alloc_cable_id();
734        self.cables.push(Cable {
735            id,
736            from,
737            to,
738            attenuation: None,
739            offset: None,
740        });
741        self.invalidate();
742        Ok(id)
743    }
744
745    /// Connect with attenuation (0.0-1.0 range for backwards compatibility)
746    pub fn connect_attenuated(
747        &mut self,
748        from: PortRef,
749        to: PortRef,
750        attenuation: f64,
751    ) -> Result<CableId, PatchError> {
752        self.validate_output_port(from)?;
753        self.validate_input_port(to)?;
754        self.validate_signal_compatibility(from, to)?;
755
756        let id = self.alloc_cable_id();
757        self.cables.push(Cable {
758            id,
759            from,
760            to,
761            attenuation: Some(attenuation.clamp(0.0, 1.0)),
762            offset: None,
763        });
764        self.invalidate();
765        Ok(id)
766    }
767
768    /// Connect with full modulation controls (attenuverter and offset)
769    /// attenuation: -2.0 to 2.0 (negative inverts, >1.0 amplifies)
770    /// offset: -10.0 to 10.0V DC offset added after attenuation
771    pub fn connect_modulated(
772        &mut self,
773        from: PortRef,
774        to: PortRef,
775        attenuation: f64,
776        offset: f64,
777    ) -> Result<CableId, PatchError> {
778        self.validate_output_port(from)?;
779        self.validate_input_port(to)?;
780        self.validate_signal_compatibility(from, to)?;
781
782        let id = self.alloc_cable_id();
783        self.cables.push(Cable {
784            id,
785            from,
786            to,
787            attenuation: Some(attenuation.clamp(-2.0, 2.0)),
788            offset: Some(offset.clamp(-10.0, 10.0)),
789        });
790        self.invalidate();
791        Ok(id)
792    }
793
794    /// Validate signal kind compatibility between ports
795    fn validate_signal_compatibility(
796        &mut self,
797        from: PortRef,
798        to: PortRef,
799    ) -> Result<(), PatchError> {
800        if self.validation_mode == ValidationMode::None {
801            return Ok(());
802        }
803
804        // Get the signal kinds for both ports
805        let from_kind = self.get_output_port_kind(from);
806        let to_kind = self.get_input_port_kind(to);
807
808        if let (Some(from_kind), Some(to_kind)) = (from_kind, to_kind) {
809            let result = from_kind.is_compatible_with(&to_kind);
810
811            if let Some(warning) = result.warning {
812                let from_name = self.get_name(from.node).unwrap_or("unknown");
813                let to_name = self.get_name(to.node).unwrap_or("unknown");
814                let full_warning = format!(
815                    "{}.{} -> {}.{}: {}",
816                    from_name, from.port, to_name, to.port, warning
817                );
818
819                match self.validation_mode {
820                    ValidationMode::Warn => {
821                        self.warnings.push(full_warning);
822                    }
823                    ValidationMode::Strict => {
824                        return Err(PatchError::SignalMismatch {
825                            from_kind,
826                            to_kind,
827                            message: warning,
828                        });
829                    }
830                    ValidationMode::None => {}
831                }
832            }
833        }
834
835        Ok(())
836    }
837
838    /// Get the signal kind for an output port
839    fn get_output_port_kind(&self, port_ref: PortRef) -> Option<SignalKind> {
840        let node = self.nodes.get(port_ref.node)?;
841        node.module
842            .port_spec()
843            .outputs
844            .iter()
845            .find(|p| p.id == port_ref.port)
846            .map(|p| p.kind)
847    }
848
849    /// Get the signal kind for an input port
850    fn get_input_port_kind(&self, port_ref: PortRef) -> Option<SignalKind> {
851        let node = self.nodes.get(port_ref.node)?;
852        node.module
853            .port_spec()
854            .inputs
855            .iter()
856            .find(|p| p.id == port_ref.port)
857            .map(|p| p.kind)
858    }
859
860    /// Connect one output to multiple inputs (mult)
861    pub fn mult(&mut self, from: PortRef, to: &[PortRef]) -> Result<Vec<CableId>, PatchError> {
862        to.iter().map(|&dest| self.connect(from, dest)).collect()
863    }
864
865    /// Disconnect a cable by its stable [`CableId`].
866    ///
867    /// Scans for the cable whose id matches (patch cable counts are small), so previously
868    /// returned ids stay valid regardless of how many other cables have been removed.
869    pub fn disconnect(&mut self, cable_id: CableId) -> Result<(), PatchError> {
870        let idx = self
871            .cables
872            .iter()
873            .position(|c| c.id == cable_id)
874            .ok_or(PatchError::InvalidCable)?;
875        self.cables.remove(idx);
876        self.invalidate();
877        Ok(())
878    }
879
880    /// Set the output node for the patch (infallible convenience).
881    ///
882    /// Marks the patch dirty so the next [`tick`](Self::tick) reflects the new routing. If
883    /// `node` is invalid or exposes no output ports, [`tick`](Self::tick) simply reads
884    /// silence; use [`try_set_output`](Self::try_set_output) for a validated, fallible
885    /// alternative.
886    pub fn set_output(&mut self, node: NodeId) {
887        self.output_node = Some(node);
888        self.dirty = true;
889    }
890
891    /// The node currently designated as the patch's stereo output, if any.
892    pub fn output_node(&self) -> Option<NodeId> {
893        self.output_node
894    }
895
896    /// Set the output node, validating that it exists and exposes at least one output port.
897    ///
898    /// The checked companion to [`set_output`](Self::set_output), for callers (e.g. GUIs
899    /// or loaders) that prefer a `Result` over silent misrouting.
900    pub fn try_set_output(&mut self, node: NodeId) -> Result<(), PatchError> {
901        let n = self
902            .nodes
903            .get(node)
904            .ok_or(PatchError::InvalidNode { node })?;
905        if n.module.port_spec().outputs.is_empty() {
906            return Err(PatchError::InvalidPort {
907                node,
908                name: None,
909                port: None,
910                available: Vec::new(),
911            });
912        }
913        self.output_node = Some(node);
914        self.dirty = true;
915        Ok(())
916    }
917
918    /// Set a parameter on a module
919    pub fn set_param(&mut self, node: NodeId, param: ParamId, value: f64) {
920        if let Some(n) = self.nodes.get_mut(node) {
921            n.module.set_param(param, value);
922        }
923    }
924
925    /// Get a parameter value from a module
926    pub fn get_param(&self, node: NodeId, param: ParamId) -> Option<f64> {
927        self.nodes.get(node).and_then(|n| n.module.get_param(param))
928    }
929
930    /// Set module position (for UI)
931    pub fn set_position(&mut self, node: NodeId, position: (f32, f32)) {
932        if let Some(n) = self.nodes.get_mut(node) {
933            n.position = Some(position);
934        }
935    }
936
937    /// Get module position (for UI/serialization)
938    pub fn get_position(&self, node: NodeId) -> Option<(f32, f32)> {
939        self.nodes.get(node).and_then(|n| n.position)
940    }
941
942    /// Get module name
943    pub fn get_name(&self, node: NodeId) -> Option<&str> {
944        self.nodes.get(node).map(|n| n.name.as_str())
945    }
946
947    /// Get number of nodes
948    pub fn node_count(&self) -> usize {
949        self.nodes.len()
950    }
951
952    /// Get number of cables
953    pub fn cable_count(&self) -> usize {
954        self.cables.len()
955    }
956
957    /// Get all cables
958    pub fn cables(&self) -> &[Cable] {
959        &self.cables
960    }
961
962    /// Get execution order (after compile)
963    pub fn execution_order(&self) -> &[NodeId] {
964        &self.execution_order
965    }
966
967    /// Mark the compiled schedule stale and drop stale output buffers.
968    ///
969    /// Called after every structural mutation. Clearing `buffers` here guarantees that a
970    /// read after a mutation (before the next recompile) cannot leak the previous graph's
971    /// last-tick values; `tick()` recompiles lazily via the `dirty` flag.
972    fn invalidate(&mut self) {
973        self.execution_order.clear();
974        self.routing = Routing::default();
975        self.dirty = true;
976    }
977
978    fn validate_output_port(&self, port_ref: PortRef) -> Result<(), PatchError> {
979        let node = self
980            .nodes
981            .get(port_ref.node)
982            .ok_or(PatchError::InvalidNode {
983                node: port_ref.node,
984            })?;
985        let spec = node.module.port_spec();
986        if spec.outputs.iter().any(|p| p.id == port_ref.port) {
987            Ok(())
988        } else {
989            Err(PatchError::InvalidPort {
990                node: port_ref.node,
991                name: None,
992                port: Some(port_ref.port),
993                available: spec.outputs.iter().map(|p| p.name.clone()).collect(),
994            })
995        }
996    }
997
998    fn validate_input_port(&self, port_ref: PortRef) -> Result<(), PatchError> {
999        let node = self
1000            .nodes
1001            .get(port_ref.node)
1002            .ok_or(PatchError::InvalidNode {
1003                node: port_ref.node,
1004            })?;
1005        let spec = node.module.port_spec();
1006        if spec.inputs.iter().any(|p| p.id == port_ref.port) {
1007            Ok(())
1008        } else {
1009            Err(PatchError::InvalidPort {
1010                node: port_ref.node,
1011                name: None,
1012                port: Some(port_ref.port),
1013                available: spec.inputs.iter().map(|p| p.name.clone()).collect(),
1014            })
1015        }
1016    }
1017
1018    /// Compile the patch into an executable order.
1019    ///
1020    /// On success clears the dirty flag and any previous compile error. On failure
1021    /// (e.g. an unbroken feedback cycle) the stale schedule and buffers are dropped so a
1022    /// subsequent [`tick`](Self::tick) outputs silence, the error is stored (retrievable
1023    /// via [`last_compile_error`](Self::last_compile_error)), and the same error is
1024    /// returned.
1025    pub fn compile(&mut self) -> Result<(), PatchError> {
1026        let order = match self.topological_sort() {
1027            Ok(order) => order,
1028            Err(e) => {
1029                self.execution_order.clear();
1030                self.routing = Routing::default();
1031                // Do not stay dirty: avoid re-running a known-failing sort every tick.
1032                // A later structural mutation re-sets dirty via invalidate().
1033                self.dirty = false;
1034                self.last_compile_error = Some(e.clone());
1035                return Err(e);
1036            }
1037        };
1038        self.execution_order = order;
1039
1040        // Build the dense, preallocated routing plan (adjacency + buffers).
1041        self.build_routing();
1042
1043        self.dirty = false;
1044        self.last_compile_error = None;
1045        Ok(())
1046    }
1047
1048    /// Build the compiled [`Routing`] from the current `execution_order`, cables and output
1049    /// node. Runs only at compile time; all per-tick buffers are preallocated here so that
1050    /// [`tick`](Self::tick) never allocates.
1051    fn build_routing(&mut self) {
1052        let mut routing = Routing::default();
1053
1054        // Pass A: assign a dense output slot to every node's output ports (in PortSpec
1055        // order) and record each node's output base/ids. Slot order is stable and used by
1056        // both edge resolution and get_output_value.
1057        let mut slot: usize = 0;
1058        for &node_id in &self.execution_order {
1059            let node = self
1060                .nodes
1061                .get(node_id)
1062                .expect("execution_order only holds live nodes");
1063            let spec = node.module.port_spec();
1064            let out_base = slot;
1065            let mut out_ids = Vec::with_capacity(spec.outputs.len());
1066            for output in &spec.outputs {
1067                routing.out_slot_index.insert(
1068                    PortRef {
1069                        node: node_id,
1070                        port: output.id,
1071                    },
1072                    slot,
1073                );
1074                out_ids.push(output.id);
1075                slot += 1;
1076            }
1077            routing.nodes.push(NodeExec {
1078                node_id,
1079                out_base,
1080                out_ids,
1081                inputs: Vec::new(),
1082            });
1083        }
1084        routing.out_buf.resize(slot, 0.0);
1085
1086        // Pass B: resolve each input's incoming cables into dense edges and preallocate the
1087        // reusable scratch buffers (keys inserted here so the hot path never grows them).
1088        for (exec_idx, &node_id) in self.execution_order.iter().enumerate() {
1089            let node = self
1090                .nodes
1091                .get(node_id)
1092                .expect("execution_order only holds live nodes");
1093            let spec = node.module.port_spec();
1094
1095            let mut scratch_in = PortValues::new();
1096            let mut scratch_out = PortValues::new();
1097            let mut inputs = Vec::with_capacity(spec.inputs.len());
1098
1099            for input in &spec.inputs {
1100                let port_ref = PortRef {
1101                    node: node_id,
1102                    port: input.id,
1103                };
1104                let mut edges = Vec::new();
1105                let mut has_connection = false;
1106                for cable in &self.cables {
1107                    if cable.to == port_ref {
1108                        has_connection = true;
1109                        // A validated cable's source output always has a slot; guard anyway.
1110                        if let Some(&src_slot) = routing.out_slot_index.get(&cable.from) {
1111                            edges.push(InEdge {
1112                                src_slot,
1113                                attenuation: cable.attenuation,
1114                                offset: cable.offset,
1115                            });
1116                        }
1117                    }
1118                }
1119                // A per-node parameter override (set via `set_param_by_id`) replaces the
1120                // spec's static default for this unpatched control input. Baked in here at
1121                // compile time so the zero-alloc tick path is untouched.
1122                let default = node
1123                    .param_overrides
1124                    .get(&input.name)
1125                    .copied()
1126                    .unwrap_or(input.default);
1127                inputs.push(InputPlan {
1128                    port_id: input.id,
1129                    default,
1130                    normalled_to: input.normalled_to,
1131                    has_connection,
1132                    edges,
1133                });
1134                scratch_in.set(input.id, 0.0);
1135            }
1136            // Collapse each normalled chain to its pass-1-resolvable terminal so the
1137            // runtime two-pass `gather` is order-independent (see the fn's docs).
1138            resolve_normalled_chains(&mut inputs);
1139            for output in &spec.outputs {
1140                scratch_out.set(output.id, 0.0);
1141            }
1142
1143            routing.nodes[exec_idx].inputs = inputs;
1144            routing.scratch_in.push(scratch_in);
1145            routing.scratch_out.push(scratch_out);
1146        }
1147
1148        // Pass C: precompute the stereo output read slots (right = left when mono).
1149        routing.output_slots = self.output_node.and_then(|out_node| {
1150            let node = self.nodes.get(out_node)?;
1151            let outputs = &node.module.port_spec().outputs;
1152            let left_id = outputs.first()?.id;
1153            let left = *routing.out_slot_index.get(&PortRef {
1154                node: out_node,
1155                port: left_id,
1156            })?;
1157            let right = outputs
1158                .get(1)
1159                .and_then(|p| {
1160                    routing
1161                        .out_slot_index
1162                        .get(&PortRef {
1163                            node: out_node,
1164                            port: p.id,
1165                        })
1166                        .copied()
1167                })
1168                .unwrap_or(left);
1169            Some((left, right))
1170        });
1171
1172        self.routing = routing;
1173    }
1174
1175    /// Whether the module at `node` is a feedback cycle-breaker (delay-style).
1176    fn node_breaks_feedback(&self, node: NodeId) -> bool {
1177        self.nodes
1178            .get(node)
1179            .map(|n| n.module.breaks_feedback_cycle())
1180            .unwrap_or(false)
1181    }
1182
1183    fn topological_sort(&self) -> Result<Vec<NodeId>, PatchError> {
1184        let mut in_degree: StdMap<NodeId, usize> = self.nodes.keys().map(|k| (k, 0)).collect();
1185        let mut successors: StdMap<NodeId, Vec<NodeId>> =
1186            self.nodes.keys().map(|k| (k, Vec::new())).collect();
1187
1188        for cable in &self.cables {
1189            // Feedback support: exclude edges feeding INTO a cycle-breaker (delay) node.
1190            // Such a node is scheduled without waiting for its upstream producers and, at
1191            // runtime, reads their previous-tick output buffers — a one-sample feedback
1192            // delay. This lets loops routed through a UnitDelay/DelayLine compile while
1193            // genuine breakerless cycles are still rejected below.
1194            if self.node_breaks_feedback(cable.to.node) {
1195                continue;
1196            }
1197            if let Some(deg) = in_degree.get_mut(&cable.to.node) {
1198                *deg += 1;
1199            }
1200            if let Some(succ) = successors.get_mut(&cable.from.node) {
1201                succ.push(cable.to.node);
1202            }
1203        }
1204
1205        // Kahn's algorithm, seeded in deterministic slotmap (insertion) order so the
1206        // resulting execution_order is reproducible across runs/builds (no HashMap
1207        // iteration order dependence).
1208        let mut queue: VecDeque<NodeId> = VecDeque::new();
1209        for id in self.nodes.keys() {
1210            if in_degree.get(&id).copied().unwrap_or(0) == 0 {
1211                queue.push_back(id);
1212            }
1213        }
1214
1215        let mut result = Vec::with_capacity(self.nodes.len());
1216
1217        while let Some(node) = queue.pop_front() {
1218            result.push(node);
1219            // Successor lists are built in cable order (a Vec), keeping this deterministic.
1220            if let Some(succ) = successors.get(&node) {
1221                for &s in succ {
1222                    if let Some(deg) = in_degree.get_mut(&s) {
1223                        *deg -= 1;
1224                        if *deg == 0 {
1225                            queue.push_back(s);
1226                        }
1227                    }
1228                }
1229            }
1230        }
1231
1232        if result.len() != self.nodes.len() {
1233            // Collect stuck nodes deterministically and capture their names for Display.
1234            let nodes: Vec<NodeId> = self
1235                .nodes
1236                .keys()
1237                .filter(|k| in_degree.get(k).copied().unwrap_or(0) > 0)
1238                .collect();
1239            let names = nodes
1240                .iter()
1241                .map(|&id| self.get_name(id).unwrap_or("<unknown>").to_string())
1242                .collect();
1243            return Err(PatchError::CycleDetected { nodes, names });
1244        }
1245
1246        Ok(result)
1247    }
1248
1249    /// The error from the most recent failed compile (auto or explicit), if any.
1250    ///
1251    /// Cleared by the next successful [`compile`](Self::compile) or [`tick`](Self::tick).
1252    /// After [`tick`](Self::tick) unexpectedly returns silence, check this to learn why the
1253    /// graph did not compile (e.g. [`PatchError::CycleDetected`]).
1254    pub fn last_compile_error(&self) -> Option<&PatchError> {
1255        self.last_compile_error.as_ref()
1256    }
1257
1258    /// Process a single sample, returning stereo output.
1259    ///
1260    /// # Lazy (re)compilation
1261    ///
1262    /// `tick` is self-healing. Every structural mutation
1263    /// (`add`/`connect`/`disconnect`/`remove`/`set_output`) marks the patch dirty; `tick`
1264    /// detects this and recompiles automatically before processing, so the output always
1265    /// reflects the *current* graph — you never have to remember to call
1266    /// [`compile`](Self::compile) again after an edit, and a `tick` before the first
1267    /// `compile` works too.
1268    ///
1269    /// If the automatic recompile fails (for example a mutation introduced a feedback
1270    /// cycle with no delay to break it), `tick` outputs silence `(0.0, 0.0)` and the error
1271    /// is retained in [`last_compile_error`](Self::last_compile_error). A patch with no
1272    /// output node, or an empty graph, likewise ticks to silence.
1273    pub fn tick(&mut self) -> (f64, f64) {
1274        // Lazily (re)compile if the graph was mutated since the last compile. On failure
1275        // compile() records last_compile_error and leaves an empty schedule, so the step
1276        // below is a no-op and we fall through to silence.
1277        if self.dirty {
1278            let _ = self.compile();
1279        }
1280        self.tick_step()
1281    }
1282
1283    /// Process a block of samples into stereo `out_left`/`out_right` slices with **no
1284    /// per-frame heap allocation**.
1285    ///
1286    /// This is the allocation-free block entry point (in contrast to the default
1287    /// [`GraphModule::process_block`], which builds a fresh [`PortValues`] per frame). It
1288    /// (re)compiles once if needed, then drives the same per-sample engine over
1289    /// `n = out_left.len().min(out_right.len())` frames, reusing the preallocated routing
1290    /// buffers across every frame. Full SIMD-vectorized block execution is not performed;
1291    /// the guarantee here is zero allocation, not vectorization.
1292    pub fn tick_block(&mut self, out_left: &mut [f64], out_right: &mut [f64]) {
1293        if self.dirty {
1294            let _ = self.compile();
1295        }
1296        let frames = out_left.len().min(out_right.len());
1297        for frame in 0..frames {
1298            let (left, right) = self.tick_step();
1299            out_left[frame] = left;
1300            out_right[frame] = right;
1301        }
1302    }
1303
1304    /// Execute one sample of the already-compiled schedule (no dirty/recompile check).
1305    ///
1306    /// Allocation-free: iterates the execution order by index (no `execution_order.clone()`),
1307    /// gathering into and scattering from reusable per-node scratch buffers via precompiled
1308    /// adjacency and a dense output buffer. `nodes` and `routing` are disjoint fields, so the
1309    /// module borrow and the routing-buffer borrows coexist without conflict.
1310    fn tick_step(&mut self) -> (f64, f64) {
1311        let routing = &mut self.routing;
1312        let nodes = &mut self.nodes;
1313
1314        for i in 0..routing.nodes.len() {
1315            // Gather this node's inputs from the dense output buffer via precompiled edges.
1316            routing.nodes[i].gather(&routing.out_buf, &mut routing.scratch_in[i]);
1317
1318            // Run the module. scratch_out is cleared first so unwritten outputs are absent,
1319            // matching the previous "fresh PortValues per tick" semantics.
1320            let node_id = routing.nodes[i].node_id;
1321            routing.scratch_out[i].clear();
1322            if let Some(node) = nodes.get_mut(node_id) {
1323                node.module
1324                    .tick(&routing.scratch_in[i], &mut routing.scratch_out[i]);
1325            }
1326
1327            // Scatter outputs back into the dense buffer (with denormal flushing).
1328            routing.nodes[i].scatter(&routing.scratch_out[i], &mut routing.out_buf);
1329        }
1330
1331        routing.read_output()
1332    }
1333
1334    /// Reset all modules in the patch
1335    pub fn reset(&mut self) {
1336        for (_, node) in &mut self.nodes {
1337            node.module.reset();
1338        }
1339        for value in self.routing.out_buf.iter_mut() {
1340            *value = 0.0;
1341        }
1342    }
1343
1344    /// Iterate over all nodes
1345    pub fn nodes(&self) -> impl Iterator<Item = (NodeId, &str, &dyn GraphModule)> {
1346        self.nodes
1347            .iter()
1348            .map(|(id, node)| (id, node.name.as_str(), node.module.as_ref()))
1349    }
1350
1351    /// Get a NodeId by module name
1352    pub fn get_node_id_by_name(&self, name: &str) -> Option<NodeId> {
1353        self.nodes
1354            .iter()
1355            .find(|(_, node)| node.name == name)
1356            .map(|(id, _)| id)
1357    }
1358
1359    /// Get a NodeHandle by module name
1360    pub fn get_handle_by_name(&self, name: &str) -> Option<NodeHandle> {
1361        self.nodes
1362            .iter()
1363            .find(|(_, node)| node.name == name)
1364            .map(|(id, node)| NodeHandle::from_module(id, node.module.as_ref()))
1365    }
1366
1367    /// Disconnect a cable by finding matching port refs
1368    pub fn disconnect_ports(&mut self, from: PortRef, to: PortRef) -> Result<(), PatchError> {
1369        let idx = self
1370            .cables
1371            .iter()
1372            .position(|c| c.from == from && c.to == to)
1373            .ok_or(PatchError::InvalidCable)?;
1374
1375        self.cables.remove(idx);
1376        self.invalidate();
1377        Ok(())
1378    }
1379
1380    /// Get all module names
1381    pub fn module_names(&self) -> Vec<&str> {
1382        self.nodes
1383            .iter()
1384            .map(|(_, node)| node.name.as_str())
1385            .collect()
1386    }
1387
1388    /// Get the current output buffer value for a specific port
1389    ///
1390    /// This is used by the observer to collect real-time values for metering,
1391    /// scope display, and other visualizations.
1392    pub fn get_output_value(&self, node: NodeId, port: PortId) -> Option<f64> {
1393        self.routing
1394            .out_slot_index
1395            .get(&PortRef { node, port })
1396            .map(|&slot| self.routing.out_buf[slot])
1397    }
1398
1399    /// Get the signal kind for an output port by node ID and port ID
1400    pub fn get_output_signal_kind(&self, node: NodeId, port: PortId) -> Option<SignalKind> {
1401        let node_data = self.nodes.get(node)?;
1402        node_data
1403            .module
1404            .port_spec()
1405            .outputs
1406            .iter()
1407            .find(|p| p.id == port)
1408            .map(|p| p.kind)
1409    }
1410}
1411
1412/// A control input is one whose base value behaves as a UI-settable "knob": everything
1413/// except raw [`SignalKind::Audio`] carriers (which are meant to be patched, not dialed).
1414#[cfg(feature = "alloc")]
1415fn is_control_input(kind: SignalKind) -> bool {
1416    kind != SignalKind::Audio
1417}
1418
1419/// Synthesize a [`ParamInfo`](crate::introspection::ParamInfo) for a control-input port,
1420/// using the port's signal range for bounds and the supplied effective value.
1421#[cfg(feature = "alloc")]
1422fn port_param_info(port: &crate::port::PortDef, value: f64) -> crate::introspection::ParamInfo {
1423    let (min, max) = port.kind.voltage_range();
1424    crate::introspection::ParamInfo::new(port.name.clone(), port.name.clone())
1425        .with_range(min, max)
1426        .with_default(port.default)
1427        .with_value(value)
1428}
1429
1430/// Introspection / parameter dispatch for a live patch (alloc tier).
1431///
1432/// A node's parameters come from two places, unified here:
1433/// * **Control-input ports** — any non-audio input. Its base (unpatched) value is a knob,
1434///   overridable per node; the override is applied at [`compile`](Self::compile).
1435/// * **Internal state** — parameters that are *not* ports (waveform tables, scales, oversample
1436///   factor, …), reached through the module's [`ModuleIntrospection`](crate::introspection::ModuleIntrospection) via the
1437///   [`introspect`](crate::port::GraphModule::introspect) hook.
1438///
1439/// Port parameters take precedence when an id names both, because in a compiled graph the
1440/// module reads the injected port value, not any mirrored internal field.
1441#[cfg(feature = "alloc")]
1442impl Patch {
1443    /// All UI-exposable parameters for a node.
1444    ///
1445    /// Returns internal-state parameters (from `ModuleIntrospection`, minus any shadowed by a
1446    /// same-named port) followed by one entry per control-input port with its current
1447    /// effective value. Empty if `node` is unknown.
1448    pub fn param_infos(&self, node: NodeId) -> Vec<crate::introspection::ParamInfo> {
1449        let Some(n) = self.nodes.get(node) else {
1450            return Vec::new();
1451        };
1452        let spec = n.module.port_spec();
1453        let mut infos: Vec<crate::introspection::ParamInfo> = n
1454            .module
1455            .introspect()
1456            .map(|i| i.param_infos())
1457            .unwrap_or_default()
1458            .into_iter()
1459            // Drop internal params shadowed by a real port of the same id (the port wins).
1460            .filter(|p| spec.input_by_name(&p.id).is_none())
1461            .collect();
1462
1463        for input in &spec.inputs {
1464            if !is_control_input(input.kind) {
1465                continue;
1466            }
1467            let value = n
1468                .param_overrides
1469                .get(&input.name)
1470                .copied()
1471                .unwrap_or(input.default);
1472            infos.push(port_param_info(input, value));
1473        }
1474        infos
1475    }
1476
1477    /// Read a single parameter's current value by id (port name or internal param id).
1478    pub fn get_param_by_id(&self, node: NodeId, id: &str) -> Option<f64> {
1479        let n = self.nodes.get(node)?;
1480        // Port parameters are authoritative.
1481        if let Some(port) = n.module.port_spec().input_by_name(id) {
1482            if is_control_input(port.kind) {
1483                return Some(n.param_overrides.get(id).copied().unwrap_or(port.default));
1484            }
1485        }
1486        n.module
1487            .introspect()
1488            .and_then(|i| i.get_param_info(id))
1489            .map(|p| p.value)
1490    }
1491
1492    /// Set a parameter by id. Returns `true` if the id was recognized.
1493    ///
1494    /// A control-input port id sets a per-node base-value override (and marks the graph for
1495    /// recompile so the next tick observes it). Otherwise the module's `ModuleIntrospection`
1496    /// is asked to set internal state.
1497    pub fn set_param_by_id(&mut self, node: NodeId, id: &str, value: f64) -> bool {
1498        // Decide the routing without holding a mutable borrow across the invalidate() call.
1499        let is_port = self
1500            .nodes
1501            .get(node)
1502            .map(|n| {
1503                n.module
1504                    .port_spec()
1505                    .input_by_name(id)
1506                    .map(|p| is_control_input(p.kind))
1507                    .unwrap_or(false)
1508            })
1509            .unwrap_or(false);
1510
1511        if is_port {
1512            if let Some(n) = self.nodes.get_mut(node) {
1513                n.param_overrides.insert(id.to_string(), value);
1514            }
1515            // The override is baked into InputPlan defaults at compile time.
1516            self.invalidate();
1517            return true;
1518        }
1519
1520        if let Some(n) = self.nodes.get_mut(node) {
1521            if let Some(intro) = n.module.introspect_mut() {
1522                return intro.set_param_by_id(id, value);
1523            }
1524        }
1525        false
1526    }
1527
1528    /// Restore opaque, non-scalar module state captured by
1529    /// [`GraphModule::serialize_state`] (e.g. a
1530    /// [`ScaleQuantizer`](crate::modules::ScaleQuantizer) custom/Scala tuning table that does
1531    /// not fit the scalar parameter surface). Used by `Patch::from_def` to reconstruct such
1532    /// state on load.
1533    ///
1534    /// Returns the module's own error string if the state is malformed; an unknown `node` is a
1535    /// no-op (`Ok`). Internal state, not routing — no recompile is triggered.
1536    pub fn deserialize_module_state(
1537        &mut self,
1538        node: NodeId,
1539        state: &serde_json::Value,
1540    ) -> Result<(), String> {
1541        match self.nodes.get_mut(node) {
1542            Some(n) => n.module.deserialize_state(state),
1543            None => Ok(()),
1544        }
1545    }
1546}
1547
1548/// Manual `Debug` for `Patch` so `println!("{:?}", patch)` works for inspection without
1549/// requiring `GraphModule: Debug`. Prints each node's name and `type_id`, the cable list,
1550/// the output node, validation mode, dirty flag, and warning count.
1551impl core::fmt::Debug for Patch {
1552    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1553        // Wrapper so nodes format as `name (type_id)` without a GraphModule: Debug bound.
1554        struct NodeDebug<'a> {
1555            id: NodeId,
1556            name: &'a str,
1557            type_id: &'a str,
1558        }
1559        impl core::fmt::Debug for NodeDebug<'_> {
1560            fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1561                write!(f, "{:?}: {} ({})", self.id, self.name, self.type_id)
1562            }
1563        }
1564
1565        let nodes: Vec<NodeDebug> = self
1566            .nodes
1567            .iter()
1568            .map(|(id, n)| NodeDebug {
1569                id,
1570                name: n.name.as_str(),
1571                type_id: n.module.type_id(),
1572            })
1573            .collect();
1574
1575        f.debug_struct("Patch")
1576            .field("sample_rate", &self.sample_rate)
1577            .field("nodes", &nodes)
1578            .field("cables", &self.cables)
1579            .field("output_node", &self.output_node)
1580            .field("validation_mode", &self.validation_mode)
1581            .field("dirty", &self.dirty)
1582            .field("warnings", &self.warnings.len())
1583            .finish()
1584    }
1585}
1586
1587#[cfg(test)]
1588mod tests {
1589    use super::*;
1590    use crate::port::{PortDef, SignalKind};
1591    use alloc::vec;
1592
1593    // Simple passthrough module for testing
1594    struct Passthrough {
1595        spec: PortSpec,
1596    }
1597
1598    impl Passthrough {
1599        fn new() -> Self {
1600            Self {
1601                spec: PortSpec {
1602                    inputs: vec![PortDef::new(0, "in", SignalKind::Audio)],
1603                    outputs: vec![PortDef::new(10, "out", SignalKind::Audio)],
1604                },
1605            }
1606        }
1607    }
1608
1609    impl GraphModule for Passthrough {
1610        fn port_spec(&self) -> &PortSpec {
1611            &self.spec
1612        }
1613
1614        fn tick(&mut self, inputs: &PortValues, outputs: &mut PortValues) {
1615            let input = inputs.get_or(0, 0.0);
1616            outputs.set(10, input);
1617        }
1618
1619        fn reset(&mut self) {}
1620
1621        fn set_sample_rate(&mut self, _: f64) {}
1622    }
1623
1624    #[test]
1625    fn test_add_module() {
1626        let mut patch = Patch::new(44100.0);
1627        let handle = patch.add("test", Passthrough::new());
1628        assert_eq!(patch.node_count(), 1);
1629        assert!(patch.get_name(handle.id()).is_some());
1630    }
1631
1632    #[test]
1633    fn test_connect() {
1634        let mut patch = Patch::new(44100.0);
1635        let a = patch.add("a", Passthrough::new());
1636        let b = patch.add("b", Passthrough::new());
1637
1638        let result = patch.connect(a.out("out"), b.in_("in"));
1639        assert!(result.is_ok());
1640        assert_eq!(patch.cable_count(), 1);
1641    }
1642
1643    #[test]
1644    fn test_topological_sort() {
1645        let mut patch = Patch::new(44100.0);
1646        let a = patch.add("a", Passthrough::new());
1647        let b = patch.add("b", Passthrough::new());
1648        let c = patch.add("c", Passthrough::new());
1649
1650        // A -> B -> C
1651        patch.connect(a.out("out"), b.in_("in")).unwrap();
1652        patch.connect(b.out("out"), c.in_("in")).unwrap();
1653
1654        patch.compile().unwrap();
1655
1656        let order = patch.execution_order();
1657        let a_pos = order.iter().position(|&x| x == a.id()).unwrap();
1658        let b_pos = order.iter().position(|&x| x == b.id()).unwrap();
1659        let c_pos = order.iter().position(|&x| x == c.id()).unwrap();
1660
1661        assert!(a_pos < b_pos, "A should come before B");
1662        assert!(b_pos < c_pos, "B should come before C");
1663    }
1664
1665    #[test]
1666    fn test_cycle_detection() {
1667        let mut patch = Patch::new(44100.0);
1668        let a = patch.add("a", Passthrough::new());
1669        let b = patch.add("b", Passthrough::new());
1670
1671        // Create cycle: A -> B -> A
1672        patch.connect(a.out("out"), b.in_("in")).unwrap();
1673        patch.connect(b.out("out"), a.in_("in")).unwrap();
1674
1675        let result = patch.compile();
1676        assert!(matches!(result, Err(PatchError::CycleDetected { .. })));
1677    }
1678
1679    #[test]
1680    fn test_mult() {
1681        let mut patch = Patch::new(44100.0);
1682        let a = patch.add("a", Passthrough::new());
1683        let b = patch.add("b", Passthrough::new());
1684        let c = patch.add("c", Passthrough::new());
1685
1686        let result = patch.mult(a.out("out"), &[b.in_("in"), c.in_("in")]);
1687        assert!(result.is_ok());
1688        assert_eq!(patch.cable_count(), 2);
1689    }
1690
1691    #[test]
1692    fn test_disconnect() {
1693        let mut patch = Patch::new(44100.0);
1694        let a = patch.add("a", Passthrough::new());
1695        let b = patch.add("b", Passthrough::new());
1696
1697        let cable_id = patch.connect(a.out("out"), b.in_("in")).unwrap();
1698        assert_eq!(patch.cable_count(), 1);
1699
1700        patch.disconnect(cable_id).unwrap();
1701        assert_eq!(patch.cable_count(), 0);
1702    }
1703
1704    #[test]
1705    fn test_remove_module() {
1706        let mut patch = Patch::new(44100.0);
1707        let a = patch.add("a", Passthrough::new());
1708        let b = patch.add("b", Passthrough::new());
1709
1710        patch.connect(a.out("out"), b.in_("in")).unwrap();
1711        assert_eq!(patch.node_count(), 2);
1712        assert_eq!(patch.cable_count(), 1);
1713
1714        patch.remove(a.id()).unwrap();
1715        assert_eq!(patch.node_count(), 1);
1716        assert_eq!(patch.cable_count(), 0); // Cable should be removed too
1717    }
1718
1719    // ========================================================================
1720    // Phase 2 Tests: Signal Validation & Modulation
1721    // ========================================================================
1722
1723    // Test modules with different signal types
1724    struct GateModule {
1725        spec: PortSpec,
1726    }
1727
1728    impl GateModule {
1729        fn new() -> Self {
1730            Self {
1731                spec: PortSpec {
1732                    inputs: vec![PortDef::new(0, "in", SignalKind::Gate)],
1733                    outputs: vec![PortDef::new(10, "out", SignalKind::Gate)],
1734                },
1735            }
1736        }
1737    }
1738
1739    impl GraphModule for GateModule {
1740        fn port_spec(&self) -> &PortSpec {
1741            &self.spec
1742        }
1743        fn tick(&mut self, inputs: &PortValues, outputs: &mut PortValues) {
1744            outputs.set(10, inputs.get_or(0, 0.0));
1745        }
1746        fn reset(&mut self) {}
1747        fn set_sample_rate(&mut self, _: f64) {}
1748    }
1749
1750    #[test]
1751    fn test_validation_mode_none() {
1752        let mut patch = Patch::new(44100.0);
1753        patch.set_validation_mode(ValidationMode::None);
1754
1755        let audio = patch.add("audio", Passthrough::new());
1756        let gate = patch.add("gate", GateModule::new());
1757
1758        // Should succeed without warnings
1759        let result = patch.connect(audio.out("out"), gate.in_("in"));
1760        assert!(result.is_ok());
1761        assert!(patch.warnings().is_empty());
1762    }
1763
1764    #[test]
1765    fn test_validation_mode_warn() {
1766        let mut patch = Patch::new(44100.0);
1767        patch.set_validation_mode(ValidationMode::Warn);
1768
1769        let audio = patch.add("audio", Passthrough::new());
1770        let gate = patch.add("gate", GateModule::new());
1771
1772        // Should succeed but generate warning
1773        let result = patch.connect(audio.out("out"), gate.in_("in"));
1774        assert!(result.is_ok());
1775        assert!(!patch.warnings().is_empty());
1776    }
1777
1778    #[test]
1779    fn test_validation_mode_strict() {
1780        let mut patch = Patch::new(44100.0);
1781        patch.set_validation_mode(ValidationMode::Strict);
1782
1783        let audio = patch.add("audio", Passthrough::new());
1784        let gate = patch.add("gate", GateModule::new());
1785
1786        // Should fail with SignalMismatch error
1787        let result = patch.connect(audio.out("out"), gate.in_("in"));
1788        assert!(matches!(result, Err(PatchError::SignalMismatch { .. })));
1789    }
1790
1791    #[test]
1792    fn test_same_signal_type_no_warning() {
1793        let mut patch = Patch::new(44100.0);
1794        patch.set_validation_mode(ValidationMode::Warn);
1795
1796        let a = patch.add("a", Passthrough::new());
1797        let b = patch.add("b", Passthrough::new());
1798
1799        // Same type should not generate warning
1800        let result = patch.connect(a.out("out"), b.in_("in"));
1801        assert!(result.is_ok());
1802        assert!(patch.warnings().is_empty());
1803    }
1804
1805    #[test]
1806    fn test_connect_modulated() {
1807        let mut patch = Patch::new(44100.0);
1808        let a = patch.add("a", Passthrough::new());
1809        let b = patch.add("b", Passthrough::new());
1810
1811        // Connect with attenuation 0.5 and offset 1.0
1812        let result = patch.connect_modulated(a.out("out"), b.in_("in"), 0.5, 1.0);
1813        assert!(result.is_ok());
1814
1815        let cables = patch.cables();
1816        assert_eq!(cables.len(), 1);
1817        assert_eq!(cables[0].attenuation, Some(0.5));
1818        assert_eq!(cables[0].offset, Some(1.0));
1819    }
1820
1821    #[test]
1822    fn test_modulated_signal_processing() {
1823        let mut patch = Patch::new(44100.0);
1824
1825        // Use a module that outputs a constant value
1826        struct ConstModule {
1827            spec: PortSpec,
1828            value: f64,
1829        }
1830
1831        impl ConstModule {
1832            fn new(value: f64) -> Self {
1833                Self {
1834                    value,
1835                    spec: PortSpec {
1836                        inputs: vec![],
1837                        outputs: vec![PortDef::new(10, "out", SignalKind::Audio)],
1838                    },
1839                }
1840            }
1841        }
1842
1843        impl GraphModule for ConstModule {
1844            fn port_spec(&self) -> &PortSpec {
1845                &self.spec
1846            }
1847            fn tick(&mut self, _: &PortValues, outputs: &mut PortValues) {
1848                outputs.set(10, self.value);
1849            }
1850            fn reset(&mut self) {}
1851            fn set_sample_rate(&mut self, _: f64) {}
1852        }
1853
1854        struct RecordModule {
1855            spec: PortSpec,
1856            last_value: f64,
1857        }
1858
1859        impl RecordModule {
1860            fn new() -> Self {
1861                Self {
1862                    spec: PortSpec {
1863                        inputs: vec![PortDef::new(0, "in", SignalKind::Audio)],
1864                        outputs: vec![],
1865                    },
1866                    last_value: 0.0,
1867                }
1868            }
1869        }
1870
1871        impl GraphModule for RecordModule {
1872            fn port_spec(&self) -> &PortSpec {
1873                &self.spec
1874            }
1875            fn tick(&mut self, inputs: &PortValues, _: &mut PortValues) {
1876                self.last_value = inputs.get_or(0, 0.0);
1877            }
1878            fn reset(&mut self) {}
1879            fn set_sample_rate(&mut self, _: f64) {}
1880        }
1881
1882        let source = patch.add("source", ConstModule::new(4.0));
1883        let sink = patch.add("sink", RecordModule::new());
1884
1885        // Attenuation 0.5, offset 2.0: 4.0 * 0.5 + 2.0 = 4.0
1886        patch
1887            .connect_modulated(source.out("out"), sink.in_("in"), 0.5, 2.0)
1888            .unwrap();
1889        patch.set_output(sink.id());
1890        patch.compile().unwrap();
1891        patch.tick();
1892
1893        // The value should be processed through attenuation and offset
1894        // We can't easily check the internal value, but we verified the connection works
1895    }
1896
1897    #[test]
1898    fn test_signal_compatibility() {
1899        // Test specific compatibility cases
1900        assert!(SignalKind::Audio
1901            .is_compatible_with(&SignalKind::Audio)
1902            .warning
1903            .is_none());
1904        assert!(SignalKind::Audio
1905            .is_compatible_with(&SignalKind::CvBipolar)
1906            .warning
1907            .is_some());
1908        assert!(SignalKind::Gate
1909            .is_compatible_with(&SignalKind::Trigger)
1910            .warning
1911            .is_some());
1912        assert!(SignalKind::Clock
1913            .is_compatible_with(&SignalKind::Trigger)
1914            .warning
1915            .is_none());
1916    }
1917
1918    #[test]
1919    fn test_patch_get_name() {
1920        let mut patch = Patch::new(44100.0);
1921        let a = patch.add("my_module", Passthrough::new());
1922
1923        let name = patch.get_name(a.id());
1924        assert_eq!(name, Some("my_module"));
1925
1926        // Non-existent node
1927        use slotmap::DefaultKey;
1928        let fake_id: NodeId = DefaultKey::default();
1929        assert!(patch.get_name(fake_id).is_none());
1930    }
1931
1932    #[test]
1933    fn test_patch_set_position() {
1934        let mut patch = Patch::new(44100.0);
1935        let a = patch.add("a", Passthrough::new());
1936
1937        patch.set_position(a.id(), (100.0, 200.0));
1938        // Position is stored but not exposed directly in tests
1939    }
1940
1941    #[test]
1942    fn test_patch_clear_warnings() {
1943        let mut patch = Patch::new(44100.0);
1944        patch.set_validation_mode(ValidationMode::Warn);
1945
1946        let audio = patch.add("audio", Passthrough::new());
1947        let gate = patch.add("gate", GateModule::new());
1948
1949        patch.connect(audio.out("out"), gate.in_("in")).unwrap();
1950        assert!(!patch.warnings().is_empty());
1951
1952        patch.clear_warnings();
1953        assert!(patch.warnings().is_empty());
1954    }
1955
1956    #[test]
1957    fn test_patch_validation_mode_getter() {
1958        let mut patch = Patch::new(44100.0);
1959        patch.set_validation_mode(ValidationMode::Strict);
1960        assert_eq!(patch.validation_mode(), ValidationMode::Strict);
1961    }
1962
1963    #[test]
1964    fn test_patch_sample_rate() {
1965        let patch = Patch::new(48000.0);
1966        assert_eq!(patch.sample_rate(), 48000.0);
1967    }
1968
1969    #[test]
1970    fn test_patch_execution_order() {
1971        let mut patch = Patch::new(44100.0);
1972        let a = patch.add("a", Passthrough::new());
1973        let b = patch.add("b", Passthrough::new());
1974        patch.connect(a.out("out"), b.in_("in")).unwrap();
1975        patch.compile().unwrap();
1976
1977        let order = patch.execution_order();
1978        assert_eq!(order.len(), 2);
1979    }
1980
1981    #[test]
1982    fn test_patch_mult() {
1983        let mut patch = Patch::new(44100.0);
1984        let a = patch.add("a", Passthrough::new());
1985        let b = patch.add("b", Passthrough::new());
1986        let c = patch.add("c", Passthrough::new());
1987
1988        // Connect one output to multiple inputs
1989        let result = patch.mult(a.out("out"), &[b.in_("in"), c.in_("in")]);
1990        assert!(result.is_ok());
1991        assert_eq!(patch.cable_count(), 2);
1992    }
1993
1994    #[test]
1995    fn test_patch_reset() {
1996        let mut patch = Patch::new(44100.0);
1997        let a = patch.add("a", Passthrough::new());
1998        patch.set_output(a.id());
1999        patch.compile().unwrap();
2000
2001        for _ in 0..100 {
2002            patch.tick();
2003        }
2004
2005        patch.reset();
2006        // Reset clears internal state
2007    }
2008
2009    #[test]
2010    fn test_patch_set_param_get_param() {
2011        use crate::modules::Vco;
2012        let mut patch = Patch::new(44100.0);
2013        let vco = patch.add("vco", Vco::new(44100.0));
2014
2015        // Try to set/get param (may or may not have params)
2016        patch.set_param(vco.id(), 0, 0.5);
2017        let _ = patch.get_param(vco.id(), 0);
2018    }
2019
2020    #[test]
2021    fn test_node_handle_spec() {
2022        let mut patch = Patch::new(44100.0);
2023        let a = patch.add("a", Passthrough::new());
2024
2025        let spec = a.spec();
2026        assert!(!spec.inputs.is_empty());
2027        assert!(!spec.outputs.is_empty());
2028    }
2029
2030    #[test]
2031    fn test_patch_validation_mode() {
2032        let mut patch = Patch::new(44100.0);
2033
2034        patch.set_validation_mode(ValidationMode::Strict);
2035        assert_eq!(patch.validation_mode(), ValidationMode::Strict);
2036
2037        patch.set_validation_mode(ValidationMode::Warn);
2038        assert_eq!(patch.validation_mode(), ValidationMode::Warn);
2039    }
2040
2041    // ========================================================================
2042    // Wave B-0 audit remediation tests
2043    // ========================================================================
2044
2045    // A module that sums two inputs (ids 0, 1) into one output (id 10).
2046    struct SumModule {
2047        spec: PortSpec,
2048    }
2049    impl SumModule {
2050        fn new() -> Self {
2051            Self {
2052                spec: PortSpec {
2053                    inputs: vec![
2054                        PortDef::new(0, "a", SignalKind::Audio),
2055                        PortDef::new(1, "b", SignalKind::Audio),
2056                    ],
2057                    outputs: vec![PortDef::new(10, "out", SignalKind::Audio)],
2058                },
2059            }
2060        }
2061    }
2062    impl GraphModule for SumModule {
2063        fn port_spec(&self) -> &PortSpec {
2064            &self.spec
2065        }
2066        fn tick(&mut self, inputs: &PortValues, outputs: &mut PortValues) {
2067            outputs.set(10, inputs.get_or(0, 0.0) + inputs.get_or(1, 0.0));
2068        }
2069        fn reset(&mut self) {}
2070        fn set_sample_rate(&mut self, _: f64) {}
2071    }
2072
2073    // A one-sample delay that declares itself a feedback cycle-breaker.
2074    struct FeedbackDelay {
2075        spec: PortSpec,
2076        buffer: f64,
2077    }
2078    impl FeedbackDelay {
2079        fn new() -> Self {
2080            Self {
2081                spec: PortSpec {
2082                    inputs: vec![PortDef::new(0, "in", SignalKind::Audio)],
2083                    outputs: vec![PortDef::new(10, "out", SignalKind::Audio)],
2084                },
2085                buffer: 0.0,
2086            }
2087        }
2088    }
2089    impl GraphModule for FeedbackDelay {
2090        fn port_spec(&self) -> &PortSpec {
2091            &self.spec
2092        }
2093        fn tick(&mut self, inputs: &PortValues, outputs: &mut PortValues) {
2094            outputs.set(10, self.buffer);
2095            self.buffer = inputs.get_or(0, 0.0);
2096        }
2097        fn reset(&mut self) {
2098            self.buffer = 0.0;
2099        }
2100        fn set_sample_rate(&mut self, _: f64) {}
2101        fn breaks_feedback_cycle(&self) -> bool {
2102            true
2103        }
2104    }
2105
2106    // A constant source with a single non-zero-id output (id 10).
2107    struct ConstSource {
2108        spec: PortSpec,
2109        value: f64,
2110    }
2111    impl ConstSource {
2112        fn new(value: f64) -> Self {
2113            Self {
2114                spec: PortSpec {
2115                    inputs: vec![],
2116                    outputs: vec![PortDef::new(10, "out", SignalKind::Audio)],
2117                },
2118                value,
2119            }
2120        }
2121    }
2122    impl GraphModule for ConstSource {
2123        fn port_spec(&self) -> &PortSpec {
2124            &self.spec
2125        }
2126        fn tick(&mut self, _: &PortValues, outputs: &mut PortValues) {
2127            outputs.set(10, self.value);
2128        }
2129        fn reset(&mut self) {}
2130        fn set_sample_rate(&mut self, _: f64) {}
2131    }
2132
2133    // Q075: CableIds are stable across disconnects of other cables.
2134    #[test]
2135    fn test_cable_ids_are_stable_across_disconnect() {
2136        let mut patch = Patch::new(44100.0);
2137        let a = patch.add("a", Passthrough::new());
2138        let b = patch.add("b", SumModule::new());
2139        let c = patch.add("c", SumModule::new());
2140
2141        let c1 = patch.connect(a.out("out"), b.in_("a")).unwrap();
2142        let c2 = patch.connect(a.out("out"), b.in_("b")).unwrap();
2143        let c3 = patch.connect(a.out("out"), c.in_("a")).unwrap();
2144        assert_eq!(patch.cable_count(), 3);
2145
2146        // Disconnect the FIRST cable. With Vec-index ids this would shift c3 down.
2147        patch.disconnect(c1).unwrap();
2148        assert_eq!(patch.cable_count(), 2);
2149
2150        // Disconnecting the THIRD cable by its still-valid id must remove exactly it,
2151        // leaving only c2 (a.out -> b.b).
2152        patch.disconnect(c3).unwrap();
2153        assert_eq!(patch.cable_count(), 1);
2154        let remaining = &patch.cables()[0];
2155        assert_eq!(remaining.id, c2);
2156        assert_eq!(remaining.to, b.in_("b"));
2157
2158        // A stale id (already removed) errors rather than dropping the wrong cable.
2159        assert!(matches!(
2160            patch.disconnect(c1),
2161            Err(PatchError::InvalidCable)
2162        ));
2163    }
2164
2165    // Q076/Q181: mutating after compile is reflected on the next tick (lazy recompile).
2166    #[test]
2167    fn test_mutation_after_compile_is_reflected_on_tick() {
2168        let mut patch = Patch::new(44100.0);
2169        let src = patch.add("src", ConstSource::new(1.0));
2170        let out = patch.add("out", Passthrough::new());
2171        patch.connect(src.out("out"), out.in_("in")).unwrap();
2172        patch.set_output(out.id());
2173        patch.compile().unwrap();
2174
2175        // First tick: passthrough carries the source through.
2176        let (l0, _) = patch.tick();
2177        assert!((l0 - 1.0).abs() < 1e-9);
2178
2179        // Mutate AFTER compile: add a second source summed in. No explicit recompile.
2180        let src2 = patch.add("src2", ConstSource::new(2.0));
2181        let sum = patch.add("sum", SumModule::new());
2182        // Rewire: src -> sum.a, src2 -> sum.b, sum -> out
2183        patch
2184            .disconnect_ports(src.out("out"), out.in_("in"))
2185            .unwrap();
2186        patch.connect(src.out("out"), sum.in_("a")).unwrap();
2187        patch.connect(src2.out("out"), sum.in_("b")).unwrap();
2188        patch.connect(sum.out("out"), out.in_("in")).unwrap();
2189
2190        // tick() must lazily recompile and reflect the NEW graph (1.0 + 2.0 = 3.0),
2191        // not freeze at the stale 1.0.
2192        let (l1, _) = patch.tick();
2193        assert!((l1 - 3.0).abs() < 1e-9, "expected 3.0, got {}", l1);
2194    }
2195
2196    // Q076/Q181: a cycle-creating mutation surfaces via last_compile_error and ticks silent.
2197    #[test]
2198    fn test_cycle_mutation_surfaces_via_last_compile_error() {
2199        let mut patch = Patch::new(44100.0);
2200        let a = patch.add("a", Passthrough::new());
2201        let b = patch.add("b", Passthrough::new());
2202        patch.connect(a.out("out"), b.in_("in")).unwrap();
2203        patch.set_output(b.id());
2204        patch.compile().unwrap();
2205        assert!(patch.last_compile_error().is_none());
2206
2207        // Introduce a breakerless cycle: b -> a.
2208        patch.connect(b.out("out"), a.in_("in")).unwrap();
2209
2210        // tick() auto-recompiles, fails, outputs silence, and records the error.
2211        let (l, r) = patch.tick();
2212        assert_eq!((l, r), (0.0, 0.0));
2213        match patch.last_compile_error() {
2214            Some(PatchError::CycleDetected { names, .. }) => {
2215                assert_eq!(names.len(), 2);
2216            }
2217            other => panic!("expected CycleDetected, got {:?}", other),
2218        }
2219    }
2220
2221    // Q077: a feedback loop routed through a cycle-breaker compiles and decays.
2222    #[test]
2223    fn test_feedback_loop_with_delay_compiles_and_decays() {
2224        // A one-shot impulse: 1.0 on the first tick, 0.0 thereafter. It stays connected so
2225        // no mid-run mutation clears the feedback buffers.
2226        struct Impulse {
2227            spec: PortSpec,
2228            fired: bool,
2229        }
2230        impl GraphModule for Impulse {
2231            fn port_spec(&self) -> &PortSpec {
2232                &self.spec
2233            }
2234            fn tick(&mut self, _: &PortValues, outputs: &mut PortValues) {
2235                outputs.set(10, if self.fired { 0.0 } else { 1.0 });
2236                self.fired = true;
2237            }
2238            fn reset(&mut self) {
2239                self.fired = false;
2240            }
2241            fn set_sample_rate(&mut self, _: f64) {}
2242        }
2243
2244        let mut patch = Patch::new(44100.0);
2245        let impulse = patch.add(
2246            "impulse",
2247            Impulse {
2248                spec: PortSpec {
2249                    inputs: vec![],
2250                    outputs: vec![PortDef::new(10, "out", SignalKind::Audio)],
2251                },
2252                fired: false,
2253            },
2254        );
2255        let sum = patch.add("sum", SumModule::new());
2256        let delay = patch.add("delay", FeedbackDelay::new());
2257
2258        // impulse -> sum.a ; delay.out -> sum.b (feedback, x0.5) ; sum.out -> delay.in
2259        // (edge into the breaker) ; output = delay.out
2260        patch.connect(impulse.out("out"), sum.in_("a")).unwrap();
2261        patch
2262            .connect_attenuated(delay.out("out"), sum.in_("b"), 0.5)
2263            .unwrap();
2264        patch.connect(sum.out("out"), delay.in_("in")).unwrap();
2265        patch.set_output(delay.id());
2266
2267        // Must compile despite the sum<->delay cycle (delay breaks it).
2268        patch.compile().expect("feedback loop should compile");
2269        assert!(patch.last_compile_error().is_none());
2270
2271        let mut outs = Vec::new();
2272        for _ in 0..14 {
2273            outs.push(patch.tick().0);
2274        }
2275
2276        // The loop must ring: there are several non-zero echoes...
2277        let nonzero: Vec<f64> = outs.iter().copied().filter(|v| v.abs() > 1e-9).collect();
2278        assert!(
2279            nonzero.len() >= 3,
2280            "expected multiple decaying echoes, got {:?}",
2281            outs
2282        );
2283        // ...and successive echo magnitudes decay (0.5 feedback): the first echo is the
2284        // loudest, the tail is quieter.
2285        let peak_early = outs.iter().cloned().fold(0.0_f64, f64::max);
2286        let peak_late = outs[outs.len() - 3..]
2287            .iter()
2288            .cloned()
2289            .fold(0.0_f64, f64::max);
2290        assert!(
2291            peak_late < peak_early,
2292            "echo should decay: early peak {}, late peak {}",
2293            peak_early,
2294            peak_late
2295        );
2296    }
2297
2298    // Q077: a cycle with no breaker still fails to compile.
2299    #[test]
2300    fn test_breakerless_cycle_still_errors() {
2301        let mut patch = Patch::new(44100.0);
2302        let a = patch.add("a", Passthrough::new());
2303        let b = patch.add("b", Passthrough::new());
2304        patch.connect(a.out("out"), b.in_("in")).unwrap();
2305        patch.connect(b.out("out"), a.in_("in")).unwrap();
2306        assert!(matches!(
2307            patch.compile(),
2308            Err(PatchError::CycleDetected { .. })
2309        ));
2310    }
2311
2312    // Q079: normalled input reads the sibling INPUT's current-tick value, not a stale
2313    // output-buffer read. StereoOutput with only `left` patched -> both channels identical.
2314    #[test]
2315    fn test_normalled_input_uses_current_sibling_value() {
2316        use crate::modules::StereoOutput;
2317        let mut patch = Patch::new(44100.0);
2318        // Time-varying source so a one-sample lag would be detectable.
2319        struct Ramp {
2320            spec: PortSpec,
2321            n: f64,
2322        }
2323        impl GraphModule for Ramp {
2324            fn port_spec(&self) -> &PortSpec {
2325                &self.spec
2326            }
2327            fn tick(&mut self, _: &PortValues, outputs: &mut PortValues) {
2328                self.n += 1.0;
2329                outputs.set(10, self.n);
2330            }
2331            fn reset(&mut self) {
2332                self.n = 0.0;
2333            }
2334            fn set_sample_rate(&mut self, _: f64) {}
2335        }
2336        let ramp = patch.add(
2337            "ramp",
2338            Ramp {
2339                spec: PortSpec {
2340                    inputs: vec![],
2341                    outputs: vec![PortDef::new(10, "out", SignalKind::Audio)],
2342                },
2343                n: 0.0,
2344            },
2345        );
2346        let out = patch.add("out", StereoOutput::new());
2347        // Patch only LEFT; RIGHT is normalled to LEFT.
2348        patch.connect(ramp.out("out"), out.in_("left")).unwrap();
2349        patch.set_output(out.id());
2350        patch.compile().unwrap();
2351
2352        for _ in 0..5 {
2353            let (l, r) = patch.tick();
2354            assert!(l > 0.0);
2355            assert_eq!(l, r, "mono fallback must be current-sample, not delayed");
2356        }
2357    }
2358
2359    // A module whose inputs form a forward-ordered + transitive normalled chain:
2360    // input 0 normals to 1, input 1 normals to 2, input 2 is a plain patched input.
2361    // Each tick echoes the three resolved input values to outputs 10/11/12 so a test
2362    // can observe how normalling resolved. `cycle` swaps in a 0<->1 cycle to exercise
2363    // the compile-time cycle guard (both collapse to their own defaults).
2364    struct NormalChain {
2365        spec: PortSpec,
2366    }
2367    impl NormalChain {
2368        fn new(cycle: bool) -> Self {
2369            let (n0, n1) = if cycle { (1, 0) } else { (1, 2) };
2370            Self {
2371                spec: PortSpec {
2372                    inputs: vec![
2373                        PortDef::new(0, "a", SignalKind::Audio)
2374                            .with_default(0.1)
2375                            .normalled_to(n0),
2376                        PortDef::new(1, "b", SignalKind::Audio)
2377                            .with_default(0.2)
2378                            .normalled_to(n1),
2379                        PortDef::new(2, "c", SignalKind::Audio).with_default(0.3),
2380                    ],
2381                    outputs: vec![
2382                        PortDef::new(10, "oa", SignalKind::Audio),
2383                        PortDef::new(11, "ob", SignalKind::Audio),
2384                        PortDef::new(12, "oc", SignalKind::Audio),
2385                    ],
2386                },
2387            }
2388        }
2389    }
2390    impl GraphModule for NormalChain {
2391        fn port_spec(&self) -> &PortSpec {
2392            &self.spec
2393        }
2394        fn tick(&mut self, inputs: &PortValues, outputs: &mut PortValues) {
2395            outputs.set(10, inputs.get_or(0, f64::NAN));
2396            outputs.set(11, inputs.get_or(1, f64::NAN));
2397            outputs.set(12, inputs.get_or(2, f64::NAN));
2398        }
2399        fn reset(&mut self) {}
2400        fn set_sample_rate(&mut self, _: f64) {}
2401    }
2402
2403    // Regression: a forward-ordered + transitive normalled chain must resolve
2404    // order-independently. Input 0 normals to 1, 1 normals to 2, only 2 is
2405    // patched -> all three inputs must read the patched source value, not the
2406    // per-input default. Pre-fix, pass 2's single fixed-order pass left input 0
2407    // (and 1) collapsing to the default because their sibling was not yet set.
2408    #[test]
2409    fn test_normalled_chain_resolves_transitively() {
2410        let mut patch = Patch::new(44100.0);
2411        let src = patch.add("src", ConstSource::new(0.75));
2412        let node = patch.add("chain", NormalChain::new(false));
2413        // Patch ONLY the deepest input (id 2, "c"); 0 and 1 fall back through it.
2414        patch.connect(src.out("out"), node.in_("c")).unwrap();
2415        patch.set_output(node.id());
2416        patch.compile().unwrap();
2417
2418        // tick() reads outputs 10/11 (the output node's first two outputs) which
2419        // echo resolved inputs 0 and 1; inspect input 2 via the raw output buffer.
2420        let (oa, ob) = patch.tick();
2421        assert!(
2422            (oa - 0.75).abs() < 1e-9,
2423            "forward-normalled input 0 must resolve to patched source, got {oa}"
2424        );
2425        assert!(
2426            (ob - 0.75).abs() < 1e-9,
2427            "transitively-normalled input 1 must resolve to patched source, got {ob}"
2428        );
2429    }
2430
2431    // Regression companion: a normalled *cycle* (0<->1, both unpatched) must not
2432    // hang at compile time and each input falls back to its own default.
2433    #[test]
2434    fn test_normalled_cycle_falls_back_to_default() {
2435        let mut patch = Patch::new(44100.0);
2436        let node = patch.add("chain", NormalChain::new(true));
2437        patch.set_output(node.id());
2438        patch.compile().unwrap();
2439        let (oa, ob) = patch.tick();
2440        assert!(
2441            (oa - 0.1).abs() < 1e-9,
2442            "cycled input 0 -> own default, got {oa}"
2443        );
2444        assert!(
2445            (ob - 0.2).abs() < 1e-9,
2446            "cycled input 1 -> own default, got {ob}"
2447        );
2448    }
2449
2450    // Q080: compilation is deterministic — same patch built twice -> same execution_order.
2451    #[test]
2452    fn test_execution_order_is_deterministic() {
2453        fn build_order() -> Vec<usize> {
2454            let mut patch = Patch::new(44100.0);
2455            // Several independent sources feeding one sum -> deterministic tie-breaking.
2456            let s1 = patch.add("s1", ConstSource::new(1.0));
2457            let s2 = patch.add("s2", ConstSource::new(2.0));
2458            let s3 = patch.add("s3", ConstSource::new(3.0));
2459            let sum = patch.add("sum", SumModule::new());
2460            patch.connect(s1.out("out"), sum.in_("a")).unwrap();
2461            patch.connect(s2.out("out"), sum.in_("b")).unwrap();
2462            patch.connect(s3.out("out"), sum.in_("a")).unwrap();
2463            patch.compile().unwrap();
2464            // Map NodeIds to their insertion rank for a build-independent comparison.
2465            let ids = [s1.id(), s2.id(), s3.id(), sum.id()];
2466            patch
2467                .execution_order()
2468                .iter()
2469                .map(|nid| ids.iter().position(|x| x == nid).unwrap())
2470                .collect()
2471        }
2472        assert_eq!(build_order(), build_order());
2473    }
2474
2475    // Q121: read_output reads the output node's first two outputs (mono duplicated),
2476    // regardless of their port ids (here a single output with id 10).
2477    #[test]
2478    fn test_read_output_uses_first_two_outputs_mono_duplicated() {
2479        let mut patch = Patch::new(44100.0);
2480        let src = patch.add("src", ConstSource::new(0.7));
2481        patch.set_output(src.id());
2482        patch.compile().unwrap();
2483        let (l, r) = patch.tick();
2484        assert!((l - 0.7).abs() < 1e-9);
2485        assert_eq!(l, r, "mono node must duplicate to both channels");
2486    }
2487
2488    // Q121/6a: try_set_output validates node existence and presence of outputs.
2489    #[test]
2490    fn test_try_set_output_validates() {
2491        let mut patch = Patch::new(44100.0);
2492        let src = patch.add("src", ConstSource::new(1.0));
2493        assert!(patch.try_set_output(src.id()).is_ok());
2494
2495        // A node with no outputs is rejected.
2496        struct SinkNoOut {
2497            spec: PortSpec,
2498        }
2499        impl GraphModule for SinkNoOut {
2500            fn port_spec(&self) -> &PortSpec {
2501                &self.spec
2502            }
2503            fn tick(&mut self, _: &PortValues, _: &mut PortValues) {}
2504            fn reset(&mut self) {}
2505            fn set_sample_rate(&mut self, _: f64) {}
2506        }
2507        let sink = patch.add(
2508            "sink",
2509            SinkNoOut {
2510                spec: PortSpec {
2511                    inputs: vec![PortDef::new(0, "in", SignalKind::Audio)],
2512                    outputs: vec![],
2513                },
2514            },
2515        );
2516        assert!(matches!(
2517            patch.try_set_output(sink.id()),
2518            Err(PatchError::InvalidPort { .. })
2519        ));
2520    }
2521
2522    // Q122/Q180: NodeHandle fallible port lookups and name discovery.
2523    #[test]
2524    fn test_node_handle_fallible_ports_and_names() {
2525        let mut patch = Patch::new(44100.0);
2526        let a = patch.add("a", Passthrough::new());
2527
2528        assert!(a.output("out").is_ok());
2529        assert!(a.input("in").is_ok());
2530
2531        // Unknown ports return an InvalidPort carrying the available names.
2532        match a.output("nope") {
2533            Err(PatchError::InvalidPort { available, .. }) => {
2534                assert!(available.iter().any(|n| n == "out"));
2535            }
2536            other => panic!("expected InvalidPort, got {:?}", other),
2537        }
2538        assert!(a.input("nope").is_err());
2539
2540        assert_eq!(a.input_names(), vec!["in"]);
2541        assert_eq!(a.output_names(), vec!["out"]);
2542    }
2543
2544    // Q182: Display lists the module's available ports on an invalid connection.
2545    #[test]
2546    fn test_invalid_port_display_lists_available() {
2547        let mut patch = Patch::new(44100.0);
2548        let a = patch.add("a", Passthrough::new());
2549        let b = patch.add("b", Passthrough::new());
2550        // Connect to a non-existent input port id on b.
2551        let bad = PortRef {
2552            node: b.id(),
2553            port: 999,
2554        };
2555        let err = patch.connect(a.out("out"), bad).unwrap_err();
2556        let msg = alloc::format!("{}", err);
2557        assert!(msg.contains("Invalid port"), "got: {}", msg);
2558        assert!(
2559            msg.contains("in"),
2560            "should list available port 'in': {}",
2561            msg
2562        );
2563    }
2564
2565    // Q185: CycleDetected Display prints the module names in the cycle.
2566    #[test]
2567    fn test_cycle_detected_display_names() {
2568        let mut patch = Patch::new(44100.0);
2569        let a = patch.add("osc", Passthrough::new());
2570        let b = patch.add("filt", Passthrough::new());
2571        patch.connect(a.out("out"), b.in_("in")).unwrap();
2572        patch.connect(b.out("out"), a.in_("in")).unwrap();
2573        let err = patch.compile().unwrap_err();
2574        let msg = alloc::format!("{}", err);
2575        assert!(msg.contains("Cycle detected"), "got: {}", msg);
2576        assert!(
2577            msg.contains("osc") && msg.contains("filt"),
2578            "cycle message should name modules: {}",
2579            msg
2580        );
2581    }
2582
2583    // Q183: the default validation mode is Warn.
2584    #[test]
2585    fn test_default_validation_mode_is_warn() {
2586        let patch = Patch::new(44100.0);
2587        assert_eq!(patch.validation_mode(), ValidationMode::Warn);
2588        assert_eq!(ValidationMode::default(), ValidationMode::Warn);
2589    }
2590
2591    // Q187: Patch implements Debug for println-style inspection.
2592    #[test]
2593    fn test_patch_debug_impl() {
2594        let mut patch = Patch::new(44100.0);
2595        let a = patch.add("my_osc", Passthrough::new());
2596        let b = patch.add("my_out", Passthrough::new());
2597        patch.connect(a.out("out"), b.in_("in")).unwrap();
2598        patch.set_output(b.id());
2599        let s = alloc::format!("{:?}", patch);
2600        assert!(s.contains("Patch"));
2601        assert!(s.contains("my_osc"));
2602        assert!(s.contains("my_out"));
2603        assert!(s.contains("validation_mode"));
2604    }
2605
2606    // ========================================================================
2607    // Wave C-0 performance remediation tests (zero-alloc routing)
2608    // ========================================================================
2609
2610    // Q111: tick_block produces exactly the same stereo stream as an equal number of
2611    // per-sample tick() calls (the block path is a pure loop over the same engine).
2612    #[test]
2613    fn test_tick_block_matches_per_sample_tick() {
2614        fn ramp_svf_patch() -> (Patch, NodeHandle) {
2615            let mut patch = Patch::new(44100.0);
2616            let src = patch.add("src", ConstSource::new(0.9));
2617            let pass = patch.add("pass", Passthrough::new());
2618            patch.connect(src.out("out"), pass.in_("in")).unwrap();
2619            patch.set_output(pass.id());
2620            patch.compile().unwrap();
2621            (patch, pass)
2622        }
2623
2624        // Reference: 8 per-sample ticks.
2625        let (mut a, _) = ramp_svf_patch();
2626        let mut reference = Vec::new();
2627        for _ in 0..8 {
2628            reference.push(a.tick());
2629        }
2630
2631        // Block: one tick_block of length 8 on a fresh identical patch.
2632        let (mut b, _) = ramp_svf_patch();
2633        let mut left = [0.0_f64; 8];
2634        let mut right = [0.0_f64; 8];
2635        b.tick_block(&mut left, &mut right);
2636
2637        for (i, &(l, r)) in reference.iter().enumerate() {
2638            assert!((left[i] - l).abs() < 1e-12, "left[{}] mismatch", i);
2639            assert!((right[i] - r).abs() < 1e-12, "right[{}] mismatch", i);
2640        }
2641    }
2642
2643    // Q111: tick_block only writes min(left.len(), right.len()) frames.
2644    #[test]
2645    fn test_tick_block_uses_min_length() {
2646        let mut patch = Patch::new(44100.0);
2647        let src = patch.add("src", ConstSource::new(1.0));
2648        patch.set_output(src.id());
2649        patch.compile().unwrap();
2650
2651        let mut left = [0.0_f64; 4];
2652        let mut right = [0.0_f64; 2]; // shorter -> only 2 frames processed
2653        patch.tick_block(&mut left, &mut right);
2654
2655        assert_eq!(left[0], 1.0);
2656        assert_eq!(left[1], 1.0);
2657        assert_eq!(left[2], 0.0, "frame beyond min length must be untouched");
2658        assert_eq!(left[3], 0.0);
2659        assert_eq!(right[0], 1.0);
2660        assert_eq!(right[1], 1.0);
2661    }
2662
2663    // Q108: a subnormal value produced by a module is flushed to zero as it is scattered
2664    // into the routing buffers, so it cannot circulate through the graph.
2665    #[test]
2666    fn test_denormal_is_flushed_at_scatter() {
2667        let mut patch = Patch::new(44100.0);
2668        // Source emits a subnormal-magnitude value (< 1e-20 flush threshold).
2669        let src = patch.add("src", ConstSource::new(1e-30));
2670        let pass = patch.add("pass", Passthrough::new());
2671        patch.connect(src.out("out"), pass.in_("in")).unwrap();
2672        patch.set_output(pass.id());
2673        patch.compile().unwrap();
2674
2675        let (l, r) = patch.tick();
2676        assert_eq!(l, 0.0, "subnormal must be flushed to zero");
2677        assert_eq!(r, 0.0);
2678        // The source's own output slot is flushed too (observable via get_output_value).
2679        assert_eq!(patch.get_output_value(src.id(), 10), Some(0.0));
2680    }
2681
2682    // Q107: precompiled adjacency preserves multi-cable input summing with per-cable
2683    // attenuation/offset, and get_output_value reflects the dense buffer.
2684    #[test]
2685    fn test_precompiled_adjacency_sums_and_exposes_outputs() {
2686        let mut patch = Patch::new(44100.0);
2687        let s1 = patch.add("s1", ConstSource::new(2.0));
2688        let s2 = patch.add("s2", ConstSource::new(3.0));
2689        let sum = patch.add("sum", SumModule::new());
2690        // Two cables into input "a": 2.0 and (3.0 * 0.5 + 1.0) = 2.5 -> a = 4.5; b = default 0.
2691        patch.connect(s1.out("out"), sum.in_("a")).unwrap();
2692        patch
2693            .connect_modulated(s2.out("out"), sum.in_("a"), 0.5, 1.0)
2694            .unwrap();
2695        patch.set_output(sum.id());
2696        patch.compile().unwrap();
2697
2698        let (l, _) = patch.tick();
2699        assert!((l - 4.5).abs() < 1e-12, "expected 4.5, got {}", l);
2700        assert_eq!(patch.get_output_value(sum.id(), 10), Some(4.5));
2701        // Non-existent port -> None (dense slot index miss).
2702        assert_eq!(patch.get_output_value(sum.id(), 999), None);
2703    }
2704}