//! bench — llm-transpile file benchmark runner & HTML report generator
//!
//! Used as `transpile bench run` / `transpile bench report`.
use llm_transpile::{FidelityLevel, InputFormat, token_count, transpile};
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::{self, BufRead, Write as IoWrite};
use std::path::{Path, PathBuf};
use std::time::Instant;
// ── Data model ────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
struct BenchRecord {
ts: String,
run_id: String,
file: String,
format: String,
input_bytes: usize,
input_tok: usize,
semantic_tok: usize,
compressed_tok: usize,
lossless_tok: usize,
sem_pct: f64,
cmp_pct: f64,
los_pct: f64,
semantic_us: u128,
compressed_us: u128,
tok_per_ms: f64,
word_coverage: f64,
}
// ── Path helpers ──────────────────────────────────────────────────────────────
fn default_log_dir() -> PathBuf {
home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".agents/transpile/bench")
}
fn home_dir() -> Option<PathBuf> {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
}
fn expand_tilde(path: &str) -> String {
if let Some(rest) = path.strip_prefix("~/")
&& let Ok(home) = std::env::var("HOME")
{
return format!("{home}/{rest}");
}
path.to_string()
}
fn ensure_parent_dir(path: &str) -> std::io::Result<()> {
let p = std::path::Path::new(path);
if let Some(parent) = p.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent)?;
}
Ok(())
}
// ── Time helpers (no chrono) ──────────────────────────────────────────────────
fn now_iso() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
secs_to_iso(secs)
}
fn secs_to_iso(secs: u64) -> String {
let sec = secs % 60;
let min = (secs / 60) % 60;
let hour = (secs / 3600) % 24;
let (year, month, day) = days_to_ymd(secs / 86400);
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{min:02}:{sec:02}Z")
}
fn days_to_ymd(days: u64) -> (u32, u32, u32) {
let z = days + 719_468;
let era = z / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y as u32, m as u32, d as u32)
}
/// "2026-05-12T01:10:01Z" → "2026-05-12_01-10-01"
fn iso_to_run_id(iso: &str) -> String {
iso.replace('T', "_")
.replace(':', "-")
.trim_end_matches('Z')
.to_string()
}
// ── Metrics ───────────────────────────────────────────────────────────────────
fn pct_reduction(output: usize, input: usize) -> f64 {
if input == 0 {
return 0.0;
}
100.0 - (output as f64 / input as f64 * 100.0)
}
/// Lossless word coverage: % of unique content words (>5 chars, all-alpha)
/// from source that appear in the lossless output.
fn word_coverage(source: &str, lossless_out: &str) -> f64 {
use std::collections::HashSet;
let words: HashSet<&str> = source
.split_whitespace()
.filter(|w| w.len() > 5 && w.chars().all(|c| c.is_alphabetic()))
.collect();
if words.is_empty() {
return 100.0;
}
let matched = words.iter().filter(|w| lossless_out.contains(*w)).count();
matched as f64 / words.len() as f64 * 100.0
}
// ── File collection ───────────────────────────────────────────────────────────
fn collect_files(dir: &str, ext: &str) -> Vec<PathBuf> {
let Ok(entries) = fs::read_dir(dir) else {
return vec![];
};
let mut files: Vec<PathBuf> = entries
.flatten()
.filter_map(|e| {
let p = e.path();
if p.extension().and_then(|x| x.to_str()) == Some(ext) {
Some(p)
} else {
None
}
})
.collect();
files.sort();
files
}
// ── Single-file benchmark ─────────────────────────────────────────────────────
fn bench_file(path: &Path, fmt: InputFormat, ts: &str, run_id: &str) -> Option<BenchRecord> {
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(e) => {
eprintln!("WARN skip {}: {e}", path.display());
return None; // R8: skip on error, no panic
}
};
let input_tok = token_count(&content);
let input_bytes = content.len();
// 3 runs, take median (R1)
let timed = |fidelity: FidelityLevel, budget: Option<usize>| -> Option<(String, u128)> {
let mut timings = [0u128; 3];
let mut out = String::new();
for t in &mut timings {
let t0 = Instant::now();
out = match transpile(&content, fmt, fidelity, budget) {
Ok(o) => o,
Err(e) => {
eprintln!("WARN transpile failed {}: {e}", path.display());
return None; // R8
}
};
*t = t0.elapsed().as_micros();
}
timings.sort_unstable();
Some((out, timings[1]))
};
let (sem, semantic_us) = timed(FidelityLevel::Semantic, Some(4096))?;
let (cmp, compressed_us) = timed(FidelityLevel::Compressed, Some(2048))?;
let (los, _) = timed(FidelityLevel::Lossless, None)?;
let semantic_tok = token_count(&sem);
let compressed_tok = token_count(&cmp);
let lossless_tok = token_count(&los);
let coverage = word_coverage(&content, &los);
let tok_per_ms = if semantic_us > 0 {
input_tok as f64 / semantic_us as f64 * 1000.0
} else {
input_tok as f64 * 1000.0
};
let fname = path.file_name()?.to_string_lossy().into_owned();
let format_str = match fmt {
InputFormat::Markdown => "markdown",
InputFormat::Html => "html",
InputFormat::PlainText => "plaintext",
};
Some(BenchRecord {
ts: ts.to_string(),
run_id: run_id.to_string(),
file: fname,
format: format_str.to_string(),
input_bytes,
input_tok,
semantic_tok,
compressed_tok,
lossless_tok,
sem_pct: pct_reduction(semantic_tok, input_tok),
cmp_pct: pct_reduction(compressed_tok, input_tok),
los_pct: pct_reduction(lossless_tok, input_tok),
semantic_us,
compressed_us,
tok_per_ms,
word_coverage: coverage,
})
}
// ── run subcommand ────────────────────────────────────────────────────────────
pub fn cmd_run(dataset: &str, log_dir_opt: Option<String>, report: bool, report_out: &str) -> i32 {
let ts = now_iso();
let run_id = iso_to_run_id(&ts);
let log_dir = log_dir_opt
.map(PathBuf::from)
.unwrap_or_else(default_log_dir);
// R3: auto-create log directory
if let Err(e) = fs::create_dir_all(&log_dir) {
eprintln!(
"ERROR: cannot create log directory {}: {e}",
log_dir.display()
);
return 1;
}
let log_path = log_dir.join(format!("{run_id}.jsonl"));
let mut log_file = match fs::File::create(&log_path) {
Ok(f) => f,
Err(e) => {
eprintln!("ERROR: cannot create log file {}: {e}", log_path.display());
return 1;
}
};
println!("▶ bench run [{ts}]");
println!(" dataset : {dataset}");
println!(" log : {}", log_path.display());
println!();
let sections: &[(&str, &str, InputFormat)] = &[
("policy", "md", InputFormat::Markdown),
("hf", "md", InputFormat::Markdown),
("multilingual", "md", InputFormat::Markdown),
("html", "html", InputFormat::Html),
("plaintext", "txt", InputFormat::PlainText),
];
let mut all: Vec<BenchRecord> = Vec::new();
for (sub, ext, fmt) in sections {
let dir = format!("{dataset}/{sub}");
let files = collect_files(&dir, ext);
if files.is_empty() {
continue;
}
println!(" ▸ {sub}/ ({} files)", files.len());
print_table_header();
for path in &files {
if let Some(rec) = bench_file(path, *fmt, &ts, &run_id) {
print_table_row(&rec);
let line = serde_json::to_string(&rec).expect("serialize");
writeln!(log_file, "{line}").expect("write log");
all.push(rec);
}
}
println!();
}
// R7: no records produced → error exit
if all.is_empty() {
eprintln!("ERROR: no records produced. Check dataset path: {dataset}");
return 1;
}
print_grand_summary(&all);
if report {
let report_out = expand_tilde(report_out);
println!("\n Generating report → {report_out}");
if let Err(e) = ensure_parent_dir(&report_out) {
eprintln!("ERROR: cannot create report directory: {e}");
return 1;
}
let records = load_all_logs(&log_dir);
generate_html(&records, &report_out);
println!(" ✓ {report_out}");
}
0
}
// ── report subcommand ─────────────────────────────────────────────────────────
pub fn cmd_report(log_dir_opt: Option<String>, out: &str, no_open: bool) -> i32 {
let log_dir = log_dir_opt
.map(PathBuf::from)
.unwrap_or_else(default_log_dir);
println!("▶ bench report");
println!(" log dir : {}", log_dir.display());
let records = load_all_logs(&log_dir);
if records.is_empty() {
eprintln!(
"ERROR: no JSONL logs found in {}. Run `transpile bench run` first.",
log_dir.display()
);
return 1;
}
println!(" records : {}", records.len());
let out = expand_tilde(out);
if let Err(e) = ensure_parent_dir(&out) {
eprintln!("ERROR: cannot create report directory: {e}");
return 1;
}
generate_html(&records, &out);
println!(" ✓ {out}");
if !no_open {
let _ = std::process::Command::new("open").arg(&out).spawn();
}
0
}
// ── Log loader ────────────────────────────────────────────────────────────────
fn load_all_logs(log_dir: &Path) -> Vec<BenchRecord> {
let Ok(entries) = fs::read_dir(log_dir) else {
return vec![];
};
let mut paths: Vec<PathBuf> = entries
.flatten()
.filter_map(|e| {
let p = e.path();
if p.extension().and_then(|x| x.to_str()) == Some("jsonl") {
Some(p)
} else {
None
}
})
.collect();
paths.sort();
let mut all: Vec<BenchRecord> = Vec::new();
for path in paths {
let Ok(f) = fs::File::open(&path) else {
continue;
};
for line in io::BufReader::new(f).lines().map_while(|r| r.ok()) {
if let Ok(rec) = serde_json::from_str::<BenchRecord>(&line) {
all.push(rec);
}
}
}
all
}
// ── Terminal table helpers ────────────────────────────────────────────────────
fn print_table_header() {
println!(
" {:<36} {:>4} {:>6} {:>7} {:>7} {:>7} {:>7} {:>9}",
"file", "fmt", "in_tok", "sem%", "cmp%", "sem_ms", "cmp_ms", "tok/ms"
);
println!(" {}", "─".repeat(90));
}
fn print_table_row(r: &BenchRecord) {
let sem_ms = r.semantic_us as f64 / 1000.0;
let cmp_ms = r.compressed_us as f64 / 1000.0;
println!(
" {:<36} {:>4} {:>6} {:>7.1} {:>7.1} {:>7.1} {:>7.1} {:>9.0}",
trunc(&r.file, 36),
r.format.get(..3).unwrap_or(&r.format),
r.input_tok,
r.sem_pct,
r.cmp_pct,
sem_ms,
cmp_ms,
r.tok_per_ms,
);
}
fn print_grand_summary(all: &[BenchRecord]) {
if all.is_empty() {
return;
}
let total_in: usize = all.iter().map(|r| r.input_tok).sum();
let total_sem: usize = all.iter().map(|r| r.semantic_tok).sum();
let total_cmp: usize = all.iter().map(|r| r.compressed_tok).sum();
let total_us: u128 = all.iter().map(|r| r.semantic_us).sum();
let avg_cov: f64 = all.iter().map(|r| r.word_coverage).sum::<f64>() / all.len() as f64;
let tok_ms = if total_us > 0 {
total_in as f64 / total_us as f64 * 1000.0
} else {
0.0
};
println!(" ── Grand summary ({} files) ──", all.len());
println!(" input tokens : {total_in}");
println!(
" sem reduction: {:.1}%",
pct_reduction(total_sem, total_in)
);
println!(
" cmp reduction: {:.1}%",
pct_reduction(total_cmp, total_in)
);
println!(" word coverage: {avg_cov:.1}% avg");
println!(" throughput : {tok_ms:.0} tok/ms");
}
fn trunc(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let cut: String = s.chars().take(max.saturating_sub(1)).collect();
format!("{cut}…")
}
}
/// Escape `</` so JSON strings embedded in `<script>` cannot prematurely
/// close the script block (JS spec allows `<\/` as valid escape).
fn js_safe(json: &str) -> String {
json.replace("</", "<\\/")
}
// ── HTML report generator ─────────────────────────────────────────────────────
/// Escape HTML special characters to prevent XSS (R6).
fn esc(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
fn round2(v: f64) -> f64 {
(v * 100.0).round() / 100.0
}
fn pct_class(pct: f64) -> &'static str {
if pct >= 20.0 {
"good"
} else if pct >= 5.0 {
"ok"
} else {
"low"
}
}
fn percentile(sorted: &[f64], p: f64) -> f64 {
if sorted.is_empty() {
return 0.0;
}
let idx = (p / 100.0 * (sorted.len() - 1) as f64).round() as usize;
sorted[idx.min(sorted.len() - 1)]
}
fn generate_html(records: &[BenchRecord], out_path: &str) {
// Collect unique run IDs in sorted order
let mut run_ids: Vec<String> = {
let mut set = std::collections::BTreeSet::new();
for r in records {
set.insert(r.run_id.clone());
}
set.into_iter().collect()
};
run_ids.sort();
// Per-run aggregates
struct RunSummary {
run_id: String,
ts: String,
files: usize,
sem_pct: f64,
cmp_pct: f64,
tok_ms: f64,
coverage: f64,
}
let summaries: Vec<RunSummary> = run_ids
.iter()
.map(|rid| {
let recs: Vec<&BenchRecord> = records.iter().filter(|r| &r.run_id == rid).collect();
let ti: usize = recs.iter().map(|r| r.input_tok).sum();
let ts_val: usize = recs.iter().map(|r| r.semantic_tok).sum();
let tc: usize = recs.iter().map(|r| r.compressed_tok).sum();
let avg_cov = if recs.is_empty() {
0.0
} else {
recs.iter().map(|r| r.word_coverage).sum::<f64>() / recs.len() as f64
};
let avg_tok = if recs.is_empty() {
0.0
} else {
recs.iter().map(|r| r.tok_per_ms).sum::<f64>() / recs.len() as f64
};
let ts_str = recs.first().map(|r| r.ts.clone()).unwrap_or_default();
RunSummary {
run_id: rid.clone(),
ts: ts_str,
files: recs.len(),
sem_pct: pct_reduction(ts_val, ti),
cmp_pct: pct_reduction(tc, ti),
tok_ms: avg_tok,
coverage: avg_cov,
}
})
.collect();
// Chart.js data
let labels = serde_json::to_string(
&summaries
.iter()
.map(|s| s.run_id.clone())
.collect::<Vec<_>>(),
)
.unwrap();
let sem_data = serde_json::to_string(
&summaries
.iter()
.map(|s| round2(s.sem_pct))
.collect::<Vec<_>>(),
)
.unwrap();
let cmp_data = serde_json::to_string(
&summaries
.iter()
.map(|s| round2(s.cmp_pct))
.collect::<Vec<_>>(),
)
.unwrap();
let tok_data = serde_json::to_string(
&summaries
.iter()
.map(|s| round2(s.tok_ms))
.collect::<Vec<_>>(),
)
.unwrap();
let cov_data = serde_json::to_string(
&summaries
.iter()
.map(|s| round2(s.coverage))
.collect::<Vec<_>>(),
)
.unwrap();
let scatter_data = serde_json::to_string(
&records
.iter()
.map(|r| {
serde_json::json!({
"x": round2(r.sem_pct), "y": round2(r.tok_per_ms),
"file": r.file, "fmt": r.format, "run": r.run_id,
})
})
.collect::<Vec<_>>(),
)
.unwrap();
// Format distribution (pie chart)
let fmt_list = ["markdown", "html", "plaintext"];
let fmt_counts: Vec<usize> = fmt_list
.iter()
.map(|f| records.iter().filter(|r| r.format == *f).count())
.collect();
let fmt_count_data = serde_json::to_string(&fmt_counts).unwrap();
// Input token size histogram (8 buckets)
let tok_buckets = [128usize, 512, 1024, 2048, 4096, 8192, 32768, usize::MAX];
let bucket_labels = ["<128", "<512", "<1K", "<2K", "<4K", "<8K", "<32K", "32K+"];
let mut hist = vec![0usize; tok_buckets.len()];
for r in records {
let bucket = tok_buckets
.iter()
.position(|&cap| r.input_tok < cap)
.unwrap_or(tok_buckets.len() - 1);
hist[bucket] += 1;
}
let hist_labels = serde_json::to_string(&bucket_labels).unwrap();
let hist_data = serde_json::to_string(&hist).unwrap();
// Word coverage donut buckets
let cov_buckets = [
(
"100%",
records.iter().filter(|r| r.word_coverage >= 100.0).count(),
),
(
"≥95%",
records
.iter()
.filter(|r| r.word_coverage >= 95.0 && r.word_coverage < 100.0)
.count(),
),
(
"≥80%",
records
.iter()
.filter(|r| r.word_coverage >= 80.0 && r.word_coverage < 95.0)
.count(),
),
(
"<80%",
records.iter().filter(|r| r.word_coverage < 80.0).count(),
),
];
let cov_donut_labels =
serde_json::to_string(&cov_buckets.iter().map(|(l, _)| *l).collect::<Vec<_>>()).unwrap();
let cov_donut_data =
serde_json::to_string(&cov_buckets.iter().map(|(_, c)| *c).collect::<Vec<_>>()).unwrap();
let box_data = serde_json::to_string(
&fmt_list
.iter()
.map(|fmt| {
let mut vals: Vec<f64> = records
.iter()
.filter(|r| r.format == *fmt)
.map(|r| r.sem_pct)
.collect();
vals.sort_by(|a, b| a.partial_cmp(b).unwrap());
if vals.is_empty() {
return serde_json::json!({"fmt": fmt, "min":0,"q1":0,"med":0,"q3":0,"max":0});
}
serde_json::json!({
"fmt": fmt,
"min": round2(vals[0]),
"q1": round2(percentile(&vals, 25.0)),
"med": round2(percentile(&vals, 50.0)),
"q3": round2(percentile(&vals, 75.0)),
"max": round2(*vals.last().unwrap()),
})
})
.collect::<Vec<_>>(),
)
.unwrap();
// Summary totals
let total_in: usize = records.iter().map(|r| r.input_tok).sum();
let total_sem: usize = records.iter().map(|r| r.semantic_tok).sum();
let total_cmp: usize = records.iter().map(|r| r.compressed_tok).sum();
let avg_cov: f64 = if records.is_empty() {
0.0
} else {
records.iter().map(|r| r.word_coverage).sum::<f64>() / records.len() as f64
};
let avg_tok_ms: f64 = if records.is_empty() {
0.0
} else {
records.iter().map(|r| r.tok_per_ms).sum::<f64>() / records.len() as f64
};
// Table rows — all escaped (R6)
let table_rows: String = records
.iter()
.map(|r| {
format!(
"<tr><td>{}</td><td>{}</td><td>{}</td><td class='n'>{}</td>\
<td class='n {}'>{:.1}%</td><td class='n {}'>{:.1}%</td>\
<td class='n'>{:.1}</td><td class='n'>{:.1}</td>\
<td class='n'>{:.0}</td><td class='n'>{:.1}%</td></tr>",
esc(&r.run_id),
esc(&r.file),
esc(&r.format),
r.input_tok,
pct_class(r.sem_pct),
r.sem_pct,
pct_class(r.cmp_pct),
r.cmp_pct,
r.semantic_us as f64 / 1000.0,
r.compressed_us as f64 / 1000.0,
r.tok_per_ms,
r.word_coverage,
)
})
.collect::<Vec<_>>()
.join("\n");
let run_rows: String = summaries
.iter()
.map(|s| {
format!(
"<tr><td>{}</td><td>{}</td><td class='n'>{}</td>\
<td class='n {}'>{:.1}%</td><td class='n {}'>{:.1}%</td>\
<td class='n'>{:.0}</td><td class='n'>{:.1}%</td></tr>",
esc(&s.run_id),
esc(&s.ts),
s.files,
pct_class(s.sem_pct),
s.sem_pct,
pct_class(s.cmp_pct),
s.cmp_pct,
s.tok_ms,
s.coverage,
)
})
.collect::<Vec<_>>()
.join("\n");
let run_options: String = run_ids
.iter()
.map(|r| format!("<option>{}</option>", esc(r)))
.collect::<Vec<_>>()
.join("");
let html = format!(
r##"<!DOCTYPE html>
<html lang="en" data-lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>bench — llm-transpile report</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.2/dist/chart.umd.min.js" integrity="sha384-e6cc9LaIG7xZ3XD5B+jtr1NhTWPQGQdRCh6xiZ+ZFUtWCpg4ycv3Sh+SkZoopvUY" crossorigin="anonymous"></script>
<style>
:root{{--bg:#0f1117;--surf:#1a1d27;--bdr:#2e3147;--txt:#e2e8f0;--mut:#8892a4;
--acc:#6366f1;--grn:#22c55e;--ylw:#eab308;--red:#ef4444;--thead:#1e2235;}}
:root[data-theme="light"]{{--bg:#f5f5f7;--surf:#ffffff;--bdr:#d1d5db;--txt:#1e293b;--mut:#374151;
--acc:#4f46e5;--grn:#16a34a;--ylw:#ca8a04;--red:#dc2626;--thead:#f1f5f9;}}
*{{box-sizing:border-box;margin:0;padding:0;}}
body{{background:var(--bg);color:var(--txt);font-family:system-ui,sans-serif;font-size:14px;}}
header{{padding:16px 28px;border-bottom:1px solid var(--bdr);display:flex;align-items:center;gap:12px;flex-wrap:wrap;}}
header h1{{font-size:20px;font-weight:700;}}
.badge{{background:var(--acc);color:#fff;font-size:11px;padding:2px 8px;border-radius:99px;font-weight:600;}}
.lang-btn{{background:transparent;border:1px solid var(--bdr);color:var(--mut);
border-radius:7px;padding:4px 12px;font-size:12px;cursor:pointer;font-weight:600;transition:all .15s;}}
.lang-btn:hover{{border-color:var(--acc);color:var(--txt);}}
.hdr-actions{{display:flex;gap:6px;flex-shrink:0;}}
.hdr-meta{{font-size:12px;color:var(--mut);}}
.wrap{{max-width:1400px;margin:0 auto;padding:20px 28px;}}
/* ── KPI cards ── */
.cards{{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:14px;margin-bottom:28px;}}
.card{{background:var(--surf);border:1px solid var(--bdr);border-radius:10px;padding:18px;position:relative;cursor:help;}}
.card .lbl{{font-size:11px;color:var(--mut);text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px;}}
.card .val{{font-size:26px;font-weight:700;}}
.card .sub{{font-size:11px;color:var(--mut);margin-top:3px;}}
.card .tip{{display:none;position:absolute;bottom:calc(100% + 8px);left:50%;transform:translateX(-50%);
background:var(--surf);border:1px solid var(--bdr);border-radius:8px;padding:10px 14px;
font-size:12px;color:var(--txt);line-height:1.6;width:220px;z-index:99;
box-shadow:0 4px 20px rgba(0,0,0,.5);pointer-events:none;white-space:normal;}}
.card:hover .tip{{display:block;}}
/* ── Charts ── */
.charts{{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-bottom:28px;}}
@media(max-width:1200px){{.charts{{grid-template-columns:1fr 1fr;gap:14px;}}}}
@media(max-width:768px){{
.charts{{grid-template-columns:1fr;gap:12px;}}
.cards{{grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:10px;}}
header{{padding:12px 16px;flex-wrap:wrap;}}
header h1{{font-size:16px;}}
.hdr-meta{{display:none;}}
.wrap{{padding:14px 16px;}}
.cbox{{padding:14px;}}
.cbox canvas{{max-height:200px;}}
table{{font-size:12px;}}
th,td{{padding:5px 6px;}}
}}
@media(max-width:480px){{
.cards{{grid-template-columns:1fr 1fr;}}
.card .val{{font-size:22px;}}
header h1{{font-size:14px;}}
.badge{{font-size:10px;padding:2px 6px;}}
table{{display:block;overflow-x:auto;}}
.frow{{flex-direction:column;align-items:stretch;}}
}}
.cbox{{background:var(--surf);border:1px solid var(--bdr);border-radius:10px;padding:18px;}}
.cbox-hdr{{display:flex;align-items:center;gap:6px;margin-bottom:14px;}}
.cbox h3{{font-size:11px;font-weight:600;color:var(--mut);text-transform:uppercase;letter-spacing:.5px;flex:1;}}
.tip-icon{{width:16px;height:16px;border-radius:50%;background:var(--bdr);color:var(--mut);
font-size:10px;font-weight:700;display:flex;align-items:center;justify-content:center;
cursor:help;position:relative;flex-shrink:0;}}
.tip-icon .tip{{display:none;position:absolute;top:calc(100% + 6px);right:0;
background:var(--surf);border:1px solid var(--bdr);border-radius:8px;padding:10px 14px;
font-size:12px;color:var(--txt);line-height:1.6;width:240px;z-index:99;
box-shadow:0 4px 20px rgba(0,0,0,.5);pointer-events:none;white-space:normal;text-transform:none;letter-spacing:0;}}
.tip-icon:hover .tip{{display:block;}}
.cbox canvas{{max-height:240px;}}
/* ── Tables ── */
section{{margin-bottom:28px;}}
section .sec-hdr{{display:flex;align-items:center;gap:8px;margin-bottom:12px;padding-bottom:7px;border-bottom:1px solid var(--bdr);}}
section .sec-hdr h2{{font-size:15px;font-weight:600;}}
table{{width:100%;border-collapse:collapse;background:var(--surf);border-radius:10px;overflow:hidden;border:1px solid var(--bdr);}}
thead tr{{background:var(--thead);}}
th{{padding:9px 12px;text-align:left;font-size:11px;font-weight:600;color:var(--mut);
text-transform:uppercase;letter-spacing:.5px;white-space:nowrap;}}
td{{padding:8px 12px;border-top:1px solid var(--bdr);font-size:13px;white-space:nowrap;}}
td.n{{text-align:right;font-variant-numeric:tabular-nums;}}
tr:hover td{{background:rgba(255,255,255,.02);}}
.good{{color:var(--grn);}} .ok{{color:var(--ylw);}} .low{{color:var(--red);}}
/* ── Filters ── */
.frow{{display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;align-items:center;}}
input[type=text],select{{background:var(--surf);border:1px solid var(--bdr);border-radius:7px;
padding:5px 10px;color:var(--txt);font-size:13px;outline:none;}}
input[type=text]:focus,select:focus{{border-color:var(--acc);}}
button{{background:var(--acc);border:none;color:#fff;border-radius:7px;
padding:5px 12px;font-size:13px;cursor:pointer;font-weight:600;}}
button:hover{{opacity:.85;}}
/* ── Legend ── */
.legend{{display:flex;gap:16px;flex-wrap:wrap;margin-bottom:10px;font-size:12px;color:var(--mut);}}
.legend span{{display:flex;align-items:center;gap:5px;}}
.dot{{width:10px;height:10px;border-radius:50%;display:inline-block;}}
</style>
</head>
<body>
<div class="wrap">
<header style="margin-bottom:18px;">
<h1>bench</h1>
<span class="badge">llm-transpile</span>
<span class="hdr-meta" data-i18n="hdr_meta">{runs} runs · {total_files} records</span>
<span class="hdr-actions">
<button class="lang-btn" onclick="toggleLang()" id="langBtn">한국어</button>
<button class="lang-btn" onclick="toggleTheme()" id="themeBtn">☀</button>
</span>
</header>
<!-- ── KPI Cards ── -->
<div class="cards">
<div class="card">
<div class="lbl" data-i18n="kpi_sem_lbl">Semantic Reduction</div>
<div class="val" style="color:var(--grn)">{sem_pct:.1}%</div>
<div class="sub" data-i18n="kpi_avg_files">avg all files</div>
<div class="tip" data-i18n="tip_sem"></div>
</div>
<div class="card">
<div class="lbl" data-i18n="kpi_cmp_lbl">Compressed Reduction</div>
<div class="val" style="color:var(--grn)">{cmp_pct:.1}%</div>
<div class="sub" data-i18n="kpi_avg_files">avg all files</div>
<div class="tip" data-i18n="tip_cmp"></div>
</div>
<div class="card">
<div class="lbl" data-i18n="kpi_thru_lbl">Throughput</div>
<div class="val" style="color:var(--acc)">{avg_tok_ms:.0}</div>
<div class="sub" data-i18n="kpi_tokms">tok/ms avg</div>
<div class="tip" data-i18n="tip_thru"></div>
</div>
<div class="card">
<div class="lbl" data-i18n="kpi_cov_lbl">Word Coverage</div>
<div class="val" style="color:var(--ylw)">{avg_cov:.1}%</div>
<div class="sub" data-i18n="kpi_lossless">lossless avg</div>
<div class="tip" data-i18n="tip_cov"></div>
</div>
<div class="card">
<div class="lbl" data-i18n="kpi_total_lbl">Total Input Tokens</div>
<div class="val">{total_in}</div>
<div class="sub" data-i18n="kpi_all_runs">across all runs</div>
<div class="tip" data-i18n="tip_total"></div>
</div>
<div class="card">
<div class="lbl" data-i18n="kpi_runs_lbl">Runs</div>
<div class="val">{runs}</div>
<div class="sub"><span data-i18n="kpi_measurements">{total_files} measurements</span></div>
<div class="tip" data-i18n="tip_runs"></div>
</div>
</div>
<!-- colour guide -->
<div class="legend" style="margin-bottom:18px;">
<span><span class="dot" style="background:var(--grn)"></span><span data-i18n="legend_good">≥20% — good</span></span>
<span><span class="dot" style="background:var(--ylw)"></span><span data-i18n="legend_ok">5–20% — ok</span></span>
<span><span class="dot" style="background:var(--red)"></span><span data-i18n="legend_low"><5% — low / negative</span></span>
</div>
<!-- ── Charts ── -->
<div class="charts">
<div class="cbox">
<div class="cbox-hdr">
<h3 data-i18n="chart_trend_title">Token Reduction Over Time (%)</h3>
<div class="tip-icon">?<div class="tip" data-i18n="tip_chart_trend"></div></div>
</div>
<canvas id="trendChart"></canvas>
</div>
<div class="cbox">
<div class="cbox-hdr">
<h3 data-i18n="chart_thru_title">Throughput Over Time (tok/ms)</h3>
<div class="tip-icon">?<div class="tip" data-i18n="tip_chart_thru"></div></div>
</div>
<canvas id="thruChart"></canvas>
</div>
<div class="cbox">
<div class="cbox-hdr">
<h3 data-i18n="chart_scatter_title">Sem% vs Throughput — Scatter</h3>
<div class="tip-icon">?<div class="tip" data-i18n="tip_chart_scatter"></div></div>
</div>
<canvas id="scatterChart"></canvas>
</div>
<div class="cbox">
<div class="cbox-hdr">
<h3 data-i18n="chart_box_title">Reduction by Format (min/Q1/med/Q3/max)</h3>
<div class="tip-icon">?<div class="tip" data-i18n="tip_chart_box"></div></div>
</div>
<canvas id="boxChart"></canvas>
</div>
<div class="cbox">
<div class="cbox-hdr">
<h3 data-i18n="chart_pie_title">File Count by Format</h3>
<div class="tip-icon">?<div class="tip" data-i18n="tip_chart_pie"></div></div>
</div>
<canvas id="pieChart"></canvas>
</div>
<div class="cbox">
<div class="cbox-hdr">
<h3 data-i18n="chart_hist_title">Input Token Size Distribution</h3>
<div class="tip-icon">?<div class="tip" data-i18n="tip_chart_hist"></div></div>
</div>
<canvas id="histChart"></canvas>
</div>
<div class="cbox">
<div class="cbox-hdr">
<h3 data-i18n="chart_cov_title">Word Coverage (Lossless Quality)</h3>
<div class="tip-icon">?<div class="tip" data-i18n="tip_chart_cov"></div></div>
</div>
<canvas id="covDonut"></canvas>
</div>
</div>
<!-- ── Runs table ── -->
<section>
<div class="sec-hdr">
<h2 data-i18n="sec_runs">Runs</h2>
<div class="tip-icon">?<div class="tip" data-i18n="tip_sec_runs"></div></div>
</div>
<table>
<thead><tr>
<th data-i18n="col_run_id">run id</th>
<th data-i18n="col_timestamp">timestamp</th>
<th data-i18n="col_files">files</th>
<th>sem%</th><th>cmp%</th>
<th data-i18n="col_tokms">tok/ms</th>
<th data-i18n="col_coverage">coverage</th>
</tr></thead>
<tbody>{run_rows}</tbody>
</table>
</section>
<!-- ── All records ── -->
<section>
<div class="sec-hdr">
<h2 data-i18n="sec_records">All Records</h2>
<div class="tip-icon">?<div class="tip" data-i18n="tip_sec_records"></div></div>
</div>
<div class="frow">
<input type="text" id="ftxt" data-i18n-ph="filter_ph" placeholder="Filter file…" oninput="filterTbl()">
<select id="ffmt" onchange="filterTbl()">
<option value="" data-i18n="fmt_all">All formats</option>
<option>markdown</option><option>html</option><option>plaintext</option>
</select>
<select id="frun" onchange="filterTbl()">
<option value="" data-i18n="run_all">All runs</option>
{run_options}
</select>
<button onclick="exportCsv()" data-i18n="btn_csv">⬇ CSV</button>
</div>
<table id="tbl">
<thead><tr>
<th data-i18n="col_run">run</th>
<th data-i18n="col_file">file</th>
<th data-i18n="col_format">format</th>
<th data-i18n="col_intok">in tok</th>
<th>sem%</th><th>cmp%</th>
<th data-i18n="col_sem_ms">sem ms</th>
<th data-i18n="col_cmp_ms">cmp ms</th>
<th data-i18n="col_tokms">tok/ms</th>
<th data-i18n="col_coverage">coverage</th>
</tr></thead>
<tbody id="tbody">{table_rows}</tbody>
</table>
</section>
</div>
<script>
// ── i18n ─────────────────────────────────────────────────────────────────────
const I18N = {{
en: {{
hdr_meta: '{runs} runs · {total_files} records',
kpi_sem_lbl: 'Semantic Reduction',
kpi_cmp_lbl: 'Compressed Reduction',
kpi_thru_lbl: 'Throughput',
kpi_cov_lbl: 'Word Coverage',
kpi_total_lbl: 'Total Input Tokens',
kpi_runs_lbl: 'Runs',
kpi_avg_files: 'avg all files',
kpi_tokms: 'tok/ms avg',
kpi_lossless: 'lossless avg',
kpi_all_runs: 'across all runs',
kpi_measurements: '{total_files} measurements',
tip_sem: 'Tokens saved in Semantic mode vs raw input. Target ≥15%. Green ≥20%, yellow 5–20%, red <5%.',
tip_cmp: 'Tokens saved in Compressed mode (aggressive). Higher than Semantic because more content is pruned.',
tip_thru: 'How many input tokens are processed per millisecond. Higher = faster. Depends on file size and CPU warmup.',
tip_cov: 'Fraction of unique content words (>5 chars) from source that survive in Lossless output. Should stay ≥95%.',
tip_total: 'Sum of all input tokens across every file and every run. Gives a sense of total workload processed.',
tip_runs: 'Each run = one invocation of `bench run`. Running repeatedly reveals throughput variance and warmup effects.',
legend_good: '≥20% — good',
legend_ok: '5–20% — ok',
legend_low: '<5% — low / negative',
chart_trend_title: 'Token Reduction Over Time (%)',
chart_thru_title: 'Throughput Over Time (tok/ms)',
chart_scatter_title: 'Sem% vs Throughput — Scatter',
chart_box_title: 'Reduction by Format (min/Q1/med/Q3/max)',
chart_pie_title: 'File Count by Format',
chart_hist_title: 'Input Token Size Distribution',
chart_cov_title: 'Word Coverage (Lossless Quality)',
tip_chart_trend: 'Line chart of Semantic% and Compressed% reduction per run. Rising trend = the compressor is getting better over time (or input files changed).',
tip_chart_thru: 'Bar chart: tok/ms per run. A large jump between runs usually indicates CPU cache warmup. Subsequent runs are more representative.',
tip_chart_scatter: 'Each dot is one file. X = semantic reduction %, Y = throughput. Hover to see filename. Files clustered top-right are both compact and fast.',
tip_chart_box: 'Box plot per format showing min, Q1, median, Q3, max of Semantic reduction. Wide spread = inconsistent compression. Negative means the output grew.',
tip_chart_pie: 'Share of each file format in the dataset. Dominated by Markdown; HTML/Plaintext are minority.',
tip_chart_hist: 'How many files fall into each token-size bucket. Most eval files are <4K tokens. 32K+ indicates very large documents.',
tip_chart_cov: 'Word coverage buckets for Lossless output. Ideally 100% (deep green). Yellow/red means content words were lost — investigate the compressor.',
sec_runs: 'Runs',
sec_records: 'All Records',
tip_sec_runs: 'One row per bench run. Compare sem% and cmp% across runs to spot regressions after code changes.',
tip_sec_records: 'Full record table. Filter by file name, format, or run. Click CSV to export the visible rows.',
col_run_id: 'run id', col_timestamp: 'timestamp', col_files: 'files',
col_tokms: 'tok/ms', col_coverage: 'coverage', col_run: 'run',
col_file: 'file', col_format: 'format', col_intok: 'in tok',
col_sem_ms: 'sem ms', col_cmp_ms: 'cmp ms',
filter_ph: 'Filter file…', fmt_all: 'All formats', run_all: 'All runs', btn_csv: '⬇ CSV',
}},
ko: {{
hdr_meta: '{runs}회 실행 · {total_files}개 기록',
kpi_sem_lbl: '시맨틱 압축률',
kpi_cmp_lbl: '최대 압축률',
kpi_thru_lbl: '처리 속도',
kpi_cov_lbl: '단어 보존율',
kpi_total_lbl: '총 입력 토큰',
kpi_runs_lbl: '실행 횟수',
kpi_avg_files: '전체 파일 평균',
kpi_tokms: 'tok/ms 평균',
kpi_lossless: '무손실 평균',
kpi_all_runs: '전체 실행 합산',
kpi_measurements: '{total_files}개 측정값',
tip_sem: '시맨틱 모드에서 원본 대비 줄어든 토큰 비율입니다. 목표 ≥15%. 초록 ≥20%, 노랑 5~20%, 빨강 <5%.',
tip_cmp: '압축 모드(적극적 제거)에서 절약된 토큰 비율. 시맨틱보다 높지만 정보 손실 가능성도 큽니다.',
tip_thru: '1밀리초당 처리되는 입력 토큰 수. 높을수록 빠릅니다. 파일 크기와 CPU 웜업 상태에 영향 받습니다.',
tip_cov: '원본의 주요 단어(5자 초과) 중 무손실 출력에 살아남은 비율. 95% 이상 유지 권장.',
tip_total: '모든 실행·파일에 걸친 입력 토큰 총합. 처리된 전체 워크로드 규모를 나타냅니다.',
tip_runs: '실행 1회 = `bench run` 1번 호출. 반복 실행으로 처리 속도 분산과 웜업 효과를 확인하세요.',
legend_good: '≥20% — 양호',
legend_ok: '5~20% — 보통',
legend_low: '<5% — 낮음 / 음수',
chart_trend_title: '실행별 토큰 압축률 추이 (%)',
chart_thru_title: '실행별 처리 속도 (tok/ms)',
chart_scatter_title: '시맨틱 압축률 vs 처리 속도 (파일별)',
chart_box_title: '포맷별 압축률 분포 (최소/Q1/중앙/Q3/최대)',
chart_pie_title: '포맷별 파일 수',
chart_hist_title: '입력 토큰 크기 분포',
chart_cov_title: '단어 보존율 (무손실 품질)',
tip_chart_trend: '실행별 시맨틱·압축 모드의 토큰 절약률 꺾은선 그래프입니다. 상승 추세라면 압축기 성능이 향상되거나 입력 파일이 바뀐 것입니다.',
tip_chart_thru: '실행별 처리 속도 막대 그래프. 첫 실행과 이후 실행 간 큰 차이는 CPU 캐시 웜업 때문입니다. 두 번째 이후 값이 더 신뢰할 수 있습니다.',
tip_chart_scatter: '각 점 = 파일 1개. X = 시맨틱 압축률, Y = 처리 속도. 마우스를 올리면 파일명이 표시됩니다. 오른쪽 위에 모일수록 빠르고 효율적입니다.',
tip_chart_box: '포맷별 시맨틱 압축률의 최솟값·Q1·중앙값·Q3·최댓값 박스 그래프. 범위가 넓으면 압축 결과가 일관되지 않은 것입니다. 음수면 출력이 오히려 커진 것.',
tip_chart_pie: '데이터셋 내 파일 포맷 비율. Markdown이 대부분을 차지하며 HTML·Plaintext는 소수입니다.',
tip_chart_hist: '토큰 크기 구간별 파일 수. 평가 파일 대부분은 4K 토큰 이하. 32K+ 는 매우 큰 문서입니다.',
tip_chart_cov: '무손실 출력의 단어 보존율 구간 도넛 차트. 진한 초록(100%)이 이상적입니다. 노랑·빨강이 나타나면 압축기가 핵심 단어를 제거하고 있다는 신호입니다.',
sec_runs: '실행 기록',
sec_records: '전체 측정값',
tip_sec_runs: '실행 1회당 1행. sem%·cmp%를 비교해 코드 변경 후 성능 저하를 감지하세요.',
tip_sec_records: '전체 레코드 테이블. 파일명·포맷·실행 ID로 필터링하고 CSV로 내보낼 수 있습니다.',
col_run_id: '실행 ID', col_timestamp: '타임스탬프', col_files: '파일 수',
col_tokms: 'tok/ms', col_coverage: '보존율', col_run: '실행',
col_file: '파일', col_format: '포맷', col_intok: '입력 토큰',
col_sem_ms: '시맨틱 ms', col_cmp_ms: '압축 ms',
filter_ph: '파일 필터…', fmt_all: '전체 포맷', run_all: '전체 실행', btn_csv: '⬇ CSV 내보내기',
}},
}};
let LANG = 'en';
function t(k) {{ return (I18N[LANG]||I18N.en)[k] || k; }}
function applyLang() {{
document.documentElement.setAttribute('lang', LANG);
document.querySelectorAll('[data-i18n]').forEach(el => {{
const k = el.getAttribute('data-i18n');
el.textContent = t(k);
}});
document.querySelectorAll('[data-i18n-ph]').forEach(el => {{
el.placeholder = t(el.getAttribute('data-i18n-ph'));
}});
document.getElementById('langBtn').textContent = LANG === 'en' ? '한국어' : 'English';
}}
function toggleLang() {{
LANG = LANG === 'en' ? 'ko' : 'en';
applyLang();
}}
function toggleTheme(){{
const r=document.documentElement;
const next=(r.getAttribute('data-theme')||'dark')==='dark'?'light':'dark';
r.setAttribute('data-theme',next);
document.getElementById('themeBtn').textContent=next==='dark'?'☀':'🌙';
localStorage.setItem('bench-theme',next);
renderCharts();
}}
(function(){{const s=localStorage.getItem('bench-theme');if(s){{document.documentElement.setAttribute('data-theme',s);document.getElementById('themeBtn').textContent=s==='dark'?'☀':'🌙';}}}})();
// auto-detect browser language
if (navigator.language && navigator.language.startsWith('ko')) {{ LANG = 'ko'; }}
applyLang();
// ── Chart.js ─────────────────────────────────────────────────────────────────
function getStyle(v){{return getComputedStyle(document.documentElement).getPropertyValue(v).trim();}}
function getTC(){{return getStyle('--mut')||'#8892a4';}}
function getGC(){{return getStyle('--bdr')||'rgba(128,128,128,.15)';}}
const LABELS={labels}, SEM={sem_data}, CMP={cmp_data}, TOK={tok_data}, COV={cov_data};
const SCATTER={scatter_data}, BOX={box_data};
const FMT_COL={{'markdown':'#6366f1','html':'#22c55e','plaintext':'#eab308'}};
const grps={{}};
for(const p of SCATTER){{if(!grps[p.fmt])grps[p.fmt]=[];grps[p.fmt].push({{x:p.x,y:p.y,file:p.file,run:p.run}});}}
let charts={{}};
function mkChart(id,cfg){{if(charts[id]){{charts[id].destroy();}}charts[id]=new Chart(document.getElementById(id),cfg);}}
function renderCharts(){{
const tc=getTC(),gc=getGC();
const F={{color:tc}},G={{color:gc}};
mkChart('trendChart',{{type:'line',data:{{labels:LABELS,datasets:[
{{label:'Semantic%',data:SEM,borderColor:'#22c55e',backgroundColor:'rgba(34,197,94,.08)',tension:.3,pointRadius:4}},
{{label:'Compressed%',data:CMP,borderColor:'#6366f1',backgroundColor:'rgba(99,102,241,.08)',tension:.3,pointRadius:4}},
]}},options:{{plugins:{{legend:{{labels:{{color:tc}}}}}},scales:{{x:{{ticks:F,grid:G}},y:{{ticks:F,grid:G,title:{{display:true,text:'% reduction',color:tc}}}}}}}}}});
mkChart('thruChart',{{type:'bar',data:{{labels:LABELS,datasets:[{{label:'tok/ms',data:TOK,backgroundColor:'rgba(99,102,241,.7)',borderRadius:4}}]}},options:{{plugins:{{legend:{{labels:{{color:tc}}}}}},scales:{{x:{{ticks:F,grid:G}},y:{{ticks:F,grid:G,title:{{display:true,text:'tok/ms',color:tc}}}}}}}}}});
mkChart('scatterChart',{{type:'scatter',data:{{datasets:Object.entries(grps).map(([fmt,pts])=>({{'label':fmt,'data':pts,'backgroundColor':(FMT_COL[fmt]||'#fff')+'cc','pointRadius':5}}))}},options:{{plugins:{{legend:{{labels:{{color:tc}}}},tooltip:{{callbacks:{{label:c=>`${{c.raw.file}} (${{c.raw.run}}): ${{c.raw.x}}% / ${{c.raw.y.toFixed(0)}} tok/ms`}}}}}},scales:{{x:{{ticks:F,grid:G,title:{{display:true,text:'Semantic reduction %',color:tc}}}},y:{{ticks:F,grid:G,title:{{display:true,text:'Throughput (tok/ms)',color:tc}}}}}}}}}});
mkChart('boxChart',{{type:'bar',data:{{labels:BOX.map(b=>b.fmt),datasets:[
{{label:'min–Q1',data:BOX.map(b=>[b.min,b.q1]),backgroundColor:'rgba(99,102,241,.3)',borderSkipped:false}},
{{label:'Q1–med',data:BOX.map(b=>[b.q1,b.med]),backgroundColor:'rgba(99,102,241,.6)',borderSkipped:false}},
{{label:'med–Q3',data:BOX.map(b=>[b.med,b.q3]),backgroundColor:'rgba(34,197,94,.6)',borderSkipped:false}},
{{label:'Q3–max',data:BOX.map(b=>[b.q3,b.max]),backgroundColor:'rgba(34,197,94,.3)',borderSkipped:false}},
]}},options:{{plugins:{{legend:{{labels:{{color:tc}}}}}},scales:{{x:{{ticks:F,grid:G}},y:{{ticks:F,grid:G,title:{{display:true,text:'Semantic reduction %',color:tc}}}}}}}}}});
mkChart('pieChart',{{type:'doughnut',data:{{labels:{fmt_labels},datasets:[{{data:{fmt_count_data},backgroundColor:['rgba(99,102,241,.8)','rgba(34,197,94,.8)','rgba(234,179,8,.8)'],borderColor:['#6366f1','#22c55e','#eab308'],borderWidth:2}}]}},options:{{plugins:{{legend:{{labels:{{color:tc,padding:16}}}},tooltip:{{callbacks:{{label:c=>`${{c.label}}: ${{c.raw}} files (${{Math.round(c.raw/c.dataset.data.reduce((a,b)=>a+b,0)*100)}}%)`}}}}}}}}}});
mkChart('histChart',{{type:'bar',data:{{labels:{hist_labels},datasets:[{{label:'files',data:{hist_data},backgroundColor:'rgba(99,102,241,.65)',borderColor:'#6366f1',borderWidth:1,borderRadius:4}}]}},options:{{plugins:{{legend:{{display:false}}}},scales:{{x:{{ticks:F,grid:G,title:{{display:true,text:'Token range',color:tc}}}},y:{{ticks:{{...F,stepSize:1}},grid:G,title:{{display:true,text:'# files',color:tc}}}}}}}}}});
mkChart('covDonut',{{type:'doughnut',data:{{labels:{cov_donut_labels},datasets:[{{data:{cov_donut_data},backgroundColor:['rgba(34,197,94,.85)','rgba(34,197,94,.45)','rgba(234,179,8,.7)','rgba(239,68,68,.7)'],borderColor:['#22c55e','#22c55e','#eab308','#ef4444'],borderWidth:2}}]}},options:{{plugins:{{legend:{{labels:{{color:tc,padding:14}}}},tooltip:{{callbacks:{{label:c=>`${{c.label}}: ${{c.raw}} files`}}}}}},cutout:'60%'}}}});
}}
renderCharts();
// ── Filter & CSV ─────────────────────────────────────────────────────────────
function filterTbl(){{
const txt=document.getElementById('ftxt').value.toLowerCase();
const fmt=document.getElementById('ffmt').value;
const run=document.getElementById('frun').value;
document.querySelectorAll('#tbody tr').forEach(row=>{{
const c=row.querySelectorAll('td');
row.style.display=(
(!txt||c[1].textContent.toLowerCase().includes(txt))&&
(!fmt||c[2].textContent===fmt)&&
(!run||c[0].textContent===run)
)?'':'none';
}});
}}
function exportCsv(){{
const hdr=['run','file','format','in_tok','sem%','cmp%','sem_ms','cmp_ms','tok_ms','coverage'];
const rows=[hdr];
document.querySelectorAll('#tbody tr').forEach(row=>{{
if(row.style.display==='none')return;
rows.push([...row.querySelectorAll('td')].map(td=>td.textContent));
}});
const a=document.createElement('a');
a.href=URL.createObjectURL(new Blob([rows.map(r=>r.join(',')).join('\n')],{{type:'text/csv'}}));
a.download='bench.csv';a.click();
}}
</script>
</body>
</html>
"##,
runs = summaries.len(),
total_files = records.len(),
sem_pct = pct_reduction(total_sem, total_in),
cmp_pct = pct_reduction(total_cmp, total_in),
avg_tok_ms = avg_tok_ms,
avg_cov = avg_cov,
total_in = total_in,
run_rows = run_rows,
run_options = run_options,
table_rows = table_rows,
labels = js_safe(&labels),
sem_data = js_safe(&sem_data),
cmp_data = js_safe(&cmp_data),
tok_data = js_safe(&tok_data),
cov_data = js_safe(&cov_data),
scatter_data = js_safe(&scatter_data),
box_data = js_safe(&box_data),
fmt_labels = js_safe(&serde_json::to_string(&["markdown", "html", "plaintext"]).unwrap()),
fmt_count_data = js_safe(&fmt_count_data),
hist_labels = js_safe(&hist_labels),
hist_data = js_safe(&hist_data),
cov_donut_labels = js_safe(&cov_donut_labels),
cov_donut_data = js_safe(&cov_donut_data),
);
if let Err(e) = fs::write(out_path, &html) {
eprintln!("ERROR: cannot write HTML to {out_path}: {e}");
std::process::exit(1);
}
}
// ── Unit tests ────────────────────────────────────────────────────────────────
// ── Unit tests ────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pct_reduction_zero_input_is_zero() {
assert_eq!(pct_reduction(100, 0), 0.0);
}
#[test]
fn pct_reduction_half() {
assert!((pct_reduction(50, 100) - 50.0).abs() < 1e-9);
}
#[test]
fn esc_prevents_xss() {
let s = esc("<img onerror=alert(1)>.md");
assert!(!s.contains('<'));
assert!(!s.contains('>'));
assert!(s.contains("<"));
}
#[test]
fn iso_to_run_id_format() {
let id = iso_to_run_id("2026-05-12T01:10:01Z");
assert_eq!(id, "2026-05-12_01-10-01");
}
#[test]
fn word_coverage_full() {
assert_eq!(word_coverage("", "anything"), 100.0);
}
#[test]
fn trunc_short_unchanged() {
assert_eq!(trunc("hello", 10), "hello");
}
#[test]
fn trunc_long_truncated() {
let s = trunc("abcdefghijk", 5);
// 4 ASCII chars + '…' (3 UTF-8 bytes) = 7 bytes max
assert!(s.len() <= 7);
assert!(s.chars().count() <= 5);
}
#[test]
fn trunc_multibyte_no_panic() {
// Korean chars are 3 bytes each — must not panic on byte boundary
let s = trunc("안녕하세요반갑습니다", 5);
assert!(s.chars().count() <= 5);
}
#[test]
fn js_safe_escapes_script_close() {
assert_eq!(js_safe("</script>"), "<\\/script>");
}
#[test]
fn js_safe_leaves_normal_json_unchanged() {
let json = r#"{"x":1,"y":"hello"}"#;
assert_eq!(js_safe(json), json);
}
#[test]
fn secs_to_iso_epoch() {
assert_eq!(secs_to_iso(0), "1970-01-01T00:00:00Z");
}
#[test]
fn load_all_logs_missing_dir_returns_empty() {
let result = load_all_logs(Path::new("/tmp/nonexistent_bench_dir_xyz"));
assert!(result.is_empty());
}
}