Skip to main content

pedalkernel_validate/
runner.rs

1//! Test execution and orchestration.
2//!
3//! This module provides the [`ValidationRunner`] for executing validation tests,
4//! as well as the [`quick_validate`] function for single-circuit validation.
5//!
6//! # Using ValidationRunner
7//!
8//! ```rust,ignore
9//! use pedalkernel_validate::{
10//!     config::ValidationConfig,
11//!     runner::{RunnerConfig, ValidationRunner},
12//! };
13//!
14//! // Configure the runner
15//! let runner_config = RunnerConfig {
16//!     circuits_dir: "circuits".into(),
17//!     golden_dir: "golden".into(),
18//!     output_dir: "output".into(),
19//!     sample_rate: 96000,
20//!     oversample: 4,
21//!     save_output: true,
22//!     regenerate_golden: false,
23//! };
24//!
25//! // Load validation config
26//! let validation_config = ValidationConfig::default_config();
27//!
28//! // Create runner and execute
29//! let runner = ValidationRunner::new(runner_config, validation_config);
30//! let results = runner.run_all().unwrap();
31//!
32//! // Check results
33//! for (suite_name, suite_result) in &results {
34//!     println!("{}: {}/{} passed",
35//!         suite_name, suite_result.passed, suite_result.passed + suite_result.failed);
36//! }
37//! ```
38//!
39//! # Quick Validation
40//!
41//! For validating a single circuit without full configuration:
42//!
43//! ```rust,ignore
44//! use pedalkernel_validate::runner::quick_validate;
45//! use std::path::Path;
46//!
47//! let result = quick_validate(
48//!     Path::new("my_circuit.pedal"),
49//!     Some(Path::new("golden.npy")),
50//!     96000.0
51//! ).unwrap();
52//!
53//! println!("RMS error: {:.1} dB", result.normalized_rms_error_db);
54//! ```
55
56use crate::config::{PassCriteria, SignalConfig, TestCase, TestSuite, ValidationConfig};
57use crate::metrics::{self, ComparisonResult};
58use crate::npy;
59use crate::report::{SignalResult, SuiteResult, TestResult};
60use pedalkernel::compiler::{compile_pedal_with_options, CompileOptions, CompiledPedal};
61use pedalkernel::oversampling::OversamplingFactor;
62use pedalkernel::PedalProcessor;
63use std::collections::BTreeMap;
64use std::path::{Path, PathBuf};
65use thiserror::Error;
66
67#[derive(Error, Debug)]
68pub enum RunnerError {
69    #[error("Failed to load circuit: {0}")]
70    CircuitLoadError(String),
71    #[error("Failed to compile circuit: {0}")]
72    CompileError(String),
73    #[error("Golden reference not found: {0}")]
74    GoldenNotFound(PathBuf),
75    #[error("NPY error: {0}")]
76    NpyError(#[from] crate::npy::NpyError),
77    #[error("IO error: {0}")]
78    IoError(#[from] std::io::Error),
79}
80
81/// Validation runner configuration.
82pub struct RunnerConfig {
83    /// Base directory for circuit files.
84    pub circuits_dir: PathBuf,
85    /// Base directory for golden references.
86    pub golden_dir: PathBuf,
87    /// Directory to write WDF output for comparison.
88    pub output_dir: PathBuf,
89    /// Sample rate for processing.
90    pub sample_rate: u32,
91    /// Oversampling factor.
92    pub oversample: u32,
93    /// Whether to save WDF output to disk.
94    pub save_output: bool,
95    /// Whether to regenerate golden references (requires SPICE).
96    pub regenerate_golden: bool,
97    /// Skip K-method table generation (offline NR fallback). Saves build time
98    /// for validation runs, which are not real-time.
99    pub skip_k_tables: bool,
100}
101
102impl Default for RunnerConfig {
103    fn default() -> Self {
104        Self {
105            circuits_dir: PathBuf::from("circuits"),
106            golden_dir: PathBuf::from("golden"),
107            output_dir: PathBuf::from("output"),
108            sample_rate: 96000,
109            oversample: 4,
110            save_output: true,
111            regenerate_golden: false,
112            skip_k_tables: false,
113        }
114    }
115}
116
117/// Main validation runner.
118pub struct ValidationRunner {
119    config: RunnerConfig,
120    validation_config: ValidationConfig,
121}
122
123impl ValidationRunner {
124    /// Create a new runner with the given configuration.
125    pub fn new(config: RunnerConfig, validation_config: ValidationConfig) -> Self {
126        Self {
127            config,
128            validation_config,
129        }
130    }
131
132    /// Run all test suites.
133    pub fn run_all(&self) -> Result<BTreeMap<String, SuiteResult>, RunnerError> {
134        let mut results = BTreeMap::new();
135        for (suite_name, suite) in &self.validation_config.suites {
136            let suite_result = self.run_suite(suite_name, suite)?;
137            results.insert(suite_name.clone(), suite_result);
138        }
139        Ok(results)
140    }
141
142    /// Run a specific suite by name.
143    pub fn run_suite(
144        &self,
145        suite_name: &str,
146        suite: &TestSuite,
147    ) -> Result<SuiteResult, RunnerError> {
148        let mut test_results = BTreeMap::new();
149        let mut passed = 0;
150        let mut failed = 0;
151        let mut pending = 0;
152
153        for (test_name, test_case) in &suite.tests {
154            match self.run_test(suite_name, test_name, test_case) {
155                Ok(result) => {
156                    if result.pending {
157                        // Excluded from both passed and failed (and thus the
158                        // gate total/denominator).
159                        pending += 1;
160                    } else if result.passed {
161                        passed += 1;
162                    } else {
163                        failed += 1;
164                    }
165                    test_results.insert(test_name.clone(), result);
166                }
167                Err(e) => {
168                    failed += 1;
169                    test_results.insert(
170                        test_name.clone(),
171                        TestResult {
172                            profile: test_case.effective_profile(suite_name, test_name),
173                            passed: false,
174                            pending: false,
175                            error: Some(e.to_string()),
176                            signals: vec![],
177                        },
178                    );
179                }
180            }
181        }
182
183        Ok(SuiteResult {
184            description: suite.description.clone(),
185            passed,
186            failed,
187            pending,
188            tests: test_results,
189        })
190    }
191
192    /// Run a single test case.
193    pub fn run_test(
194        &self,
195        suite_name: &str,
196        test_name: &str,
197        test_case: &TestCase,
198    ) -> Result<TestResult, RunnerError> {
199        let profile = test_case.effective_profile(suite_name, test_name);
200
201        // Resolve circuit path
202        let circuit_path = self.config.circuits_dir.join(&test_case.circuit);
203
204        // Load and compile the circuit
205        let mut pedal = self.load_and_compile(&circuit_path)?;
206
207        let effective_sr = self.config.sample_rate * self.config.oversample;
208        let warmup_trim_ms = test_case.effective_warmup_trim_ms(&self.validation_config.global);
209        let mut signal_results = vec![];
210        let mut all_passed = true;
211        let mut any_pending = false;
212
213        for signal_config in &test_case.signals {
214            let signal_result = self.run_signal(
215                suite_name,
216                test_name,
217                signal_config,
218                &mut pedal,
219                effective_sr as f64,
220                &test_case.pass_criteria,
221                warmup_trim_ms,
222                test_case.pending_reference,
223            )?;
224
225            if signal_result.pending {
226                any_pending = true;
227            } else if !signal_result.passed {
228                all_passed = false;
229            }
230            signal_results.push(signal_result);
231        }
232
233        // A test is PENDING when at least one of its signals is pending (golden
234        // missing + pending_reference). Pending tests are neither passed nor
235        // failed; the suite excludes them from the gate counts.
236        if any_pending {
237            return Ok(TestResult {
238                profile,
239                passed: false,
240                pending: true,
241                error: Some("golden not generated".to_string()),
242                signals: signal_results,
243            });
244        }
245
246        Ok(TestResult {
247            profile,
248            passed: all_passed,
249            pending: false,
250            error: None,
251            signals: signal_results,
252        })
253    }
254
255    /// Run a single signal through the circuit and compare to golden.
256    ///
257    /// `warmup_trim_ms` milliseconds are discarded from the head of both the
258    /// WDF output and the golden reference before any metric is computed.  The
259    /// stored golden files are left untouched.
260    #[allow(clippy::too_many_arguments)]
261    fn run_signal(
262        &self,
263        suite_name: &str,
264        test_name: &str,
265        signal_config: &SignalConfig,
266        pedal: &mut CompiledPedal,
267        sample_rate: f64,
268        pass_criteria: &PassCriteria,
269        warmup_trim_ms: f64,
270        pending_reference: bool,
271    ) -> Result<SignalResult, RunnerError> {
272        let signal_label = signal_config.label();
273
274        // Generate input signal
275        let spec = signal_config.to_spec();
276        let input = spec.generate(sample_rate);
277
278        // Process through WDF
279        let output = self.process_signal(pedal, &input);
280
281        // Save WDF output if configured (full signal, pre-trim)
282        if self.config.save_output {
283            let output_path = self
284                .config
285                .output_dir
286                .join(suite_name)
287                .join(test_name)
288                .join(format!("{}.npy", signal_label));
289            npy::write_f64(&output_path, &output)?;
290        }
291
292        // Load golden reference
293        let golden_path = self
294            .config
295            .golden_dir
296            .join(suite_name)
297            .join(test_name)
298            .join(format!("{}.npy", signal_label));
299
300        if !golden_path.exists() {
301            // No golden reference - can't compare, but circuit ran.
302            if pending_reference {
303                // Pending reference: the .npy has not been generated yet (e.g.
304                // committed before ngspice produced it). Report as PENDING so it
305                // is excluded from the gate's passed AND total counts, rather
306                // than failing. Once the golden is dropped in, golden_path will
307                // exist and the comparison below runs normally — no code change
308                // needed (auto-activation).
309                return Ok(SignalResult::pending(
310                    signal_label,
311                    format!("golden not generated: {:?}", golden_path),
312                ));
313            }
314            // Not pending: a missing golden is a genuine failure (e.g. an
315            // accidental deletion), so fail loudly.
316            return Ok(SignalResult {
317                label: signal_label,
318                passed: false,
319                pending: false,
320                comparison: None,
321                error: Some(format!("Golden reference not found: {:?}", golden_path)),
322            });
323        }
324
325        let golden = npy::read_f64(&golden_path)?;
326
327        // Apply steady-state warmup trim to both signals before metric computation.
328        // Trimming happens here (metric time) so stored golden files remain valid.
329        let trim_samples = ((warmup_trim_ms / 1000.0) * sample_rate).round() as usize;
330        let output_trimmed = if trim_samples < output.len() {
331            &output[trim_samples..]
332        } else {
333            &output[..]
334        };
335        let golden_trimmed = if trim_samples < golden.len() {
336            &golden[trim_samples..]
337        } else {
338            &golden[..]
339        };
340
341        // Compute comparison metrics on trimmed windows
342        let fundamental_hz = signal_config.fundamental_hz();
343        let mut comparison =
344            metrics::compare(output_trimmed, golden_trimmed, sample_rate, fundamental_hz);
345
346        // Cap the spectral comparison at the audio Nyquist (base_rate / 2).
347        // Circuits run oversampled and the engine anti-aliases, deliberately
348        // rolling off content above the audio band before downsampling. The raw
349        // ngspice golden, sampled at the oversampled rate, retains that
350        // ultrasonic content — so comparing above the audio Nyquist scores our
351        // anti-aliasing as error. `metrics::compare` only sees the oversampled
352        // `sample_rate`, so override its bundled spectral value here where the
353        // base rate is known.
354        let audio_nyquist_hz = self.config.sample_rate as f64 / 2.0;
355        comparison.spectral_error_db = metrics::spectral_error_db(
356            output_trimmed,
357            golden_trimmed,
358            sample_rate,
359            Some(audio_nyquist_hz),
360        );
361
362        // Check pass/fail
363        let passed = comparison.passes(pass_criteria);
364
365        Ok(SignalResult {
366            label: signal_label,
367            passed,
368            pending: false,
369            comparison: Some(comparison.into()),
370            error: None,
371        })
372    }
373
374    /// Load a .pedal file and compile it.
375    fn load_and_compile(&self, path: &Path) -> Result<CompiledPedal, RunnerError> {
376        let contents = std::fs::read_to_string(path)
377            .map_err(|e| RunnerError::CircuitLoadError(format!("{}: {}", path.display(), e)))?;
378
379        let pedal_def = pedalkernel::dsl::parse_pedal_file(&contents)
380            .map_err(|e| RunnerError::CompileError(format!("Parse error: {}", e)))?;
381
382        let effective_sr = (self.config.sample_rate * self.config.oversample) as f64;
383
384        // Test circuits have no Gain/Drive controls, so they automatically get unity gain.
385        // Only pedals with explicit gain controls get automatic distortion gain.
386        let options = CompileOptions {
387            oversampling: OversamplingFactor::X1,
388            skip_k_tables: self.config.skip_k_tables,
389            ..CompileOptions::default()
390        };
391
392        compile_pedal_with_options(&pedal_def, effective_sr, options)
393            .map_err(|e| RunnerError::CompileError(format!("Compile error: {}", e)))
394    }
395
396    /// Process input signal through the compiled pedal.
397    fn process_signal(&self, pedal: &mut CompiledPedal, input: &[f64]) -> Vec<f64> {
398        let mut output = Vec::with_capacity(input.len());
399
400        // Reset pedal state
401        pedal.reset();
402
403        for &sample in input {
404            let out = pedal.process(sample);
405            output.push(out);
406        }
407
408        // If oversampling, we might want to decimate here
409        // For now, return at full rate for comparison
410        output
411    }
412
413    /// Get the sample rate used for processing.
414    pub fn effective_sample_rate(&self) -> f64 {
415        (self.config.sample_rate * self.config.oversample) as f64
416    }
417}
418
419/// Quick validation of a single circuit with default settings.
420pub fn quick_validate(
421    circuit_path: &Path,
422    golden_path: Option<&Path>,
423    sample_rate: f64,
424) -> Result<ComparisonResult, RunnerError> {
425    let contents = std::fs::read_to_string(circuit_path)
426        .map_err(|e| RunnerError::CircuitLoadError(e.to_string()))?;
427
428    let pedal_def = pedalkernel::dsl::parse_pedal_file(&contents)
429        .map_err(|e| RunnerError::CompileError(e.to_string()))?;
430
431    let options = CompileOptions {
432        oversampling: OversamplingFactor::X1,
433        ..CompileOptions::default()
434    };
435
436    let mut pedal = compile_pedal_with_options(&pedal_def, sample_rate, options)
437        .map_err(|e| RunnerError::CompileError(e.to_string()))?;
438
439    // Generate 1kHz sine test signal
440    let input = crate::signals::sine(sample_rate, 1000.0, 0.1, 1.0);
441
442    // Process
443    let mut output = Vec::with_capacity(input.len());
444    for &sample in &input {
445        output.push(pedal.process(sample));
446    }
447
448    // Compare to golden if provided
449    if let Some(gp) = golden_path {
450        let golden = npy::read_f64(gp)?;
451        Ok(metrics::compare(
452            &output,
453            &golden,
454            sample_rate,
455            Some(1000.0),
456        ))
457    } else {
458        // Self-comparison (useful for sanity check)
459        Ok(metrics::compare(
460            &output,
461            &output,
462            sample_rate,
463            Some(1000.0),
464        ))
465    }
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471
472    #[test]
473    fn runner_config_has_sensible_defaults() {
474        let config = RunnerConfig::default();
475        assert_eq!(config.sample_rate, 96000);
476        assert_eq!(config.oversample, 4);
477        assert!(config.save_output);
478    }
479
480    #[test]
481    fn warmup_trim_sample_count_is_correct() {
482        // At 384 kHz internal rate, 10 ms = 3840 samples.
483        let sample_rate = 384_000.0_f64;
484        let warmup_trim_ms = 10.0_f64;
485        let trim_samples = ((warmup_trim_ms / 1000.0) * sample_rate).round() as usize;
486        assert_eq!(trim_samples, 3840);
487    }
488
489    #[test]
490    fn warmup_trim_preserves_enough_window() {
491        // Shortest signal: 50 ms at 384 kHz = 19200 samples.
492        // After 10 ms trim: 15360 samples remain = 40 ms.
493        // 1 kHz fundamental at 40 ms = 40 cycles — sufficient for THD (needs ~5).
494        let sample_rate = 384_000.0_f64;
495        let signal_len = (0.050 * sample_rate) as usize;
496        let trim_samples = ((10.0_f64 / 1000.0) * sample_rate).round() as usize;
497        let remaining = signal_len - trim_samples;
498        let remaining_ms = remaining as f64 / sample_rate * 1000.0;
499        let cycles_at_1khz = remaining_ms / 1.0; // 1 ms per cycle at 1 kHz
500        assert!(remaining_ms >= 20.0, "at least 20 ms must remain after trim");
501        assert!(
502            cycles_at_1khz >= 5.0,
503            "at least 5 cycles required for THD; got {cycles_at_1khz}"
504        );
505    }
506
507    #[test]
508    fn warmup_trim_clamps_when_signal_shorter_than_trim() {
509        // If trim >= signal length, fall back to full slice (do not panic).
510        let output: Vec<f64> = vec![1.0, 2.0, 3.0];
511        let trim_samples = 10;
512        let trimmed = if trim_samples < output.len() {
513            &output[trim_samples..]
514        } else {
515            &output[..]
516        };
517        // Full slice returned, not empty.
518        assert_eq!(trimmed.len(), output.len());
519    }
520}