Skip to main content

open_eeg_codec_standard/
report_html.rs

1//! Self-contained HTML report — the shareable artifact (host-side DX).
2//!
3//! [`render`] turns one or more [`EcsSubmission`]s (one per codec) into a
4//! single HTML page with inline CSS and inline SVG charts ([`crate::charts`]):
5//! a codec-comparison table, a pooled-CR bar chart, a rate–distortion scatter,
6//! and per-codec per-file tables. No external assets, no JavaScript — open the
7//! file anywhere.
8
9use crate::charts;
10use crate::report::{EcsReport, EcsSubmission};
11use crate::stats::{self, Ci};
12
13/// HTML-escape a string for safe embedding in text / attribute context.
14fn esc(s: &str) -> String {
15    s.replace('&', "&")
16        .replace('<', "&lt;")
17        .replace('>', "&gt;")
18        .replace('"', "&quot;")
19}
20
21/// Tier code → an accessible badge color.
22fn grade_color(grade: char) -> &'static str {
23    match grade {
24        'L' => "#1a7f37", // green
25        'N' => "#2da44e", // bright green
26        'C' => "#0969da", // blue
27        'M' => "#9a6700", // amber
28        'A' => "#8250df", // purple
29        _ => "#cf222e",   // red
30    }
31}
32
33fn grade_label(grade: char) -> String {
34    if grade == '\0' {
35        "— below floor".to_string()
36    } else {
37        format!("ECS-{grade}")
38    }
39}
40
41/// Fixed seed so bootstrap CIs in the report are reproducible across renders.
42const CI_SEED: u64 = 0x1c5_5eed;
43
44/// Bootstrap 95% CI of per-file Pearson R for a codec's reports.
45fn r_ci(reports: &[EcsReport]) -> Ci {
46    let rs: Vec<f64> = reports.iter().map(|r| r.r).collect();
47    stats::bootstrap_ci(&rs, 0.95, 2000, CI_SEED)
48}
49
50/// Render a complete HTML report for the given per-codec submissions.
51pub fn render(submissions: &[EcsSubmission]) -> String {
52    let corpus = submissions
53        .first()
54        .map(|s| format!("{} v{}", s.corpus.name, s.corpus.version))
55        .unwrap_or_else(|| "(no corpus)".to_string());
56    let spec = submissions.first().map(|s| s.spec_version.clone()).unwrap_or_else(|| "1.0".into());
57
58    // Charts: pooled CR per codec + per-file R–D scatter per codec.
59    let cr_rows: Vec<(String, f64)> = submissions
60        .iter()
61        .map(|s| (s.codec.name.clone(), s.summary.mean_cr))
62        .collect();
63    let rd_series: Vec<(String, Vec<(f64, f64)>)> = submissions
64        .iter()
65        .map(|s| {
66            (
67                s.codec.name.clone(),
68                s.reports.iter().map(|r| (r.cr, r.prd)).collect(),
69            )
70        })
71        .collect();
72    let cr_svg = charts::svg_codec_bars("Pooled compression ratio by codec", "CR", &cr_rows);
73    let rd_svg = charts::svg_rd_scatter(&rd_series);
74
75    let mut h = String::new();
76    h.push_str("<!doctype html>\n<html lang=\"en\"><head><meta charset=\"utf-8\">\n");
77    h.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
78    h.push_str(&format!("<title>OpenECS report — {}</title>\n", esc(&corpus)));
79    h.push_str(STYLE);
80    h.push_str("</head>\n<body>\n<main>\n");
81    h.push_str(&format!(
82        "<h1>OpenECS report <span class=\"sub\">v{}</span></h1>\n<p class=\"meta\">corpus: <b>{}</b> · {} codec(s)</p>\n",
83        esc(&spec),
84        esc(&corpus),
85        submissions.len()
86    ));
87
88    // ── Codec comparison table. ────────────────────────────────────────
89    h.push_str("<h2>Codecs</h2>\n<table class=\"cmp\">\n<thead><tr>\
90        <th>codec</th><th>grade</th><th>pooled CR</th><th>mean R (95% CI)</th>\
91        <th>mean PRD</th><th>bit-exact</th><th>files</th></tr></thead>\n<tbody>\n");
92    // Rank: worst grade first (stronger tier), then pooled CR desc.
93    let mut order: Vec<usize> = (0..submissions.len()).collect();
94    order.sort_by(|&a, &b| {
95        let (sa, sb) = (&submissions[a].summary, &submissions[b].summary);
96        grade_rank(sa.worst_grade)
97            .cmp(&grade_rank(sb.worst_grade))
98            .then(sb.mean_cr.partial_cmp(&sa.mean_cr).unwrap_or(std::cmp::Ordering::Equal))
99    });
100    for &i in &order {
101        let s = &submissions[i];
102        let ci = r_ci(&s.reports);
103        let g = s.summary.worst_grade;
104        h.push_str(&format!(
105            "<tr><td class=\"codec\">{}</td>\
106             <td><span class=\"badge\" style=\"background:{}\">{}</span></td>\
107             <td>{:.2}:1</td><td>{:.4} <span class=\"ci\">[{:.4}, {:.4}]</span></td>\
108             <td>{:.3}%</td><td>{}</td><td>{}</td></tr>\n",
109            esc(&s.codec.name),
110            grade_color(g),
111            esc(&grade_label(g)),
112            s.summary.mean_cr,
113            ci.mean,
114            ci.lo,
115            ci.hi,
116            s.summary.mean_prd,
117            if s.summary.all_bit_exact { "yes" } else { "no" },
118            s.summary.n_files,
119        ));
120    }
121    h.push_str("</tbody></table>\n");
122
123    // ── Charts. ────────────────────────────────────────────────────────
124    h.push_str("<h2>Charts</h2>\n<div class=\"charts\">\n");
125    h.push_str(&format!("<figure>{cr_svg}</figure>\n"));
126    h.push_str(&format!("<figure>{rd_svg}</figure>\n"));
127    h.push_str("</div>\n");
128
129    // ── Per-codec per-file detail. ─────────────────────────────────────
130    h.push_str("<h2>Per-file</h2>\n");
131    for &i in &order {
132        let s = &submissions[i];
133        h.push_str(&format!("<h3>{}</h3>\n<table class=\"files\">\n\
134            <thead><tr><th>#</th><th>grade</th><th>CR</th><th>PRD%</th><th>R</th>\
135            <th>SNR dB</th><th>MiB/s</th></tr></thead>\n<tbody>\n", esc(&s.codec.name)));
136        for (j, r) in s.reports.iter().enumerate() {
137            h.push_str(&format!(
138                "<tr><td>{}</td><td><span class=\"badge sm\" style=\"background:{}\">{}</span></td>\
139                 <td>{:.2}</td><td>{:.3}</td><td>{:.4}</td><td>{:.1}</td><td>{:.2}</td></tr>\n",
140                j + 1,
141                grade_color(r.grade),
142                esc(&grade_label(r.grade)),
143                r.cr,
144                r.prd,
145                r.r,
146                r.snr_db,
147                r.throughput_mibs,
148            ));
149        }
150        h.push_str("</tbody></table>\n");
151    }
152
153    h.push_str("<footer>Generated by <code>openecs</code> · OpenECS v");
154    h.push_str(&esc(&spec));
155    h.push_str(" · grades are signal-fidelity + resource only (task concordance is advisory).</footer>\n");
156    h.push_str("</main>\n</body></html>\n");
157    h
158}
159
160/// Tier strength for ordering (lower = stronger).
161fn grade_rank(g: char) -> u8 {
162    match g {
163        'L' => 0,
164        'N' => 1,
165        'C' => 2,
166        'M' => 3,
167        'A' => 4,
168        _ => 5,
169    }
170}
171
172/// Inline stylesheet — keeps the report a single self-contained file.
173const STYLE: &str = r#"<style>
174:root{color-scheme:light dark}
175body{font:15px/1.5 -apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;margin:0;background:#fbfbfd;color:#1d1d1f}
176main{max-width:980px;margin:0 auto;padding:32px 20px 64px}
177h1{font-size:28px;margin:0 0 4px}h1 .sub{font-size:14px;color:#86868b;font-weight:500}
178h2{font-size:20px;margin:32px 0 8px;border-bottom:1px solid #e5e5ea;padding-bottom:4px}
179h3{font-size:15px;margin:20px 0 6px;color:#3a3a3c}
180.meta{color:#6e6e73;margin:0 0 8px}
181table{border-collapse:collapse;width:100%;margin:8px 0;font-variant-numeric:tabular-nums}
182th,td{text-align:right;padding:6px 10px;border-bottom:1px solid #ececf0}
183th{font-weight:600;color:#6e6e73;font-size:12px;text-transform:uppercase;letter-spacing:.03em}
184td.codec,td:first-child,th:first-child{text-align:left}
185.cmp td.codec{font-weight:600}
186.badge{display:inline-block;color:#fff;border-radius:6px;padding:2px 8px;font-size:12px;font-weight:700}
187.badge.sm{padding:1px 6px;font-size:11px}
188.ci{color:#86868b;font-size:12px}
189.charts{display:flex;flex-wrap:wrap;gap:16px}
190figure{margin:0;background:#fff;border:1px solid #e5e5ea;border-radius:10px;padding:8px}
191figure svg{display:block;max-width:100%;height:auto}
192footer{margin-top:40px;color:#86868b;font-size:12px;border-top:1px solid #e5e5ea;padding-top:12px}
193code{background:#f0f0f3;border-radius:4px;padding:1px 5px}
194</style>
195"#;
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use crate::report::{CodecIdentity, CorpusIdentity};
201    use crate::adapter::Store;
202    use crate::harness;
203
204    fn submission(name: &str) -> EcsSubmission {
205        let sig = vec![vec![0i64, 1, 2, 3, 100, -100], vec![5, 6, 7, 8, 9, 10]];
206        let (reports, summary) = harness::run_corpus(&Store, &[(sig, 256.0)]);
207        EcsSubmission::new(
208            CodecIdentity { name: name.into(), manifest_sha256: None },
209            CorpusIdentity { name: "ecs-smoke".into(), version: "1.0.0".into() },
210            reports,
211            summary,
212        )
213    }
214
215    #[test]
216    fn html_is_self_contained_and_has_charts() {
217        let html = render(&[submission("store"), submission("gzip")]);
218        assert!(html.starts_with("<!doctype html>"));
219        assert!(html.contains("</html>"));
220        assert!(html.contains("<svg"), "embedded SVG charts present");
221        assert!(html.contains("store") && html.contains("gzip"));
222        assert!(html.contains("ECS-L"));
223        // No external resource fetches or scripts (self-contained). The only
224        // URLs present are SVG/XML namespace identifiers, which are not loads.
225        assert!(!html.contains("src=\"http"), "no external resource src");
226        assert!(!html.contains("href=\"http"), "no external resource href");
227        assert!(!html.contains("<script"), "no scripts");
228    }
229
230    #[test]
231    fn empty_submissions_is_safe() {
232        let html = render(&[]);
233        assert!(html.contains("</html>"));
234        assert!(html.contains("no corpus"));
235    }
236}