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}