Skip to main content

pedalkernel_validate/
report.rs

1//! Validation report generation and display.
2//!
3//! This module provides types for structured validation results and functions
4//! to generate human-readable output and JSON reports.
5//!
6//! # Report Structure
7//!
8//! - [`ValidationReport`] - Top-level report containing all results
9//!   - [`SuiteResult`] - Results for a test suite (e.g., "linear", "nonlinear")
10//!     - [`TestResult`] - Results for a single test case
11//!       - [`SignalResult`] - Results for each test signal
12//!         - [`ComparisonMetrics`] - Detailed metric values
13//!
14//! # Example
15//!
16//! ```rust,ignore
17//! use pedalkernel_validate::report::ValidationReport;
18//! use std::collections::HashMap;
19//!
20//! // After running tests, create a report
21//! let report = ValidationReport::new(suite_results, 96000, 4);
22//!
23//! // Print to terminal
24//! report.print_summary();
25//!
26//! // Print detailed metrics table
27//! report.print_detailed();
28//!
29//! // Save as JSON
30//! report.save_json("report.json").unwrap();
31//! ```
32//!
33//! # JSON Format
34//!
35//! The JSON output includes:
36//! - Timestamp and git commit (if available)
37//! - Sample rate and oversampling settings
38//! - Per-suite and per-test results
39//! - Summary statistics (pass/fail counts, pass rate)
40
41use crate::config::ValidationProfile;
42use crate::metrics::ComparisonResult;
43use serde::{Deserialize, Serialize};
44use std::collections::BTreeMap;
45use std::path::Path;
46
47/// Full validation report.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct ValidationReport {
50    /// Timestamp of the validation run.
51    pub timestamp: String,
52    /// Git commit hash (if available).
53    pub git_commit: Option<String>,
54    /// Sample rate used.
55    pub sample_rate: u32,
56    /// Oversampling factor.
57    pub oversample: u32,
58    /// Suite results.
59    pub suites: BTreeMap<String, SuiteResult>,
60    /// Summary statistics.
61    pub summary: ReportSummary,
62}
63
64/// Summary of all test results.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct ReportSummary {
67    /// Number of tests counted by the gate (passed + failed). PENDING tests are
68    /// EXCLUDED from this total so the pass-rate denominator is unaffected by a
69    /// committed-but-not-yet-generated reference.
70    pub total_tests: usize,
71    pub passed: usize,
72    pub failed: usize,
73    pub skipped: usize,
74    /// Tests in the PENDING state (golden not generated, `pending_reference`).
75    /// Excluded from both `passed` and `total_tests`.
76    #[serde(default)]
77    pub pending: usize,
78    pub pass_rate: f64,
79    /// Gate summary split by validation profile.
80    #[serde(default)]
81    pub profiles: BTreeMap<String, ProfileSummary>,
82}
83
84/// Summary of test results for one validation profile.
85#[derive(Debug, Clone, Default, Serialize, Deserialize)]
86pub struct ProfileSummary {
87    pub total_tests: usize,
88    pub passed: usize,
89    pub failed: usize,
90    #[serde(default)]
91    pub pending: usize,
92    pub pass_rate: f64,
93}
94
95/// Result for a single test suite.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct SuiteResult {
98    pub description: String,
99    pub passed: usize,
100    pub failed: usize,
101    /// Pending tests in this suite (golden not generated). Excluded from the
102    /// gate's passed/total counts.
103    #[serde(default)]
104    pub pending: usize,
105    pub tests: BTreeMap<String, TestResult>,
106}
107
108/// Result for a single test case.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct TestResult {
111    /// Validation intent/profile used to interpret pass/fail.
112    #[serde(default)]
113    pub profile: ValidationProfile,
114    pub passed: bool,
115    /// `true` when the test is a pending reference (golden missing + the test
116    /// is flagged `pending_reference`). A pending test is neither passed nor
117    /// failed; it is excluded from the gate denominator entirely.
118    #[serde(default)]
119    pub pending: bool,
120    pub error: Option<String>,
121    pub signals: Vec<SignalResult>,
122}
123
124/// Result for a single signal within a test.
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct SignalResult {
127    pub label: String,
128    pub passed: bool,
129    /// `true` when this signal's golden is missing and the test is pending.
130    #[serde(default)]
131    pub pending: bool,
132    pub comparison: Option<ComparisonMetrics>,
133    pub error: Option<String>,
134}
135
136/// Serializable version of ComparisonResult.
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct ComparisonMetrics {
139    pub normalized_rms_error_db: f64,
140    pub peak_error_db: f64,
141    pub thd_error_db: Option<f64>,
142    /// THD+N error (includes broadband noise/intermod). None when fundamental
143    /// frequency is not provided.  `#[serde(default)]` for forward compat.
144    #[serde(default)]
145    pub thd_plus_n_error_db: Option<f64>,
146    /// Maximum per-harmonic magnitude error in dB.  None when fundamental
147    /// frequency is not provided.  `#[serde(default)]` for forward compat.
148    #[serde(default)]
149    pub harmonic_mag_error_db: Option<f64>,
150    /// Even/odd ratio error in dB.  None when fundamental frequency is not
151    /// provided.  `#[serde(default)]` for forward compat.
152    #[serde(default)]
153    pub even_odd_ratio_error_db: Option<f64>,
154    /// Audio-band spectral error (capped at the audio Nyquist) — the gating value.
155    pub spectral_error_db: f64,
156    /// Full-band (raw) spectral error up to the data Nyquist. Informational.
157    /// `#[serde(default)]` so older reports without this field still deserialize.
158    #[serde(default)]
159    pub spectral_error_full_db: f64,
160    pub even_odd_ratio_db: Option<f64>,
161    pub dc_drift_mv: Option<f64>,
162}
163
164impl From<ComparisonResult> for ComparisonMetrics {
165    fn from(cr: ComparisonResult) -> Self {
166        Self {
167            normalized_rms_error_db: cr.normalized_rms_error_db,
168            peak_error_db: cr.peak_error_db,
169            thd_error_db: cr.thd_error_db,
170            thd_plus_n_error_db: cr.thd_plus_n_error_db,
171            harmonic_mag_error_db: cr.harmonic_mag_error_db,
172            even_odd_ratio_error_db: cr.even_odd_ratio_error_db,
173            spectral_error_db: cr.spectral_error_db,
174            spectral_error_full_db: cr.spectral_error_full_db,
175            even_odd_ratio_db: cr.even_odd_ratio_db,
176            dc_drift_mv: cr.dc_drift_mv,
177        }
178    }
179}
180
181impl ValidationReport {
182    /// Create a new report from suite results.
183    pub fn new(suites: BTreeMap<String, SuiteResult>, sample_rate: u32, oversample: u32) -> Self {
184        let mut total = 0;
185        let mut passed = 0;
186        let mut failed = 0;
187        let mut pending = 0;
188
189        for suite in suites.values() {
190            // PENDING tests are excluded from the gate total entirely, so the
191            // pass-rate denominator does not move when a not-yet-generated
192            // reference is committed.
193            total += suite.passed + suite.failed;
194            passed += suite.passed;
195            failed += suite.failed;
196            pending += suite.pending;
197        }
198
199        let mut profiles: BTreeMap<String, ProfileSummary> = BTreeMap::new();
200        for suite in suites.values() {
201            for test in suite.tests.values() {
202                let bucket = profiles
203                    .entry(test.profile.as_str().to_string())
204                    .or_default();
205                if test.pending {
206                    bucket.pending += 1;
207                } else {
208                    bucket.total_tests += 1;
209                    if test.passed {
210                        bucket.passed += 1;
211                    } else {
212                        bucket.failed += 1;
213                    }
214                }
215            }
216        }
217        for bucket in profiles.values_mut() {
218            bucket.pass_rate = if bucket.total_tests > 0 {
219                bucket.passed as f64 / bucket.total_tests as f64
220            } else {
221                0.0
222            };
223        }
224
225        let pass_rate = if total > 0 {
226            passed as f64 / total as f64
227        } else {
228            0.0
229        };
230
231        Self {
232            timestamp: chrono_lite_timestamp(),
233            git_commit: get_git_commit(),
234            sample_rate,
235            oversample,
236            suites,
237            summary: ReportSummary {
238                total_tests: total,
239                passed,
240                failed,
241                skipped: 0,
242                pending,
243                pass_rate,
244                profiles,
245            },
246        }
247    }
248
249    /// Save report to JSON file.
250    pub fn save_json(&self, path: impl AsRef<Path>) -> Result<(), std::io::Error> {
251        let path = path.as_ref();
252        if let Some(parent) = path.parent() {
253            std::fs::create_dir_all(parent)?;
254        }
255        let json = serde_json::to_string_pretty(self)
256            .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
257        std::fs::write(path, json)
258    }
259
260    /// Print human-readable summary to terminal.
261    pub fn print_summary(&self) {
262        use colored::Colorize;
263
264        println!("\n{}", "═".repeat(60).bold());
265        println!("{}", " PEDALKERNEL VALIDATION REPORT ".bold().on_blue());
266        println!("{}", "═".repeat(60).bold());
267
268        if let Some(ref commit) = self.git_commit {
269            println!("Git commit: {}", commit.dimmed());
270        }
271        println!("Timestamp:  {}", self.timestamp.dimmed());
272        println!(
273            "Config:     {}Hz × {}x oversample",
274            self.sample_rate, self.oversample
275        );
276        if !self.summary.profiles.is_empty() {
277            let buckets: Vec<String> = self
278                .summary
279                .profiles
280                .iter()
281                .map(|(name, summary)| {
282                    if summary.pending > 0 {
283                        format!(
284                            "{} {}/{} ({} pending)",
285                            name, summary.passed, summary.total_tests, summary.pending
286                        )
287                    } else {
288                        format!("{} {}/{}", name, summary.passed, summary.total_tests)
289                    }
290                })
291                .collect();
292            println!("Profiles:   {}", buckets.join(" | ").dimmed());
293        }
294        println!();
295
296        for (suite_name, suite) in &self.suites {
297            let status = if suite.failed == 0 {
298                "PASS".green().bold()
299            } else {
300                "FAIL".red().bold()
301            };
302
303            let pend_note = if suite.pending > 0 {
304                format!(" [{} pending]", suite.pending)
305            } else {
306                String::new()
307            };
308            println!(
309                "[{}] {} - {} ({}/{}){}",
310                status,
311                suite_name.bold(),
312                suite.description.dimmed(),
313                suite.passed,
314                suite.passed + suite.failed,
315                pend_note.yellow()
316            );
317
318            for (test_name, test) in &suite.tests {
319                if test.pending {
320                    // PENDING: golden not generated yet. Printed clearly and
321                    // excluded from the pass/fail counts above.
322                    let reason = test
323                        .error
324                        .clone()
325                        .unwrap_or_else(|| "golden not generated".to_string());
326                    println!(
327                        "  {} {} ({})",
328                        "[PEND]".yellow().bold(),
329                        test_name,
330                        reason.dimmed()
331                    );
332                    continue;
333                }
334
335                let test_status = if test.passed {
336                    "✓".green()
337                } else {
338                    "✗".red()
339                };
340
341                println!(
342                    "  {} {} [{}]",
343                    test_status,
344                    test_name,
345                    test.profile.as_str().dimmed()
346                );
347
348                if let Some(ref err) = test.error {
349                    println!("    {} {}", "Error:".red(), err);
350                }
351
352                for signal in &test.signals {
353                    if let Some(ref comp) = signal.comparison {
354                        let signal_status = if signal.passed {
355                            "✓".green()
356                        } else {
357                            "✗".red()
358                        };
359                        println!(
360                            "    {} {} | RMS: {:.1}dB | Peak: {:.1}dB | Spectral: {:.1}dB",
361                            signal_status,
362                            signal.label.dimmed(),
363                            comp.normalized_rms_error_db,
364                            comp.peak_error_db,
365                            comp.spectral_error_db
366                        );
367                        if let Some(thd) = comp.thd_error_db {
368                            println!("      THD error: {:.2}dB", thd);
369                        }
370                    } else if let Some(ref err) = signal.error {
371                        println!("    {} {} - {}", "⚠".yellow(), signal.label.dimmed(), err);
372                    }
373                }
374            }
375            println!();
376        }
377
378        println!("{}", "─".repeat(60));
379        let overall_status = if self.summary.failed == 0 {
380            "ALL TESTS PASSED".green().bold()
381        } else {
382            format!("{} TESTS FAILED", self.summary.failed).red().bold()
383        };
384        let pend_suffix = if self.summary.pending > 0 {
385            format!(" | {} pending", self.summary.pending)
386        } else {
387            String::new()
388        };
389        println!(
390            "{} | {}/{} passed ({:.1}%){}",
391            overall_status,
392            self.summary.passed,
393            self.summary.total_tests,
394            self.summary.pass_rate * 100.0,
395            pend_suffix.yellow()
396        );
397        println!("{}\n", "═".repeat(60).bold());
398    }
399
400    /// Print detailed metrics table.
401    pub fn print_detailed(&self) {
402        use tabled::{Table, Tabled};
403
404        #[derive(Tabled)]
405        struct MetricRow {
406            suite: String,
407            test: String,
408            signal: String,
409            profile: String,
410            #[tabled(rename = "RMS (dB)")]
411            rms_db: String,
412            #[tabled(rename = "Peak (dB)")]
413            peak_db: String,
414            #[tabled(rename = "THD Err")]
415            thd_err: String,
416            #[tabled(rename = "Spectral")]
417            spectral: String,
418            status: String,
419        }
420
421        let mut rows = vec![];
422
423        for (suite_name, suite) in &self.suites {
424            for (test_name, test) in &suite.tests {
425                if test.pending {
426                    rows.push(MetricRow {
427                        suite: suite_name.clone(),
428                        test: test_name.clone(),
429                        signal: "-".to_string(),
430                        profile: test.profile.as_str().to_string(),
431                        rms_db: "-".to_string(),
432                        peak_db: "-".to_string(),
433                        thd_err: "-".to_string(),
434                        spectral: "-".to_string(),
435                        status: "PEND".to_string(),
436                    });
437                    continue;
438                }
439                for signal in &test.signals {
440                    let (rms, peak, thd, spectral) = if let Some(ref c) = signal.comparison {
441                        (
442                            format!("{:.1}", c.normalized_rms_error_db),
443                            format!("{:.1}", c.peak_error_db),
444                            c.thd_error_db
445                                .map(|t| format!("{:.2}", t))
446                                .unwrap_or_else(|| "-".to_string()),
447                            format!("{:.1}", c.spectral_error_db),
448                        )
449                    } else {
450                        (
451                            "-".to_string(),
452                            "-".to_string(),
453                            "-".to_string(),
454                            "-".to_string(),
455                        )
456                    };
457
458                    rows.push(MetricRow {
459                        suite: suite_name.clone(),
460                        test: test_name.clone(),
461                        signal: signal.label.clone(),
462                        profile: test.profile.as_str().to_string(),
463                        rms_db: rms,
464                        peak_db: peak,
465                        thd_err: thd,
466                        spectral: spectral,
467                        status: if signal.passed {
468                            "PASS".to_string()
469                        } else {
470                            "FAIL".to_string()
471                        },
472                    });
473                }
474            }
475        }
476
477        if !rows.is_empty() {
478            let table = Table::new(rows);
479            println!("\nDetailed Metrics:\n{}", table);
480        }
481    }
482}
483
484// ============================================================================
485// Boundary-load decision table (compile-time output-boundary analysis)
486// ============================================================================
487
488use pedalkernel_rt::processor::{
489    BoundaryLoadBinding, BoundaryLoadDisposition, BoundaryLoadSummary,
490};
491
492/// Engineering-notation formatter for component values (`10µF`, `10kΩ`).
493fn si_value(value: f64, unit: &str) -> String {
494    if !value.is_finite() {
495        return format!("{value}{unit}");
496    }
497    let a = value.abs();
498    let (scale, prefix) = if a == 0.0 {
499        (1.0, "")
500    } else if a >= 1e6 {
501        (1e-6, "M")
502    } else if a >= 1e3 {
503        (1e-3, "k")
504    } else if a >= 1.0 {
505        (1.0, "")
506    } else if a >= 1e-3 {
507        (1e3, "m")
508    } else if a >= 1e-6 {
509        (1e6, "µ")
510    } else if a >= 1e-9 {
511        (1e9, "n")
512    } else {
513        (1e12, "p")
514    };
515    let scaled = value * scale;
516    let s = if (scaled - scaled.round()).abs() < 5e-3 {
517        format!("{}", scaled.round() as i64)
518    } else {
519        format!("{scaled:.2}")
520    };
521    format!("{s}{prefix}{unit}")
522}
523
524/// Compact human-readable boundary-load MODEL summary — kind + element values +
525/// component ids, e.g. `SeriesCapIntoLoad{10µF → 10kΩ; Cout,RL}`.
526pub fn format_boundary_model(m: &BoundaryLoadSummary) -> String {
527    match m {
528        BoundaryLoadSummary::Unloaded => "Unloaded".to_string(),
529        BoundaryLoadSummary::GroundedResistive {
530            r_total,
531            component_ids,
532        } => format!(
533            "GroundedResistive{{{}; {}}}",
534            si_value(*r_total, "Ω"),
535            component_ids.join(",")
536        ),
537        BoundaryLoadSummary::SeriesCapIntoLoad {
538            c,
539            r_total,
540            component_ids,
541        } => format!(
542            "SeriesCapIntoLoad{{{} → {}; {}}}",
543            si_value(*c, "F"),
544            si_value(*r_total, "Ω"),
545            component_ids.join(",")
546        ),
547        BoundaryLoadSummary::PassiveNetwork { component_ids } => {
548            format!("PassiveNetwork{{{}}}", component_ids.join(","))
549        }
550        BoundaryLoadSummary::SeriesCapIntoPotLoad {
551            c,
552            pot_id,
553            r_pot,
554            r_fixed,
555            component_ids,
556        } => {
557            let load = match r_fixed {
558                Some(rf) => format!(
559                    "{pot_id}:{} pot ∥ {}",
560                    si_value(*r_pot, "Ω"),
561                    si_value(*rf, "Ω")
562                ),
563                None => format!("{pot_id}:{} pot", si_value(*r_pot, "Ω")),
564            };
565            format!(
566                "SeriesCapIntoPotLoad{{{} → {}; {}}}",
567                si_value(*c, "F"),
568                load,
569                component_ids.join(",")
570            )
571        }
572        BoundaryLoadSummary::PotDividerAtOut {
573            pot_id,
574            r_pot,
575            component_ids,
576        } => format!(
577            "PotDividerAtOut{{{pot_id}:{} pot; {}}}",
578            si_value(*r_pot, "Ω"),
579            component_ids.join(",")
580        ),
581        BoundaryLoadSummary::CollectorChainIntoOut {
582            c,
583            pot_id,
584            r_pot,
585            r_tap_fixed,
586            component_ids,
587        } => format!(
588            "CollectorChainIntoOut{{{} → {} tap ∥ {}:{} pot; {}}}",
589            si_value(*c, "F"),
590            si_value(*r_tap_fixed, "Ω"),
591            pot_id,
592            si_value(*r_pot, "Ω"),
593            component_ids.join(",")
594        ),
595    }
596}
597
598/// Compact human-readable boundary-load DISPOSITION — `FusedUpstream` or
599/// `Unloaded{reason}` (namespaced reasons: `env:*`, `gate:*`, `analysis:*`).
600pub fn format_boundary_disposition(d: &BoundaryLoadDisposition) -> String {
601    match d {
602        BoundaryLoadDisposition::FusedUpstream => "FusedUpstream".to_string(),
603        BoundaryLoadDisposition::Unloaded { reason } => format!("Unloaded{{{reason}}}"),
604        BoundaryLoadDisposition::ReflectedThroughTransformer {
605            turns_ratio,
606            r_reflected,
607        } => format!("ReflectedThroughTransformer{{n={turns_ratio}, r_reflected={r_reflected}}}"),
608    }
609}
610
611/// WSL/NaN triage flag: `Some(flag)` when a circuit's OUTPUT boundary is left
612/// electrically open — either an analyzed boundary whose disposition is
613/// `Unloaded{reason}`, or an EMPTY table (no feedback group ever reached the
614/// general-MNA call site, e.g. the whole pedal compiled via blockwise/other
615/// paths — the boundary was never even analyzed). The two absence modes are
616/// surfaced DISTINCTLY because they need different fixes (widen the policy vs
617/// widen the analysis coverage).
618///
619/// WHY this flag rides next to the bias verdict: unloaded output boundaries and
620/// marginal op-points travel together — the stage solves against an open
621/// circuit, so the compile-time op-point never feels the load-current demand,
622/// and the resulting bias sits closer to cutoff/rail than the real circuit's.
623/// Live cases: Klon Centaur (renders +16 dBFS over on macOS and NaN-flushes to
624/// silence on x86/WSL), ProCo RAT, Pultec — all with silently-unloaded output
625/// boundaries. This table is the triage lever for the ~76 corpus failures.
626pub fn unloaded_output_flag(loads: &[BoundaryLoadBinding]) -> Option<String> {
627    if loads.iter().any(|b| {
628        matches!(
629            b.disposition,
630            BoundaryLoadDisposition::FusedUpstream
631                | BoundaryLoadDisposition::ReflectedThroughTransformer { .. }
632        )
633    }) {
634        return None; // Output boundary load is fused into the upstream solve.
635    }
636    if loads.is_empty() {
637        return Some(
638            "⚠ OUTPUT BOUNDARY UNANALYZED: empty table — no feedback group reached the \
639             general-MNA call site (blockwise/other compile paths)"
640                .to_string(),
641        );
642    }
643    let mut reasons: Vec<&str> = loads
644        .iter()
645        .filter_map(|b| match &b.disposition {
646            BoundaryLoadDisposition::Unloaded { reason } => Some(reason.as_str()),
647            BoundaryLoadDisposition::FusedUpstream
648            | BoundaryLoadDisposition::ReflectedThroughTransformer { .. } => None,
649        })
650        .collect();
651    reasons.sort_unstable();
652    reasons.dedup();
653    Some(format!(
654        "⚠ OUTPUT BOUNDARY UNLOADED: {}",
655        reasons.join(", ")
656    ))
657}
658
659/// Render the compact per-circuit boundary-load decision table: one row per
660/// analyzed stage output boundary (model + disposition), one explicit
661/// `no-analysis (empty table)` row per circuit whose table is EMPTY (that
662/// absence is itself a triage signal, distinct from `Unloaded{reason}`), and a
663/// trailing flag line per circuit whose output boundary is open (see
664/// [`unloaded_output_flag`]).
665pub fn render_boundary_loads(circuits: &[(String, Vec<BoundaryLoadBinding>)]) -> String {
666    use std::fmt::Write as _;
667    use tabled::settings::Style;
668    use tabled::{Table, Tabled};
669
670    #[derive(Tabled)]
671    struct BoundaryRow {
672        circuit: String,
673        stage: String,
674        #[tabled(rename = "boundary node")]
675        node: String,
676        #[tabled(rename = "load model")]
677        model: String,
678        disposition: String,
679    }
680
681    let mut rows = Vec::new();
682    for (circuit, loads) in circuits {
683        if loads.is_empty() {
684            rows.push(BoundaryRow {
685                circuit: circuit.clone(),
686                stage: "-".into(),
687                node: "-".into(),
688                model: "-".into(),
689                disposition: "no-analysis (empty table)".into(),
690            });
691            continue;
692        }
693        for (i, b) in loads.iter().enumerate() {
694            rows.push(BoundaryRow {
695                circuit: if i == 0 { circuit.clone() } else { String::new() },
696                stage: if b.upstream_stage == usize::MAX {
697                    "?".into()
698                } else {
699                    b.upstream_stage.to_string()
700                },
701                node: b.boundary_node.to_string(),
702                model: format_boundary_model(&b.model),
703                disposition: format_boundary_disposition(&b.disposition),
704            });
705        }
706    }
707
708    let mut out = String::new();
709    out.push_str(
710        "\n────────────────── BOUNDARY-LOAD DECISION TABLE (compile-time) ──────────────────\n  \
711         What hangs downstream of each stage's OUTPUT boundary and what the compiler did.\n  \
712         `Unloaded{reason}` = analyzed, declined (boundary left open — stage solves into an\n  \
713         open circuit). `no-analysis (empty table)` = no feedback group reached the\n  \
714         general-MNA call site at all (blockwise/other paths) — a DISTINCT triage signal.\n",
715    );
716    if !rows.is_empty() {
717        let mut table = Table::new(rows);
718        table.with(Style::rounded());
719        let _ = writeln!(out, "{}", table);
720    }
721    for (circuit, loads) in circuits {
722        if let Some(flag) = unloaded_output_flag(loads) {
723            let _ = writeln!(out, "  {circuit}: {flag}");
724        }
725    }
726    out.push_str(
727        "──────────────────────────────────────────────────────────────────────────────────\n",
728    );
729    out
730}
731
732// ============================================================================
733// DC bias-accuracy dashboard section
734// ============================================================================
735
736/// Render the bias-accuracy table for a set of per-circuit op-point results.
737///
738/// Produces a `circuit × device × (Vbe / Vce / Ic: ours / ngspice / Δ)` table
739/// plus, per circuit, the worst DC-balance residual `F(ngspice_op)` and the
740/// formulation-vs-solver verdict. Returns the rendered string (so tests can
741/// print AND assert on it).
742///
743/// `boundary_loads` maps circuit name → its compile-time boundary-load table
744/// (`CompiledPedal::boundary_loads`). Every circuit whose OUTPUT boundary is
745/// left open — `Unloaded{reason}` rows or an EMPTY table — gets a triage flag
746/// rendered directly under its verdict line (see [`unloaded_output_flag`] for
747/// the rationale: unloaded boundaries and marginal op-points travel together).
748/// Circuits absent from the map (e.g. pro pedal not compiled) are not flagged.
749pub fn render_bias_accuracy(
750    results: &[crate::metrics::op_point::OpPointResult],
751    criteria: &crate::metrics::op_point::OpPassCriteria,
752    boundary_loads: &BTreeMap<String, Vec<BoundaryLoadBinding>>,
753) -> String {
754    use crate::metrics::op_point::Verdict;
755    use std::fmt::Write as _;
756    use tabled::settings::Style;
757    use tabled::{Table, Tabled};
758
759    #[derive(Tabled)]
760    struct BiasRow {
761        circuit: String,
762        device: String,
763        model: String,
764        #[tabled(rename = "Vbe ours")]
765        vbe_ours: String,
766        #[tabled(rename = "Vbe ng")]
767        vbe_ng: String,
768        #[tabled(rename = "ΔVbe")]
769        d_vbe: String,
770        #[tabled(rename = "Vce ours")]
771        vce_ours: String,
772        #[tabled(rename = "Vce ng")]
773        vce_ng: String,
774        #[tabled(rename = "ΔVce")]
775        d_vce: String,
776        #[tabled(rename = "Ic ours")]
777        ic_ours: String,
778        #[tabled(rename = "Ic ng")]
779        ic_ng: String,
780        #[tabled(rename = "ΔIc%")]
781        d_ic: String,
782    }
783
784    fn fmt_i(a: f64) -> String {
785        if a.abs() >= 1e-3 {
786            format!("{:.3}mA", a * 1e3)
787        } else {
788            format!("{:.1}µA", a * 1e6)
789        }
790    }
791
792    let mut rows = Vec::new();
793    for r in results {
794        if r.devices.is_empty() {
795            rows.push(BiasRow {
796                circuit: r.circuit.clone(),
797                device: "(no BJT devices)".to_string(),
798                model: "-".into(),
799                vbe_ours: "-".into(),
800                vbe_ng: "-".into(),
801                d_vbe: "-".into(),
802                vce_ours: "-".into(),
803                vce_ng: "-".into(),
804                d_vce: "-".into(),
805                ic_ours: "-".into(),
806                ic_ng: "-".into(),
807                d_ic: "-".into(),
808            });
809        }
810        for (i, d) in r.devices.iter().enumerate() {
811            let flag = if d.d_vbe.abs() > criteria.vbe_fail_v {
812                "!"
813            } else if d.d_vbe.abs() > criteria.vbe_warn_v {
814                "~"
815            } else {
816                ""
817            };
818            rows.push(BiasRow {
819                circuit: if i == 0 { r.circuit.clone() } else { String::new() },
820                device: d.reference.clone(),
821                model: d.model.clone(),
822                vbe_ours: format!("{:+.4}", d.ours.0),
823                vbe_ng: format!("{:+.4}", d.spice.0),
824                d_vbe: format!("{:+.4}{}", d.d_vbe, flag),
825                vce_ours: format!("{:+.3}", d.ours.1),
826                vce_ng: format!("{:+.3}", d.spice.1),
827                d_vce: format!("{:+.3}", d.d_vce),
828                ic_ours: fmt_i(d.ours.2),
829                ic_ng: fmt_i(d.spice.2),
830                d_ic: format!("{:+.1}", d.d_ic_pct),
831            });
832        }
833    }
834
835    let mut out = String::new();
836    out.push_str("\n══════════════════════ DC BIAS ACCURACY (WDF vs ngspice .op) ══════════════════════\n");
837    out.push_str(
838        "  MATCH = our settled DC bias vs ngspice .op (Layer B / solver-root).\n  \
839         ΔVbe flags: '~' warn >",
840    );
841    let _ = write!(
842        out,
843        "{:.0}mV, '!' fail >{:.0}mV; |ΔVce| fail >{:.2}V; |ΔIc| fail >{:.0}%.\n",
844        criteria.vbe_warn_v * 1e3,
845        criteria.vbe_fail_v * 1e3,
846        criteria.vce_fail_v,
847        criteria.ic_fail_pct,
848    );
849    if !rows.is_empty() {
850        let mut table = Table::new(rows);
851        table.with(Style::rounded());
852        let _ = write!(out, "{}\n", table);
853    }
854
855    // Per-circuit residual + verdict roll-up.
856    out.push_str(
857        "\n  RESIDUAL = F(ngspice_op): is ngspice's .op a fixed point of OUR DC equations?\n  \
858         (residual ≈ 0 ⇒ formulation ok → Layer B; residual ≫ 0 ⇒ formulation bug → Layer A)\n",
859    );
860    for r in results {
861        let mark = match r.verdict {
862            Verdict::BiasOk => "✓",
863            Verdict::FormulationOkSolverOff => "→B",
864            Verdict::FormulationBug => "→A",
865            Verdict::NoData => "·",
866        };
867        let _ = write!(
868            out,
869            "  [{mark}] {:<22} max|resid|={:>7.4}V  (full={:.2e}V, {} ports)  \
870             max ΔVbe={:.4}V ΔVce={:.3}V ΔIc={:.1}%  ⇒ {}\n",
871            r.circuit,
872            r.max_abs_residual,
873            r.max_abs_residual_full,
874            r.n_residual_ports,
875            r.max_abs_d_vbe,
876            r.max_abs_d_vce,
877            r.max_abs_d_ic_pct,
878            r.verdict.label(),
879        );
880        // WSL/NaN triage: an open output boundary rides RIGHT NEXT TO the bias
881        // verdict — the two travel together (see `unloaded_output_flag`).
882        if let Some(loads) = boundary_loads.get(&r.circuit) {
883            if let Some(flag) = unloaded_output_flag(loads) {
884                let _ = writeln!(out, "        {flag}");
885            }
886        }
887    }
888    out.push_str("═══════════════════════════════════════════════════════════════════════════════════\n");
889    out
890}
891
892// ============================================================================
893// AC-signal accuracy dashboard section (the audio twin of the bias dashboard)
894// ============================================================================
895
896/// Render the AC-accuracy table for a set of per-circuit results.
897///
898/// Mirrors [`render_bias_accuracy`], but for the AC SIGNAL instead of the DC
899/// operating point. One summary row per circuit — LEVEL (`gain@f`), the
900/// gain-normalized SHAPE residual (rms + phase-immune spectral), THD (WDF vs
901/// ngspice) and its Δ, the response tilt (max−min gain across the sweep), and the
902/// verdict — followed by the per-harmonic breakdown and the full frequency sweep.
903/// Returns the rendered string so tests can print AND assert on it.
904pub fn render_ac_accuracy(
905    results: &[crate::metrics::ac_accuracy::AcResult],
906    th: &crate::metrics::ac_accuracy::AcThresholds,
907) -> String {
908    use crate::metrics::ac_accuracy::AcVerdict;
909    use std::fmt::Write as _;
910    use tabled::settings::Style;
911    use tabled::{Table, Tabled};
912
913    #[derive(Tabled)]
914    struct AcRow {
915        circuit: String,
916        #[tabled(rename = "gain@f")]
917        gain: String,
918        #[tabled(rename = "shape rms")]
919        shape_rms: String,
920        #[tabled(rename = "shape spec")]
921        shape_spec: String,
922        #[tabled(rename = "THD wdf")]
923        thd_wdf: String,
924        #[tabled(rename = "THD ng")]
925        thd_ng: String,
926        #[tabled(rename = "ΔTHD")]
927        d_thd: String,
928        #[tabled(rename = "resp tilt")]
929        tilt: String,
930        verdict: String,
931    }
932
933    let mut rows = Vec::new();
934    for r in results {
935        let mark = match r.verdict {
936            AcVerdict::Clean => "✓",
937            AcVerdict::LevelOnly => "≈",
938            AcVerdict::ShapeError => "!S",
939            AcVerdict::HarmonicError => "!H",
940            AcVerdict::ResponseError => "!R",
941            AcVerdict::NoData => "·",
942        };
943        rows.push(AcRow {
944            circuit: format!("{mark} {}", r.circuit),
945            gain: format!("{:+.2}dB", r.gain_db),
946            shape_rms: format!("{:.1}dB", r.shape_rms_db),
947            shape_spec: format!("{:.1}dB", r.shape_spectral_db),
948            thd_wdf: format!("{:.1}", r.thd_wdf_db),
949            thd_ng: format!("{:.1}", r.thd_golden_db),
950            d_thd: format!("{:.1}", r.thd_error_db()),
951            tilt: format!("{:.2}dB", r.response_tilt_db),
952            verdict: r.verdict.label().to_string(),
953        });
954    }
955
956    let mut out = String::new();
957    out.push_str("\n══════════════════════ AC SIGNAL ACCURACY (WDF vs ngspice golden) ══════════════════════\n");
958    let _ = write!(
959        out,
960        "  LEVEL = ac_gain@f (scalar offset).  SHAPE = gain-normalized residual (rms time-domain,\n  \
961         spec = phase-immune magnitude, in-band).  THD ratio is level-independent.  \n  \
962         resp tilt = max−min gain across the sweep (≈0 ⇒ flat scalar; ≫0 ⇒ frequency-shaped).\n  \
963         flags: '≈' level-only (shape clean); '!S' shape; '!H' harmonic; '!R' response.  \n  \
964         thresholds: |gain|>{:.1}dB=level, shape_rms>{:.0}dB, shape_spec>{:.0}dB, ΔTHD>{:.0}dB, \
965         harm>{:.0}dB, tilt>{:.0}dB.\n",
966        th.gain_warn_db,
967        th.shape_rms_fail_db,
968        th.shape_spectral_fail_db,
969        th.thd_error_fail_db,
970        th.harmonic_fail_db,
971        th.response_tilt_fail_db,
972    );
973    if !rows.is_empty() {
974        let mut table = Table::new(rows);
975        table.with(Style::rounded());
976        let _ = write!(out, "{}\n", table);
977    }
978
979    // Per-harmonic breakdown (2nd–5th, dBc) + full sweep, per circuit.
980    for r in results {
981        let _ = write!(
982            out,
983            "\n  [{}] {} @ {:.0} Hz  —  {}\n",
984            match r.verdict {
985                AcVerdict::Clean => "✓",
986                AcVerdict::LevelOnly => "≈",
987                AcVerdict::ShapeError => "!S",
988                AcVerdict::HarmonicError => "!H",
989                AcVerdict::ResponseError => "!R",
990                AcVerdict::NoData => "·",
991            },
992            r.circuit,
993            r.test_freq_hz,
994            r.verdict.label(),
995        );
996        out.push_str("    harmonics (dBc, wdf / ng / Δ): ");
997        if r.harmonics.is_empty() {
998            out.push_str("(none)");
999        }
1000        for h in &r.harmonics {
1001            let note = if h.scored { "" } else { "·" };
1002            let _ = write!(
1003                out,
1004                "h{}={:.1}/{:.1}/{:.1}{}  ",
1005                h.order, h.wdf_dbc, h.golden_dbc, h.error_db, note
1006            );
1007        }
1008        out.push('\n');
1009        if !r.response.is_empty() {
1010            out.push_str("    response gain vs freq: ");
1011            for p in &r.response {
1012                let g = if p.gain_db.is_finite() {
1013                    format!("{:+.2}", p.gain_db)
1014                } else {
1015                    "n/a".to_string()
1016                };
1017                let _ = write!(out, "{:.0}Hz={}dB  ", p.freq_hz, g);
1018            }
1019            let _ = write!(out, " (tilt {:.2} dB)\n", r.response_tilt_db);
1020        }
1021    }
1022    out.push_str("══════════════════════════════════════════════════════════════════════════════════════\n");
1023    out
1024}
1025
1026/// Get a simple timestamp without pulling in chrono.
1027fn chrono_lite_timestamp() -> String {
1028    use std::time::{SystemTime, UNIX_EPOCH};
1029    let duration = SystemTime::now()
1030        .duration_since(UNIX_EPOCH)
1031        .unwrap_or_default();
1032    format!("{}", duration.as_secs())
1033}
1034
1035/// Try to get the current git commit hash.
1036fn get_git_commit() -> Option<String> {
1037    std::process::Command::new("git")
1038        .args(["rev-parse", "--short", "HEAD"])
1039        .output()
1040        .ok()
1041        .and_then(|o| {
1042            if o.status.success() {
1043                String::from_utf8(o.stdout)
1044                    .ok()
1045                    .map(|s| s.trim().to_string())
1046            } else {
1047                None
1048            }
1049        })
1050}
1051
1052/// Wrapper to make SignalResult from comparison.
1053impl SignalResult {
1054    pub fn from_comparison(label: String, comparison: ComparisonResult, passed: bool) -> Self {
1055        Self {
1056            label,
1057            passed,
1058            pending: false,
1059            comparison: Some(comparison.into()),
1060            error: None,
1061        }
1062    }
1063
1064    pub fn from_error(label: String, error: String) -> Self {
1065        Self {
1066            label,
1067            passed: false,
1068            pending: false,
1069            comparison: None,
1070            error: Some(error),
1071        }
1072    }
1073
1074    /// A pending signal: golden missing and the test is `pending_reference`.
1075    pub fn pending(label: String, reason: String) -> Self {
1076        Self {
1077            label,
1078            passed: false,
1079            pending: true,
1080            comparison: None,
1081            error: Some(reason),
1082        }
1083    }
1084}
1085
1086#[cfg(test)]
1087mod tests {
1088    use super::*;
1089
1090    #[test]
1091    fn report_summary_calculation() {
1092        let mut suites = BTreeMap::new();
1093        suites.insert(
1094            "test_suite".to_string(),
1095            SuiteResult {
1096                description: "Test".to_string(),
1097                passed: 3,
1098                failed: 1,
1099                pending: 0,
1100                tests: BTreeMap::new(),
1101            },
1102        );
1103
1104        let report = ValidationReport::new(suites, 96000, 4);
1105        assert_eq!(report.summary.total_tests, 4);
1106        assert_eq!(report.summary.passed, 3);
1107        assert_eq!(report.summary.failed, 1);
1108        assert!((report.summary.pass_rate - 0.75).abs() < 0.001);
1109    }
1110}