Skip to main content

open_eeg_codec_standard/
report.rs

1//! Reporting — the standard OpenECS report, JSON serialization, and the
2//! human-readable summary + cross-codec leaderboard.
3//!
4//! Two layers live here:
5//!
6//! 1. The thin [`ComplianceResult`] helpers ([`to_json`], [`badge`])
7//!    used by the CI gate and the CLI to emit a machine-readable verdict
8//!    and a one-line badge.
9//! 2. The full [`EcsReport`] — the standard's canonical, self-describing
10//!    report record. It carries the run metadata (codec, dataset, file
11//!    count), the aggregate metrics, the per-band breakdown, the resource
12//!    cost (throughput, peak memory), and the grade + violation list. It
13//!    serializes to stable JSON (the wire format two labs exchange to
14//!    compare codecs) and renders a human-aligned table. [`leaderboard`]
15//!    is the standard's comparison output: a ranked cross-codec table.
16
17use serde::{Deserialize, Serialize};
18
19use crate::harness::CorpusSummary;
20use crate::levels::ComplianceResult;
21
22/// Serialize a compliance result to pretty JSON.
23///
24/// Used by the CI gate to emit a machine-readable verdict. Serialization
25/// of the small `ComplianceResult` struct cannot fail in practice; the
26/// `expect` guards a genuinely unreachable serde error.
27pub fn to_json(result: &ComplianceResult) -> String {
28    serde_json::to_string_pretty(result).expect("ComplianceResult serializes to JSON")
29}
30
31/// Render a one-line human-readable badge for a result.
32///
33/// Placeholder format; the full boxed badge with run metadata lands in
34/// the fill phase.
35pub fn badge(result: &ComplianceResult) -> String {
36    if result.passed() {
37        format!("ECS-{} COMPLIANT", result.grade)
38    } else {
39        "OpenECS NON-COMPLIANT (below alerting floor)".to_string()
40    }
41}
42
43/// One per-EEG-band fidelity result inside an [`EcsReport`].
44///
45/// `r` is the per-band Pearson correlation, `prd` the per-band PRD
46/// (percent), and `snr` the per-band SNR (dB). Band names follow the
47/// canonical [`crate::bands::EEG_BANDS`] ordering.
48#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
49pub struct BandResult {
50    /// Canonical band name, e.g. "delta".
51    pub band: String,
52    /// Per-band Pearson correlation R (higher is better).
53    pub r: f64,
54    /// Per-band PRD in percent (lower is better).
55    pub prd: f64,
56    /// Per-band SNR in dB (higher is better).
57    pub snr: f64,
58}
59
60impl BandResult {
61    /// Convenience constructor.
62    pub fn new(band: impl Into<String>, r: f64, prd: f64, snr: f64) -> Self {
63        Self {
64            band: band.into(),
65            r,
66            prd,
67            snr,
68        }
69    }
70}
71
72/// The standard OpenECS report for one codec evaluated on one dataset.
73///
74/// This is the canonical, self-describing record the standard produces:
75/// every field a third party needs to reproduce, audit, or compare the
76/// claim is present. It serializes to stable JSON (the exchange format)
77/// via [`EcsReport::to_json`] and renders a human-aligned summary via
78/// [`EcsReport::human_table`]. The cross-codec comparison output is
79/// [`leaderboard`].
80///
81/// Field groups:
82/// - identity: `codec`, `dataset`, `n_files`
83/// - verdict: `bit_exact`, `grade`
84/// - aggregate fidelity: `cr`, `prd`, `prdn`, `r`, `snr_db`, `qs`
85/// - per-band breakdown: `per_band`
86/// - resource cost: `throughput_mibs`, `peak_bytes`
87/// - audit trail: `violations` (why the next-higher tier failed)
88#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
89pub struct EcsReport {
90    /// OpenECS spec version this report conforms to (e.g. "1.0"), so a report
91    /// is self-identifying out of context. Stamped by the harness.
92    pub spec_version: String,
93    /// Codec name, e.g. "lamquant-lossless" or "gzip".
94    pub codec: String,
95    /// Dataset / holdout corpus identifier, e.g. "tuh-eeg-holdout".
96    pub dataset: String,
97    /// Number of files in the run.
98    pub n_files: usize,
99    /// True iff reconstruction was bit-exact on the integer sample domain.
100    pub bit_exact: bool,
101    /// OpenECS tier code: 'L', 'C', 'M', 'A', or '\0' for below-floor.
102    pub grade: char,
103    /// Aggregate (pooled) compression ratio (raw / compressed).
104    pub cr: f64,
105    /// Aggregate PRD in percent.
106    pub prd: f64,
107    /// Aggregate normalized (mean-subtracted) PRD in percent.
108    pub prdn: f64,
109    /// Aggregate Pearson correlation R.
110    pub r: f64,
111    /// Aggregate SNR in dB.
112    pub snr_db: f64,
113    /// Quality score `CR / PRD` (raw CR when lossless). Higher is better.
114    pub qs: f64,
115    /// Per-band fidelity breakdown, in canonical band order.
116    pub per_band: Vec<BandResult>,
117    /// Encode+decode throughput in MiB/s.
118    pub throughput_mibs: f64,
119    /// Peak resident memory during the run, in bytes.
120    pub peak_bytes: u64,
121    /// Why the next-higher tier failed — the climb-a-tier to-do list.
122    pub violations: Vec<String>,
123}
124
125impl EcsReport {
126    /// The grade as a display string, or "" for the below-floor sentinel.
127    pub fn grade_str(&self) -> String {
128        if self.grade == '\0' {
129            String::new()
130        } else {
131            self.grade.to_string()
132        }
133    }
134
135    /// True iff the codec reached any compliant tier (grade != '\0').
136    pub fn passed(&self) -> bool {
137        self.grade != '\0'
138    }
139
140    /// Serialize this report to pretty JSON — the standard exchange format.
141    ///
142    /// Serialization of this plain-data struct cannot fail in practice;
143    /// the `expect` guards a genuinely unreachable serde error.
144    pub fn to_json(&self) -> String {
145        serde_json::to_string_pretty(self).expect("EcsReport serializes to JSON")
146    }
147
148    /// Parse an [`EcsReport`] back from its JSON form.
149    pub fn from_json(s: &str) -> Result<Self, serde_json::Error> {
150        serde_json::from_str(s)
151    }
152
153    /// Render an aligned, human-readable text summary of this report.
154    ///
155    /// Two blocks: a header with identity + verdict + aggregate metrics,
156    /// then a fixed-width per-band table. Memory is shown in MiB for
157    /// readability. The exact byte / float values stay in the JSON form.
158    pub fn human_table(&self) -> String {
159        let grade = if self.grade == '\0' {
160            "— (below floor)".to_string()
161        } else {
162            format!("ECS-{}", self.grade)
163        };
164        let peak_mib = self.peak_bytes as f64 / (1024.0 * 1024.0);
165
166        let mut s = String::new();
167        s.push_str(&format!(
168            "OpenECS report — {} on {} ({} file{})\n",
169            self.codec,
170            self.dataset,
171            self.n_files,
172            if self.n_files == 1 { "" } else { "s" },
173        ));
174        s.push_str(&format!("{:-<60}\n", ""));
175        s.push_str(&format!("  Grade        : {grade}\n"));
176        s.push_str(&format!(
177            "  Bit-exact    : {}\n",
178            if self.bit_exact { "yes" } else { "no" }
179        ));
180        s.push_str(&format!("  CR           : {:>10.2} : 1\n", self.cr));
181        s.push_str(&format!("  PRD          : {:>10.3} %\n", self.prd));
182        s.push_str(&format!("  PRDN         : {:>10.3} %\n", self.prdn));
183        s.push_str(&format!("  R            : {:>10.4}\n", self.r));
184        s.push_str(&format!("  SNR          : {:>10.2} dB\n", self.snr_db));
185        s.push_str(&format!("  Quality score: {:>10.3}\n", self.qs));
186        s.push_str(&format!(
187            "  Throughput   : {:>10.2} MiB/s\n",
188            self.throughput_mibs
189        ));
190        s.push_str(&format!("  Peak memory  : {peak_mib:>10.2} MiB\n"));
191
192        if !self.per_band.is_empty() {
193            s.push_str(&format!("{:-<60}\n", ""));
194            s.push_str(&format!(
195                "  {:<8} {:>10} {:>10} {:>10}\n",
196                "band", "R", "PRD %", "SNR dB"
197            ));
198            for b in &self.per_band {
199                s.push_str(&format!(
200                    "  {:<8} {:>10.4} {:>10.3} {:>10.2}\n",
201                    b.band, b.r, b.prd, b.snr
202                ));
203            }
204        }
205
206        if !self.violations.is_empty() {
207            s.push_str(&format!("{:-<60}\n", ""));
208            s.push_str("  To climb a tier:\n");
209            for v in &self.violations {
210                s.push_str(&format!("    - {v}\n"));
211            }
212        }
213
214        s
215    }
216}
217
218/// Rank order for an OpenECS grade: lower number = stronger tier.
219///
220/// L (lossless) is the strongest, then C, M, A; the below-floor sentinel
221/// sorts last. Used only by [`leaderboard`] to order rows.
222fn grade_rank(grade: char) -> u8 {
223    match grade {
224        'L' => 0,
225        'N' => 1,
226        'C' => 2,
227        'M' => 3,
228        'A' => 4,
229        _ => 5, // '\0' below-floor sentinel sorts last.
230    }
231}
232
233/// Render the standard cross-codec comparison table.
234///
235/// This is the standard's headline comparison output: one row per codec,
236/// ranked best-first. Ordering is **grade first** (a stronger tier always
237/// outranks a weaker one), then within a tier by the quality score `qs`
238/// (descending — more compression per unit distortion wins), and the
239/// raw compression ratio `cr` (descending) as the final tie-breaker.
240/// Ties beyond that fall back to the codec name for a stable, determin-
241/// istic order.
242///
243/// The input slice is not mutated; a sorted copy of references drives the
244/// table. An empty slice yields just the header and a `(no codecs)` note.
245pub fn leaderboard(reports: &[EcsReport]) -> String {
246    let mut ranked: Vec<&EcsReport> = reports.iter().collect();
247    ranked.sort_by(|a, b| {
248        grade_rank(a.grade)
249            .cmp(&grade_rank(b.grade))
250            // Higher qs first.
251            .then(
252                b.qs.partial_cmp(&a.qs)
253                    .unwrap_or(std::cmp::Ordering::Equal),
254            )
255            // Higher cr first.
256            .then(
257                b.cr.partial_cmp(&a.cr)
258                    .unwrap_or(std::cmp::Ordering::Equal),
259            )
260            // Stable, deterministic final tie-break.
261            .then_with(|| a.codec.cmp(&b.codec))
262    });
263
264    let mut s = String::new();
265    s.push_str("OpenECS leaderboard (best first)\n");
266    s.push_str(&format!("{:=<78}\n", ""));
267    s.push_str(&format!(
268        "  {:>3}  {:<22} {:<14} {:>6} {:>9} {:>8} {:>8}\n",
269        "#", "codec", "dataset", "grade", "CR", "PRD %", "QS"
270    ));
271    s.push_str(&format!("{:-<78}\n", ""));
272
273    if ranked.is_empty() {
274        s.push_str("  (no codecs)\n");
275        return s;
276    }
277
278    for (i, rep) in ranked.iter().enumerate() {
279        let grade = if rep.grade == '\0' {
280            "—".to_string()
281        } else {
282            format!("ECS-{}", rep.grade)
283        };
284        s.push_str(&format!(
285            "  {:>3}  {:<22} {:<14} {:>6} {:>9.2} {:>8.3} {:>8.3}\n",
286            i + 1,
287            rep.codec,
288            rep.dataset,
289            grade,
290            rep.cr,
291            rep.prd,
292            rep.qs,
293        ));
294    }
295
296    s
297}
298
299/// The codec identity carried in a submission: the report name plus, for a
300/// manifest-defined external codec, the SHA-256 of its manifest (so the
301/// exact invocation is auditable). `None` for a built-in codec.
302#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
303pub struct CodecIdentity {
304    /// Codec report identifier.
305    pub name: String,
306    /// SHA-256 of the codec manifest, or `None` for a built-in codec.
307    pub manifest_sha256: Option<String>,
308}
309
310/// The corpus identity carried in a submission: name + version. Two
311/// submissions are only directly comparable when these agree.
312#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
313pub struct CorpusIdentity {
314    /// Corpus identifier, e.g. "ecs-smoke".
315    pub name: String,
316    /// Corpus version, e.g. "1.0.0".
317    pub version: String,
318}
319
320/// The OpenECS v1.0 results-submission envelope — the wire format two labs
321/// exchange (spec §9). Wraps the per-file reports, the corpus roll-up, and
322/// the codec/corpus identities, plus an **optional, advisory**
323/// task-concordance block that MUST NOT affect any grade (spec §10).
324#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
325pub struct EcsSubmission {
326    /// OpenECS spec version this submission conforms to (e.g. "1.0").
327    pub spec_version: String,
328    /// The codec under test.
329    pub codec: CodecIdentity,
330    /// The corpus the codec was graded on.
331    pub corpus: CorpusIdentity,
332    /// One report per graded file.
333    pub reports: Vec<EcsReport>,
334    /// The corpus roll-up (pooled CR, mean PRD/R, worst grade).
335    pub summary: CorpusSummary,
336    /// Optional, advisory downstream-task-preservation block. Opaque to the
337    /// grader; never alters a grade. `None` when not run.
338    #[serde(default, skip_serializing_if = "Option::is_none")]
339    pub task_concordance: Option<serde_json::Value>,
340}
341
342impl EcsSubmission {
343    /// Assemble a submission, stamping the current [`crate::SPEC_VERSION`].
344    pub fn new(
345        codec: CodecIdentity,
346        corpus: CorpusIdentity,
347        reports: Vec<EcsReport>,
348        summary: CorpusSummary,
349    ) -> Self {
350        Self {
351            spec_version: crate::SPEC_VERSION.to_string(),
352            codec,
353            corpus,
354            reports,
355            summary,
356            task_concordance: None,
357        }
358    }
359
360    /// Attach an advisory task-concordance block (spec §10).
361    pub fn with_task_concordance(mut self, block: serde_json::Value) -> Self {
362        self.task_concordance = Some(block);
363        self
364    }
365
366    /// Serialize to pretty JSON — the standard exchange format.
367    pub fn to_json(&self) -> String {
368        serde_json::to_string_pretty(self).expect("EcsSubmission serializes to JSON")
369    }
370
371    /// Parse a submission back from its JSON form.
372    pub fn from_json(s: &str) -> Result<Self, serde_json::Error> {
373        serde_json::from_str(s)
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    fn sample_report() -> EcsReport {
382        EcsReport {
383            spec_version: crate::SPEC_VERSION.to_string(),
384            codec: "lamquant-lossless".to_string(),
385            dataset: "tuh-eeg-holdout".to_string(),
386            n_files: 42,
387            bit_exact: true,
388            grade: 'L',
389            cr: 3.71,
390            prd: 0.0,
391            prdn: 0.0,
392            r: 1.0,
393            snr_db: 120.0,
394            qs: 3.71,
395            per_band: vec![
396                BandResult::new("delta", 1.0, 0.0, 120.0),
397                BandResult::new("theta", 1.0, 0.0, 120.0),
398                BandResult::new("alpha", 1.0, 0.0, 120.0),
399                BandResult::new("beta", 1.0, 0.0, 120.0),
400                BandResult::new("gamma", 1.0, 0.0, 120.0),
401            ],
402            throughput_mibs: 88.5,
403            peak_bytes: 12 * 1024 * 1024,
404            violations: Vec::new(),
405        }
406    }
407
408    fn lossy_report() -> EcsReport {
409        EcsReport {
410            spec_version: crate::SPEC_VERSION.to_string(),
411            codec: "neural-lmq".to_string(),
412            dataset: "tuh-eeg-holdout".to_string(),
413            n_files: 42,
414            bit_exact: false,
415            grade: 'C',
416            cr: 42.0,
417            prd: 4.2,
418            prdn: 4.5,
419            r: 0.972,
420            snr_db: 27.6,
421            qs: 10.0,
422            per_band: vec![
423                BandResult::new("delta", 0.99, 3.0, 30.0),
424                BandResult::new("gamma", 0.90, 12.0, 18.0),
425            ],
426            throughput_mibs: 14.2,
427            peak_bytes: 256 * 1024 * 1024,
428            violations: vec!["CR 42.0 < 100.0".to_string()],
429        }
430    }
431
432    #[test]
433    fn report_round_trips_through_json() {
434        let rep = sample_report();
435        let json = rep.to_json();
436        // Sanity: pretty JSON carries the field names.
437        assert!(json.contains("\"codec\""));
438        assert!(json.contains("lamquant-lossless"));
439        assert!(json.contains("\"per_band\""));
440
441        // Round-trip is value-preserving.
442        let back = EcsReport::from_json(&json).expect("valid JSON round-trips");
443        assert_eq!(back, rep);
444    }
445
446    #[test]
447    fn char_grade_round_trips() {
448        // The `grade: char` field survives a JSON round-trip including
449        // the below-floor '\0' sentinel.
450        let mut rep = sample_report();
451        rep.grade = '\0';
452        let back = EcsReport::from_json(&rep.to_json()).expect("round-trips");
453        assert_eq!(back.grade, '\0');
454        assert_eq!(back.grade_str(), "");
455        assert!(!back.passed());
456    }
457
458    #[test]
459    fn human_table_has_key_fields() {
460        let t = sample_report().human_table();
461        assert!(t.contains("lamquant-lossless"));
462        assert!(t.contains("tuh-eeg-holdout"));
463        assert!(t.contains("ECS-L"));
464        assert!(t.contains("delta"));
465        assert!(t.contains("gamma"));
466        // Memory rendered in MiB (12 MiB peak).
467        assert!(t.contains("12.00 MiB"));
468    }
469
470    #[test]
471    fn human_table_lists_violations() {
472        let t = lossy_report().human_table();
473        assert!(t.contains("To climb a tier"));
474        assert!(t.contains("CR 42.0 < 100.0"));
475    }
476
477    #[test]
478    fn leaderboard_sorts_by_grade_then_quality() {
479        // A: strong lossless. B: clinical. C: clinical but lower qs.
480        let a = sample_report(); // grade L
481        let mut b = lossy_report(); // grade C, qs 10
482        b.codec = "codec-b".to_string();
483        let mut c = lossy_report(); // grade C, qs 5 (ranks below b)
484        c.codec = "codec-c".to_string();
485        c.qs = 5.0;
486
487        let table = leaderboard(&[c.clone(), b.clone(), a.clone()]);
488
489        // L tier must appear before either C tier.
490        let pos_a = table.find("lamquant-lossless").unwrap();
491        let pos_b = table.find("codec-b").unwrap();
492        let pos_c = table.find("codec-c").unwrap();
493        assert!(pos_a < pos_b, "L should outrank C");
494        assert!(pos_a < pos_c, "L should outrank C");
495        // Within C, higher qs (codec-b) ranks above lower qs (codec-c).
496        assert!(pos_b < pos_c, "higher QS ranks first within a tier");
497
498        // Rank 1 is the lossless codec.
499        assert!(table.contains("  1  lamquant-lossless"));
500    }
501
502    #[test]
503    fn leaderboard_empty_is_safe() {
504        let table = leaderboard(&[]);
505        assert!(table.contains("OpenECS leaderboard"));
506        assert!(table.contains("(no codecs)"));
507    }
508}