Skip to main content

pedalkernel/
precompute.rs

1//! Precomputed scattering matrix binary format (`.pksc`).
2//!
3//! Stores scattering matrices, VS injection vectors, port resistances,
4//! and pot interpolation tables so Cortex-M7 can skip MNA derivation
5//! at runtime.
6//!
7//! # Binary format
8//!
9//! All multi-byte values are little-endian.  `f64` values are transmitted
10//! as their IEEE-754 bit pattern.
11//!
12//! ```text
13//! Header (32 bytes):
14//!   magic:              [u8; 4]  = b"PKSC"
15//!   version:            u16      = 1
16//!   flags:              u16      = 0
17//!   pedal_hash:         u32      (FNV-1a of source text)
18//!   sample_rate_bits:   u64      (f64::to_bits)
19//!   num_stages:         u16
20//!   num_interp_tables:  u16
21//!   reserved:           [u8; 8]
22//!
23//! Per Stage:
24//!   stage_type:        u8     (0=WDF, 1=MultiNL, 2=PushPull)
25//!   n_ports:           u16
26//!   has_vs_injection:  u8
27//!   has_extract:       u8
28//!   padding:           [u8; 3]
29//!   scattering:        [f64; n_ports × n_ports]  row-major
30//!   vs_injection:      [f64; n_ports]             only if has_vs_injection != 0
31//!   port_resistances:  [f64; n_ports]
32//!   extract_coeffs:    [f64; n_ports]             only if has_extract != 0
33//!   extract_vs:        f64                        only if has_extract != 0
34//!
35//! Per Interpolation Table:
36//!   stage_index:       u16
37//!   n_positions:       u16
38//!   n_ports:           u16
39//!   has_vs_injection:  u8
40//!   padding:           u8
41//!   resistances:       [f64; n_positions]
42//!   matrices:          [f64; n_positions × n_ports × n_ports]
43//!   injections:        [f64; n_positions × n_ports]   only if has_vs_injection != 0
44//! ```
45
46use std::io::{Cursor, Read, Write};
47
48const MAGIC: [u8; 4] = *b"PKSC";
49const VERSION: u16 = 1;
50
51// ═══════════════════════════════════════════════════════════════════════════
52// Error type
53// ═══════════════════════════════════════════════════════════════════════════
54
55/// Errors produced by `.pksc` serialization / deserialization.
56#[derive(Debug)]
57pub enum PrecomputeError {
58    /// Underlying I/O failure during byte-level read/write.
59    Io(std::io::Error),
60    /// Magic bytes did not match `b"PKSC"`.
61    BadMagic,
62    /// File version is newer than this library knows about.
63    BadVersion(u16),
64    /// A field value is outside the range this library accepts.
65    Consistency(String),
66}
67
68impl std::fmt::Display for PrecomputeError {
69    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        match self {
71            Self::Io(e) => write!(f, "I/O error: {e}"),
72            Self::BadMagic => write!(f, "invalid magic bytes (expected PKSC)"),
73            Self::BadVersion(v) => write!(f, "unsupported version {v}"),
74            Self::Consistency(msg) => write!(f, "data consistency error: {msg}"),
75        }
76    }
77}
78
79impl std::error::Error for PrecomputeError {
80    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
81        if let Self::Io(e) = self {
82            Some(e)
83        } else {
84            None
85        }
86    }
87}
88
89impl From<std::io::Error> for PrecomputeError {
90    fn from(e: std::io::Error) -> Self {
91        Self::Io(e)
92    }
93}
94
95// ═══════════════════════════════════════════════════════════════════════════
96// Public data types
97// ═══════════════════════════════════════════════════════════════════════════
98
99/// A complete precomputed scattering package for one `.pedal` file.
100#[derive(Debug, Clone)]
101pub struct PrecomputedScattering {
102    /// FNV-1a hash of the `.pedal` source text used to produce this file.
103    /// Checked at load time to detect stale `.pksc` files.
104    pub pedal_hash: u32,
105    /// Sample rate this package was compiled for (Hz).
106    pub sample_rate: f64,
107    /// One entry per compiled stage in topological processing order.
108    pub stages: Vec<PrecomputedStage>,
109    /// Pot interpolation tables (one per stage that has a single pot).
110    pub interp_tables: Vec<PrecomputedInterpTable>,
111}
112
113/// Precomputed data for one stage (WDF, MultiNL, or PushPull).
114#[derive(Debug, Clone)]
115pub struct PrecomputedStage {
116    /// Stage type discriminant: 0 = WDF, 1 = MultiNL, 2 = PushPull.
117    pub stage_type: u8,
118    /// Number of WDF / MNA ports.
119    pub n_ports: usize,
120    /// Scattering matrix, `n_ports × n_ports`, row-major.
121    ///
122    /// For `RTypeAdaptor`-based stages this is the **power-normalized** S̅
123    /// matrix (as stored internally).  See the `RTypeAdaptor` docs for the
124    /// normalization convention.  If the raw matrix is ever needed, it can be
125    /// recovered via `S[i][j] = S̅[i][j] * √(R_i / R_j)`.
126    pub scattering: Vec<f64>,
127    /// VS injection vector (length `n_ports`), present when the stage is
128    /// driven by an ideal voltage source input.
129    pub vs_injection: Option<Vec<f64>>,
130    /// Port resistances (Ω), length `n_ports`.
131    pub port_resistances: Vec<f64>,
132    /// Node-voltage extraction coefficients (length `n_ports`), present when
133    /// the output is read directly from MNA node voltages.
134    pub extract_coeffs: Option<Vec<f64>>,
135    /// VS component of the extraction formula:
136    ///   `V_out = Σ extract_coeffs[k]·b[k]  +  extract_vs · V_in`
137    pub extract_vs: f64,
138}
139
140/// Precomputed pot-sweep interpolation table for a single-pot stage.
141#[derive(Debug, Clone)]
142pub struct PrecomputedInterpTable {
143    /// Index into `PrecomputedScattering::stages` this table belongs to.
144    pub stage_index: usize,
145    /// Number of WDF ports.
146    pub n_ports: usize,
147    /// Whether VS injection vectors are present.
148    pub has_vs_injection: bool,
149    /// `K` log-spaced resistance values (ascending).
150    pub resistances: Vec<f64>,
151    /// `K` scattering matrices, each `n_ports²` entries, row-major.
152    pub matrices: Vec<Vec<f64>>,
153    /// `K` VS injection vectors, each `n_ports` entries.
154    /// Empty when `has_vs_injection` is `false`.
155    pub injections: Vec<Vec<f64>>,
156}
157
158// ═══════════════════════════════════════════════════════════════════════════
159// FNV-1a hash helper
160// ═══════════════════════════════════════════════════════════════════════════
161
162/// Compute the 32-bit FNV-1a hash of `text`.
163///
164/// Used to fingerprint the `.pedal` source so stale `.pksc` files can be
165/// detected at load time.
166///
167/// # Example
168///
169/// ```
170/// use pedalkernel::precompute::pedal_hash;
171/// let h = pedal_hash("hello");
172/// assert_ne!(h, pedal_hash("world"));
173/// ```
174pub fn pedal_hash(text: &str) -> u32 {
175    const FNV_OFFSET: u32 = 0x811c_9dc5;
176    const FNV_PRIME: u32 = 0x0100_0193;
177    let mut hash = FNV_OFFSET;
178    for byte in text.bytes() {
179        hash ^= u32::from(byte);
180        hash = hash.wrapping_mul(FNV_PRIME);
181    }
182    hash
183}
184
185// ═══════════════════════════════════════════════════════════════════════════
186// Serialization helpers
187// ═══════════════════════════════════════════════════════════════════════════
188
189fn write_u8(w: &mut impl Write, v: u8) -> std::io::Result<()> {
190    w.write_all(&[v])
191}
192
193fn write_u16_le(w: &mut impl Write, v: u16) -> std::io::Result<()> {
194    w.write_all(&v.to_le_bytes())
195}
196
197fn write_u32_le(w: &mut impl Write, v: u32) -> std::io::Result<()> {
198    w.write_all(&v.to_le_bytes())
199}
200
201fn write_u64_le(w: &mut impl Write, v: u64) -> std::io::Result<()> {
202    w.write_all(&v.to_le_bytes())
203}
204
205fn write_f64_le(w: &mut impl Write, v: f64) -> std::io::Result<()> {
206    write_u64_le(w, v.to_bits())
207}
208
209fn write_f64_slice(w: &mut impl Write, s: &[f64]) -> std::io::Result<()> {
210    for &v in s {
211        write_f64_le(w, v)?;
212    }
213    Ok(())
214}
215
216fn read_u8(r: &mut impl Read) -> std::io::Result<u8> {
217    let mut buf = [0u8; 1];
218    r.read_exact(&mut buf)?;
219    Ok(buf[0])
220}
221
222fn read_u16_le(r: &mut impl Read) -> std::io::Result<u16> {
223    let mut buf = [0u8; 2];
224    r.read_exact(&mut buf)?;
225    Ok(u16::from_le_bytes(buf))
226}
227
228fn read_u32_le(r: &mut impl Read) -> std::io::Result<u32> {
229    let mut buf = [0u8; 4];
230    r.read_exact(&mut buf)?;
231    Ok(u32::from_le_bytes(buf))
232}
233
234fn read_u64_le(r: &mut impl Read) -> std::io::Result<u64> {
235    let mut buf = [0u8; 8];
236    r.read_exact(&mut buf)?;
237    Ok(u64::from_le_bytes(buf))
238}
239
240fn read_f64_le(r: &mut impl Read) -> std::io::Result<f64> {
241    Ok(f64::from_bits(read_u64_le(r)?))
242}
243
244fn read_f64_vec(r: &mut impl Read, n: usize) -> std::io::Result<Vec<f64>> {
245    let mut v = vec![0.0f64; n];
246    for x in &mut v {
247        *x = read_f64_le(r)?;
248    }
249    Ok(v)
250}
251
252// ═══════════════════════════════════════════════════════════════════════════
253// PrecomputedScattering  —  to_bytes / from_bytes
254// ═══════════════════════════════════════════════════════════════════════════
255
256impl PrecomputedScattering {
257    /// Serialize to the `.pksc` binary format.
258    ///
259    /// # Errors
260    ///
261    /// Returns an error if the internal `Vec` write fails (practically
262    /// infallible, but propagated for API consistency).
263    pub fn to_bytes(&self) -> Result<Vec<u8>, PrecomputeError> {
264        let mut buf: Vec<u8> = Vec::new();
265        self.write_to(&mut buf)?;
266        Ok(buf)
267    }
268
269    fn write_to(&self, w: &mut impl Write) -> Result<(), PrecomputeError> {
270        // ── Header (32 bytes) ───────────────────────────────────────────
271        w.write_all(&MAGIC)?;
272        write_u16_le(w, VERSION)?;
273        write_u16_le(w, 0u16)?; // flags
274        write_u32_le(w, self.pedal_hash)?;
275        write_u64_le(w, self.sample_rate.to_bits())?;
276
277        let num_stages = self.stages.len();
278        let num_tables = self.interp_tables.len();
279        if num_stages > u16::MAX as usize {
280            return Err(PrecomputeError::Consistency(format!(
281                "too many stages: {num_stages}"
282            )));
283        }
284        if num_tables > u16::MAX as usize {
285            return Err(PrecomputeError::Consistency(format!(
286                "too many interp tables: {num_tables}"
287            )));
288        }
289        write_u16_le(w, num_stages as u16)?;
290        write_u16_le(w, num_tables as u16)?;
291        w.write_all(&[0u8; 8])?; // reserved
292
293        // ── Stages ──────────────────────────────────────────────────────
294        for stage in &self.stages {
295            stage.write_to(w)?;
296        }
297
298        // ── Interpolation tables ────────────────────────────────────────
299        for table in &self.interp_tables {
300            table.write_to(w)?;
301        }
302
303        Ok(())
304    }
305
306    /// Deserialize from the `.pksc` binary format.
307    ///
308    /// Returns [`PrecomputeError::BadMagic`] if the magic bytes are wrong,
309    /// [`PrecomputeError::BadVersion`] if the version is unsupported, or an
310    /// I/O error if the data is truncated.
311    pub fn from_bytes(data: &[u8]) -> Result<Self, PrecomputeError> {
312        let mut cursor = Cursor::new(data);
313        Self::read_from(&mut cursor)
314    }
315
316    fn read_from(r: &mut impl Read) -> Result<Self, PrecomputeError> {
317        // ── Header ───────────────────────────────────────────────────────
318        let mut magic = [0u8; 4];
319        r.read_exact(&mut magic)?;
320        if magic != MAGIC {
321            return Err(PrecomputeError::BadMagic);
322        }
323
324        let version = read_u16_le(r)?;
325        if version != VERSION {
326            return Err(PrecomputeError::BadVersion(version));
327        }
328
329        let _flags = read_u16_le(r)?;
330        let pedal_hash = read_u32_le(r)?;
331        let sample_rate = f64::from_bits(read_u64_le(r)?);
332        let num_stages = read_u16_le(r)? as usize;
333        let num_tables = read_u16_le(r)? as usize;
334        let mut _reserved = [0u8; 8];
335        r.read_exact(&mut _reserved)?;
336
337        // ── Stages ───────────────────────────────────────────────────────
338        let mut stages = Vec::with_capacity(num_stages);
339        for _ in 0..num_stages {
340            stages.push(PrecomputedStage::read_from(r)?);
341        }
342
343        // ── Interpolation tables ─────────────────────────────────────────
344        let mut interp_tables = Vec::with_capacity(num_tables);
345        for _ in 0..num_tables {
346            interp_tables.push(PrecomputedInterpTable::read_from(r)?);
347        }
348
349        Ok(Self {
350            pedal_hash,
351            sample_rate,
352            stages,
353            interp_tables,
354        })
355    }
356}
357
358// ── PrecomputedStage ─────────────────────────────────────────────────────
359
360impl PrecomputedStage {
361    fn write_to(&self, w: &mut impl Write) -> Result<(), PrecomputeError> {
362        let n = self.n_ports;
363        if n > u16::MAX as usize {
364            return Err(PrecomputeError::Consistency(format!(
365                "n_ports too large: {n}"
366            )));
367        }
368
369        write_u8(w, self.stage_type)?;
370        write_u16_le(w, n as u16)?;
371        write_u8(w, u8::from(self.vs_injection.is_some()))?;
372        write_u8(w, u8::from(self.extract_coeffs.is_some()))?;
373        w.write_all(&[0u8; 3])?; // padding
374
375        write_f64_slice(w, &self.scattering)?;
376
377        if let Some(ref vs) = self.vs_injection {
378            write_f64_slice(w, vs)?;
379        }
380
381        write_f64_slice(w, &self.port_resistances)?;
382
383        if let Some(ref ec) = self.extract_coeffs {
384            write_f64_slice(w, ec)?;
385            write_f64_le(w, self.extract_vs)?;
386        }
387
388        Ok(())
389    }
390
391    fn read_from(r: &mut impl Read) -> Result<Self, PrecomputeError> {
392        let stage_type = read_u8(r)?;
393        let n = read_u16_le(r)? as usize;
394        let has_vs = read_u8(r)? != 0;
395        let has_extract = read_u8(r)? != 0;
396        let mut _pad = [0u8; 3];
397        r.read_exact(&mut _pad)?;
398
399        let scattering = read_f64_vec(r, n * n)?;
400        let vs_injection = if has_vs {
401            Some(read_f64_vec(r, n)?)
402        } else {
403            None
404        };
405        let port_resistances = read_f64_vec(r, n)?;
406        let (extract_coeffs, extract_vs) = if has_extract {
407            let ec = read_f64_vec(r, n)?;
408            let ev = read_f64_le(r)?;
409            (Some(ec), ev)
410        } else {
411            (None, 0.0)
412        };
413
414        Ok(Self {
415            stage_type,
416            n_ports: n,
417            scattering,
418            vs_injection,
419            port_resistances,
420            extract_coeffs,
421            extract_vs,
422        })
423    }
424}
425
426// ── PrecomputedInterpTable ───────────────────────────────────────────────
427
428impl PrecomputedInterpTable {
429    fn write_to(&self, w: &mut impl Write) -> Result<(), PrecomputeError> {
430        let k = self.resistances.len();
431        let n = self.n_ports;
432
433        if self.stage_index > u16::MAX as usize {
434            return Err(PrecomputeError::Consistency(format!(
435                "stage_index too large: {}",
436                self.stage_index
437            )));
438        }
439        if k > u16::MAX as usize {
440            return Err(PrecomputeError::Consistency(format!(
441                "n_positions too large: {k}"
442            )));
443        }
444        if n > u16::MAX as usize {
445            return Err(PrecomputeError::Consistency(format!(
446                "n_ports too large: {n}"
447            )));
448        }
449
450        write_u16_le(w, self.stage_index as u16)?;
451        write_u16_le(w, k as u16)?;
452        write_u16_le(w, n as u16)?;
453        write_u8(w, u8::from(self.has_vs_injection))?;
454        write_u8(w, 0u8)?; // padding
455
456        write_f64_slice(w, &self.resistances)?;
457
458        for mat in &self.matrices {
459            write_f64_slice(w, mat)?;
460        }
461
462        if self.has_vs_injection {
463            for inj in &self.injections {
464                write_f64_slice(w, inj)?;
465            }
466        }
467
468        Ok(())
469    }
470
471    fn read_from(r: &mut impl Read) -> Result<Self, PrecomputeError> {
472        let stage_index = read_u16_le(r)? as usize;
473        let k = read_u16_le(r)? as usize;
474        let n = read_u16_le(r)? as usize;
475        let has_vs_injection = read_u8(r)? != 0;
476        let _pad = read_u8(r)?;
477
478        let resistances = read_f64_vec(r, k)?;
479
480        let mut matrices = Vec::with_capacity(k);
481        for _ in 0..k {
482            matrices.push(read_f64_vec(r, n * n)?);
483        }
484
485        let mut injections = Vec::with_capacity(k);
486        if has_vs_injection {
487            for _ in 0..k {
488                injections.push(read_f64_vec(r, n)?);
489            }
490        }
491
492        Ok(Self {
493            stage_index,
494            n_ports: n,
495            has_vs_injection,
496            resistances,
497            matrices,
498            injections,
499        })
500    }
501}
502
503// ═══════════════════════════════════════════════════════════════════════════
504// Extract from CompiledPedal
505// ═══════════════════════════════════════════════════════════════════════════
506
507/// Extract a [`PrecomputedScattering`] from a compiled pedal.
508///
509/// Iterates over the compiled pedal's MultiNL stages and extracts scattering
510/// matrices, port resistances, VS injection vectors, and pot interpolation
511/// tables.
512///
513/// # Arguments
514///
515/// * `compiled`      – the compiled pedal processor
516/// * `pedal_source`  – the original `.pedal` source text (used to compute the hash)
517/// * `sample_rate`   – the sample rate used during compilation (Hz)
518pub fn extract_precomputed(
519    compiled: &crate::compiler::CompiledPedal,
520    pedal_source: &str,
521    sample_rate: f64,
522) -> PrecomputedScattering {
523    crate::compiler::extract_precomputed_from_compiled(compiled, pedal_source, sample_rate)
524}
525
526// ═══════════════════════════════════════════════════════════════════════════
527// Tests
528// ═══════════════════════════════════════════════════════════════════════════
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533
534    // ── round-trip test ──────────────────────────────────────────────────
535
536    #[test]
537    fn precompute_round_trip() {
538        let original = PrecomputedScattering {
539            pedal_hash: 0xDEAD_BEEF,
540            sample_rate: 48000.0,
541            stages: vec![
542                PrecomputedStage {
543                    stage_type: 1,
544                    n_ports: 3,
545                    scattering: vec![
546                        1.0, 0.0, 0.0, // row 0
547                        0.0, 1.0, 0.0, // row 1
548                        0.0, 0.0, 0.0, // row 2 (adapted)
549                    ],
550                    vs_injection: Some(vec![0.5, -0.5, 1.0]),
551                    port_resistances: vec![100.0, 200.0, 300.0],
552                    extract_coeffs: Some(vec![0.1, 0.2, 0.3]),
553                    extract_vs: 0.42,
554                },
555                PrecomputedStage {
556                    stage_type: 0,
557                    n_ports: 2,
558                    scattering: vec![-1.0, 2.0, 2.0, -1.0],
559                    vs_injection: None,
560                    port_resistances: vec![50.0, 50.0],
561                    extract_coeffs: None,
562                    extract_vs: 0.0,
563                },
564            ],
565            interp_tables: vec![PrecomputedInterpTable {
566                stage_index: 0,
567                n_ports: 3,
568                has_vs_injection: true,
569                resistances: vec![10.0, 100.0, 1000.0],
570                matrices: vec![
571                    vec![1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0],
572                    vec![0.5, 0.5, 0.0, 0.5, 0.5, 0.0, 0.0, 0.0, 0.0],
573                    vec![0.0, 1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0],
574                ],
575                injections: vec![
576                    vec![1.0, 0.0, 0.0],
577                    vec![0.5, 0.5, 0.0],
578                    vec![0.0, 1.0, 0.0],
579                ],
580            }],
581        };
582
583        let bytes = original.to_bytes().expect("serialize");
584        let restored = PrecomputedScattering::from_bytes(&bytes).expect("deserialize");
585
586        assert_eq!(restored.pedal_hash, original.pedal_hash);
587        assert!((restored.sample_rate - original.sample_rate).abs() < 1e-9);
588        assert_eq!(restored.stages.len(), original.stages.len());
589
590        // Stage 0 checks
591        let s0 = &restored.stages[0];
592        assert_eq!(s0.stage_type, 1);
593        assert_eq!(s0.n_ports, 3);
594        assert_eq!(s0.scattering, original.stages[0].scattering);
595        assert_eq!(s0.vs_injection, original.stages[0].vs_injection);
596        assert_eq!(s0.port_resistances, original.stages[0].port_resistances);
597        assert_eq!(s0.extract_coeffs, original.stages[0].extract_coeffs);
598        assert!((s0.extract_vs - original.stages[0].extract_vs).abs() < 1e-12);
599
600        // Stage 1 checks
601        let s1 = &restored.stages[1];
602        assert_eq!(s1.stage_type, 0);
603        assert!(s1.vs_injection.is_none());
604        assert!(s1.extract_coeffs.is_none());
605
606        // Interp table checks
607        assert_eq!(restored.interp_tables.len(), 1);
608        let t0 = &restored.interp_tables[0];
609        assert_eq!(t0.stage_index, 0);
610        assert_eq!(t0.n_ports, 3);
611        assert!(t0.has_vs_injection);
612        assert_eq!(t0.resistances, original.interp_tables[0].resistances);
613        assert_eq!(t0.matrices, original.interp_tables[0].matrices);
614        assert_eq!(t0.injections, original.interp_tables[0].injections);
615    }
616
617    // ── hash stability test ──────────────────────────────────────────────
618
619    #[test]
620    fn precompute_hash_changes() {
621        let h1 = pedal_hash("pedal A {}");
622        let h2 = pedal_hash("pedal B {}");
623        let h3 = pedal_hash("pedal A {}");
624        assert_ne!(h1, h2, "different sources must produce different hashes");
625        assert_eq!(h1, h3, "same source must produce identical hash");
626    }
627
628    // ── bad-magic rejection ──────────────────────────────────────────────
629
630    #[test]
631    fn precompute_rejects_bad_magic() {
632        let mut bad = b"NOPE\x01\x00".to_vec();
633        bad.extend_from_slice(&[0u8; 26]);
634        let err = PrecomputedScattering::from_bytes(&bad).unwrap_err();
635        assert!(matches!(err, PrecomputeError::BadMagic));
636    }
637
638    // ── pedal integration test ───────────────────────────────────────────
639
640    #[test]
641    fn precompute_from_pedal() {
642        // BJT differential pair — two coupled NPN transistors generate a MultiNL stage
643        // (R-type adaptor + multi-port NR solver).
644        let src = r#"
645pedal "ClipTest" subtitle "precompute integration test" {
646  supply 12V
647  components {
648    C1:     cap(100n)
649    R_B1:   resistor(100k)
650    R_B2:   resistor(100k)
651    Q1:     npn(2n3904)
652    Q2:     npn(2n3904)
653    RC1:    resistor(10k)
654    RC2:    resistor(10k)
655    R_tail: resistor(10k)
656    C_out:  cap(1u)
657    RL:     resistor(10k)
658  }
659  nets {
660    in -> C1.a
661    C1.b -> R_B1.a, Q1.base
662    R_B1.b -> vcc
663    R_B2.a -> Q2.base
664    R_B2.b -> vcc
665    Q1.collector -> RC1.a
666    Q2.collector -> RC2.a
667    RC1.b -> vcc
668    RC2.b -> vcc
669    Q1.emitter -> Q2.emitter, R_tail.a
670    R_tail.b -> gnd
671    Q1.collector -> C_out.a
672    C_out.b -> RL.a, out
673    RL.b -> gnd
674  }
675  controls {}
676}
677"#;
678        use crate::compiler::{compile_pedal, CompiledPedal};
679        use crate::dsl::parse_pedal_file;
680
681        let pedal_def = parse_pedal_file(src).expect("parse");
682        let compiled: CompiledPedal = compile_pedal(&pedal_def, 48000.0).expect("compile");
683
684        let precomp = extract_precomputed(&compiled, src, 48000.0);
685
686        // The hash must match what pedal_hash() returns for the same source.
687        assert_eq!(precomp.pedal_hash, pedal_hash(src));
688        assert!((precomp.sample_rate - 48000.0).abs() < 1e-9);
689
690        // At least one MultiNL stage must be present (opamp + diode uses MNA/NR).
691        assert!(
692            !precomp.stages.is_empty(),
693            "expected at least one MultiNL stage, got none \
694             (debug: compiled has {} wdf + {} multi_nl stages)",
695            compiled.debug_stage_count(),
696            compiled.debug_multi_nl_count(),
697        );
698
699        // Every stage must have a well-formed scattering matrix.
700        for (i, stage) in precomp.stages.iter().enumerate() {
701            let expected_len = stage.n_ports * stage.n_ports;
702            assert_eq!(
703                stage.scattering.len(),
704                expected_len,
705                "stage {i}: scattering matrix has wrong length"
706            );
707            assert!(
708                stage.scattering.iter().all(|x| x.is_finite()),
709                "stage {i}: scattering matrix contains non-finite values"
710            );
711        }
712    }
713}