1use 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
81pub struct RunnerConfig {
83 pub circuits_dir: PathBuf,
85 pub golden_dir: PathBuf,
87 pub output_dir: PathBuf,
89 pub sample_rate: u32,
91 pub oversample: u32,
93 pub save_output: bool,
95 pub regenerate_golden: bool,
97 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
117pub struct ValidationRunner {
119 config: RunnerConfig,
120 validation_config: ValidationConfig,
121}
122
123impl ValidationRunner {
124 pub fn new(config: RunnerConfig, validation_config: ValidationConfig) -> Self {
126 Self {
127 config,
128 validation_config,
129 }
130 }
131
132 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 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 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 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 let circuit_path = self.config.circuits_dir.join(&test_case.circuit);
203
204 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 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 #[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 let spec = signal_config.to_spec();
276 let input = spec.generate(sample_rate);
277
278 let output = self.process_signal(pedal, &input);
280
281 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 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 if pending_reference {
303 return Ok(SignalResult::pending(
310 signal_label,
311 format!("golden not generated: {:?}", golden_path),
312 ));
313 }
314 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 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 let fundamental_hz = signal_config.fundamental_hz();
343 let mut comparison =
344 metrics::compare(output_trimmed, golden_trimmed, sample_rate, fundamental_hz);
345
346 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 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 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 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 fn process_signal(&self, pedal: &mut CompiledPedal, input: &[f64]) -> Vec<f64> {
398 let mut output = Vec::with_capacity(input.len());
399
400 pedal.reset();
402
403 for &sample in input {
404 let out = pedal.process(sample);
405 output.push(out);
406 }
407
408 output
411 }
412
413 pub fn effective_sample_rate(&self) -> f64 {
415 (self.config.sample_rate * self.config.oversample) as f64
416 }
417}
418
419pub 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 let input = crate::signals::sine(sample_rate, 1000.0, 0.1, 1.0);
441
442 let mut output = Vec::with_capacity(input.len());
444 for &sample in &input {
445 output.push(pedal.process(sample));
446 }
447
448 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 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 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 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; 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 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 assert_eq!(trimmed.len(), output.len());
519 }
520}