Skip to main content

pedalkernel_validate/
metrics.rs

1//! Audio signal comparison metrics.
2//!
3//! This module provides functions for comparing audio signals and measuring
4//! various quality metrics commonly used in audio circuit validation.
5//!
6//! # Available Metrics
7//!
8//! | Metric | Function | Description |
9//! |--------|----------|-------------|
10//! | Normalized RMS Error | [`normalized_rms_error_db`] | Overall signal difference (lower is better) |
11//! | Peak Error | [`peak_error_db`] | Maximum sample-level difference |
12//! | THD | [`thd_db`] | Total Harmonic Distortion |
13//! | THD Error | [`thd_error_db`] | THD difference between two signals |
14//! | Even/Odd Ratio | [`even_odd_ratio_db`] | Even vs odd harmonic balance |
15//! | Spectral Error | [`spectral_error_db`] | Frequency domain difference |
16//! | DC Drift | [`dc_drift_mv`] | Low-frequency offset measurement |
17//!
18//! # Example
19//!
20//! ```rust
21//! use pedalkernel_validate::metrics;
22//!
23//! let wdf_output: Vec<f64> = vec![0.1, 0.2, 0.3, 0.2, 0.1];
24//! let reference: Vec<f64> = vec![0.1, 0.2, 0.3, 0.2, 0.1];
25//! let sample_rate = 48000.0;
26//!
27//! // Compare individual metrics
28//! let rms_err = metrics::normalized_rms_error_db(&wdf_output, &reference);
29//! let peak_err = metrics::peak_error_db(&wdf_output, &reference);
30//!
31//! // Or use the combined comparison function
32//! let result = metrics::compare(&wdf_output, &reference, sample_rate, Some(1000.0));
33//! println!("RMS error: {:.1} dB", result.normalized_rms_error_db);
34//! println!("Peak error: {:.1} dB", result.peak_error_db);
35//! ```
36//!
37//! # Interpreting Results
38//!
39//! - **RMS Error**: -60 dB means error is 0.1% of signal. -80 dB is excellent.
40//! - **Peak Error**: Maximum instantaneous difference. Usually higher than RMS.
41//! - **THD**: Pure sine should be < -80 dB. Audible distortion starts around -40 dB.
42//! - **Even/Odd Ratio**: Push-pull amps should have < -40 dB (even harmonics suppressed)
43
44use realfft::RealFftPlanner;
45use rustfft::num_complex::Complex;
46
47/// Compute normalized RMS error in dB.
48///
49/// `error_db = 20 * log10(rms(wdf - ref) / rms(ref))`
50///
51/// Lower is better. -60 dB means error is 0.1% of signal.
52pub fn normalized_rms_error_db(wdf: &[f64], reference: &[f64]) -> f64 {
53    let n = wdf.len().min(reference.len());
54    if n == 0 {
55        return f64::NEG_INFINITY;
56    }
57
58    let mut err_sum = 0.0;
59    let mut ref_sum = 0.0;
60
61    for i in 0..n {
62        let diff = wdf[i] - reference[i];
63        err_sum += diff * diff;
64        ref_sum += reference[i] * reference[i];
65    }
66
67    let err_rms = (err_sum / n as f64).sqrt();
68    let ref_rms = (ref_sum / n as f64).sqrt();
69
70    if ref_rms < 1e-30 {
71        return -200.0;
72    }
73
74    20.0 * (err_rms / ref_rms).log10()
75}
76
77/// Compute peak absolute error in dB relative to reference peak.
78///
79/// `error_db = 20 * log10(max|wdf - ref| / max|ref|)`
80pub fn peak_error_db(wdf: &[f64], reference: &[f64]) -> f64 {
81    let n = wdf.len().min(reference.len());
82    if n == 0 {
83        return f64::NEG_INFINITY;
84    }
85
86    let mut max_err = 0.0_f64;
87    let mut max_ref = 0.0_f64;
88
89    for i in 0..n {
90        max_err = max_err.max((wdf[i] - reference[i]).abs());
91        max_ref = max_ref.max(reference[i].abs());
92    }
93
94    if max_ref < 1e-30 {
95        return -200.0;
96    }
97
98    20.0 * (max_err / max_ref).log10()
99}
100
101// ===========================================================================
102// Steady-state, level-INDEPENDENT measurement
103//
104// `normalized_rms_error_db` is a DIFFERENCE metric: rms(wdf-ref)/rms(ref).  When
105// the two levels differ by a factor k it computes |k-1|, NOT shape — and worse,
106// when the WDF is far BELOW the golden (k→0) it reads ≈0 dB (because
107// rms(wdf-ref) ≈ rms(ref)), falsely implying "shapes match" while the WDF is
108// effectively silent.  These functions measure the magnitude of the test tone
109// directly so the true gain gap is visible and is immune to any DC pedestal.
110// ===========================================================================
111
112/// Mean (DC offset) of a signal.  Use as the AC-coupling / settle guard: an
113/// AC-coupled output that has reached steady state has |mean| ≈ 0.
114pub fn dc_offset(signal: &[f64]) -> f64 {
115    if signal.is_empty() {
116        return 0.0;
117    }
118    signal.iter().sum::<f64>() / signal.len() as f64
119}
120
121/// Single-bin DFT amplitude (peak, in signal units) of the component at exactly
122/// `freq_hz`.
123///
124/// Goertzel-style projection onto cos/sin at `freq_hz`.  The DC offset is
125/// removed first, so the result is the AC amplitude AT THE TEST FREQUENCY and is
126/// immune to any DC pedestal (a device sitting at its bias point) or slow drift
127/// baseline.  This is the steady-state level measurement that
128/// [`normalized_rms_error_db`] cannot provide.
129pub fn single_bin_amplitude(signal: &[f64], freq_hz: f64, sample_rate: f64) -> f64 {
130    let n = signal.len();
131    if n == 0 || sample_rate <= 0.0 {
132        return 0.0;
133    }
134    let mean = dc_offset(signal);
135    let w = 2.0 * std::f64::consts::PI * freq_hz / sample_rate;
136    let (mut re, mut im) = (0.0_f64, 0.0_f64);
137    for (i, &x) in signal.iter().enumerate() {
138        let a = w * i as f64;
139        let v = x - mean;
140        re += v * a.cos();
141        im += v * a.sin();
142    }
143    2.0 * (re * re + im * im).sqrt() / n as f64
144}
145
146/// Steady-state, level-independent AC gain ratio in dB at `freq_hz`:
147/// `20*log10(amp_wdf / amp_ref)`.  Positive = WDF louder than the reference.
148///
149/// Unlike [`normalized_rms_error_db`], this directly compares the magnitude of
150/// the test tone, so it reports the TRUE level gap even when one signal rides on
151/// a large DC bias or is far below the other.  Returns `-inf` when the reference
152/// tone is silent.
153pub fn ac_gain_db(wdf: &[f64], reference: &[f64], freq_hz: f64, sample_rate: f64) -> f64 {
154    let aw = single_bin_amplitude(wdf, freq_hz, sample_rate);
155    let ar = single_bin_amplitude(reference, freq_hz, sample_rate);
156    if ar < 1e-30 {
157        return f64::NEG_INFINITY;
158    }
159    20.0 * (aw / ar).log10()
160}
161
162/// AC-amplitude drift across a signal: dB difference between the single-bin
163/// amplitude of the SECOND half vs the FIRST half of `signal`.
164///
165/// ≈0 dB means the tone has SETTLED (steady state).  A large magnitude means the
166/// window is still inside a transient (e.g. a coupling cap still charging), so
167/// any gain/shape number taken on it is unreliable.  This is the programmatic
168/// guard that a measurement window is past the settling transient.
169pub fn ac_amplitude_drift_db(signal: &[f64], freq_hz: f64, sample_rate: f64) -> f64 {
170    let h = signal.len() / 2;
171    if h == 0 {
172        return 0.0;
173    }
174    let a1 = single_bin_amplitude(&signal[..h], freq_hz, sample_rate);
175    let a2 = single_bin_amplitude(&signal[h..], freq_hz, sample_rate);
176    if a1 < 1e-30 {
177        return f64::INFINITY;
178    }
179    20.0 * (a2 / a1).log10()
180}
181
182/// Compute THD (Total Harmonic Distortion) in dB.
183///
184/// THD = 10 * log10(sum(harmonic_powers) / fundamental_power)
185///
186/// Uses Blackman window for spectral analysis.
187pub fn thd_db(signal: &[f64], fundamental_hz: f64, sample_rate: f64, n_harmonics: usize) -> f64 {
188    let spectrum = compute_spectrum(signal);
189    let bin_hz = sample_rate / signal.len() as f64;
190
191    // Find fundamental bin
192    let fund_bin = (fundamental_hz / bin_hz).round() as usize;
193    if fund_bin >= spectrum.len() {
194        return -200.0;
195    }
196
197    let fund_power = spectrum[fund_bin].powi(2);
198    if fund_power < 1e-30 {
199        return -200.0;
200    }
201
202    // Sum harmonic powers
203    let mut harm_power = 0.0;
204    for h in 2..=n_harmonics {
205        let harm_freq = fundamental_hz * h as f64;
206        if harm_freq > sample_rate / 2.0 {
207            break;
208        }
209        let harm_bin = (harm_freq / bin_hz).round() as usize;
210        if harm_bin < spectrum.len() {
211            harm_power += spectrum[harm_bin].powi(2);
212        }
213    }
214
215    10.0 * (harm_power / fund_power).log10()
216}
217
218/// Compute THD error between WDF and reference in dB.
219pub fn thd_error_db(wdf: &[f64], reference: &[f64], fundamental_hz: f64, sample_rate: f64) -> f64 {
220    let thd_wdf = thd_db(wdf, fundamental_hz, sample_rate, 10);
221    let thd_ref = thd_db(reference, fundamental_hz, sample_rate, 10);
222    (thd_wdf - thd_ref).abs()
223}
224
225/// Compute even/odd harmonic ratio in dB.
226///
227/// For push-pull amplifiers, even harmonics should be suppressed.
228/// A good push-pull stage has ratio < -40 dB.
229pub fn even_odd_ratio_db(
230    signal: &[f64],
231    fundamental_hz: f64,
232    sample_rate: f64,
233    n_harmonics: usize,
234) -> f64 {
235    let spectrum = compute_spectrum(signal);
236    let bin_hz = sample_rate / signal.len() as f64;
237
238    let mut even_power = 0.0;
239    let mut odd_power = 0.0;
240
241    for h in 2..=n_harmonics {
242        let harm_freq = fundamental_hz * h as f64;
243        if harm_freq > sample_rate / 2.0 {
244            break;
245        }
246        let harm_bin = (harm_freq / bin_hz).round() as usize;
247        if harm_bin < spectrum.len() {
248            let power = spectrum[harm_bin].powi(2);
249            if h % 2 == 0 {
250                even_power += power;
251            } else {
252                odd_power += power;
253            }
254        }
255    }
256
257    if odd_power < 1e-30 {
258        return 0.0;
259    }
260
261    10.0 * (even_power / odd_power).log10()
262}
263
264/// Compute maximum spectral magnitude error in dB.
265///
266/// Compares spectra up to `max_freq_hz`, only at bins where **both** the
267/// reference and WDF signals have significant energy.  The significance gate
268/// has two components:
269///
270/// 1. **Relative gate** — bin must be within 80 dB of the reference peak.
271/// 2. **Absolute floor** — both ref *and* WDF bins must be above −100 dBFS.
272///
273/// The absolute floor prevents false errors caused by comparing WDF's
274/// pristine numerical noise floor (≈ −316 dB) against ngspice's broadband
275/// numerical noise grass (≈ −95 dB).  Without the floor, a single weak
276/// reference harmonic at −99 dBFS that the WDF places at −109 dBFS (below
277/// the simulator floor) would contribute a ≈ 200 dB "error" even though the
278/// real harmonics agree within 1–2 dB.  The floor at −100 dBFS sits 5 dB
279/// *below* a typical 8-th harmonic of a well-simulated diode clipper
280/// (≈ −80 dBFS) and 5 dB *above* ngspice's typical noise grass (≈ −95 dBFS),
281/// so genuine circuit harmonics are still scored while simulator noise bins
282/// are excluded.
283pub fn spectral_error_db(
284    wdf: &[f64],
285    reference: &[f64],
286    sample_rate: f64,
287    max_freq_hz: Option<f64>,
288) -> f64 {
289    // Absolute noise-floor threshold: bins below this in *either* signal are
290    // excluded from scoring.  Chosen to be above ngspice broadband noise
291    // grass (≈ −95 dBFS) but well below real diode harmonics (≥ −80 dBFS).
292    const ABS_FLOOR_DB: f64 = -100.0;
293
294    let n = wdf.len().min(reference.len());
295    if n == 0 {
296        return 0.0;
297    }
298
299    let wdf_spec = compute_spectrum(&wdf[..n]);
300    let ref_spec = compute_spectrum(&reference[..n]);
301
302    let bin_hz = sample_rate / n as f64;
303    let max_freq = max_freq_hz.unwrap_or(sample_rate / 4.0);
304    let max_bin = ((max_freq / bin_hz) as usize).min(wdf_spec.len());
305
306    // Relative threshold: within 80 dB of the reference peak.
307    let ref_peak_db = ref_spec[..max_bin]
308        .iter()
309        .map(|&x| 20.0 * (x + 1e-30).log10())
310        .fold(f64::NEG_INFINITY, f64::max);
311
312    // Combined threshold: whichever is higher (less permissive).
313    let threshold_db = (ref_peak_db - 80.0).max(ABS_FLOOR_DB);
314
315    let mut max_error = 0.0_f64;
316
317    for i in 1..max_bin {
318        let ref_db = 20.0 * (ref_spec[i] + 1e-30).log10();
319        // Gate 1 (relative + absolute): reference bin must be significant.
320        if ref_db > threshold_db {
321            let wdf_db = 20.0 * (wdf_spec[i] + 1e-30).log10();
322            // Gate 2 (absolute): WDF bin must also be above the noise floor.
323            // A WDF bin below ABS_FLOOR_DB compared to a reference bin just
324            // above it is simulator-noise noise, not a circuit accuracy gap.
325            if wdf_db > ABS_FLOOR_DB {
326                let error = (wdf_db - ref_db).abs();
327                max_error = max_error.max(error);
328            }
329        }
330    }
331
332    max_error
333}
334
335/// Measure DC drift over time.
336///
337/// Returns the maximum DC offset observed (using a moving average).
338pub fn dc_drift_mv(signal: &[f64], sample_rate: f64, window_ms: f64) -> f64 {
339    let window_samples = (window_ms * sample_rate / 1000.0) as usize;
340    if window_samples == 0 || signal.len() < window_samples {
341        return 0.0;
342    }
343
344    let mut max_dc = 0.0_f64;
345    let mut running_sum: f64 = signal[..window_samples].iter().sum();
346
347    for i in window_samples..signal.len() {
348        let dc = running_sum / window_samples as f64;
349        max_dc = max_dc.max(dc.abs());
350        running_sum += signal[i] - signal[i - window_samples];
351    }
352
353    max_dc * 1000.0 // Convert to mV
354}
355
356/// Compute THD+N (Total Harmonic Distortion plus Noise) in dB.
357///
358/// THD+N = 10 * log10((total_power - fundamental_power) / fundamental_power)
359///
360/// Unlike [`thd_db`], which sums only discrete harmonic bins, THD+N includes
361/// broadband noise and intermodulation products across ALL bins except the
362/// fundamental.  Uses Blackman window for spectral analysis.
363///
364/// The fundamental exclusion zone is ±3 bins to prevent Blackman window
365/// sidelobes from artificially inflating the noise estimate.
366pub fn thd_plus_n_db(signal: &[f64], fundamental_hz: f64, sample_rate: f64) -> f64 {
367    const FUND_EXCL_BINS: usize = 3;
368
369    let spectrum = compute_spectrum(signal);
370    let bin_hz = sample_rate / signal.len() as f64;
371
372    let fund_bin = (fundamental_hz / bin_hz).round() as usize;
373    if fund_bin >= spectrum.len() {
374        return -200.0;
375    }
376
377    // Sum power in the exclusion zone around the fundamental to get
378    // the reference fundamental power.
379    let excl_lo = fund_bin.saturating_sub(FUND_EXCL_BINS);
380    let excl_hi = (fund_bin + FUND_EXCL_BINS + 1).min(spectrum.len());
381    let fund_power: f64 = spectrum[excl_lo..excl_hi].iter().map(|&x| x.powi(2)).sum();
382
383    if fund_power < 1e-30 {
384        return -200.0;
385    }
386
387    // Total power over all bins.
388    let total_power: f64 = spectrum.iter().map(|&x| x.powi(2)).sum();
389    // Noise+harmonic power = total minus the fundamental exclusion zone.
390    let noise_power = total_power - fund_power;
391
392    if noise_power <= 0.0 {
393        return -200.0;
394    }
395
396    10.0 * (noise_power / fund_power).log10()
397}
398
399/// Compute THD+N error between WDF and reference in dB.
400///
401/// Returns the absolute difference in THD+N between the two signals.
402pub fn thd_plus_n_error_db(
403    wdf: &[f64],
404    reference: &[f64],
405    fundamental_hz: f64,
406    sample_rate: f64,
407) -> f64 {
408    let wdf_tpn = thd_plus_n_db(wdf, fundamental_hz, sample_rate);
409    let ref_tpn = thd_plus_n_db(reference, fundamental_hz, sample_rate);
410    (wdf_tpn - ref_tpn).abs()
411}
412
413/// Compute the maximum harmonic magnitude error between WDF and reference in dB.
414///
415/// For each harmonic h in 2..=n_harmonics, reads both spectra at the h-th
416/// harmonic bin and computes `|20*log10(wdf_h) - 20*log10(ref_h)|`.  Returns
417/// the MAX error over harmonics where **both** bins exceed the −100 dBFS
418/// absolute floor (same gate as [`spectral_error_db`]).
419///
420/// This is a targeted complement to [`spectral_error_db`]: it scores only
421/// the discrete harmonic bins, making it meaningful at high-drive levels and
422/// immune to broadband noise grass between harmonics.
423pub fn harmonic_mag_error_db(
424    wdf: &[f64],
425    reference: &[f64],
426    fundamental_hz: f64,
427    sample_rate: f64,
428    n_harmonics: usize,
429) -> f64 {
430    const ABS_FLOOR_DB: f64 = -100.0;
431
432    let n = wdf.len().min(reference.len());
433    if n == 0 {
434        return 0.0;
435    }
436
437    let wdf_spec = compute_spectrum(&wdf[..n]);
438    let ref_spec = compute_spectrum(&reference[..n]);
439    let bin_hz = sample_rate / n as f64;
440
441    let mut max_error = 0.0_f64;
442
443    for h in 2..=n_harmonics {
444        let harm_freq = fundamental_hz * h as f64;
445        if harm_freq > sample_rate / 2.0 {
446            break;
447        }
448        let harm_bin = (harm_freq / bin_hz).round() as usize;
449        if harm_bin >= wdf_spec.len() || harm_bin >= ref_spec.len() {
450            continue;
451        }
452
453        let wdf_db = 20.0 * (wdf_spec[harm_bin] + 1e-30).log10();
454        let ref_db = 20.0 * (ref_spec[harm_bin] + 1e-30).log10();
455
456        // Only score bins where both signals are above the noise floor.
457        if wdf_db > ABS_FLOOR_DB && ref_db > ABS_FLOOR_DB {
458            let error = (wdf_db - ref_db).abs();
459            max_error = max_error.max(error);
460        }
461    }
462
463    max_error
464}
465
466/// Compute even/odd harmonic ratio error between WDF and reference in dB.
467///
468/// Returns the absolute difference in [`even_odd_ratio_db`] between the two
469/// signals.  Near zero for two signals with the same harmonic character;
470/// large when one has asymmetric and the other symmetric distortion.
471pub fn even_odd_ratio_error_db(
472    wdf: &[f64],
473    reference: &[f64],
474    fundamental_hz: f64,
475    sample_rate: f64,
476    n_harmonics: usize,
477) -> f64 {
478    let wdf_ratio = even_odd_ratio_db(wdf, fundamental_hz, sample_rate, n_harmonics);
479    let ref_ratio = even_odd_ratio_db(reference, fundamental_hz, sample_rate, n_harmonics);
480    (wdf_ratio - ref_ratio).abs()
481}
482
483/// Compute magnitude spectrum using Blackman window.
484fn compute_spectrum(signal: &[f64]) -> Vec<f64> {
485    let n = signal.len();
486    if n == 0 {
487        return vec![];
488    }
489
490    // Apply Blackman window
491    let mut windowed: Vec<f64> = signal
492        .iter()
493        .enumerate()
494        .map(|(i, &x)| {
495            let w = 0.42 - 0.5 * (2.0 * std::f64::consts::PI * i as f64 / (n - 1) as f64).cos()
496                + 0.08 * (4.0 * std::f64::consts::PI * i as f64 / (n - 1) as f64).cos();
497            x * w
498        })
499        .collect();
500
501    // Perform real FFT
502    let mut planner = RealFftPlanner::<f64>::new();
503    let fft = planner.plan_fft_forward(n);
504
505    let mut spectrum = vec![Complex::new(0.0, 0.0); n / 2 + 1];
506    fft.process(&mut windowed, &mut spectrum).unwrap();
507
508    // Return magnitudes
509    spectrum.iter().map(|c| c.norm() / n as f64).collect()
510}
511
512/// Result of comparing WDF output to reference.
513#[derive(Debug, Clone)]
514pub struct ComparisonResult {
515    pub normalized_rms_error_db: f64,
516    pub peak_error_db: f64,
517    pub thd_error_db: Option<f64>,
518    /// THD+N error between WDF and reference in dB.  None when fundamental_hz
519    /// is not provided.
520    pub thd_plus_n_error_db: Option<f64>,
521    /// Maximum per-harmonic magnitude error in dB.  None when fundamental_hz
522    /// is not provided.
523    pub harmonic_mag_error_db: Option<f64>,
524    /// Even/odd ratio error between WDF and reference in dB.  None when
525    /// fundamental_hz is not provided.
526    pub even_odd_ratio_error_db: Option<f64>,
527    /// Spectral error within the audio band (capped at the audio Nyquist by the
528    /// production runners). This is the value checked against pass criteria.
529    pub spectral_error_db: f64,
530    /// Spectral error across the full data spectrum (up to sample_rate / 2, i.e.
531    /// the oversampled Nyquist). Informational only — shows what the audio-band
532    /// cap removes (ultrasonic content the engine anti-aliases away).
533    pub spectral_error_full_db: f64,
534    pub even_odd_ratio_db: Option<f64>,
535    pub dc_drift_mv: Option<f64>,
536}
537
538impl ComparisonResult {
539    /// Check if all metrics pass the given criteria.
540    pub fn passes(&self, criteria: &crate::config::PassCriteria) -> bool {
541        if self.normalized_rms_error_db > criteria.normalized_rms_error_db.unwrap_or(f64::INFINITY)
542        {
543            return false;
544        }
545        if self.peak_error_db > criteria.peak_error_db.unwrap_or(f64::INFINITY) {
546            return false;
547        }
548        if let (Some(thd_err), Some(thresh)) = (self.thd_error_db, criteria.thd_error_db) {
549            if thd_err > thresh {
550                return false;
551            }
552        }
553        if let (Some(v), Some(t)) = (self.thd_plus_n_error_db, criteria.thd_plus_n_error_db) {
554            if v > t {
555                return false;
556            }
557        }
558        if let (Some(v), Some(t)) = (self.harmonic_mag_error_db, criteria.harmonic_mag_error_db) {
559            if v > t {
560                return false;
561            }
562        }
563        if let (Some(v), Some(t)) = (
564            self.even_odd_ratio_error_db,
565            criteria.even_odd_ratio_error_db,
566        ) {
567            if v > t {
568                return false;
569            }
570        }
571        if self.spectral_error_db > criteria.spectral_error_db.unwrap_or(f64::INFINITY) {
572            return false;
573        }
574        if let (Some(dc), Some(thresh)) = (self.dc_drift_mv, criteria.max_dc_drift_mv) {
575            if dc > thresh {
576                return false;
577            }
578        }
579        true
580    }
581}
582
583/// Compare WDF output to reference with all metrics.
584pub fn compare(
585    wdf: &[f64],
586    reference: &[f64],
587    sample_rate: f64,
588    fundamental_hz: Option<f64>,
589) -> ComparisonResult {
590    let thd_err = fundamental_hz.map(|f| thd_error_db(wdf, reference, f, sample_rate));
591    let tpn_err =
592        fundamental_hz.map(|f| thd_plus_n_error_db(wdf, reference, f, sample_rate));
593    let harm_mag_err =
594        fundamental_hz.map(|f| harmonic_mag_error_db(wdf, reference, f, sample_rate, 10));
595    let eo_ratio_err =
596        fundamental_hz.map(|f| even_odd_ratio_error_db(wdf, reference, f, sample_rate, 10));
597    let even_odd = fundamental_hz.map(|f| even_odd_ratio_db(wdf, f, sample_rate, 10));
598
599    ComparisonResult {
600        normalized_rms_error_db: normalized_rms_error_db(wdf, reference),
601        peak_error_db: peak_error_db(wdf, reference),
602        thd_error_db: thd_err,
603        thd_plus_n_error_db: tpn_err,
604        harmonic_mag_error_db: harm_mag_err,
605        even_odd_ratio_error_db: eo_ratio_err,
606        spectral_error_db: spectral_error_db(wdf, reference, sample_rate, None),
607        // Full-band (raw) spectral error up to the data Nyquist. The production
608        // runners overwrite `spectral_error_db` with the audio-band cap, but
609        // leave this raw value intact for display.
610        spectral_error_full_db: spectral_error_db(
611            wdf,
612            reference,
613            sample_rate,
614            Some(sample_rate / 2.0),
615        ),
616        even_odd_ratio_db: even_odd,
617        dc_drift_mv: Some(dc_drift_mv(wdf, sample_rate, 100.0)),
618    }
619}
620
621// ============================================================================
622// DC operating-point (bias) accuracy metric
623// ============================================================================
624//
625// This module adds the missing *primary* validation axis for active circuits:
626// does our compile-time/settled DC bias agree with ngspice's `.op`? It answers
627// two distinct questions that map to the literature's Layer A / Layer B
628// (`docs/dc-operating-point-solve.md`):
629//
630//   1. **MATCH** (Layer B): per-device ΔVbe / ΔVce / ΔIc between OUR settled DC
631//      operating point and ngspice's `.op`. A large MATCH error with a small
632//      RESIDUAL is a *solver / root-selection* problem — our equations are right
633//      but the cold solve lands in the wrong basin.
634//
635//   2. **RESIDUAL** (Layer A): `F(ngspice_op)` — is ngspice's `.op` a zero of OUR
636//      DC equations? A large residual is a *formulation* bug — our bias math
637//      genuinely differs from spice, independent of which root we converge to.
638//
639// The metric is plain-numeric so it stays decoupled from `pedalkernel-rt` (which
640// is a dev-dependency only): the test harness reads the engine's per-device bias
641// and per-port residual and feeds them in here.
642pub mod op_point {
643    use crate::spice::SpiceDeviceOp;
644
645    /// Our (WDF) DC operating point for a single device, read at the engine's
646    /// settled bias. Sign convention is the engine's *port* convention; the
647    /// comparison normalizes against ngspice's junction-magnitude convention.
648    #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
649    pub struct DeviceBias {
650        /// Component reference, uppercased (e.g. `Q1`).
651        pub reference: String,
652        /// Base–emitter port voltage, volts.
653        pub vbe: f64,
654        /// Collector–emitter port voltage, volts.
655        pub vce: f64,
656        /// Collector current, amps.
657        pub ic: f64,
658    }
659
660    /// Per-device MATCH comparison (ours vs ngspice).
661    #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
662    pub struct DeviceMatch {
663        pub reference: String,
664        pub model: String,
665        /// Our reading (Vbe, Vce, Ic).
666        pub ours: (f64, f64, f64),
667        /// ngspice `.op` (Vbe, Vce, Ic).
668        pub spice: (f64, f64, f64),
669        /// ΔVbe = ours − spice (volts), compared on junction magnitude.
670        pub d_vbe: f64,
671        /// ΔVce = ours − spice (volts), compared on magnitude.
672        pub d_vce: f64,
673        /// ΔIc as a percentage of |spice Ic| (∞-guarded: 0 when spice Ic ≈ 0).
674        pub d_ic_pct: f64,
675    }
676
677    /// Per-port DC-balance residual `F(ngspice_op)` (from
678    /// `MultiNlStage::op_seed_residual`, fed in as plain numbers).
679    #[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
680    pub struct PortResidual {
681        /// Static formulation residual `a − dc_bias − cap − nl − adapt` (volts).
682        pub residual: f64,
683        /// Full residual (includes the runtime DC servo); ≈ 0 at the engine's own
684        /// fixed point — calibrates the probe.
685        pub residual_full: f64,
686    }
687
688    /// Thresholds for grading the bias-accuracy dimension. Informative, not
689    /// punitive — tuned to localize Layer A vs Layer B, not to gate CI.
690    #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
691    pub struct OpPassCriteria {
692        /// ΔVbe warn threshold (volts). Default 10 mV.
693        pub vbe_warn_v: f64,
694        /// ΔVbe fail threshold (volts). Default 25 mV.
695        pub vbe_fail_v: f64,
696        /// ΔVce fail threshold (volts). Default 0.3 V.
697        pub vce_fail_v: f64,
698        /// ΔIc fail threshold (percent). Default 10 %.
699        pub ic_fail_pct: f64,
700        /// Static-residual threshold separating "formulation ≈ ok" from a
701        /// formulation bug (volts). Default 0.25 V.
702        ///
703        /// This is a DC-balance error in volts at a device port. Once the reactive
704        /// (capacitor) ports are seeded to ngspice's `.op` node voltages — the FULL
705        /// operating-point seed, not the old cold-cap probe — a residual on the
706        /// order of a junction drop means ngspice's `.op` is genuinely NOT a fixed
707        /// point of our DC equations (a formulation bug, Layer A). The BA283 lands
708        /// at ≈ 0.615 V (Q3 Vbe dominant) → Layer A. Tens-of-mV residuals still read
709        /// "≈ ok" → Layer B (solver / root-selection). The prior 1.0 V default was
710        /// set against a CONTAMINATED, cold-cap residual (≈ 0.04 V) and mis-called
711        /// the BA283 Layer B; with the caps seeded the true 0.615 V exceeds this.
712        pub residual_formulation_v: f64,
713    }
714
715    impl Default for OpPassCriteria {
716        fn default() -> Self {
717            Self {
718                vbe_warn_v: 0.010,
719                vbe_fail_v: 0.025,
720                vce_fail_v: 0.3,
721                ic_fail_pct: 10.0,
722                residual_formulation_v: 0.25,
723            }
724        }
725    }
726
727    /// The formulation-vs-solver verdict for one circuit.
728    #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
729    pub enum Verdict {
730        /// Residual ≈ 0 AND MATCH within thresholds — bias is right.
731        BiasOk,
732        /// Residual ≈ 0 but MATCH off — ngspice's `.op` IS a fixed point of our
733        /// equations, but the cold solve converged to a different root.
734        /// (Layer B: solver / root-selection.)
735        FormulationOkSolverOff,
736        /// Residual far from 0 — ngspice's `.op` is NOT a fixed point of our
737        /// equations. (Layer A: formulation bug.)
738        FormulationBug,
739        /// No active devices / no residual ports — nothing to grade.
740        NoData,
741    }
742
743    impl Verdict {
744        pub fn label(&self) -> &'static str {
745            match self {
746                Verdict::BiasOk => "BIAS OK",
747                Verdict::FormulationOkSolverOff => "FORMULATION ~OK / SOLVER-OFF (Layer B)",
748                Verdict::FormulationBug => "FORMULATION BUG (Layer A)",
749                Verdict::NoData => "NO DATA",
750            }
751        }
752    }
753
754    /// Full bias-accuracy result for one circuit.
755    #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
756    pub struct OpPointResult {
757        pub circuit: String,
758        pub devices: Vec<DeviceMatch>,
759        /// Worst per-device readings (rolled up).
760        pub max_abs_d_vbe: f64,
761        pub max_abs_d_vce: f64,
762        pub max_abs_d_ic_pct: f64,
763        /// Worst static residual across ports (volts) — the formulation probe.
764        pub max_abs_residual: f64,
765        /// Worst full residual across ports (volts) — should be ≈ 0 (calibration).
766        pub max_abs_residual_full: f64,
767        pub n_residual_ports: usize,
768        pub verdict: Verdict,
769    }
770
771    impl OpPointResult {
772        /// True when MATCH is within all fail thresholds.
773        pub fn match_ok(&self, c: &OpPassCriteria) -> bool {
774            self.max_abs_d_vbe <= c.vbe_fail_v
775                && self.max_abs_d_vce <= c.vce_fail_v
776                && self.max_abs_d_ic_pct <= c.ic_fail_pct
777        }
778        /// True when the static residual is small enough to call the formulation
779        /// approximately correct.
780        pub fn formulation_ok(&self, c: &OpPassCriteria) -> bool {
781            self.max_abs_residual <= c.residual_formulation_v
782        }
783    }
784
785    /// Compute the per-device MATCH between our settled bias and ngspice `.op`.
786    ///
787    /// Devices are paired by uppercased reference. Comparison is on junction
788    /// magnitude (|Vbe|, |Vce|, |Ic|) so PNP/NPN and port-vs-node sign
789    /// conventions don't masquerade as errors.
790    pub fn match_devices(ours: &[DeviceBias], spice: &[SpiceDeviceOp]) -> Vec<DeviceMatch> {
791        let mut out = Vec::new();
792        for s in spice {
793            let key = s.device.to_uppercase();
794            let Some(o) = ours.iter().find(|d| d.reference.to_uppercase() == key) else {
795                continue;
796            };
797            let d_vbe = o.vbe.abs() - s.vbe.abs();
798            let d_vce = o.vce.abs() - s.vce.abs();
799            let d_ic_pct = if s.ic.abs() > 1e-12 {
800                100.0 * (o.ic.abs() - s.ic.abs()) / s.ic.abs()
801            } else {
802                0.0
803            };
804            out.push(DeviceMatch {
805                reference: o.reference.clone(),
806                model: s.model.clone(),
807                ours: (o.vbe, o.vce, o.ic),
808                spice: (s.vbe, s.vce, s.ic),
809                d_vbe,
810                d_vce,
811                d_ic_pct,
812            });
813        }
814        out
815    }
816
817    /// Assemble the full bias-accuracy result + verdict for one circuit.
818    pub fn evaluate(
819        circuit: &str,
820        ours: &[DeviceBias],
821        spice: &[SpiceDeviceOp],
822        residuals: &[PortResidual],
823        criteria: &OpPassCriteria,
824    ) -> OpPointResult {
825        let devices = match_devices(ours, spice);
826        let max_abs_d_vbe = devices.iter().map(|d| d.d_vbe.abs()).fold(0.0, f64::max);
827        let max_abs_d_vce = devices.iter().map(|d| d.d_vce.abs()).fold(0.0, f64::max);
828        let max_abs_d_ic_pct = devices.iter().map(|d| d.d_ic_pct.abs()).fold(0.0, f64::max);
829        let max_abs_residual = residuals.iter().map(|p| p.residual.abs()).fold(0.0, f64::max);
830        let max_abs_residual_full =
831            residuals.iter().map(|p| p.residual_full.abs()).fold(0.0, f64::max);
832
833        let mut result = OpPointResult {
834            circuit: circuit.to_string(),
835            devices,
836            max_abs_d_vbe,
837            max_abs_d_vce,
838            max_abs_d_ic_pct,
839            max_abs_residual,
840            max_abs_residual_full,
841            n_residual_ports: residuals.len(),
842            verdict: Verdict::NoData,
843        };
844
845        result.verdict = if result.devices.is_empty() && residuals.is_empty() {
846            Verdict::NoData
847        } else if !result.formulation_ok(criteria) {
848            Verdict::FormulationBug
849        } else if result.match_ok(criteria) {
850            Verdict::BiasOk
851        } else {
852            Verdict::FormulationOkSolverOff
853        };
854        result
855    }
856}
857
858// ============================================================================
859// AC-signal accuracy metric (the audio twin of the DC bias-accuracy dashboard)
860// ============================================================================
861//
862// The DC dashboard (`op_point`) grades the operating point; this module grades
863// the AC SIGNAL — WDF vs the ngspice-derived golden. Its whole reason to exist
864// is that `normalized_rms_error_db` DEGENERATES for a pure-gain-offset circuit:
865// when the WDF and golden differ only by a scalar level `k`, that difference
866// metric reads |k−1| (and → 0 dB as the WDF falls far below the golden), so it
867// can neither see the true level gap nor the shape. The BA283 is exactly such a
868// circuit (a clean Class-A gain block sitting at a fixed +2.42 dB level offset
869// with the DC servo on), so a single-bin gain scalar is all the old test could
870// honestly report.
871//
872// The fix here is to DECOMPOSE the AC error into orthogonal dimensions so each
873// is actually measured:
874//
875//   1. LEVEL   — `ac_gain_db` at the test tone (the scalar offset, e.g. +2.42).
876//   2. SHAPE   — gain-normalize the WDF (× golden_amp/wdf_amp) so the level is
877//                factored OUT, THEN measure `normalized_rms_error_db` (phase-
878//                sensitive, time domain) and `spectral_error_db` (phase-immune,
879//                magnitude) on the level-matched pair = the honest shape residual.
880//   3. HARMONICS — `thd_db(wdf)` vs `thd_db(golden)` (a ratio, inherently level-
881//                independent) plus per-harmonic (2nd–5th) magnitude error read in
882//                dBc (relative to the fundamental), which is also level-immune.
883//   4. RESPONSE — `ac_gain_db` across a frequency sweep. If the per-frequency
884//                gain is FLAT the error is a pure scalar (a level bug); if it is
885//                frequency-SHAPED it is a genuine response error.
886//
887// The verdict then states plainly whether the AC error is JUST the level offset
888// (shape/THD/response all clean ⇒ a flat scalar bug) or ALSO a shape / harmonic /
889// response error (⇒ more than one bug). Plain-numeric like `op_point`, so it
890// stays decoupled from `pedalkernel-rt`.
891pub mod ac_accuracy {
892    use super::{
893        ac_gain_db, normalized_rms_error_db, single_bin_amplitude, spectral_error_db, thd_db,
894    };
895    use serde::{Deserialize, Serialize};
896
897    /// Harmonic orders scored per-tone (2nd through 5th).
898    pub const HARMONIC_ORDERS: [usize; 4] = [2, 3, 4, 5];
899
900    /// A golden harmonic weaker than this (relative to the fundamental) is treated
901    /// as absent (below the simulator noise grass): its per-harmonic error is not
902    /// scored for the verdict, so ngspice noise bins don't masquerade as shape
903    /// error. −80 dBc sits above ngspice's typical noise grass yet below a real
904    /// diode/BJT harmonic.
905    pub const HARMONIC_FLOOR_DBC: f64 = -80.0;
906
907    /// One harmonic's level, WDF vs golden, expressed in dBc (relative to the
908    /// fundamental) so it is level-independent by construction.
909    #[derive(Debug, Clone, Serialize, Deserialize)]
910    pub struct HarmonicError {
911        /// Harmonic order (2..=5).
912        pub order: usize,
913        /// WDF harmonic level relative to its own fundamental, dB.
914        pub wdf_dbc: f64,
915        /// Golden harmonic level relative to its own fundamental, dB.
916        pub golden_dbc: f64,
917        /// `|wdf_dbc − golden_dbc|`, the level-independent per-harmonic residual.
918        pub error_db: f64,
919        /// True when the GOLDEN harmonic is above [`HARMONIC_FLOOR_DBC`] (present,
920        /// not noise). Only scored harmonics feed the verdict.
921        pub scored: bool,
922    }
923
924    /// One frequency-sweep point: WDF-vs-golden AC gain at that frequency.
925    #[derive(Debug, Clone, Copy, Serialize, Deserialize)]
926    pub struct FreqPoint {
927        pub freq_hz: f64,
928        /// `ac_gain_db(wdf, golden)` at this frequency (WDF louder ⇒ positive).
929        pub gain_db: f64,
930    }
931
932    /// Thresholds for grading the AC dimensions. Informative (localizes which
933    /// dimension carries the error), not a hard CI gate.
934    #[derive(Debug, Clone, Serialize, Deserialize)]
935    pub struct AcThresholds {
936        /// A |gain| above this at the test tone counts as a LEVEL offset. 1 dB.
937        pub gain_warn_db: f64,
938        /// Gain-normalized RMS shape residual (dB) above which SHAPE is bad. The
939        /// residual is `20·log10(rms(wdf_norm − golden)/rms(golden))`, so −20 dB
940        /// (≈ 10 % shape difference) is the boundary. NOTE: this is phase-
941        /// SENSITIVE — a pure group-delay difference inflates it even when the
942        /// magnitude shape agrees; cross-check with `shape_spectral`.
943        pub shape_rms_fail_db: f64,
944        /// Gain-normalized, phase-IMMUNE in-band magnitude shape error (dB) above
945        /// which SHAPE is bad. 3 dB.
946        pub shape_spectral_fail_db: f64,
947        /// |THD_wdf − THD_golden| (dB) above which HARMONICS are bad. 6 dB.
948        pub thd_error_fail_db: f64,
949        /// Max per-harmonic dBc error above which HARMONICS are bad. 6 dB.
950        pub harmonic_fail_db: f64,
951        /// Response tilt (max−min gain across the sweep, dB) above which the error
952        /// is frequency-SHAPED (a RESPONSE error, not a flat scalar). 3 dB.
953        pub response_tilt_fail_db: f64,
954    }
955
956    impl Default for AcThresholds {
957        fn default() -> Self {
958            Self {
959                gain_warn_db: 1.0,
960                shape_rms_fail_db: -20.0,
961                shape_spectral_fail_db: 3.0,
962                thd_error_fail_db: 6.0,
963                harmonic_fail_db: 6.0,
964                response_tilt_fail_db: 3.0,
965            }
966        }
967    }
968
969    /// The AC-fidelity verdict for one circuit — which dimension carries the error.
970    #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
971    pub enum AcVerdict {
972        /// Level, shape, harmonics AND response all within thresholds.
973        Clean,
974        /// ONLY the level tone is off; shape + THD + response are all clean ⇒ the
975        /// AC error is a FLAT SCALAR (a pure-gain bug), the waveform shape matches.
976        LevelOnly,
977        /// The gain-normalized shape residual exceeds threshold ⇒ the waveform
978        /// shape itself differs (beyond a scalar level).
979        ShapeError,
980        /// THD or a per-harmonic level differs ⇒ the nonlinearity/harmonic content
981        /// differs (beyond a scalar level).
982        HarmonicError,
983        /// The per-frequency gain is not flat ⇒ a frequency-shaped RESPONSE error.
984        ResponseError,
985        /// No usable golden tone (silence) — nothing to grade.
986        NoData,
987    }
988
989    impl AcVerdict {
990        pub fn label(&self) -> &'static str {
991            match self {
992                AcVerdict::Clean => "AC OK",
993                AcVerdict::LevelOnly => "LEVEL-ONLY (flat scalar; shape clean)",
994                AcVerdict::ShapeError => "SHAPE ERROR (waveform differs)",
995                AcVerdict::HarmonicError => "HARMONIC ERROR (THD/harmonics differ)",
996                AcVerdict::ResponseError => "RESPONSE ERROR (frequency-shaped)",
997                AcVerdict::NoData => "NO DATA",
998            }
999        }
1000    }
1001
1002    /// Full AC-accuracy result for one circuit.
1003    #[derive(Debug, Clone, Serialize, Deserialize)]
1004    pub struct AcResult {
1005        pub circuit: String,
1006        /// Fundamental at which the level/shape/harmonic decomposition was taken.
1007        pub test_freq_hz: f64,
1008        /// LEVEL: `ac_gain_db(wdf, golden)` at the test tone (the scalar offset).
1009        pub gain_db: f64,
1010        /// WDF single-bin amplitude at the test tone (signal units).
1011        pub wdf_amp: f64,
1012        /// Golden single-bin amplitude at the test tone (signal units).
1013        pub golden_amp: f64,
1014        /// SHAPE (level factored out, phase-sensitive time domain), dB.
1015        pub shape_rms_db: f64,
1016        /// SHAPE (level factored out, phase-immune magnitude), dB.
1017        pub shape_spectral_db: f64,
1018        /// THD of the WDF tone, dB.
1019        pub thd_wdf_db: f64,
1020        /// THD of the golden tone, dB.
1021        pub thd_golden_db: f64,
1022        /// Per-harmonic (2nd–5th) dBc levels + error.
1023        pub harmonics: Vec<HarmonicError>,
1024        /// Frequency-response sweep (per-frequency WDF-vs-golden gain).
1025        pub response: Vec<FreqPoint>,
1026        /// Response tilt = max−min gain across the sweep, dB. ≈0 ⇒ flat (a scalar
1027        /// level offset); large ⇒ frequency-shaped response error.
1028        pub response_tilt_db: f64,
1029        pub verdict: AcVerdict,
1030    }
1031
1032    impl AcResult {
1033        /// Worst per-harmonic error over SCORED harmonics (golden present).
1034        pub fn max_harmonic_error_db(&self) -> f64 {
1035            self.harmonics
1036                .iter()
1037                .filter(|h| h.scored)
1038                .map(|h| h.error_db)
1039                .fold(0.0, f64::max)
1040        }
1041
1042        /// |THD_wdf − THD_golden|.
1043        pub fn thd_error_db(&self) -> f64 {
1044            (self.thd_wdf_db - self.thd_golden_db).abs()
1045        }
1046    }
1047
1048    /// One harmonic's level relative to the fundamental, in dBc, via single-bin
1049    /// projection (DC-immune). Returns `NEG_INFINITY` if the fundamental is silent.
1050    fn harmonic_dbc(signal: &[f64], fund_hz: f64, harm_hz: f64, sample_rate: f64) -> f64 {
1051        let fund = single_bin_amplitude(signal, fund_hz, sample_rate);
1052        if fund < 1e-30 {
1053            return f64::NEG_INFINITY;
1054        }
1055        let harm = single_bin_amplitude(signal, harm_hz, sample_rate);
1056        20.0 * (harm / fund).max(1e-12).log10()
1057    }
1058
1059    /// Response tilt: max−min over the finite per-frequency gains.
1060    pub fn response_tilt_db(response: &[FreqPoint]) -> f64 {
1061        let finite: Vec<f64> = response
1062            .iter()
1063            .map(|p| p.gain_db)
1064            .filter(|g| g.is_finite())
1065            .collect();
1066        if finite.len() < 2 {
1067            return 0.0;
1068        }
1069        let max = finite.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
1070        let min = finite.iter().cloned().fold(f64::INFINITY, f64::min);
1071        max - min
1072    }
1073
1074    /// Decompose the WDF-vs-golden AC error into level / shape / harmonics /
1075    /// response and reach a verdict.
1076    ///
1077    /// `wdf` and `golden` are settled, same-rate, same-length, same-phase
1078    /// windows of the SAME test tone at `test_freq_hz`. `response` is the
1079    /// per-frequency gain sweep (may be empty; then RESPONSE is not graded).
1080    pub fn evaluate(
1081        circuit: &str,
1082        wdf: &[f64],
1083        golden: &[f64],
1084        sample_rate: f64,
1085        test_freq_hz: f64,
1086        response: Vec<FreqPoint>,
1087        th: &AcThresholds,
1088    ) -> AcResult {
1089        let n = wdf.len().min(golden.len());
1090        let wdf = &wdf[..n];
1091        let golden = &golden[..n];
1092
1093        let wdf_amp = single_bin_amplitude(wdf, test_freq_hz, sample_rate);
1094        let golden_amp = single_bin_amplitude(golden, test_freq_hz, sample_rate);
1095        let gain_db = ac_gain_db(wdf, golden, test_freq_hz, sample_rate);
1096
1097        // SHAPE: gain-normalize the WDF so the scalar level is factored OUT, then
1098        // measure the residual. This is THE fix for the degeneracy — after
1099        // normalization a pure level offset contributes nothing, so what remains
1100        // is the honest shape difference.
1101        let k = if wdf_amp > 1e-30 {
1102            golden_amp / wdf_amp
1103        } else {
1104            0.0
1105        };
1106        let wdf_norm: Vec<f64> = wdf.iter().map(|&x| k * x).collect();
1107        let shape_rms_db = normalized_rms_error_db(&wdf_norm, golden);
1108        let shape_spectral_db = spectral_error_db(&wdf_norm, golden, sample_rate, None);
1109
1110        // HARMONICS: THD ratio (level-independent) + per-harmonic dBc.
1111        let thd_wdf_db = thd_db(wdf, test_freq_hz, sample_rate, 10);
1112        let thd_golden_db = thd_db(golden, test_freq_hz, sample_rate, 10);
1113        let harmonics: Vec<HarmonicError> = HARMONIC_ORDERS
1114            .iter()
1115            .filter_map(|&order| {
1116                let hf = order as f64 * test_freq_hz;
1117                if hf >= sample_rate / 2.0 {
1118                    return None;
1119                }
1120                let wdf_dbc = harmonic_dbc(wdf, test_freq_hz, hf, sample_rate);
1121                let golden_dbc = harmonic_dbc(golden, test_freq_hz, hf, sample_rate);
1122                let scored = golden_dbc > HARMONIC_FLOOR_DBC;
1123                Some(HarmonicError {
1124                    order,
1125                    wdf_dbc,
1126                    golden_dbc,
1127                    error_db: (wdf_dbc - golden_dbc).abs(),
1128                    scored,
1129                })
1130            })
1131            .collect();
1132
1133        let response_tilt_db = response_tilt_db(&response);
1134
1135        let mut result = AcResult {
1136            circuit: circuit.to_string(),
1137            test_freq_hz,
1138            gain_db,
1139            wdf_amp,
1140            golden_amp,
1141            shape_rms_db,
1142            shape_spectral_db,
1143            thd_wdf_db,
1144            thd_golden_db,
1145            harmonics,
1146            response,
1147            response_tilt_db,
1148            verdict: AcVerdict::NoData,
1149        };
1150
1151        // Classify. Priority: a genuine shape/harmonic/response error outranks a
1152        // pure level offset (which is the benign, expected BA283 signature).
1153        let level_off = gain_db.abs() > th.gain_warn_db;
1154        let shape_bad = shape_rms_db > th.shape_rms_fail_db
1155            || shape_spectral_db > th.shape_spectral_fail_db;
1156        let harm_bad = result.thd_error_db() > th.thd_error_fail_db
1157            || result.max_harmonic_error_db() > th.harmonic_fail_db;
1158        let resp_bad =
1159            !result.response.is_empty() && response_tilt_db > th.response_tilt_fail_db;
1160
1161        result.verdict = if golden_amp < 1e-12 {
1162            AcVerdict::NoData
1163        } else if shape_bad {
1164            AcVerdict::ShapeError
1165        } else if harm_bad {
1166            AcVerdict::HarmonicError
1167        } else if resp_bad {
1168            AcVerdict::ResponseError
1169        } else if level_off {
1170            AcVerdict::LevelOnly
1171        } else {
1172            AcVerdict::Clean
1173        };
1174        result
1175    }
1176}
1177
1178#[cfg(test)]
1179mod tests {
1180    use super::*;
1181
1182    #[test]
1183    fn identical_signals_have_zero_error() {
1184        let sig: Vec<f64> = (0..1000).map(|i| (i as f64 * 0.1).sin()).collect();
1185        assert!(normalized_rms_error_db(&sig, &sig) < -100.0);
1186        assert!(peak_error_db(&sig, &sig) < -100.0);
1187    }
1188
1189    #[test]
1190    fn single_bin_amplitude_recovers_tone_level() {
1191        let sr = 48000.0;
1192        let amp = 0.7;
1193        let sig: Vec<f64> = (0..48000)
1194            .map(|i| amp * (2.0 * std::f64::consts::PI * 1000.0 * i as f64 / sr).sin())
1195            .collect();
1196        let a = single_bin_amplitude(&sig, 1000.0, sr);
1197        assert!((a - amp).abs() < 1e-3, "expected ~{amp}, got {a}");
1198    }
1199
1200    #[test]
1201    fn single_bin_amplitude_is_immune_to_dc_pedestal() {
1202        // A device output sitting on a +5 V DC bias with a small AC tone.
1203        let sr = 48000.0;
1204        let ac = 0.05;
1205        let sig: Vec<f64> = (0..48000)
1206            .map(|i| 5.0 + ac * (2.0 * std::f64::consts::PI * 1000.0 * i as f64 / sr).sin())
1207            .collect();
1208        let a = single_bin_amplitude(&sig, 1000.0, sr);
1209        assert!((a - ac).abs() < 1e-3, "DC pedestal must not affect AC amplitude; got {a}");
1210    }
1211
1212    /// The core point of the new metric: when the WDF is far BELOW the golden,
1213    /// `normalized_rms_error_db` DEGENERATES to ~0 dB ("shape matches") while
1214    /// `ac_gain_db` correctly reports the true ~-40 dB level gap.
1215    #[test]
1216    fn ac_gain_db_exposes_what_normalized_rms_hides() {
1217        let sr = 48000.0;
1218        let golden: Vec<f64> = (0..48000)
1219            .map(|i| (2.0 * std::f64::consts::PI * 1000.0 * i as f64 / sr).sin())
1220            .collect();
1221        // WDF is the same tone at 1% of the level (≈ -40 dB), e.g. an under-driven
1222        // input port.
1223        let wdf: Vec<f64> = golden.iter().map(|&x| 0.01 * x).collect();
1224
1225        let nrms = normalized_rms_error_db(&wdf, &golden);
1226        let gain = ac_gain_db(&wdf, &golden, 1000.0, sr);
1227
1228        // normalized RMS reads ~0 dB (degenerate: rms(wdf-ref) ≈ rms(ref)).
1229        assert!(
1230            nrms.abs() < 1.0,
1231            "normalized_rms_error_db is degenerate here (expected ~0 dB), got {nrms:.2}"
1232        );
1233        // ac_gain_db reports the real gap: 0.01 → -40 dB.
1234        assert!(
1235            (gain + 40.0).abs() < 0.5,
1236            "ac_gain_db must report the true -40 dB gap, got {gain:.2}"
1237        );
1238    }
1239
1240    #[test]
1241    fn ac_gain_db_doubling_is_plus_six_db_regardless_of_dc() {
1242        let sr = 48000.0;
1243        let golden: Vec<f64> = (0..48000)
1244            .map(|i| 0.1 * (2.0 * std::f64::consts::PI * 1000.0 * i as f64 / sr).sin())
1245            .collect();
1246        // WDF: double the AC amplitude AND add an unrelated DC bias.
1247        let wdf: Vec<f64> = golden.iter().map(|&x| 3.3 + 2.0 * x).collect();
1248        let gain = ac_gain_db(&wdf, &golden, 1000.0, sr);
1249        assert!((gain - 6.02).abs() < 0.1, "2x AC → +6 dB, got {gain:.2}");
1250    }
1251
1252    #[test]
1253    fn ac_amplitude_drift_zero_for_steady_large_for_ramp() {
1254        let sr = 48000.0;
1255        // Steady tone → ~0 dB drift.
1256        let steady: Vec<f64> = (0..48000)
1257            .map(|i| (2.0 * std::f64::consts::PI * 1000.0 * i as f64 / sr).sin())
1258            .collect();
1259        assert!(
1260            ac_amplitude_drift_db(&steady, 1000.0, sr).abs() < 0.2,
1261            "steady tone must show ~0 dB drift"
1262        );
1263        // Amplitude ramp (transient): envelope grows from 0.1 to 1.0 over the
1264        // window → second half is much louder than the first.
1265        let n = 48000usize;
1266        let ramp: Vec<f64> = (0..n)
1267            .map(|i| {
1268                let env = 0.1 + 0.9 * (i as f64 / n as f64);
1269                env * (2.0 * std::f64::consts::PI * 1000.0 * i as f64 / sr).sin()
1270            })
1271            .collect();
1272        let drift = ac_amplitude_drift_db(&ramp, 1000.0, sr);
1273        assert!(drift > 3.0, "ramping (unsettled) tone must show large drift, got {drift:.2}");
1274    }
1275
1276    #[test]
1277    fn thd_of_pure_sine_is_very_low() {
1278        let sr = 48000.0;
1279        let sig: Vec<f64> = (0..48000)
1280            .map(|i| (2.0 * std::f64::consts::PI * 1000.0 * i as f64 / sr).sin())
1281            .collect();
1282        let thd = thd_db(&sig, 1000.0, sr, 10);
1283        assert!(thd < -60.0, "Pure sine THD should be < -60dB, got {thd}");
1284    }
1285
1286    #[test]
1287    fn clipped_sine_has_high_thd() {
1288        let sr = 48000.0;
1289        let sig: Vec<f64> = (0..48000)
1290            .map(|i| {
1291                let x = (2.0 * std::f64::consts::PI * 1000.0 * i as f64 / sr).sin();
1292                x.clamp(-0.5, 0.5) // Hard clip at ±0.5
1293            })
1294            .collect();
1295        let thd = thd_db(&sig, 1000.0, sr, 10);
1296        assert!(thd > -20.0, "Clipped sine THD should be > -20dB, got {thd}");
1297    }
1298
1299    /// Verify that the noise-floor gate does not cause spurious large spectral
1300    /// errors when matching harmonics agree but one signal has a lower noise
1301    /// floor than the other (the "false 218 dB" single_diode scenario).
1302    ///
1303    /// Why this matters: ngspice's broadband numerical noise grass sits at
1304    /// roughly −95 dBFS.  The WDF engine's numerical floor is much lower
1305    /// (≈ −316 dBFS).  Without the absolute floor gate, bins in the ref that
1306    /// are just above ref_peak−80 dB (e.g. −99 dBFS) get compared against WDF
1307    /// bins at −316 dBFS, yielding ≈ 220 dB "error" — even though the real
1308    /// harmonics agree.  The fix requires both ref and WDF to be above
1309    /// −100 dBFS before scoring the bin's error.
1310    #[test]
1311    fn spectral_error_ignores_noise_floor_mismatch() {
1312        let sr = 48000.0;
1313        let n = sr as usize; // 1 second
1314        let freq = 1000.0_f64;
1315
1316        // Reference: sine with the first several harmonics (simulating a
1317        // nonlinear SPICE output with noise grass at −95 dBFS).
1318        let ref_signal: Vec<f64> = (0..n)
1319            .map(|i| {
1320                let t = i as f64 / sr;
1321                let x = (2.0 * std::f64::consts::PI * freq * t).sin();
1322                // Simulate ngspice noise grass: 1e-5 amplitude ≈ −100 dBFS
1323                let noise_floor = 1e-5 * ((i as f64 * 7.13).sin());
1324                x + noise_floor
1325            })
1326            .collect();
1327
1328        // WDF: same sine at same level but with effectively zero noise floor
1329        // (pristine numerical output — no added noise grass).
1330        let wdf_signal: Vec<f64> = (0..n)
1331            .map(|i| {
1332                let t = i as f64 / sr;
1333                (2.0 * std::f64::consts::PI * freq * t).sin()
1334            })
1335            .collect();
1336
1337        let err = spectral_error_db(&wdf_signal, &ref_signal, sr, None);
1338
1339        // The two signals are identical except for noise grass below −100 dBFS.
1340        // The gate must exclude those bins, so the error must be small (< 3 dB).
1341        // Pre-fix this would report ≈ 100–200 dB because WDF's −316 dBFS bins
1342        // were scored against ref's −100 dBFS noise grass.
1343        assert!(
1344            err < 3.0,
1345            "Noise-floor mismatch should produce < 3 dB spectral error, got {err:.1} dB. \
1346             Check that the absolute noise-floor gate in spectral_error_db is working."
1347        );
1348    }
1349
1350    /// THD+N of a hard-clipped sine must be much higher than a clean sine.
1351    ///
1352    /// Why this matters: THD+N includes broadband noise and intermod across all
1353    /// bins, so a hard clipper that produces strong harmonics AND intermod
1354    /// noise should score significantly higher than a pure sine.
1355    #[test]
1356    fn thd_plus_n_clipped_sine_much_higher_than_clean() {
1357        let sr = 48000.0;
1358        let clean: Vec<f64> = (0..48000)
1359            .map(|i| (2.0 * std::f64::consts::PI * 1000.0 * i as f64 / sr).sin())
1360            .collect();
1361        let clipped: Vec<f64> = clean.iter().map(|&x| x.clamp(-0.3, 0.3)).collect();
1362
1363        let clean_tpn = thd_plus_n_db(&clean, 1000.0, sr);
1364        let clipped_tpn = thd_plus_n_db(&clipped, 1000.0, sr);
1365
1366        assert!(
1367            clipped_tpn > clean_tpn + 10.0,
1368            "Clipped sine THD+N ({clipped_tpn:.1} dB) should be >10 dB higher than clean ({clean_tpn:.1} dB)"
1369        );
1370    }
1371
1372    /// harmonic_mag_error_db ~0 when wdf == ref, large when one harmonic differs by ~20 dB.
1373    ///
1374    /// Why this matters: the metric must be sensitive to per-harmonic level
1375    /// mismatches that matter for circuit accuracy but immune to noise in bins
1376    /// where both signals are below −100 dBFS.
1377    #[test]
1378    fn harmonic_mag_error_zero_for_identical_large_for_mismatch() {
1379        let sr = 48000.0;
1380        let n = 48000_usize;
1381        let f = 1000.0_f64;
1382
1383        // Shared base: fundamental + three harmonics at known levels.
1384        let base: Vec<f64> = (0..n)
1385            .map(|i| {
1386                let t = i as f64 / sr;
1387                (2.0 * std::f64::consts::PI * f * t).sin()
1388                    + 0.1 * (2.0 * std::f64::consts::PI * 2.0 * f * t).sin()
1389                    + 0.05 * (2.0 * std::f64::consts::PI * 3.0 * f * t).sin()
1390            })
1391            .collect();
1392
1393        // Identical signals → error ~0.
1394        let err_same = harmonic_mag_error_db(&base, &base, f, sr, 10);
1395        assert!(
1396            err_same < 1.0,
1397            "Identical signals should give ~0 dB harmonic_mag_error, got {err_same:.2} dB"
1398        );
1399
1400        // WDF with 2nd harmonic reduced by ~20 dB (0.1 → 0.01).
1401        let wdf_altered: Vec<f64> = (0..n)
1402            .map(|i| {
1403                let t = i as f64 / sr;
1404                (2.0 * std::f64::consts::PI * f * t).sin()
1405                    + 0.01 * (2.0 * std::f64::consts::PI * 2.0 * f * t).sin()
1406                    + 0.05 * (2.0 * std::f64::consts::PI * 3.0 * f * t).sin()
1407            })
1408            .collect();
1409
1410        let err_altered = harmonic_mag_error_db(&wdf_altered, &base, f, sr, 10);
1411        assert!(
1412            err_altered >= 15.0,
1413            "~20 dB h2 level change should give >=15 dB harmonic_mag_error, got {err_altered:.2} dB"
1414        );
1415    }
1416
1417    /// even_odd_ratio_error_db ~0 for identical signals, large when symmetry differs.
1418    ///
1419    /// Why this matters: push-pull vs single-ended topologies differ in their
1420    /// even/odd balance; this metric captures that difference.
1421    #[test]
1422    fn even_odd_ratio_error_zero_same_large_when_symmetry_differs() {
1423        let sr = 48000.0;
1424        let n = 48000_usize;
1425        let f = 1000.0_f64;
1426
1427        // Asymmetric distortion: strong even harmonics (characteristic of single-ended).
1428        let asymmetric: Vec<f64> = (0..n)
1429            .map(|i| {
1430                let t = i as f64 / sr;
1431                (2.0 * std::f64::consts::PI * f * t).sin()
1432                    + 0.3 * (2.0 * std::f64::consts::PI * 2.0 * f * t).sin()
1433                    + 0.05 * (2.0 * std::f64::consts::PI * 3.0 * f * t).sin()
1434            })
1435            .collect();
1436
1437        // Symmetric distortion: strong odd harmonics only (characteristic of push-pull).
1438        let symmetric: Vec<f64> = (0..n)
1439            .map(|i| {
1440                let t = i as f64 / sr;
1441                (2.0 * std::f64::consts::PI * f * t).sin()
1442                    + 0.01 * (2.0 * std::f64::consts::PI * 2.0 * f * t).sin()
1443                    + 0.3 * (2.0 * std::f64::consts::PI * 3.0 * f * t).sin()
1444            })
1445            .collect();
1446
1447        // Identical pair → error ~0.
1448        let err_same = even_odd_ratio_error_db(&asymmetric, &asymmetric, f, sr, 10);
1449        assert!(
1450            err_same < 1.0,
1451            "Identical signals should give ~0 dB even_odd_ratio_error, got {err_same:.2} dB"
1452        );
1453
1454        // Asymmetric vs symmetric → error must be large.
1455        let err_diff = even_odd_ratio_error_db(&symmetric, &asymmetric, f, sr, 10);
1456        assert!(
1457            err_diff > 10.0,
1458            "Asymmetric vs symmetric should give >10 dB even_odd_ratio_error, got {err_diff:.2} dB"
1459        );
1460    }
1461
1462    /// Verify that a genuine harmonic level mismatch still scores a large spectral
1463    /// error even after the noise-floor fix (regression guard for the fix).
1464    ///
1465    /// Why this matters: the absolute floor at −100 dBFS should only exclude
1466    /// bins where *both* signals are near the noise floor.  When both ref and
1467    /// WDF have strong, above-floor content at the same harmonic but at
1468    /// significantly different levels, the error must still score large.
1469    #[test]
1470    fn spectral_error_detects_real_harmonic_divergence() {
1471        let sr = 48000.0;
1472        let n = sr as usize;
1473        let freq = 1000.0_f64;
1474
1475        // Reference: fundamental + strong 2nd harmonic at −20 dBFS (amplitude 0.1).
1476        let ref_signal: Vec<f64> = (0..n)
1477            .map(|i| {
1478                let t = i as f64 / sr;
1479                let h1 = (2.0 * std::f64::consts::PI * freq * t).sin();
1480                let h2 = 0.1 * (2.0 * std::f64::consts::PI * 2.0 * freq * t).sin();
1481                h1 + h2
1482            })
1483            .collect();
1484
1485        // WDF: fundamental + 2nd harmonic 30 dB lower than ref (a real accuracy gap
1486        // where both signals have measurable content above the noise floor).
1487        // WDF h2 amplitude ≈ 0.1 * 10^(-30/20) ≈ 0.0032.
1488        let wdf_signal: Vec<f64> = (0..n)
1489            .map(|i| {
1490                let t = i as f64 / sr;
1491                let h1 = (2.0 * std::f64::consts::PI * freq * t).sin();
1492                let h2 = 0.0032 * (2.0 * std::f64::consts::PI * 2.0 * freq * t).sin();
1493                h1 + h2
1494            })
1495            .collect();
1496
1497        let err = spectral_error_db(&wdf_signal, &ref_signal, sr, None);
1498
1499        // The 2nd harmonic differs by ~30 dB between ref and WDF, and both
1500        // are above the −100 dBFS noise floor, so the error must score large.
1501        assert!(
1502            err > 20.0,
1503            "30 dB h2 level gap should produce > 20 dB spectral error, got {err:.1} dB."
1504        );
1505    }
1506
1507    // ── op_point bias-accuracy metric (ngspice-free, synthetic) ──────────────
1508    mod op_point_logic {
1509        use crate::metrics::op_point::*;
1510        use crate::spice::SpiceDeviceOp;
1511
1512        fn spice(dev: &str, vbe: f64, vce: f64, ic: f64) -> SpiceDeviceOp {
1513            SpiceDeviceOp {
1514                device: dev.into(),
1515                model: "qmod".into(),
1516                vbe,
1517                vce,
1518                ic,
1519                ib: ic / 100.0,
1520                gm: 0.03,
1521            }
1522        }
1523        fn ours(reference: &str, vbe: f64, vce: f64, ic: f64) -> DeviceBias {
1524            DeviceBias { reference: reference.into(), vbe, vce, ic }
1525        }
1526
1527        #[test]
1528        fn residual_near_zero_and_match_ok_is_bias_ok() {
1529            let c = OpPassCriteria::default();
1530            let r = evaluate(
1531                "x",
1532                &[ours("Q1", 0.6505, 1.10, 1.0e-3)],
1533                &[spice("Q1", 0.650, 1.10, 1.0e-3)],
1534                &[PortResidual { residual: 0.01, residual_full: 0.01 }],
1535                &c,
1536            );
1537            assert_eq!(r.verdict, Verdict::BiasOk);
1538            assert!(r.match_ok(&c) && r.formulation_ok(&c));
1539        }
1540
1541        #[test]
1542        fn residual_small_but_match_off_is_solver_off_layer_b() {
1543            // The BA283 signature: ngspice's .op IS a fixed point (small residual)
1544            // but our settled bias lands elsewhere (large ΔVbe / wrong Ic).
1545            let c = OpPassCriteria::default();
1546            let r = evaluate(
1547                "ba283-like",
1548                &[ours("Q1", 0.20, 19.9, 0.0)],
1549                &[spice("Q1", 0.61, 5.35, 0.26e-3)],
1550                &[PortResidual { residual: 0.027, residual_full: 0.027 }],
1551                &c,
1552            );
1553            assert_eq!(r.verdict, Verdict::FormulationOkSolverOff);
1554            assert!(r.formulation_ok(&c) && !r.match_ok(&c));
1555        }
1556
1557        #[test]
1558        fn large_residual_is_formulation_bug_layer_a() {
1559            let c = OpPassCriteria::default();
1560            let r = evaluate(
1561                "y",
1562                &[ours("Q1", 0.0, 0.0, 0.0)],
1563                &[spice("Q1", 0.0, 0.0, 0.0)],
1564                &[PortResidual { residual: 8.5, residual_full: 8.5 }],
1565                &c,
1566            );
1567            assert_eq!(r.verdict, Verdict::FormulationBug);
1568        }
1569
1570        #[test]
1571        fn match_compares_on_magnitude_for_pnp() {
1572            // PNP: our port Vbe is negative, ngspice reports positive magnitude.
1573            let dm = match_devices(
1574                &[ours("Q1", -0.62, -3.5, -1.0e-3)],
1575                &[spice("Q1", 0.62, 3.5, -1.0e-3)],
1576            );
1577            assert_eq!(dm.len(), 1);
1578            assert!(dm[0].d_vbe.abs() < 1e-9, "magnitude match ⇒ ΔVbe≈0");
1579            assert!(dm[0].d_ic_pct.abs() < 1e-6);
1580        }
1581    }
1582
1583    // ── ac_accuracy AC-fidelity metric (ngspice-free, synthetic) ─────────────
1584    mod ac_accuracy_logic {
1585        use crate::metrics::ac_accuracy::*;
1586
1587        const SR: f64 = 48_000.0;
1588        const F: f64 = 1_000.0;
1589
1590        /// A pure 1 kHz tone (+ optional harmonics) as a golden.
1591        fn tone(amp: f64, h2: f64, h3: f64) -> Vec<f64> {
1592            (0..48_000)
1593                .map(|i| {
1594                    let t = i as f64 / SR;
1595                    amp * ((2.0 * std::f64::consts::PI * F * t).sin()
1596                        + h2 * (2.0 * std::f64::consts::PI * 2.0 * F * t).sin()
1597                        + h3 * (2.0 * std::f64::consts::PI * 3.0 * F * t).sin())
1598                })
1599                .collect()
1600        }
1601
1602        /// THE core point: when the WDF differs from the golden ONLY by a scalar
1603        /// level, the verdict must be LevelOnly — the +N dB is a flat scalar and
1604        /// the gain-normalized shape residual is ~0 (what the degenerate
1605        /// `normalized_rms_error_db` could never separate).
1606        #[test]
1607        fn pure_level_offset_is_level_only_and_shape_clean() {
1608            let th = AcThresholds::default();
1609            let golden = tone(0.1, 0.02, 0.005);
1610            // WDF: same waveform SHAPE, +2.42 dB louder (the BA283 signature).
1611            let k = 10f64.powf(2.42 / 20.0);
1612            let wdf: Vec<f64> = golden.iter().map(|&x| k * x).collect();
1613            // Flat frequency response (same +2.42 dB at every frequency).
1614            let response: Vec<FreqPoint> = [50.0, 1000.0, 10000.0]
1615                .iter()
1616                .map(|&f| FreqPoint { freq_hz: f, gain_db: 2.42 })
1617                .collect();
1618
1619            let r = evaluate("scaled", &wdf, &golden, SR, F, response, &th);
1620            assert!((r.gain_db - 2.42).abs() < 0.05, "level = +2.42 dB, got {:.3}", r.gain_db);
1621            // Gain factored out ⇒ shape residual collapses.
1622            assert!(r.shape_rms_db < -60.0, "shape RMS must be clean, got {:.1}", r.shape_rms_db);
1623            assert!(r.shape_spectral_db < 1.0, "shape spectral clean, got {:.2}", r.shape_spectral_db);
1624            assert!(r.thd_error_db() < 1.0, "THD matches, got {:.2}", r.thd_error_db());
1625            assert!(r.max_harmonic_error_db() < 1.0, "harmonics match, got {:.2}", r.max_harmonic_error_db());
1626            assert!(r.response_tilt_db < 0.5, "response flat, got {:.2}", r.response_tilt_db);
1627            assert_eq!(r.verdict, AcVerdict::LevelOnly);
1628        }
1629
1630        /// A genuine shape difference (extra 2nd harmonic in the WDF) must NOT be
1631        /// hidden by gain normalization — verdict Shape or Harmonic error.
1632        #[test]
1633        fn added_harmonic_is_not_level_only() {
1634            let th = AcThresholds::default();
1635            let golden = tone(0.1, 0.0, 0.0); // clean sine
1636            let wdf = tone(0.1, 0.3, 0.0); // strong 2nd harmonic added
1637            let r = evaluate("distorted", &wdf, &golden, SR, F, vec![], &th);
1638            assert_ne!(r.verdict, AcVerdict::LevelOnly);
1639            assert_ne!(r.verdict, AcVerdict::Clean);
1640            assert!(
1641                matches!(r.verdict, AcVerdict::ShapeError | AcVerdict::HarmonicError),
1642                "added harmonic ⇒ Shape/Harmonic, got {:?}",
1643                r.verdict
1644            );
1645        }
1646
1647        /// A frequency-SHAPED gain (flat level fine at 1 kHz, but tilted across the
1648        /// sweep) must classify as ResponseError, not LevelOnly.
1649        #[test]
1650        fn frequency_shaped_response_is_response_error() {
1651            let th = AcThresholds::default();
1652            let golden = tone(0.1, 0.0, 0.0);
1653            let wdf: Vec<f64> = golden.clone(); // identical at 1 kHz (level+shape clean)
1654            let response: Vec<FreqPoint> = vec![
1655                FreqPoint { freq_hz: 50.0, gain_db: -8.0 },
1656                FreqPoint { freq_hz: 1000.0, gain_db: 0.0 },
1657                FreqPoint { freq_hz: 10000.0, gain_db: 2.0 },
1658            ];
1659            let r = evaluate("tilted", &wdf, &golden, SR, F, response, &th);
1660            assert!(r.response_tilt_db > 3.0, "tilt = {:.1} dB", r.response_tilt_db);
1661            assert_eq!(r.verdict, AcVerdict::ResponseError);
1662        }
1663
1664        /// Silence golden ⇒ NoData (no tone to grade against).
1665        #[test]
1666        fn silent_golden_is_no_data() {
1667            let th = AcThresholds::default();
1668            let golden = vec![0.0; 48_000];
1669            let wdf = tone(0.1, 0.0, 0.0);
1670            let r = evaluate("silent", &wdf, &golden, SR, F, vec![], &th);
1671            assert_eq!(r.verdict, AcVerdict::NoData);
1672        }
1673    }
1674}