1use crate::charts;
10use crate::report::{EcsReport, EcsSubmission};
11use crate::stats::{self, Ci};
12
13fn esc(s: &str) -> String {
15 s.replace('&', "&")
16 .replace('<', "<")
17 .replace('>', ">")
18 .replace('"', """)
19}
20
21fn grade_color(grade: char) -> &'static str {
23 match grade {
24 'L' => "#1a7f37", 'N' => "#2da44e", 'C' => "#0969da", 'M' => "#9a6700", 'A' => "#8250df", _ => "#cf222e", }
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
41const CI_SEED: u64 = 0x1c5_5eed;
43
44fn 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
50pub 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 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 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 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 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 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
160fn 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
172const 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 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}