+
@@ -230,465 +143,5 @@
Adaptation Curve (Hit Rate vs. Ops After Workload Shift)
-
diff --git a/bench-support/src/bin/charts_template.js b/bench-support/src/bin/charts_template.js
new file mode 100644
index 0000000..cc88e01
--- /dev/null
+++ b/bench-support/src/bin/charts_template.js
@@ -0,0 +1,488 @@
+// The `no-js` class is stripped by a parser-blocking inline script in
+// charts.html's (gated by 'sha256-β¦' in script-src) so the loading
+// placeholder is visible the moment paints. Don't duplicate it here β
+// charts.js is `defer`red and runs after , so any toggle would be too
+// late to prevent the flash.
+
+// Chart.js loads from a CDN with SRI pinning. If the request fails
+// (offline, CDN outage, ad-blocker, SRI mismatch after a CDN re-publish)
+// the browser refuses to expose `Chart` and any unconditional reference
+// would throw a synchronous ReferenceError β *before* the `.catch` below
+// has been registered. Detect the absence here and route the failure
+// through the same error-banner path as a fetch error.
+const chartReady = typeof Chart !== 'undefined';
+if (chartReady) {
+ Chart.defaults.font.family = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
+ Chart.defaults.plugins.legend.position = 'bottom';
+ Chart.defaults.plugins.tooltip.backgroundColor = 'rgba(0, 0, 0, 0.8)';
+}
+
+// Substituted by render_docs from bench_support::json_results::SCHEMA_VERSION
+// (the @SCHEMA_MAJOR@ sentinel below is replaced with a quoted string at
+// render time). The unsubstituted template parses but rejects every real
+// artifact, so a missing substitution surfaces as a runtime error in the
+// browser instead of silently accepting the wrong schema.
+const EXPECTED_SCHEMA_MAJOR = /* @SCHEMA_MAJOR@ */ '0';
+
+// Substituted by render_docs from bench_support::registry::POLICIES
+// (the @POLICY_COLORS@ sentinel below is replaced with a JSON literal).
+// Edit colors in registry.rs, NOT here. Unknown names fall back to a
+// deterministic HSL color hashed from the display name in colorForPolicy().
+const POLICY_COLORS = /* @POLICY_COLORS@ */ {};
+
+function colorForPolicy(name) {
+ if (POLICY_COLORS[name]) return POLICY_COLORS[name];
+ // FNV-1a 32-bit, then map to HSL hue for stable distinct fallbacks.
+ let h = 0x811c9dc5;
+ for (let i = 0; i < name.length; i++) {
+ h ^= name.charCodeAt(i);
+ h = Math.imul(h, 0x01000193);
+ }
+ const hue = Math.abs(h) % 360;
+ return `hsl(${hue}, 65%, 55%)`;
+}
+
+function safeText(value, fallback = 'unknown') {
+ return value == null || value === '' ? fallback : String(value);
+}
+
+function formatTimestamp(iso) {
+ if (!iso) return 'unknown';
+ const d = new Date(iso);
+ return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
+}
+
+function formatNumber(n) {
+ if (n == null || Number.isNaN(Number(n))) return 'unknown';
+ return Number(n).toLocaleString('en-US');
+}
+
+function appendMetadataItem(grid, label, value) {
+ const item = document.createElement('div');
+ item.className = 'metadata-item';
+ const l = document.createElement('span');
+ l.className = 'metadata-label';
+ l.textContent = `${label}:`;
+ const v = document.createElement('span');
+ v.textContent = String(value);
+ item.append(l, v);
+ grid.append(item);
+}
+
+// Draws a dashed reference line + label at a value on the value axis.
+// Works for both vertical (x scale) and horizontal (y scale) bar charts.
+function makeReferenceLinePlugin(value, label, axis = 'x') {
+ return {
+ id: 'referenceLine',
+ afterDatasetsDraw(chart) {
+ const scale = chart.scales[axis];
+ if (!scale) return;
+ const pos = scale.getPixelForValue(value);
+ const { top, bottom, left, right } = chart.chartArea;
+ const ctx = chart.ctx;
+ ctx.save();
+ ctx.strokeStyle = 'rgba(44, 62, 80, 0.6)';
+ ctx.setLineDash([4, 4]);
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ if (axis === 'x') {
+ if (pos < left || pos > right) { ctx.restore(); return; }
+ ctx.moveTo(pos, top);
+ ctx.lineTo(pos, bottom);
+ } else {
+ if (pos < top || pos > bottom) { ctx.restore(); return; }
+ ctx.moveTo(left, pos);
+ ctx.lineTo(right, pos);
+ }
+ ctx.stroke();
+ if (label) {
+ ctx.setLineDash([]);
+ ctx.fillStyle = 'rgba(44, 62, 80, 0.85)';
+ ctx.font = '11px -apple-system, sans-serif';
+ ctx.textBaseline = 'top';
+ if (axis === 'x') {
+ ctx.textAlign = 'left';
+ ctx.fillText(label, pos + 4, top + 2);
+ } else {
+ ctx.textAlign = 'right';
+ ctx.fillText(label, right - 4, pos + 2);
+ }
+ }
+ ctx.restore();
+ }
+ };
+}
+
+// `credentials: 'omit'` keeps cookies out of the same-origin JSON request
+// (defensive against future hosting changes). `cache: 'no-store'` makes
+// page reloads always fetch a fresh report instead of a stale cache copy.
+fetch('results.json', { credentials: 'omit', cache: 'no-store' })
+ .then(response => {
+ if (!response.ok) {
+ throw new Error(`Failed to load results.json (HTTP ${response.status})`);
+ }
+ return response.json();
+ })
+ .then(data => {
+ if (!chartReady) {
+ throw new Error(
+ 'Chart.js failed to load. The page expected it from cdn.jsdelivr.net; ' +
+ 'check your network connection, browser extensions, or any proxy ' +
+ 'that may be blocking the CDN or rewriting the response (Subresource ' +
+ 'Integrity will reject a modified file).'
+ );
+ }
+ if (data && data.schema_version) {
+ const major = String(data.schema_version).split('.')[0];
+ if (major !== EXPECTED_SCHEMA_MAJOR) {
+ throw new Error(
+ `Unsupported schema version ${data.schema_version}, ` +
+ `expected ${EXPECTED_SCHEMA_MAJOR}.x. Re-render with a matching ` +
+ `version of render_docs.`
+ );
+ }
+ }
+ document.getElementById('loading').style.display = 'none';
+ document.getElementById('content').style.display = 'block';
+
+ renderMetadata((data && data.metadata) || {});
+ renderCharts(Array.isArray(data && data.results) ? data.results : []);
+ })
+ .catch(error => {
+ document.getElementById('loading').style.display = 'none';
+ const errEl = document.getElementById('error');
+ errEl.style.display = 'block';
+ errEl.textContent = '';
+
+ const msg = document.createElement('strong');
+ msg.textContent = `Error loading benchmark data: ${error.message}`;
+ errEl.append(msg);
+
+ if (window.location.protocol === 'file:') {
+ errEl.append(
+ '\n\nBrowsers block fetch() over file://. Serve this directory over HTTP, e.g.\n'
+ );
+ const cmd = document.createElement('code');
+ cmd.textContent = 'python3 -m http.server';
+ errEl.append(cmd);
+ errEl.append('\nthen open http://localhost:8000/charts.html');
+ } else {
+ errEl.append('\n\nMake sure results.json exists in the same directory.');
+ }
+ });
+
+function showChartEmptyState(canvasId, message) {
+ const canvas = document.getElementById(canvasId);
+ if (!canvas) return;
+ const container = canvas.parentElement;
+ const empty = document.createElement('div');
+ empty.className = 'chart-empty';
+ empty.textContent = message;
+ container.replaceChildren(empty);
+}
+
+function renderMetadata(metadata) {
+ const metadataDiv = document.getElementById('metadata');
+ const config = metadata.config || {};
+ const items = [
+ ['Date', formatTimestamp(metadata.timestamp)],
+ ['Commit', safeText(metadata.git_commit)],
+ ['Branch', safeText(metadata.git_branch)],
+ ['Dirty', metadata.git_dirty ? 'Yes' : 'No'],
+ ['Rustc', safeText(metadata.rustc_version)],
+ ['Host', safeText(metadata.host_triple)],
+ ['CPU', safeText(metadata.cpu_model)],
+ ['Capacity', formatNumber(config.capacity)],
+ ['Operations', formatNumber(config.operations)]
+ ];
+
+ const grid = document.createElement('div');
+ grid.className = 'metadata-grid';
+ for (const [label, value] of items) {
+ appendMetadataItem(grid, label, value);
+ }
+ metadataDiv.replaceChildren(grid);
+}
+
+function renderCharts(results) {
+ const byCase = {};
+ for (const r of results) {
+ if (!byCase[r.case_id]) byCase[r.case_id] = [];
+ byCase[r.case_id].push(r);
+ }
+
+ renderMatrixBarChart('hitRateChart', byCase['hit_rate'] || [], {
+ emptyMessage: 'No hit-rate data available for this run.',
+ extract: r => r.metrics.hit_stats ? r.metrics.hit_stats.hit_rate * 100 : null,
+ yAxisLabel: 'Hit Rate (%)',
+ yAxisMax: 100,
+ tickCallback: v => v + '%',
+ tooltipFormat: v => `${v.toFixed(2)}%`
+ });
+
+ const comprehensive = byCase['comprehensive'] || [];
+ renderMatrixBarChart('throughputChart', comprehensive, {
+ emptyMessage: 'No throughput data available for this run.',
+ extract: r => r.metrics.throughput ? r.metrics.throughput.ops_per_sec / 1_000_000 : null,
+ yAxisLabel: 'Million ops/sec',
+ tooltipFormat: v => `${v.toFixed(2)} M ops/sec`
+ });
+ renderMatrixBarChart('latencyChart', comprehensive, {
+ emptyMessage: 'No latency data available for this run.',
+ extract: r => r.metrics.latency ? r.metrics.latency.p99_ns : null,
+ yAxisLabel: 'P99 Latency (ns)',
+ tooltipFormat: v => `${formatNumber(v)} ns`
+ });
+
+ renderRankedBarChart('scanResistanceChart', byCase['scan_resistance'] || [], {
+ emptyMessage: 'No scan-resistance data available for this run.',
+ datasetLabel: 'Resistance Score',
+ extract: r => r.metrics.scan_resistance ? r.metrics.scan_resistance.resistance_score : null,
+ xAxisLabel: 'Score (1.0 = perfect recovery)',
+ xAxisMaxFn: scores => Math.max(1.0, ...scores) * 1.05,
+ tooltipFormat: v => v.toFixed(3),
+ referenceLine: { value: 1.0, label: 'Perfect (1.0)' }
+ });
+
+ renderRankedBarChart('adaptationChart', byCase['adaptation'] || [], {
+ emptyMessage: 'No adaptation data available for this run.',
+ datasetLabel: 'Operations to 80%',
+ extract: r => r.metrics.adaptation ? r.metrics.adaptation.ops_to_80_percent : null,
+ xAxisLabel: 'Operations to reach 80% of stable hit rate (lower is better)',
+ tooltipFormat: v => formatNumber(v)
+ });
+
+ renderAdaptationCurveChart('adaptationCurveChart', byCase['adaptation'] || []);
+}
+
+// Records first-seen order for a key across results, preserving the
+// semantically meaningful ordering produced by the bench runner instead
+// of collapsing to lexicographic.
+function collectInsertionOrder(results, key) {
+ const seen = new Set();
+ const order = [];
+ for (const r of results) {
+ const v = r[key];
+ if (v != null && !seen.has(v)) {
+ seen.add(v);
+ order.push(v);
+ }
+ }
+ return order;
+}
+
+function renderMatrixBarChart(canvasId, results, opts) {
+ const workloads = collectInsertionOrder(results, 'workload_name');
+ const policies = collectInsertionOrder(results, 'policy_name');
+
+ const byPolicy = {};
+ for (const r of results) {
+ const value = opts.extract(r);
+ if (value == null) continue;
+ if (!byPolicy[r.policy_name]) byPolicy[r.policy_name] = {};
+ byPolicy[r.policy_name][r.workload_name] = value;
+ }
+
+ const datasets = policies
+ .filter(p => byPolicy[p])
+ .map(policy => ({
+ label: policy,
+ // null leaves a gap rather than misrepresenting "missing" as 0.
+ data: workloads.map(w =>
+ Object.prototype.hasOwnProperty.call(byPolicy[policy], w)
+ ? byPolicy[policy][w]
+ : null
+ ),
+ backgroundColor: colorForPolicy(policy),
+ borderColor: colorForPolicy(policy),
+ borderWidth: 1
+ }));
+
+ if (datasets.length === 0) {
+ showChartEmptyState(canvasId, opts.emptyMessage);
+ return;
+ }
+
+ const yScale = {
+ beginAtZero: true,
+ title: { display: true, text: opts.yAxisLabel }
+ };
+ if (opts.yAxisMax != null) yScale.max = opts.yAxisMax;
+ if (opts.tickCallback) yScale.ticks = { callback: opts.tickCallback };
+
+ new Chart(document.getElementById(canvasId), {
+ type: 'bar',
+ data: { labels: workloads, datasets },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ scales: {
+ y: yScale,
+ x: { title: { display: true, text: 'Workload' } }
+ },
+ plugins: {
+ tooltip: {
+ callbacks: {
+ label: ctx => {
+ const v = ctx.parsed.y;
+ if (v == null) return `${ctx.dataset.label}: n/a`;
+ return `${ctx.dataset.label}: ${opts.tooltipFormat(v)}`;
+ }
+ }
+ }
+ }
+ }
+ });
+}
+
+function renderAdaptationCurveChart(canvasId, results) {
+ // Keep only rows that actually carry a curve (older artifacts won't).
+ const usable = results.filter(r =>
+ r.metrics.adaptation
+ && Array.isArray(r.metrics.adaptation.hit_rate_curve)
+ && r.metrics.adaptation.hit_rate_curve.length > 0
+ );
+
+ if (usable.length === 0) {
+ showChartEmptyState(
+ canvasId,
+ 'No adaptation curve data available. Re-run benchmarks with bench-support β₯ schema 1.2.'
+ );
+ return;
+ }
+
+ // The harness uses a single window size across policies; pick the
+ // first non-zero one for axis labelling, falling back to indices.
+ const windowSize = usable
+ .map(r => r.metrics.adaptation.window_size || 0)
+ .find(s => s > 0) || 0;
+ const maxLen = usable.reduce(
+ (m, r) => Math.max(m, r.metrics.adaptation.hit_rate_curve.length),
+ 0
+ );
+
+ const labels = [];
+ for (let i = 0; i < maxLen; i++) {
+ labels.push(windowSize > 0 ? formatNumber((i + 1) * windowSize) : `W${i + 1}`);
+ }
+
+ const datasets = collectInsertionOrder(usable, 'policy_name')
+ .map(policy => {
+ const row = usable.find(r => r.policy_name === policy);
+ if (!row) return null;
+ const curve = row.metrics.adaptation.hit_rate_curve;
+ const data = labels.map((_, i) =>
+ i < curve.length ? curve[i] * 100 : null
+ );
+ return {
+ label: policy,
+ data,
+ borderColor: colorForPolicy(policy),
+ backgroundColor: colorForPolicy(policy),
+ // Connect across any null tail so a short curve doesn't
+ // visually drop to zero at the end.
+ spanGaps: false,
+ tension: 0.2,
+ pointRadius: 2,
+ borderWidth: 2,
+ fill: false
+ };
+ })
+ .filter(Boolean);
+
+ new Chart(document.getElementById(canvasId), {
+ type: 'line',
+ data: { labels, datasets },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ interaction: { mode: 'nearest', axis: 'x', intersect: false },
+ scales: {
+ y: {
+ beginAtZero: true,
+ max: 100,
+ title: { display: true, text: 'Hit Rate (%)' },
+ ticks: { callback: v => v + '%' }
+ },
+ x: {
+ title: {
+ display: true,
+ text: windowSize > 0
+ ? 'Operations after workload shift'
+ : 'Adaptation window'
+ }
+ }
+ },
+ plugins: {
+ tooltip: {
+ callbacks: {
+ label: ctx => {
+ const v = ctx.parsed.y;
+ if (v == null) return `${ctx.dataset.label}: n/a`;
+ return `${ctx.dataset.label}: ${v.toFixed(1)}%`;
+ }
+ }
+ }
+ }
+ }
+ });
+}
+
+function renderRankedBarChart(canvasId, results, opts) {
+ const policies = [];
+ const values = [];
+ for (const r of results) {
+ const v = opts.extract(r);
+ if (v == null) continue;
+ policies.push(r.policy_name);
+ values.push(v);
+ }
+
+ if (values.length === 0) {
+ showChartEmptyState(canvasId, opts.emptyMessage);
+ return;
+ }
+
+ const xScale = {
+ beginAtZero: true,
+ title: { display: true, text: opts.xAxisLabel }
+ };
+ if (typeof opts.xAxisMaxFn === 'function') {
+ xScale.max = opts.xAxisMaxFn(values);
+ } else if (opts.xAxisMax != null) {
+ xScale.max = opts.xAxisMax;
+ }
+
+ const config = {
+ type: 'bar',
+ data: {
+ labels: policies,
+ datasets: [{
+ label: opts.datasetLabel,
+ data: values,
+ backgroundColor: policies.map(colorForPolicy),
+ borderColor: policies.map(colorForPolicy),
+ borderWidth: 1
+ }]
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ indexAxis: 'y',
+ scales: { x: xScale },
+ plugins: {
+ legend: { display: false },
+ tooltip: {
+ callbacks: {
+ label: ctx => `${opts.datasetLabel}: ${opts.tooltipFormat(ctx.parsed.x)}`
+ }
+ }
+ }
+ },
+ plugins: opts.referenceLine
+ ? [makeReferenceLinePlugin(opts.referenceLine.value, opts.referenceLine.label, 'x')]
+ : []
+ };
+
+ new Chart(document.getElementById(canvasId), config);
+}
diff --git a/bench-support/src/bin/render_docs.rs b/bench-support/src/bin/render_docs.rs
index 170b051..8f93381 100644
--- a/bench-support/src/bin/render_docs.rs
+++ b/bench-support/src/bin/render_docs.rs
@@ -1,34 +1,57 @@
-//! Renders benchmark results from JSON to GitHub Pages Markdown.
+//! Renders benchmark results from JSON to GitHub Pages Markdown +
+//! charts page.
//!
-//! Pipeline: parse `BenchmarkArtifact` β group rows by `case_id` β emit one
-//! Markdown section per case (pivoting policy Γ workload tables where
-//! applicable) β write `index.md`, copy `results.json`, embed `charts.html`.
+//! ## Architecture
+//! Thin binary entry point. The actual pipeline lives in three
+//! sibling modules so each layer can be tested and reasoned about in
+//! isolation:
+//! - [`templates`]: bundled `charts_template.{html,js,css}` assets,
+//! sentinel substitution, schema-version + renderer-stamp logic.
+//! - [`markdown`]: pure `BenchmarkArtifact β String` rendering for
+//! `index.md`.
+//! - [`io`]: argv parsing, atomic file writes, the `run`/`run_with_paths`
+//! orchestrator, and bounded JSON reads.
+//! - [`test_helpers`]: `#[cfg(test)]`-only fixtures shared between the
+//! three modules above.
//!
-//! Usage:
-//! cargo run --package bench-support --bin render_docs -- \
-//!
[output-dir]
+//! ## Core Operations
+//! `main` exits non-zero on render failure so a CI job that reuses
+//! `render_docs` in a script halts on the first bad artifact instead
+//! of silently publishing stale or partial docs.
//!
-//! `output-dir` defaults to `docs/benchmarks/latest` (relative to cwd).
+//! ## Usage
+//! ```text
+//! cargo run --package bench-support --bin render_docs -- \
+//! [output-dir]
+//! ```
+//!
+//! `output-dir` defaults to `docs/benchmarks/latest` (relative to
+//! cwd).
-use std::collections::{BTreeMap, BTreeSet};
-use std::error::Error;
-use std::fmt::Write as _;
-use std::fs;
-use std::io::BufReader;
-use std::path::{Path, PathBuf};
-use std::process::ExitCode;
+// Submodules live alongside this file in `render_docs/`. Cargo's
+// `bin` target doesn't auto-discover sibling-folder submodules, so
+// each declaration carries an explicit `#[path]` instead of moving
+// the binary entry point to `render_docs/main.rs` (which would
+// require either renaming the artifact or pinning a `[[bin]] path`
+// override in `Cargo.toml`).
+
+#[path = "render_docs/io.rs"]
+mod io;
-use bench_support::json_results::{BenchmarkArtifact, ResultRow, SCHEMA_VERSION, case_id};
-use bench_support::registry::{POLICIES, PolicyMeta};
+#[path = "render_docs/markdown.rs"]
+mod markdown;
-/// Markdown table for the static policy selection guide.
-const POLICY_GUIDE_MD: &str = include_str!("policy_guide.md");
+#[path = "render_docs/templates.rs"]
+mod templates;
+
+#[cfg(test)]
+#[path = "render_docs/test_helpers.rs"]
+mod test_helpers;
-/// HTML page that fetches `results.json` and renders interactive charts.
-const CHARTS_HTML: &str = include_str!("charts_template.html");
+use std::process::ExitCode;
fn main() -> ExitCode {
- match run() {
+ match io::run() {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e}");
@@ -36,910 +59,3 @@ fn main() -> ExitCode {
},
}
}
-
-fn run() -> Result<(), Box> {
- let args: Vec = std::env::args().collect();
- let prog = args.first().map(String::as_str).unwrap_or("render_docs");
-
- if args.iter().any(|a| a == "-h" || a == "--help") {
- print_usage(prog, &mut std::io::stdout())?;
- return Ok(());
- }
-
- if args.len() < 2 {
- let mut stderr = std::io::stderr();
- print_usage(prog, &mut stderr)?;
- return Err("missing argument".into());
- }
-
- let json_path = PathBuf::from(&args[1]);
- let output_dir = args
- .get(2)
- .map(PathBuf::from)
- .unwrap_or_else(|| PathBuf::from("docs/benchmarks/latest"));
-
- println!("Reading benchmark results from: {}", json_path.display());
-
- let artifact = read_artifact(&json_path)?;
- check_schema_version(&artifact.schema_version)?;
-
- fs::create_dir_all(&output_dir)
- .map_err(|e| format!("creating {}: {e}", output_dir.display()))?;
-
- let markdown = generate_markdown(&artifact);
-
- let index_path = output_dir.join("index.md");
- fs::write(&index_path, markdown)
- .map_err(|e| format!("writing {}: {e}", index_path.display()))?;
-
- let json_dest = output_dir.join("results.json");
- copy_json(&json_path, &json_dest)?;
-
- let charts_path = output_dir.join("charts.html");
- let charts_html = inject_policy_colors(CHARTS_HTML, POLICIES)?;
- fs::write(&charts_path, charts_html)
- .map_err(|e| format!("writing {}: {e}", charts_path.display()))?;
-
- println!("Generated documentation:");
- println!(" - {}", index_path.display());
- println!(" - {}", json_dest.display());
- println!(" - {}", charts_path.display());
- Ok(())
-}
-
-fn print_usage(prog: &str, out: &mut dyn std::io::Write) -> std::io::Result<()> {
- writeln!(out, "Usage: {prog} [output-dir]")?;
- writeln!(out)?;
- writeln!(out, "Example:")?;
- writeln!(
- out,
- " {prog} target/benchmarks/latest/results.json docs/benchmarks/latest"
- )?;
- writeln!(out)?;
- writeln!(
- out,
- "output-dir defaults to 'docs/benchmarks/latest' (relative to cwd)."
- )?;
- Ok(())
-}
-
-fn read_artifact(path: &Path) -> Result> {
- let file = fs::File::open(path).map_err(|e| format!("opening {}: {e}", path.display()))?;
- let reader = BufReader::new(file);
- let artifact: BenchmarkArtifact =
- serde_json::from_reader(reader).map_err(|e| format!("parsing {}: {e}", path.display()))?;
- Ok(artifact)
-}
-
-/// Refuses results whose major schema version differs from this binary's.
-fn check_schema_version(found: &str) -> Result<(), Box> {
- let major = |v: &str| v.split('.').next().and_then(|s| s.parse::().ok());
- match (major(found), major(SCHEMA_VERSION)) {
- (Some(f), Some(e)) if f == e => Ok(()),
- (Some(f), Some(e)) => Err(format!(
- "schema version mismatch: artifact is {found}, renderer expects {SCHEMA_VERSION} (major {e}, got {f})"
- )
- .into()),
- _ => Err(format!(
- "unrecognized schema version {found:?} (renderer expects {SCHEMA_VERSION})"
- )
- .into()),
- }
-}
-
-/// Skips the copy when source and destination resolve to the same file.
-///
-/// Handles the first-run case where `dest` does not exist yet by canonicalising
-/// the destination's parent directory (which `create_dir_all` has just made)
-/// and re-joining the filename, instead of canonicalising `dest` directly.
-fn copy_json(src: &Path, dest: &Path) -> Result<(), Box> {
- if same_file(src, dest) {
- return Ok(());
- }
- fs::copy(src, dest)
- .map_err(|e| format!("copying {} -> {}: {e}", src.display(), dest.display()))?;
- Ok(())
-}
-
-/// Sentinel substituted in the bundled charts HTML at render time.
-///
-/// The exact byte sequence must match the placeholder in
-/// `charts_template.html`. Keeping it as a JS comment + empty object literal
-/// means the template is still syntactically valid (and would render with
-/// FNV fallback colors only) if substitution ever fails β but we'd rather
-/// fail loudly, hence the explicit `Err` below.
-const POLICY_COLORS_PLACEHOLDER: &str = "/* @POLICY_COLORS@ */ {}";
-
-/// Replace [`POLICY_COLORS_PLACEHOLDER`] in the charts template with a JS
-/// object literal sourced from `policies`. Errors when the placeholder is
-/// missing (template malformed) or appears more than once (would lead to
-/// non-deterministic output).
-fn inject_policy_colors(template: &str, policies: &[PolicyMeta]) -> Result> {
- let count = template.matches(POLICY_COLORS_PLACEHOLDER).count();
- match count {
- 0 => Err(format!(
- "charts template is missing the policy-colors sentinel `{POLICY_COLORS_PLACEHOLDER}`; \
- refusing to render with stale colors"
- )
- .into()),
- 1 => {
- let literal = render_policy_colors_literal(policies);
- Ok(template.replacen(POLICY_COLORS_PLACEHOLDER, &literal, 1))
- },
- n => Err(
- format!("charts template has {n} policy-colors sentinels; expected exactly 1").into(),
- ),
- }
-}
-
-/// Render a JS object literal `{ "Display": "#hex", ... }` from `policies`.
-/// Display names are emitted as JSON strings so any special characters are
-/// safely escaped.
-fn render_policy_colors_literal(policies: &[PolicyMeta]) -> String {
- let mut out = String::from("{\n");
- for (i, meta) in policies.iter().enumerate() {
- let key = serde_json::to_string(meta.display_name).expect("display_name is valid UTF-8");
- let value = serde_json::to_string(meta.color).expect("color is valid UTF-8");
- let comma = if i + 1 == policies.len() { "" } else { "," };
- let _ = writeln!(out, " {key}: {value}{comma}");
- }
- out.push_str(" }");
- out
-}
-
-fn same_file(src: &Path, dest: &Path) -> bool {
- let Ok(src_abs) = src.canonicalize() else {
- return false;
- };
- if let Ok(dest_abs) = dest.canonicalize() {
- return src_abs == dest_abs;
- }
- let (Some(parent), Some(name)) = (dest.parent(), dest.file_name()) else {
- return false;
- };
- match parent.canonicalize() {
- Ok(parent_abs) => src_abs == parent_abs.join(name),
- Err(_) => false,
- }
-}
-
-fn generate_markdown(artifact: &BenchmarkArtifact) -> String {
- let mut md = String::with_capacity(4 * 1024);
-
- writeln!(md, "# Benchmark Results\n").unwrap();
- writeln!(
- md,
- "**Quick Links**: [Interactive Charts](charts.html) | [Raw JSON](results.json)\n"
- )
- .unwrap();
- writeln!(md, "---\n").unwrap();
-
- write_environment(&mut md, artifact);
- write_configuration(&mut md, artifact);
-
- let by_case = artifact.results_by_case();
-
- if let Some(rows) = by_case.get(case_id::HIT_RATE) {
- write_pivot_section(
- &mut md,
- "Hit Rate Comparison",
- rows,
- |r| r.metrics.hit_stats.as_ref().map(|s| s.hit_rate),
- |v| format!("{:.2}%", v * 100.0),
- );
- }
-
- if let Some(rows) = by_case.get(case_id::COMPREHENSIVE) {
- write_pivot_section(
- &mut md,
- "Throughput (Million ops/sec)",
- rows,
- |r| {
- r.metrics
- .throughput
- .as_ref()
- .map(|t| t.ops_per_sec / 1_000_000.0)
- },
- |v| format!("{v:.2}"),
- );
- write_pivot_section(
- &mut md,
- "Latency P99 (nanoseconds)",
- rows,
- |r| r.metrics.latency.as_ref().map(|l| l.p99_ns),
- |v| v.to_string(),
- );
- }
-
- if let Some(rows) = by_case.get(case_id::SCAN_RESISTANCE) {
- write_scan_resistance_section(&mut md, rows);
- }
-
- if let Some(rows) = by_case.get(case_id::ADAPTATION) {
- write_adaptation_section(&mut md, rows);
- }
-
- writeln!(md, "## Policy Selection Guide\n").unwrap();
- md.push_str(POLICY_GUIDE_MD);
- if !POLICY_GUIDE_MD.ends_with('\n') {
- md.push('\n');
- }
- md.push('\n');
-
- writeln!(md, "---\n").unwrap();
- writeln!(
- md,
- "*Generated from `results.json` (schema v{})*",
- artifact.schema_version
- )
- .unwrap();
-
- md
-}
-
-fn write_environment(md: &mut String, artifact: &BenchmarkArtifact) {
- let m = &artifact.metadata;
- writeln!(md, "## Environment\n").unwrap();
- writeln!(md, "- **Date**: {}", m.timestamp).unwrap();
- if let Some(commit) = &m.git_commit {
- writeln!(md, "- **Commit**: `{commit}`").unwrap();
- }
- if let Some(branch) = &m.git_branch {
- writeln!(md, "- **Branch**: `{branch}`").unwrap();
- }
- writeln!(md, "- **Dirty**: {}", m.git_dirty).unwrap();
- writeln!(md, "- **Rustc**: {}", m.rustc_version).unwrap();
- writeln!(md, "- **Host**: {}", m.host_triple).unwrap();
- if let Some(cpu) = &m.cpu_model {
- writeln!(md, "- **CPU**: {cpu}").unwrap();
- }
- md.push('\n');
-}
-
-fn write_configuration(md: &mut String, artifact: &BenchmarkArtifact) {
- let c = &artifact.metadata.config;
- writeln!(md, "## Configuration\n").unwrap();
- writeln!(md, "- **Capacity**: {}", c.capacity).unwrap();
- writeln!(md, "- **Universe**: {}", c.universe).unwrap();
- writeln!(md, "- **Operations**: {}", c.operations).unwrap();
- writeln!(md, "- **Seed**: {}", c.seed).unwrap();
- md.push('\n');
-}
-
-/// Pivots `rows` into a policy Γ workload matrix and emits a Markdown table.
-///
-/// `extract` returns `Some(value)` for rows that contribute to this metric.
-/// Rows for which it returns `None` are skipped entirely (they do not even
-/// contribute their workload column). Duplicate `(policy, workload)` pairs
-/// emit a stderr warning and keep the first occurrence.
-fn write_pivot_section(
- md: &mut String,
- title: &str,
- rows: &[&ResultRow],
- extract: E,
- fmt_cell: F,
-) where
- E: Fn(&ResultRow) -> Option,
- F: Fn(&V) -> String,
-{
- writeln!(md, "## {title}\n").unwrap();
-
- // BTreeMap gives deterministic, sorted policy iteration.
- // BTreeSet gives deterministic, sorted column ordering.
- let mut by_policy: BTreeMap<&str, BTreeMap<&str, V>> = BTreeMap::new();
- let mut workloads: BTreeSet<&str> = BTreeSet::new();
-
- for row in rows {
- let Some(value) = extract(row) else { continue };
- workloads.insert(row.workload_name.as_str());
- let cell = by_policy
- .entry(row.policy_name.as_str())
- .or_default()
- .entry(row.workload_name.as_str());
- match cell {
- std::collections::btree_map::Entry::Vacant(v) => {
- v.insert(value);
- },
- std::collections::btree_map::Entry::Occupied(_) => {
- eprintln!(
- "warning: duplicate ({}, {}) in section {title:?}; keeping first",
- row.policy_name, row.workload_name
- );
- },
- }
- }
-
- if by_policy.is_empty() {
- writeln!(md, "_No data._\n").unwrap();
- return;
- }
-
- write!(md, "| Policy |").unwrap();
- for w in &workloads {
- write!(md, " {w} |").unwrap();
- }
- md.push('\n');
-
- write!(md, "|--------|").unwrap();
- for _ in &workloads {
- write!(md, "-------:|").unwrap();
- }
- md.push('\n');
-
- for (policy, cells) in &by_policy {
- write!(md, "| **{policy}** |").unwrap();
- for w in &workloads {
- match cells.get(w) {
- Some(v) => write!(md, " {} |", fmt_cell(v)).unwrap(),
- None => md.push_str(" - |"),
- }
- }
- md.push('\n');
- }
- md.push('\n');
-}
-
-fn write_scan_resistance_section(md: &mut String, rows: &[&ResultRow]) {
- writeln!(md, "## Scan Resistance\n").unwrap();
-
- let mut sorted: Vec<&ResultRow> = rows
- .iter()
- .copied()
- .filter(|r| r.metrics.scan_resistance.is_some())
- .collect();
- if sorted.is_empty() {
- writeln!(md, "_No data._\n").unwrap();
- return;
- }
- sorted.sort_unstable_by(|a, b| a.policy_name.cmp(&b.policy_name));
-
- md.push_str("| Policy | Baseline | During Scan | Recovery | Score |\n");
- md.push_str("|--------|---------:|------------:|---------:|------:|\n");
-
- for row in sorted {
- let s = row
- .metrics
- .scan_resistance
- .as_ref()
- .expect("filtered to Some above");
- let score = match s.resistance_score {
- Some(v) => format!("{v:.3}"),
- None => "n/a".to_string(),
- };
- writeln!(
- md,
- "| **{}** | {:.2}% | {:.2}% | {:.2}% | {} |",
- row.policy_name,
- s.baseline_hit_rate * 100.0,
- s.scan_hit_rate * 100.0,
- s.recovery_hit_rate * 100.0,
- score,
- )
- .unwrap();
- }
- writeln!(
- md,
- "\n*Score = Recovery/Baseline (1.0 = perfect recovery, n/a = baseline too low to compare)*\n"
- )
- .unwrap();
-}
-
-fn write_adaptation_section(md: &mut String, rows: &[&ResultRow]) {
- writeln!(md, "## Adaptation Speed\n").unwrap();
-
- let mut sorted: Vec<&ResultRow> = rows
- .iter()
- .copied()
- .filter(|r| r.metrics.adaptation.is_some())
- .collect();
- if sorted.is_empty() {
- writeln!(md, "_No data._\n").unwrap();
- return;
- }
- sorted.sort_unstable_by(|a, b| a.policy_name.cmp(&b.policy_name));
-
- let any_curve = sorted.iter().any(|r| {
- !r.metrics
- .adaptation
- .as_ref()
- .unwrap()
- .hit_rate_curve
- .is_empty()
- });
-
- if any_curve {
- md.push_str("| Policy | Stable Hit Rate | Ops to 50% | Ops to 80% | Curve |\n");
- md.push_str("|--------|----------------:|-----------:|-----------:|:------|\n");
- } else {
- md.push_str("| Policy | Stable Hit Rate | Ops to 50% | Ops to 80% |\n");
- md.push_str("|--------|----------------:|-----------:|-----------:|\n");
- }
-
- for row in &sorted {
- let a = row
- .metrics
- .adaptation
- .as_ref()
- .expect("filtered to Some above");
- if any_curve {
- writeln!(
- md,
- "| **{}** | {:.2}% | {} | {} | `{}` |",
- row.policy_name,
- a.stable_hit_rate * 100.0,
- a.ops_to_50_percent,
- a.ops_to_80_percent,
- sparkline(&a.hit_rate_curve),
- )
- .unwrap();
- } else {
- writeln!(
- md,
- "| **{}** | {:.2}% | {} | {} |",
- row.policy_name,
- a.stable_hit_rate * 100.0,
- a.ops_to_50_percent,
- a.ops_to_80_percent,
- )
- .unwrap();
- }
- }
-
- if any_curve {
- let sample = sorted.iter().find_map(|r| {
- let a = r.metrics.adaptation.as_ref().unwrap();
- (a.window_size > 0).then_some((a.window_size, a.hit_rate_curve.len()))
- });
- let curve_note = match sample {
- Some((window, len)) if len > 0 => format!(
- " Curve = per-window hit rate after the workload shift, low β high (`β` β 0%, `β` β 100%); each cell is {window} ops, total {} ops measured.",
- window * len,
- ),
- _ => " Curve = per-window hit rate after the workload shift, low β high (`β` β 0%, `β` β 100%).".into(),
- };
- writeln!(
- md,
- "\n*Lower ops-to-X% is better (faster adaptation).{curve_note}*\n",
- )
- .unwrap();
- } else {
- writeln!(md, "\n*Lower ops-to-X% is better (faster adaptation)*\n").unwrap();
- }
-}
-
-/// Render a hit-rate curve as a Unicode block sparkline. Each cell maps
-/// `[0.0, 1.0]` to one of eight block heights so a long curve still fits in
-/// a Markdown table cell.
-fn sparkline(values: &[f64]) -> String {
- const BLOCKS: [char; 8] = ['β', 'β', 'β', 'β', 'β
', 'β', 'β', 'β'];
- if values.is_empty() {
- return String::new();
- }
- values
- .iter()
- .map(|&v| {
- let clamped = v.clamp(0.0, 1.0);
- // 0.0 β block 0, 1.0 β block 7; midpoints round nearest.
- let idx = (clamped * (BLOCKS.len() as f64 - 1.0)).round() as usize;
- BLOCKS[idx.min(BLOCKS.len() - 1)]
- })
- .collect()
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use bench_support::json_results::{
- AdaptationStats, BenchmarkConfig, HitStats, LatencyStats, Metrics, RunMetadata,
- ScanResistanceStats, ThroughputStats,
- };
-
- fn metadata() -> RunMetadata {
- RunMetadata {
- timestamp: "2026-01-01T00:00:00Z".into(),
- git_commit: None,
- git_branch: None,
- git_dirty: false,
- rustc_version: "rustc test".into(),
- host_triple: "x86_64-unknown-linux-gnu".into(),
- cpu_model: None,
- config: BenchmarkConfig {
- capacity: 1024,
- universe: 10_000,
- operations: 100_000,
- seed: 42,
- },
- }
- }
-
- fn empty_metrics() -> Metrics {
- Metrics {
- hit_stats: None,
- throughput: None,
- latency: None,
- eviction: None,
- scan_resistance: None,
- adaptation: None,
- }
- }
-
- fn row(policy: &str, workload: &str, case: &str, metrics: Metrics) -> ResultRow {
- ResultRow {
- policy_id: policy.to_lowercase(),
- policy_name: policy.into(),
- workload_id: workload.to_lowercase(),
- workload_name: workload.into(),
- case_id: case.into(),
- metrics,
- }
- }
-
- fn hit_metrics(hit_rate: f64) -> Metrics {
- Metrics {
- hit_stats: Some(HitStats {
- hits: 0,
- misses: 0,
- inserts: 0,
- updates: 0,
- hit_rate,
- miss_rate: 1.0 - hit_rate,
- }),
- ..empty_metrics()
- }
- }
-
- #[test]
- fn pivot_section_renders_sorted_rows_and_columns() {
- let rows = [
- row("LRU", "Zipf", "hit_rate", hit_metrics(0.42)),
- row("LFU", "Uniform", "hit_rate", hit_metrics(0.15)),
- row("LFU", "Zipf", "hit_rate", hit_metrics(0.51)),
- ];
- let refs: Vec<&ResultRow> = rows.iter().collect();
- let mut md = String::new();
- write_pivot_section(
- &mut md,
- "Hit Rate Comparison",
- &refs,
- |r| r.metrics.hit_stats.as_ref().map(|s| s.hit_rate),
- |v| format!("{:.2}%", v * 100.0),
- );
-
- // Header alphabetised: Uniform before Zipf.
- assert!(md.contains("| Policy | Uniform | Zipf |"));
- // Policies alphabetised: LFU before LRU.
- let lfu = md.find("| **LFU** |").expect("LFU row present");
- let lru = md.find("| **LRU** |").expect("LRU row present");
- assert!(lfu < lru, "LFU should sort before LRU");
- // LRU is missing the Uniform workload, so column shows '-'.
- assert!(md.contains("| **LRU** | - | 42.00% |"));
- assert!(md.contains("| **LFU** | 15.00% | 51.00% |"));
- }
-
- #[test]
- fn pivot_section_empty_emits_no_data_marker() {
- let mut md = String::new();
- write_pivot_section(
- &mut md,
- "Hit Rate Comparison",
- &[],
- |r: &ResultRow| r.metrics.hit_stats.as_ref().map(|s| s.hit_rate),
- |v| format!("{v}"),
- );
- assert!(md.contains("_No data._"));
- assert!(!md.contains("| Policy |"));
- }
-
- #[test]
- fn scan_resistance_section_skips_rows_without_metric() {
- let rows = [
- row("LRU", "Scan", "scan_resistance", empty_metrics()),
- row(
- "S3-FIFO",
- "Scan",
- "scan_resistance",
- Metrics {
- scan_resistance: Some(ScanResistanceStats {
- baseline_hit_rate: 0.80,
- scan_hit_rate: 0.10,
- recovery_hit_rate: 0.78,
- resistance_score: Some(0.975),
- }),
- ..empty_metrics()
- },
- ),
- ];
- let refs: Vec<&ResultRow> = rows.iter().collect();
- let mut md = String::new();
- write_scan_resistance_section(&mut md, &refs);
- assert!(md.contains("**S3-FIFO**"));
- assert!(!md.contains("**LRU**"));
- assert!(md.contains("0.975"));
- }
-
- #[test]
- fn scan_resistance_section_renders_n_a_for_missing_score() {
- let rows = [row(
- "LIFO",
- "Scan",
- "scan_resistance",
- Metrics {
- scan_resistance: Some(ScanResistanceStats {
- baseline_hit_rate: 0.001,
- scan_hit_rate: 0.0,
- recovery_hit_rate: 0.001,
- resistance_score: None,
- }),
- ..empty_metrics()
- },
- )];
- let refs: Vec<&ResultRow> = rows.iter().collect();
- let mut md = String::new();
- write_scan_resistance_section(&mut md, &refs);
- assert!(md.contains("| **LIFO** |"));
- assert!(
- md.contains(" n/a |"),
- "expected n/a placeholder, got:\n{md}"
- );
- }
-
- #[test]
- fn adaptation_section_renders_counts() {
- let rows = [row(
- "LRU",
- "Shift",
- "adaptation",
- Metrics {
- adaptation: Some(AdaptationStats {
- stable_hit_rate: 0.62,
- ops_to_50_percent: 1234,
- ops_to_80_percent: 9999,
- hit_rate_curve: Vec::new(),
- window_size: 0,
- }),
- ..empty_metrics()
- },
- )];
- let refs: Vec<&ResultRow> = rows.iter().collect();
- let mut md = String::new();
- write_adaptation_section(&mut md, &refs);
- assert!(md.contains("| **LRU** | 62.00% | 1234 | 9999 |"));
- }
-
- #[test]
- fn adaptation_section_renders_sparkline_when_curve_present() {
- let rows = [
- row(
- "LRU",
- "Shift",
- "adaptation",
- Metrics {
- adaptation: Some(AdaptationStats {
- stable_hit_rate: 0.85,
- ops_to_50_percent: 500,
- ops_to_80_percent: 1500,
- hit_rate_curve: vec![0.0, 0.25, 0.5, 0.75, 1.0],
- window_size: 256,
- }),
- ..empty_metrics()
- },
- ),
- row(
- "FIFO",
- "Shift",
- "adaptation",
- Metrics {
- adaptation: Some(AdaptationStats {
- stable_hit_rate: 0.4,
- ops_to_50_percent: 800,
- ops_to_80_percent: 4000,
- hit_rate_curve: Vec::new(),
- window_size: 0,
- }),
- ..empty_metrics()
- },
- ),
- ];
- let refs: Vec<&ResultRow> = rows.iter().collect();
- let mut md = String::new();
- write_adaptation_section(&mut md, &refs);
-
- assert!(
- md.contains("| Curve |"),
- "expected Curve column header, got:\n{md}",
- );
- // LRU has a 5-point curve from 0.0 to 1.0 stepping by 0.25:
- // each value Γ 7 then rounded β 0, 2, 4 (round-half-away), 5, 7.
- assert!(
- md.contains("`βββ
ββ`"),
- "expected sparkline for LRU, got:\n{md}",
- );
- // FIFO has no curve; cell should be an empty backtick pair.
- assert!(
- md.contains("4000 | `` |"),
- "expected empty sparkline cell for FIFO, got:\n{md}",
- );
- // Footnote reports window size when at least one row supplied it.
- assert!(
- md.contains("each cell is 256 ops"),
- "expected window-size note, got:\n{md}",
- );
- }
-
- #[test]
- fn sparkline_maps_extremes_and_midpoints() {
- assert_eq!(sparkline(&[]), "");
- assert_eq!(sparkline(&[0.0, 1.0]), "ββ");
- // Out-of-range inputs are clamped, not panicked on.
- assert_eq!(sparkline(&[-0.5, 1.5]), "ββ");
- // Midpoint rounds to the middle bucket.
- assert_eq!(sparkline(&[0.5]).chars().count(), 1);
- }
-
- #[test]
- fn schema_version_matches_when_major_equal() {
- assert!(check_schema_version(SCHEMA_VERSION).is_ok());
- assert!(check_schema_version("1.99.0").is_ok());
- assert!(check_schema_version("2.0.0").is_err());
- assert!(check_schema_version("not a version").is_err());
- }
-
- #[test]
- fn pivot_section_keeps_first_on_duplicate_pair() {
- // Two rows with the same (policy, workload); first hit_rate is 0.10,
- // second is 0.99. The renderer must keep the first.
- let rows = [
- row("LRU", "Zipf", "hit_rate", hit_metrics(0.10)),
- row("LRU", "Zipf", "hit_rate", hit_metrics(0.99)),
- ];
- let refs: Vec<&ResultRow> = rows.iter().collect();
- let mut md = String::new();
- write_pivot_section(
- &mut md,
- "Hit Rate Comparison",
- &refs,
- |r| r.metrics.hit_stats.as_ref().map(|s| s.hit_rate),
- |v| format!("{:.2}%", v * 100.0),
- );
- assert!(
- md.contains("| **LRU** | 10.00% |"),
- "expected first-wins (10.00%), got:\n{md}"
- );
- assert!(!md.contains("99.00%"));
- }
-
- #[test]
- fn same_file_detects_existing_paths() {
- let dir = std::env::temp_dir().join("cachekit-render-docs-same-file");
- let _ = std::fs::remove_dir_all(&dir);
- std::fs::create_dir_all(&dir).unwrap();
- let p = dir.join("a.json");
- std::fs::write(&p, b"{}").unwrap();
-
- // Self-comparison: same physical file.
- assert!(same_file(&p, &p));
-
- // Different file in same dir, neither pointing at the other.
- let q = dir.join("b.json");
- std::fs::write(&q, b"{}").unwrap();
- assert!(!same_file(&p, &q));
-
- // Same file reached via a non-canonical path (uses parent canonicalisation).
- let alt = dir.join(".").join("a.json");
- assert!(same_file(&p, &alt));
-
- // Nonexistent dest with a different filename in the same dir.
- let missing = dir.join("never-created.json");
- assert!(!same_file(&p, &missing));
-
- std::fs::remove_dir_all(&dir).unwrap();
- }
-
- #[test]
- fn render_policy_colors_literal_emits_valid_js_object() {
- let metas = [
- PolicyMeta {
- id: "lru",
- display_name: "LRU",
- color: "#3498db",
- },
- PolicyMeta {
- id: "two_q",
- display_name: "2Q",
- color: "#e67e22",
- },
- ];
- let lit = render_policy_colors_literal(&metas);
- // Quoted keys (JSON-safe even for "2Q") and the trailing item has no comma.
- assert!(lit.contains("\"LRU\": \"#3498db\","), "got:\n{lit}");
- assert!(lit.contains("\"2Q\": \"#e67e22\""), "got:\n{lit}");
- assert!(!lit.contains("#e67e22\","), "trailing comma in last entry");
- assert!(lit.starts_with('{') && lit.trim_end().ends_with('}'));
- }
-
- #[test]
- fn inject_policy_colors_replaces_sentinel_once() {
- let template = "head\nconst POLICY_COLORS = /* @POLICY_COLORS@ */ {};\ntail";
- let metas = [PolicyMeta {
- id: "lru",
- display_name: "LRU",
- color: "#3498db",
- }];
- let out = inject_policy_colors(template, &metas).expect("substitution");
- assert!(out.contains("\"LRU\": \"#3498db\""));
- // Sentinel must be gone after substitution.
- assert!(!out.contains("@POLICY_COLORS@"));
- // Surrounding lines preserved.
- assert!(out.starts_with("head\n"));
- assert!(out.ends_with("\ntail"));
- }
-
- #[test]
- fn inject_policy_colors_errors_when_sentinel_missing() {
- let err = inject_policy_colors("no sentinel here", &[]).unwrap_err();
- assert!(
- err.to_string()
- .contains("missing the policy-colors sentinel"),
- "unexpected error: {err}",
- );
- }
-
- #[test]
- fn inject_policy_colors_errors_when_sentinel_duplicated() {
- let template = "/* @POLICY_COLORS@ */ {} and again /* @POLICY_COLORS@ */ {}";
- let err = inject_policy_colors(template, &[]).unwrap_err();
- assert!(
- err.to_string().contains("expected exactly 1"),
- "unexpected error: {err}",
- );
- }
-
- #[test]
- fn bundled_charts_template_contains_the_sentinel() {
- // Cheap guard: the include_str! template must actually carry the
- // sentinel. If someone hand-edits charts_template.html and removes it,
- // every render_docs run would fail at the substitution step; this test
- // catches the regression at `cargo test` time instead.
- assert!(
- CHARTS_HTML.contains(POLICY_COLORS_PLACEHOLDER),
- "charts_template.html no longer contains `{POLICY_COLORS_PLACEHOLDER}`",
- );
- }
-
- #[test]
- fn generate_markdown_smoke() {
- let mut artifact = BenchmarkArtifact::new(metadata());
- artifact.add_result(row("LRU", "Zipf", "hit_rate", hit_metrics(0.5)));
- artifact.add_result(row(
- "LRU",
- "Zipf",
- "comprehensive",
- Metrics {
- throughput: Some(ThroughputStats {
- duration_ms: 100.0,
- ops_per_sec: 5_000_000.0,
- gets_per_sec: 4_000_000.0,
- inserts_per_sec: 1_000_000.0,
- }),
- latency: Some(LatencyStats {
- sample_count: 1000,
- min_ns: 10,
- p50_ns: 50,
- p95_ns: 200,
- p99_ns: 500,
- max_ns: 9999,
- mean_ns: 75,
- }),
- ..empty_metrics()
- },
- ));
- let md = generate_markdown(&artifact);
- assert!(md.contains("# Benchmark Results"));
- assert!(md.contains("## Hit Rate Comparison"));
- assert!(md.contains("## Throughput (Million ops/sec)"));
- assert!(md.contains("## Latency P99 (nanoseconds)"));
- assert!(md.contains("5.00")); // 5M ops/sec
- assert!(md.contains("500")); // p99
- assert!(md.contains("schema v"));
- }
-}
diff --git a/bench-support/src/bin/render_docs/io.rs b/bench-support/src/bin/render_docs/io.rs
new file mode 100644
index 0000000..316141e
--- /dev/null
+++ b/bench-support/src/bin/render_docs/io.rs
@@ -0,0 +1,614 @@
+//! ## Architecture
+//! Glue between the user (argv + stdio) and the pure rendering layer
+//! (`templates`, `markdown`). Owns:
+//! - argv parsing ([`parse_args`] β [`ParsedArgs`]).
+//! - the in-process pipeline ([`run_with_paths`]) that drives every
+//! renderer once per invocation.
+//! - atomic file replacement so a crashed render leaves the published
+//! docs tree in a consistent state, never half-written.
+//! - input bound checks ([`MAX_RESULTS_JSON_BYTES`]).
+//!
+//! ## Performance Trade-offs
+//! Atomic writes use `rename` over a sibling tempfile (one syscall on
+//! Unix, atomic on the same filesystem). The cost is one extra
+//! `fs::write` per output; for ~5 KiB-class outputs this is below
+//! noise.
+
+use std::error::Error;
+use std::fs;
+use std::path::{Path, PathBuf};
+
+use bench_support::json_results::BenchmarkArtifact;
+use bench_support::registry::POLICIES;
+
+use crate::markdown::generate_markdown;
+use crate::templates::{CHARTS_CSS, check_schema_version, render_charts_html, render_charts_js};
+
+// ============================================================================
+// CLI front-end
+// ============================================================================
+
+/// Parsed CLI invocation, ready to drive [`run_with_paths`].
+pub(crate) struct CliArgs {
+ pub(crate) json_path: PathBuf,
+ pub(crate) output_dir: PathBuf,
+}
+
+/// Outcome of [`parse_args`]: the three branches `run` distinguishes
+/// (help, error, valid invocation). Carrying `prog` through every
+/// branch keeps usage messages anchored to the actual `argv[0]` the
+/// user typed, which differs between `cargo run` and a direct binary
+/// invocation.
+pub(crate) enum ParsedArgs {
+ Run(CliArgs),
+ HelpRequested(String),
+ Error { prog: String, message: String },
+}
+
+/// Parse the renderer's argv. Testable seam β [`run`] just routes the
+/// outcome to stdio. Accepts at most one positional ``
+/// and an optional `[output-dir]`; anything else (extra positionals,
+/// unrecognized leading flags) is rejected with a clear usage error
+/// rather than silently dropped on the floor.
+pub(crate) fn parse_args(args: &[String]) -> ParsedArgs {
+ let prog = args
+ .first()
+ .map(String::as_str)
+ .unwrap_or("render_docs")
+ .to_string();
+
+ if args.iter().any(|a| a == "-h" || a == "--help") {
+ return ParsedArgs::HelpRequested(prog);
+ }
+
+ let positionals: Vec<&str> = args.iter().skip(1).map(String::as_str).collect();
+
+ if let Some(flag) = positionals
+ .iter()
+ .find(|a| a.starts_with("--") || a.starts_with('-'))
+ {
+ return ParsedArgs::Error {
+ prog,
+ message: format!(
+ "unrecognized flag {flag:?}; render_docs only takes positional \
+ arguments. See --help for usage."
+ ),
+ };
+ }
+
+ if positionals.is_empty() {
+ return ParsedArgs::Error {
+ prog,
+ message: "missing argument".to_string(),
+ };
+ }
+
+ if positionals.len() > 2 {
+ return ParsedArgs::Error {
+ prog,
+ message: format!(
+ "too many positional arguments ({}, expected at most 2: \
+ [output-dir])",
+ positionals.len(),
+ ),
+ };
+ }
+
+ let json_path = PathBuf::from(positionals[0]);
+ let output_dir = positionals
+ .get(1)
+ .map(PathBuf::from)
+ .unwrap_or_else(|| PathBuf::from("docs/benchmarks/latest"));
+
+ ParsedArgs::Run(CliArgs {
+ json_path,
+ output_dir,
+ })
+}
+
+fn print_usage(prog: &str, out: &mut dyn std::io::Write) -> std::io::Result<()> {
+ writeln!(out, "Usage: {prog} [output-dir]")?;
+ writeln!(out)?;
+ writeln!(out, "Example:")?;
+ writeln!(
+ out,
+ " {prog} target/benchmarks/latest/results.json docs/benchmarks/latest"
+ )?;
+ writeln!(out)?;
+ writeln!(
+ out,
+ "output-dir defaults to 'docs/benchmarks/latest' (relative to cwd)."
+ )?;
+ Ok(())
+}
+
+/// Top-level entry point used by `fn main`. Stdio chatter lives here;
+/// [`run_with_paths`] is the silent core.
+pub(crate) fn run() -> Result<(), Box> {
+ let args: Vec = std::env::args().collect();
+ let parsed = match parse_args(&args) {
+ ParsedArgs::HelpRequested(prog) => {
+ print_usage(&prog, &mut std::io::stdout())?;
+ return Ok(());
+ },
+ ParsedArgs::Error { prog, message } => {
+ let mut stderr = std::io::stderr();
+ print_usage(&prog, &mut stderr)?;
+ return Err(message.into());
+ },
+ ParsedArgs::Run(parsed) => parsed,
+ };
+
+ println!(
+ "Reading benchmark results from: {}",
+ parsed.json_path.display()
+ );
+ let outputs = run_with_paths(&parsed.json_path, &parsed.output_dir)?;
+
+ println!("Generated documentation:");
+ for path in outputs.iter() {
+ println!(" - {}", path.display());
+ }
+ Ok(())
+}
+
+// ============================================================================
+// Pipeline
+// ============================================================================
+
+/// Paths to the files [`run_with_paths`] produces, in a fixed schema.
+///
+/// Returned by name (not position) so a future caller adding a sixth
+/// output can't silently shift `outputs[2]` to mean a different file.
+/// Tests and the binary both iterate via [`RenderOutputs::iter`] and
+/// look up specific files by field name.
+pub(crate) struct RenderOutputs {
+ pub(crate) index: PathBuf,
+ pub(crate) results: PathBuf,
+ pub(crate) charts_html: PathBuf,
+ pub(crate) charts_css: PathBuf,
+ pub(crate) charts_js: PathBuf,
+}
+
+impl RenderOutputs {
+ /// Iterate all output paths in the order the renderer wrote them.
+ pub(crate) fn iter(&self) -> impl Iterator- {
+ [
+ self.index.as_path(),
+ self.results.as_path(),
+ self.charts_html.as_path(),
+ self.charts_css.as_path(),
+ self.charts_js.as_path(),
+ ]
+ .into_iter()
+ }
+}
+
+/// Core renderer: produce the five output files from a parsed JSON
+/// path.
+///
+/// Split out from [`run`] (which owns argv parsing and stdout chatter)
+/// so integration tests can drive the full pipeline against a temp
+/// directory and inspect the resulting files.
+pub(crate) fn run_with_paths(
+ json_path: &Path,
+ output_dir: &Path,
+) -> Result> {
+ let artifact = read_artifact(json_path)?;
+ check_schema_version(&artifact.schema_version)?;
+
+ fs::create_dir_all(output_dir)
+ .map_err(|e| format!("creating {}: {e}", output_dir.display()))?;
+
+ let markdown = generate_markdown(&artifact);
+
+ let index = output_dir.join("index.md");
+ write_atomic(&index, markdown.as_bytes())?;
+
+ let results = output_dir.join("results.json");
+ copy_json(json_path, &results)?;
+
+ let charts_html = output_dir.join("charts.html");
+ let charts_html_body = render_charts_html()?;
+ write_atomic(&charts_html, charts_html_body.as_bytes())?;
+
+ let charts_css = output_dir.join("charts.css");
+ write_atomic(&charts_css, CHARTS_CSS.as_bytes())?;
+
+ let charts_js = output_dir.join("charts.js");
+ let charts_js_body = render_charts_js(POLICIES)?;
+ write_atomic(&charts_js, charts_js_body.as_bytes())?;
+
+ Ok(RenderOutputs {
+ index,
+ results,
+ charts_html,
+ charts_css,
+ charts_js,
+ })
+}
+
+// ============================================================================
+// Atomic writes
+// ============================================================================
+
+/// Replace `path` with the result of `produce(tmp)` atomically: stage
+/// the new contents in a sibling tempfile, then `rename` over the
+/// destination.
+///
+/// On Unix, `rename` is a single atomic syscall β a crash leaves
+/// either the old file or the new, never a half-written one. The
+/// tempfile lives in the same directory so the rename stays on one
+/// filesystem (cross-FS renames are not atomic and would fail with
+/// `EXDEV` anyway). On error, the tempfile is best-effort removed so
+/// a half-finished render leaves no `.tmp-*` debris in the published
+/// docs tree.
+fn atomic_replace(path: &Path, produce: F) -> Result<(), Box>
+where
+ F: FnOnce(&Path) -> std::io::Result<()>,
+{
+ let parent = path
+ .parent()
+ .ok_or_else(|| format!("atomic write to {} has no parent directory", path.display()))?;
+ let basename = path
+ .file_name()
+ .ok_or_else(|| format!("atomic write to {} has no filename", path.display()))?;
+ let tmp_path = parent.join(format!(".tmp-{}", basename.to_string_lossy()));
+
+ if let Err(e) = produce(&tmp_path) {
+ let _ = fs::remove_file(&tmp_path);
+ return Err(format!("staging {}: {e}", tmp_path.display()).into());
+ }
+ if let Err(e) = fs::rename(&tmp_path, path) {
+ let _ = fs::remove_file(&tmp_path);
+ return Err(format!("renaming {} -> {}: {e}", tmp_path.display(), path.display()).into());
+ }
+ Ok(())
+}
+
+/// Atomically write `bytes` to `path`. See [`atomic_replace`] for
+/// semantics.
+pub(crate) fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), Box> {
+ atomic_replace(path, |tmp| fs::write(tmp, bytes))
+}
+
+/// Skips the copy when source and destination resolve to the same
+/// file; otherwise replaces `dest` atomically (see
+/// [`atomic_replace`]).
+///
+/// Handles the first-run case where `dest` does not exist yet by
+/// canonicalising the destination's parent directory (which
+/// `create_dir_all` has just made) and re-joining the filename,
+/// instead of canonicalising `dest` directly.
+fn copy_json(src: &Path, dest: &Path) -> Result<(), Box> {
+ if same_file(src, dest) {
+ return Ok(());
+ }
+ atomic_replace(dest, |tmp| fs::copy(src, tmp).map(|_| ()))
+}
+
+fn same_file(src: &Path, dest: &Path) -> bool {
+ let Ok(src_abs) = src.canonicalize() else {
+ return false;
+ };
+ if let Ok(dest_abs) = dest.canonicalize() {
+ return src_abs == dest_abs;
+ }
+ let (Some(parent), Some(name)) = (dest.parent(), dest.file_name()) else {
+ return false;
+ };
+ match parent.canonicalize() {
+ Ok(parent_abs) => src_abs == parent_abs.join(name),
+ Err(_) => false,
+ }
+}
+
+// ============================================================================
+// Bounded JSON read
+// ============================================================================
+
+/// Generous cap for the input JSON artifact. Real benchmark runs
+/// produce well under 10 MiB; this exists to fail fast (instead of
+/// OOM) when pointed at a wrong path β a multi-GB log, `/dev/zero`,
+/// etc.
+pub(crate) const MAX_RESULTS_JSON_BYTES: u64 = 256 * 1024 * 1024;
+
+fn read_artifact(path: &Path) -> Result> {
+ read_artifact_with_limit(path, MAX_RESULTS_JSON_BYTES)
+}
+
+/// Internal seam for [`read_artifact`] that lets tests exercise the
+/// over-limit branch with a small synthetic file. Reading via
+/// `read_to_string` + `from_str` is materially faster than
+/// `from_reader` for small JSON (per the serde docs) and gives us a
+/// length we can check up-front.
+pub(crate) fn read_artifact_with_limit(
+ path: &Path,
+ limit_bytes: u64,
+) -> Result> {
+ let metadata = fs::metadata(path).map_err(|e| format!("stat {}: {e}", path.display()))?;
+ if metadata.len() > limit_bytes {
+ return Err(format!(
+ "artifact {} is {} bytes, exceeds {} byte limit (point at the right results.json)",
+ path.display(),
+ metadata.len(),
+ limit_bytes,
+ )
+ .into());
+ }
+ let body = fs::read_to_string(path).map_err(|e| format!("reading {}: {e}", path.display()))?;
+ let artifact: BenchmarkArtifact =
+ serde_json::from_str(&body).map_err(|e| format!("parsing {}: {e}", path.display()))?;
+ Ok(artifact)
+}
+
+// ============================================================================
+// Tests
+// ============================================================================
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use bench_support::json_results::SCHEMA_VERSION;
+
+ use crate::templates::{
+ CHART_JS_SRI_PLACEHOLDER, CHART_JS_VERSION_PLACEHOLDER, POLICY_COLORS_PLACEHOLDER,
+ RENDERER_STAMP_PLACEHOLDER, SCHEMA_MAJOR_PLACEHOLDER, renderer_stamp,
+ };
+ use crate::test_helpers::{hit_metrics, metadata, row, unique_temp_dir};
+
+ fn args(items: &[&str]) -> Vec {
+ items.iter().map(|s| s.to_string()).collect()
+ }
+
+ #[test]
+ fn same_file_detects_existing_paths() {
+ let dir = unique_temp_dir("same-file");
+ let p = dir.join("a.json");
+ std::fs::write(&p, b"{}").unwrap();
+
+ assert!(same_file(&p, &p));
+
+ let q = dir.join("b.json");
+ std::fs::write(&q, b"{}").unwrap();
+ assert!(!same_file(&p, &q));
+
+ let alt = dir.join(".").join("a.json");
+ assert!(same_file(&p, &alt));
+
+ let missing = dir.join("never-created.json");
+ assert!(!same_file(&p, &missing));
+
+ std::fs::remove_dir_all(&dir).unwrap();
+ }
+
+ #[test]
+ fn run_with_paths_writes_all_five_outputs() {
+ let tmp = unique_temp_dir("e2e");
+
+ let json_path = tmp.join("results.json");
+ let mut artifact = BenchmarkArtifact::new(metadata());
+ artifact.add_result(row("LRU", "Zipf", "hit_rate", hit_metrics(0.5)));
+ std::fs::write(&json_path, serde_json::to_vec(&artifact).unwrap()).expect("write fixture");
+
+ let out_dir = tmp.join("out");
+ let outputs = run_with_paths(&json_path, &out_dir).expect("render");
+
+ assert_eq!(outputs.index, out_dir.join("index.md"));
+ assert_eq!(outputs.results, out_dir.join("results.json"));
+ assert_eq!(outputs.charts_html, out_dir.join("charts.html"));
+ assert_eq!(outputs.charts_css, out_dir.join("charts.css"));
+ assert_eq!(outputs.charts_js, out_dir.join("charts.js"));
+ for path in outputs.iter() {
+ assert!(path.exists(), "output file missing: {}", path.display());
+ let bytes = std::fs::read(path).expect("read output");
+ assert!(!bytes.is_empty(), "output file empty: {}", path.display());
+ }
+
+ let html = std::fs::read_to_string(&outputs.charts_html).unwrap();
+ assert!(!html.contains(CHART_JS_VERSION_PLACEHOLDER));
+ assert!(!html.contains(CHART_JS_SRI_PLACEHOLDER));
+ assert!(!html.contains(RENDERER_STAMP_PLACEHOLDER));
+ let stamp = renderer_stamp();
+ assert!(
+ html.contains(&format!(r#""#)),
+ "rendered charts.html missing with stamp {stamp:?}",
+ );
+ let js = std::fs::read_to_string(&outputs.charts_js).unwrap();
+ assert!(!js.contains(POLICY_COLORS_PLACEHOLDER));
+ assert!(!js.contains(SCHEMA_MAJOR_PLACEHOLDER));
+
+ let md = std::fs::read_to_string(&outputs.index).unwrap();
+ assert!(
+ md.contains(&format!("Generated by `{stamp}`")),
+ "rendered index.md missing renderer stamp footer; got tail: {:?}",
+ md.lines().rev().take(3).collect::>(),
+ );
+
+ let leftover: Vec<_> = std::fs::read_dir(&out_dir)
+ .unwrap()
+ .filter_map(|e| e.ok())
+ .filter(|e| e.file_name().to_string_lossy().starts_with(".tmp-"))
+ .collect();
+ assert!(
+ leftover.is_empty(),
+ "found stray atomic-write tempfiles: {:?}",
+ leftover.iter().map(|e| e.path()).collect::>(),
+ );
+
+ std::fs::remove_dir_all(&tmp).unwrap();
+ }
+
+ #[test]
+ fn run_with_paths_is_byte_idempotent() {
+ // The renderer uses BTreeMap/BTreeSet today (deterministic
+ // iteration), but a future refactor that switches to HashMap
+ // would silently produce byte-different output across runs
+ // with no test failure. Run the pipeline twice and byte-
+ // compare the four deterministic outputs.
+ let tmp = unique_temp_dir("idempotence");
+ let json_path = tmp.join("results.json");
+ let mut artifact = BenchmarkArtifact::new(metadata());
+ artifact.add_result(row("LRU", "Zipf", "hit_rate", hit_metrics(0.5)));
+ artifact.add_result(row("LFU", "Uniform", "hit_rate", hit_metrics(0.4)));
+ std::fs::write(&json_path, serde_json::to_vec(&artifact).unwrap()).unwrap();
+
+ let out_a = tmp.join("a");
+ let out_b = tmp.join("b");
+ let outputs_a = run_with_paths(&json_path, &out_a).expect("render a");
+ let outputs_b = run_with_paths(&json_path, &out_b).expect("render b");
+
+ for (label, a, b) in [
+ ("index.md", &outputs_a.index, &outputs_b.index),
+ (
+ "charts.html",
+ &outputs_a.charts_html,
+ &outputs_b.charts_html,
+ ),
+ ("charts.css", &outputs_a.charts_css, &outputs_b.charts_css),
+ ("charts.js", &outputs_a.charts_js, &outputs_b.charts_js),
+ ] {
+ let bytes_a = std::fs::read(a).unwrap();
+ let bytes_b = std::fs::read(b).unwrap();
+ assert_eq!(
+ bytes_a,
+ bytes_b,
+ "{label} differs across runs ({} vs {} bytes); a non-deterministic \
+ collection (HashMap/HashSet?) leaked into the renderer",
+ bytes_a.len(),
+ bytes_b.len(),
+ );
+ }
+ std::fs::remove_dir_all(&tmp).unwrap();
+ }
+
+ #[test]
+ fn parse_args_accepts_one_or_two_positionals() {
+ match parse_args(&args(&["render_docs", "in.json"])) {
+ ParsedArgs::Run(p) => {
+ assert_eq!(p.json_path, PathBuf::from("in.json"));
+ assert_eq!(p.output_dir, PathBuf::from("docs/benchmarks/latest"));
+ },
+ other => panic!("expected Run, got {:?}", std::mem::discriminant(&other)),
+ }
+ match parse_args(&args(&["render_docs", "in.json", "out"])) {
+ ParsedArgs::Run(p) => {
+ assert_eq!(p.json_path, PathBuf::from("in.json"));
+ assert_eq!(p.output_dir, PathBuf::from("out"));
+ },
+ other => panic!("expected Run, got {:?}", std::mem::discriminant(&other)),
+ }
+ }
+
+ #[test]
+ fn parse_args_help_short_circuits_in_either_position() {
+ for argv in [
+ args(&["render_docs", "--help"]),
+ args(&["render_docs", "-h"]),
+ args(&["render_docs", "foo", "--help"]),
+ ] {
+ assert!(
+ matches!(parse_args(&argv), ParsedArgs::HelpRequested(_)),
+ "expected HelpRequested for {argv:?}",
+ );
+ }
+ }
+
+ #[test]
+ fn parse_args_rejects_extra_positionals() {
+ let p = parse_args(&args(&["render_docs", "in.json", "out", "extra"]));
+ match p {
+ ParsedArgs::Error { message, .. } => assert!(
+ message.contains("too many positional arguments"),
+ "got: {message}",
+ ),
+ _ => panic!("expected Error for extra positional"),
+ }
+ }
+
+ #[test]
+ fn parse_args_rejects_unknown_leading_flags() {
+ let p = parse_args(&args(&["render_docs", "--output", "docs", "in.json"]));
+ match p {
+ ParsedArgs::Error { message, .. } => {
+ assert!(message.contains("unrecognized flag"), "got: {message}",)
+ },
+ _ => panic!("expected Error for unknown flag"),
+ }
+ }
+
+ #[test]
+ fn parse_args_requires_at_least_one_positional() {
+ let p = parse_args(&args(&["render_docs"]));
+ match p {
+ ParsedArgs::Error { message, .. } => {
+ assert!(message.contains("missing "), "got: {message}",)
+ },
+ _ => panic!("expected Error for missing positional"),
+ }
+ }
+
+ #[test]
+ fn parse_args_carries_prog_name_through_branches() {
+ match parse_args(&args(&["my-render", "--help"])) {
+ ParsedArgs::HelpRequested(prog) => assert_eq!(prog, "my-render"),
+ _ => panic!(),
+ }
+ match parse_args(&args(&["my-render"])) {
+ ParsedArgs::Error { prog, .. } => assert_eq!(prog, "my-render"),
+ _ => panic!(),
+ }
+ }
+
+ #[test]
+ fn write_atomic_replaces_existing_file_and_cleans_tempfile() {
+ let tmp = unique_temp_dir("write-atomic");
+ let target = tmp.join("a.txt");
+ std::fs::write(&target, b"old").unwrap();
+
+ write_atomic(&target, b"new").expect("atomic replace");
+ assert_eq!(std::fs::read(&target).unwrap(), b"new");
+ assert!(
+ !tmp.join(".tmp-a.txt").exists(),
+ "tempfile must be renamed away on success",
+ );
+ std::fs::remove_dir_all(&tmp).unwrap();
+ }
+
+ #[test]
+ fn write_atomic_creates_new_file() {
+ let tmp = unique_temp_dir("write-atomic-new");
+ let target = tmp.join("fresh.txt");
+ assert!(!target.exists());
+ write_atomic(&target, b"hello").unwrap();
+ assert_eq!(std::fs::read(&target).unwrap(), b"hello");
+ std::fs::remove_dir_all(&tmp).unwrap();
+ }
+
+ #[test]
+ fn read_artifact_with_limit_rejects_oversize_input() {
+ let tmp = unique_temp_dir("bounded-read");
+ let path = tmp.join("big.json");
+ std::fs::write(&path, vec![0u8; 4 * 1024]).unwrap();
+ let err = read_artifact_with_limit(&path, 1024)
+ .unwrap_err()
+ .to_string();
+ assert!(
+ err.contains("exceeds") && err.contains("byte limit"),
+ "error should explain the cap: {err}",
+ );
+ std::fs::remove_dir_all(&tmp).unwrap();
+ }
+
+ #[test]
+ fn read_artifact_with_limit_accepts_valid_input_under_cap() {
+ let tmp = unique_temp_dir("bounded-read-ok");
+ let path = tmp.join("ok.json");
+ let artifact = BenchmarkArtifact::new(metadata());
+ std::fs::write(&path, serde_json::to_vec(&artifact).unwrap()).unwrap();
+ let parsed =
+ read_artifact_with_limit(&path, MAX_RESULTS_JSON_BYTES).expect("parse under cap");
+ assert_eq!(parsed.schema_version, SCHEMA_VERSION);
+ std::fs::remove_dir_all(&tmp).unwrap();
+ }
+}
diff --git a/bench-support/src/bin/render_docs/markdown.rs b/bench-support/src/bin/render_docs/markdown.rs
new file mode 100644
index 0000000..88c9c9f
--- /dev/null
+++ b/bench-support/src/bin/render_docs/markdown.rs
@@ -0,0 +1,825 @@
+//! ## Architecture
+//! Pure functions that turn a [`BenchmarkArtifact`] into Markdown.
+//! No I/O β `io::run_with_paths` does the writing.
+//!
+//! ## Key Components
+//! - [`generate_markdown`]: top-level entry that the IO orchestrator
+//! calls once per render.
+//! - `write_*_section` helpers: one per case_id family
+//! (hit-rate/throughput/latency pivot, scan resistance, adaptation).
+//! - [`escape_md_cell`] + [`sparkline`]: cross-cutting utilities that
+//! keep table cells well-formed under arbitrary input.
+//! - [`POLICY_GUIDE_MD`]: bundled static content appended verbatim to
+//! every render.
+//!
+//! ## Performance Trade-offs
+//! Uses `BTreeMap`/`BTreeSet` for deterministic iteration. We re-sort
+//! the workload column axis by registry rank so the table reads "easy
+//! β adversarial" instead of alphabetical.
+
+use std::collections::{BTreeMap, BTreeSet};
+use std::fmt::Write as _;
+
+use bench_support::json_results::{BenchmarkArtifact, ResultRow, case_id};
+use bench_support::registry::EXTENDED_WORKLOADS;
+
+use crate::templates::renderer_stamp;
+
+/// Markdown table for the static policy selection guide. Appended
+/// verbatim to every rendered `index.md` after the per-case sections.
+/// Exposed as `pub(crate)` so [`crate::templates`]'s
+/// `templates_contain_no_liquid_or_front_matter` test can scan it for
+/// Jekyll-unsafe tokens alongside the HTML/JS/CSS templates.
+pub(crate) const POLICY_GUIDE_MD: &str = include_str!("../policy_guide.md");
+
+/// Build the rendered Markdown body for `artifact`.
+pub(crate) fn generate_markdown(artifact: &BenchmarkArtifact) -> String {
+ let mut md = String::with_capacity(4 * 1024);
+
+ writeln!(md, "# Benchmark Results\n").unwrap();
+ writeln!(
+ md,
+ "**Quick Links**: [Interactive Charts](charts.html) | [Raw JSON](results.json)\n"
+ )
+ .unwrap();
+ writeln!(md, "---\n").unwrap();
+
+ write_environment(&mut md, artifact);
+ write_configuration(&mut md, artifact);
+
+ let by_case = artifact.results_by_case();
+
+ if let Some(rows) = by_case.get(case_id::HIT_RATE) {
+ write_pivot_section(
+ &mut md,
+ "Hit Rate Comparison",
+ rows,
+ |r| r.metrics.hit_stats.as_ref().map(|s| s.hit_rate),
+ |v| format!("{:.2}%", v * 100.0),
+ );
+ }
+
+ if let Some(rows) = by_case.get(case_id::COMPREHENSIVE) {
+ write_pivot_section(
+ &mut md,
+ "Throughput (Million ops/sec)",
+ rows,
+ |r| {
+ r.metrics
+ .throughput
+ .as_ref()
+ .map(|t| t.ops_per_sec / 1_000_000.0)
+ },
+ |v| format!("{v:.2}"),
+ );
+ write_pivot_section(
+ &mut md,
+ "Latency P99 (nanoseconds)",
+ rows,
+ |r| r.metrics.latency.as_ref().map(|l| l.p99_ns),
+ |v| v.to_string(),
+ );
+ }
+
+ if let Some(rows) = by_case.get(case_id::SCAN_RESISTANCE) {
+ write_scan_resistance_section(&mut md, rows);
+ }
+
+ if let Some(rows) = by_case.get(case_id::ADAPTATION) {
+ write_adaptation_section(&mut md, rows);
+ }
+
+ writeln!(md, "## Policy Selection Guide\n").unwrap();
+ md.push_str(POLICY_GUIDE_MD);
+ if !POLICY_GUIDE_MD.ends_with('\n') {
+ md.push('\n');
+ }
+ md.push('\n');
+
+ writeln!(md, "---\n").unwrap();
+ writeln!(
+ md,
+ "*Generated by `{}` from `results.json` (schema v{}).*",
+ renderer_stamp(),
+ artifact.schema_version
+ )
+ .unwrap();
+
+ md
+}
+
+fn write_environment(md: &mut String, artifact: &BenchmarkArtifact) {
+ let m = &artifact.metadata;
+ writeln!(md, "## Environment\n").unwrap();
+ writeln!(md, "- **Date**: {}", m.timestamp).unwrap();
+ if let Some(commit) = &m.git_commit {
+ writeln!(md, "- **Commit**: `{commit}`").unwrap();
+ }
+ if let Some(branch) = &m.git_branch {
+ writeln!(md, "- **Branch**: `{branch}`").unwrap();
+ }
+ writeln!(md, "- **Dirty**: {}", m.git_dirty).unwrap();
+ writeln!(md, "- **Rustc**: {}", m.rustc_version).unwrap();
+ writeln!(md, "- **Host**: {}", m.host_triple).unwrap();
+ if let Some(cpu) = &m.cpu_model {
+ writeln!(md, "- **CPU**: {cpu}").unwrap();
+ }
+ md.push('\n');
+}
+
+fn write_configuration(md: &mut String, artifact: &BenchmarkArtifact) {
+ let c = &artifact.metadata.config;
+ writeln!(md, "## Configuration\n").unwrap();
+ writeln!(md, "- **Capacity**: {}", c.capacity).unwrap();
+ writeln!(md, "- **Universe**: {}", c.universe).unwrap();
+ writeln!(md, "- **Operations**: {}", c.operations).unwrap();
+ writeln!(md, "- **Seed**: {}", c.seed).unwrap();
+ md.push('\n');
+}
+
+/// Pivots `rows` into a policy Γ workload matrix and emits a Markdown
+/// table.
+///
+/// `extract` returns `Some(value)` for rows that contribute to this
+/// metric. Rows for which it returns `None` are skipped entirely (they
+/// do not even contribute their workload column). Duplicate
+/// `(policy, workload)` pairs emit a stderr warning and keep the first
+/// occurrence.
+fn write_pivot_section(
+ md: &mut String,
+ title: &str,
+ rows: &[&ResultRow],
+ extract: E,
+ fmt_cell: F,
+) where
+ E: Fn(&ResultRow) -> Option,
+ F: Fn(&V) -> String,
+{
+ writeln!(md, "## {title}\n").unwrap();
+
+ let mut by_policy: BTreeMap<&str, BTreeMap<&str, V>> = BTreeMap::new();
+ let mut workload_set: BTreeSet<&str> = BTreeSet::new();
+
+ for row in rows {
+ let Some(value) = extract(row) else { continue };
+ workload_set.insert(row.workload_name.as_str());
+ let cell = by_policy
+ .entry(row.policy_name.as_str())
+ .or_default()
+ .entry(row.workload_name.as_str());
+ match cell {
+ std::collections::btree_map::Entry::Vacant(v) => {
+ v.insert(value);
+ },
+ std::collections::btree_map::Entry::Occupied(_) => {
+ eprintln!(
+ "warning: duplicate ({}, {}) in section {title:?}; keeping first",
+ row.policy_name, row.workload_name
+ );
+ },
+ }
+ }
+
+ if by_policy.is_empty() {
+ writeln!(md, "_No data._\n").unwrap();
+ return;
+ }
+
+ let workloads = sort_workloads_by_registry(&workload_set);
+
+ write!(md, "| Policy |").unwrap();
+ for w in &workloads {
+ write!(md, " {} |", escape_md_cell(w)).unwrap();
+ }
+ md.push('\n');
+
+ write!(md, "|--------|").unwrap();
+ for _ in &workloads {
+ write!(md, "-------:|").unwrap();
+ }
+ md.push('\n');
+
+ for (policy, cells) in &by_policy {
+ write!(md, "| **{}** |", escape_md_cell(policy)).unwrap();
+ for w in &workloads {
+ match cells.get(w) {
+ Some(v) => write!(md, " {} |", fmt_cell(v)).unwrap(),
+ None => md.push_str(" - |"),
+ }
+ }
+ md.push('\n');
+ }
+ md.push('\n');
+}
+
+/// Order observed workload names by their position in
+/// [`EXTENDED_WORKLOADS`], falling back to alphabetical for unknown
+/// names.
+///
+/// The registry suites are intentionally ordered "easy β adversarial"
+/// (Uniform β HotSet β Zipfian β Scan β ScanResistance β FlashCrowd),
+/// so reading a pivot table row left-to-right matches how a reader
+/// builds intuition. Plain `BTreeSet` ordering is alphabetical, which
+/// puts Scan ahead of Uniform β pedagogically backwards.
+///
+/// Unknown workloads (registry mismatch, hand-edited artifact) sort
+/// after known ones, alphabetically among themselves, so the rendered
+/// table is always deterministic regardless of input.
+fn sort_workloads_by_registry<'a>(observed: &BTreeSet<&'a str>) -> Vec<&'a str> {
+ let mut sorted: Vec<&'a str> = observed.iter().copied().collect();
+ sorted.sort_by(|a, b| {
+ let ra = workload_display_rank(a).unwrap_or(usize::MAX);
+ let rb = workload_display_rank(b).unwrap_or(usize::MAX);
+ ra.cmp(&rb).then_with(|| a.cmp(b))
+ });
+ sorted
+}
+
+/// Position of `display_name` in [`EXTENDED_WORKLOADS`], or `None` if
+/// the registry doesn't recognise it. Linear scan is fine β the
+/// registry is ~16 entries and this runs once per pivot section.
+fn workload_display_rank(display_name: &str) -> Option {
+ EXTENDED_WORKLOADS
+ .iter()
+ .position(|w| w.display_name == display_name)
+}
+
+fn write_scan_resistance_section(md: &mut String, rows: &[&ResultRow]) {
+ writeln!(md, "## Scan Resistance\n").unwrap();
+
+ let mut sorted: Vec<&ResultRow> = rows
+ .iter()
+ .copied()
+ .filter(|r| r.metrics.scan_resistance.is_some())
+ .collect();
+ if sorted.is_empty() {
+ writeln!(md, "_No data._\n").unwrap();
+ return;
+ }
+ sorted.sort_unstable_by(|a, b| a.policy_name.cmp(&b.policy_name));
+
+ md.push_str("| Policy | Baseline | During Scan | Recovery | Score |\n");
+ md.push_str("|--------|---------:|------------:|---------:|------:|\n");
+
+ for row in sorted {
+ let Some(s) = row.metrics.scan_resistance.as_ref() else {
+ // Defensive: `sorted` was filtered above so this branch is
+ // dead today, but `let-else` survives a future filter
+ // rewrite without turning into a panic at render time.
+ continue;
+ };
+ let score = match s.resistance_score {
+ Some(v) => format!("{v:.3}"),
+ None => "n/a".to_string(),
+ };
+ writeln!(
+ md,
+ "| **{}** | {:.2}% | {:.2}% | {:.2}% | {} |",
+ escape_md_cell(&row.policy_name),
+ s.baseline_hit_rate * 100.0,
+ s.scan_hit_rate * 100.0,
+ s.recovery_hit_rate * 100.0,
+ score,
+ )
+ .unwrap();
+ }
+ writeln!(
+ md,
+ "\n*Score = Recovery/Baseline (1.0 = perfect recovery, n/a = baseline too low to compare)*\n"
+ )
+ .unwrap();
+}
+
+fn write_adaptation_section(md: &mut String, rows: &[&ResultRow]) {
+ writeln!(md, "## Adaptation Speed\n").unwrap();
+
+ let mut sorted: Vec<&ResultRow> = rows
+ .iter()
+ .copied()
+ .filter(|r| r.metrics.adaptation.is_some())
+ .collect();
+ if sorted.is_empty() {
+ writeln!(md, "_No data._\n").unwrap();
+ return;
+ }
+ sorted.sort_unstable_by(|a, b| a.policy_name.cmp(&b.policy_name));
+
+ let any_curve = sorted.iter().any(|r| {
+ r.metrics
+ .adaptation
+ .as_ref()
+ .is_some_and(|a| !a.hit_rate_curve.is_empty())
+ });
+
+ if any_curve {
+ md.push_str("| Policy | Stable Hit Rate | Ops to 50% | Ops to 80% | Curve |\n");
+ md.push_str("|--------|----------------:|-----------:|-----------:|:------|\n");
+ } else {
+ md.push_str("| Policy | Stable Hit Rate | Ops to 50% | Ops to 80% |\n");
+ md.push_str("|--------|----------------:|-----------:|-----------:|\n");
+ }
+
+ for row in &sorted {
+ let Some(a) = row.metrics.adaptation.as_ref() else {
+ continue;
+ };
+ if any_curve {
+ writeln!(
+ md,
+ "| **{}** | {:.2}% | {} | {} | `{}` |",
+ escape_md_cell(&row.policy_name),
+ a.stable_hit_rate * 100.0,
+ a.ops_to_50_percent,
+ a.ops_to_80_percent,
+ sparkline(&a.hit_rate_curve),
+ )
+ .unwrap();
+ } else {
+ writeln!(
+ md,
+ "| **{}** | {:.2}% | {} | {} |",
+ escape_md_cell(&row.policy_name),
+ a.stable_hit_rate * 100.0,
+ a.ops_to_50_percent,
+ a.ops_to_80_percent,
+ )
+ .unwrap();
+ }
+ }
+
+ if any_curve {
+ let sample = sorted.iter().find_map(|r| {
+ let a = r.metrics.adaptation.as_ref()?;
+ (a.window_size > 0).then_some((a.window_size, a.hit_rate_curve.len()))
+ });
+ let curve_note = match sample {
+ Some((window, len)) if len > 0 => format!(
+ " Curve = per-window hit rate after the workload shift, low β high (`β` β 0%, `β` β 100%); each cell is {window} ops, total {} ops measured.",
+ window * len,
+ ),
+ _ => " Curve = per-window hit rate after the workload shift, low β high (`β` β 0%, `β` β 100%).".into(),
+ };
+ writeln!(
+ md,
+ "\n*Lower ops-to-X% is better (faster adaptation).{curve_note}*\n",
+ )
+ .unwrap();
+ } else {
+ writeln!(md, "\n*Lower ops-to-X% is better (faster adaptation)*\n").unwrap();
+ }
+}
+
+/// Escape a string for safe interpolation inside a Markdown table
+/// cell.
+///
+/// `policy_name` and `workload_name` are technically registry-controlled
+/// today, but `workload_name` round-trips through the JSON artifact
+/// and could carry hand-edited or future-generated values containing
+/// pipes, backslashes, or backticks β any of which silently misalign
+/// the table or break the bold/code formatting. Escape them here so
+/// the table structure is invariant under arbitrary input strings.
+fn escape_md_cell(s: &str) -> String {
+ let mut out = String::with_capacity(s.len());
+ for c in s.chars() {
+ match c {
+ '\\' => out.push_str("\\\\"),
+ '|' => out.push_str("\\|"),
+ '`' => out.push_str("\\`"),
+ // CR/LF inside a table cell terminate the row; collapse to space.
+ '\n' | '\r' => out.push(' '),
+ other => out.push(other),
+ }
+ }
+ out
+}
+
+/// Render a hit-rate curve as a Unicode block sparkline. Each cell
+/// maps `[0.0, 1.0]` to one of eight block heights so a long curve
+/// still fits in a Markdown table cell.
+///
+/// `NaN` is rendered as `?` rather than silently bottoming out at the
+/// lowest block β a corrupt or division-by-zero curve should be
+/// visually distinct from a genuine zero hit rate. (Without this
+/// branch, NaN passes through `clamp` unchanged and `as usize`
+/// saturates to 0, producing an indistinguishable `β`.)
+fn sparkline(values: &[f64]) -> String {
+ const BLOCKS: [char; 8] = ['β', 'β', 'β', 'β', 'β
', 'β', 'β', 'β'];
+ if values.is_empty() {
+ return String::new();
+ }
+ values
+ .iter()
+ .map(|&v| {
+ if v.is_nan() {
+ return '?';
+ }
+ let clamped = v.clamp(0.0, 1.0);
+ let idx = (clamped * (BLOCKS.len() as f64 - 1.0)).round() as usize;
+ BLOCKS[idx.min(BLOCKS.len() - 1)]
+ })
+ .collect()
+}
+
+// ============================================================================
+// Tests
+// ============================================================================
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use bench_support::json_results::{
+ AdaptationStats, LatencyStats, Metrics, ScanResistanceStats, ThroughputStats,
+ };
+
+ use crate::test_helpers::{empty_metrics, hit_metrics, metadata, row};
+
+ #[test]
+ fn pivot_section_renders_sorted_rows_and_columns() {
+ let rows = [
+ row("LRU", "Zipf", "hit_rate", hit_metrics(0.42)),
+ row("LFU", "Uniform", "hit_rate", hit_metrics(0.15)),
+ row("LFU", "Zipf", "hit_rate", hit_metrics(0.51)),
+ ];
+ let refs: Vec<&ResultRow> = rows.iter().collect();
+ let mut md = String::new();
+ write_pivot_section(
+ &mut md,
+ "Hit Rate Comparison",
+ &refs,
+ |r| r.metrics.hit_stats.as_ref().map(|s| s.hit_rate),
+ |v| format!("{:.2}%", v * 100.0),
+ );
+
+ // "Uniform" is in the registry (rank 0); "Zipf" is unknown
+ // and sorts to the right.
+ assert!(md.contains("| Policy | Uniform | Zipf |"));
+ let lfu = md.find("| **LFU** |").expect("LFU row present");
+ let lru = md.find("| **LRU** |").expect("LRU row present");
+ assert!(lfu < lru, "LFU should sort before LRU");
+ assert!(md.contains("| **LRU** | - | 42.00% |"));
+ assert!(md.contains("| **LFU** | 15.00% | 51.00% |"));
+ }
+
+ #[test]
+ fn pivot_section_orders_columns_by_registry_rank_not_alphabetical() {
+ // Alphabetical order would be: HotSet 90/10 | Scan | Uniform | Zipfian 1.0.
+ // Registry order is: Uniform | HotSet 90/10 | Scan | Zipfian 1.0
+ // (easy β skewed β adversarial), which is how the registry
+ // suite is intentionally laid out.
+ let rows = [
+ row("LRU", "Scan", "hit_rate", hit_metrics(0.05)),
+ row("LRU", "Uniform", "hit_rate", hit_metrics(0.10)),
+ row("LRU", "Zipfian 1.0", "hit_rate", hit_metrics(0.42)),
+ row("LRU", "HotSet 90/10", "hit_rate", hit_metrics(0.85)),
+ ];
+ let refs: Vec<&ResultRow> = rows.iter().collect();
+ let mut md = String::new();
+ write_pivot_section(
+ &mut md,
+ "Hit Rate Comparison",
+ &refs,
+ |r| r.metrics.hit_stats.as_ref().map(|s| s.hit_rate),
+ |v| format!("{:.2}%", v * 100.0),
+ );
+ assert!(
+ md.contains("| Policy | Uniform | HotSet 90/10 | Scan | Zipfian 1.0 |"),
+ "expected registry-ordered columns, got:\n{md}",
+ );
+ }
+
+ #[test]
+ fn pivot_section_unknown_workloads_sort_after_known_alphabetically() {
+ let rows = [
+ row("LRU", "zzz_unknown", "hit_rate", hit_metrics(0.10)),
+ row("LRU", "Uniform", "hit_rate", hit_metrics(0.20)),
+ row("LRU", "aaa_unknown", "hit_rate", hit_metrics(0.30)),
+ ];
+ let refs: Vec<&ResultRow> = rows.iter().collect();
+ let mut md = String::new();
+ write_pivot_section(
+ &mut md,
+ "Hit Rate Comparison",
+ &refs,
+ |r| r.metrics.hit_stats.as_ref().map(|s| s.hit_rate),
+ |v| format!("{:.2}%", v * 100.0),
+ );
+ assert!(
+ md.contains("| Policy | Uniform | aaa_unknown | zzz_unknown |"),
+ "expected known-then-alphabetical order, got:\n{md}",
+ );
+ }
+
+ #[test]
+ fn workload_display_rank_lookup() {
+ // Pin a couple of registry positions so a future shuffle of
+ // EXTENDED_WORKLOADS that breaks the "easy β adversarial"
+ // story fails this test.
+ assert_eq!(workload_display_rank("Uniform"), Some(0));
+ assert!(
+ workload_display_rank("Uniform") < workload_display_rank("Scan"),
+ "Uniform must rank before Scan in the registry",
+ );
+ assert!(
+ workload_display_rank("Scan") < workload_display_rank("Flash Crowd"),
+ "Scan must rank before Flash Crowd in the registry",
+ );
+ assert_eq!(workload_display_rank("not-a-real-workload"), None);
+ }
+
+ #[test]
+ fn pivot_section_empty_emits_no_data_marker() {
+ let mut md = String::new();
+ write_pivot_section(
+ &mut md,
+ "Hit Rate Comparison",
+ &[],
+ |r: &ResultRow| r.metrics.hit_stats.as_ref().map(|s| s.hit_rate),
+ |v| format!("{v}"),
+ );
+ assert!(md.contains("_No data._"));
+ assert!(!md.contains("| Policy |"));
+ }
+
+ #[test]
+ fn scan_resistance_section_skips_rows_without_metric() {
+ let rows = [
+ row("LRU", "Scan", "scan_resistance", empty_metrics()),
+ row(
+ "S3-FIFO",
+ "Scan",
+ "scan_resistance",
+ Metrics {
+ scan_resistance: Some(ScanResistanceStats {
+ baseline_hit_rate: 0.80,
+ scan_hit_rate: 0.10,
+ recovery_hit_rate: 0.78,
+ resistance_score: Some(0.975),
+ }),
+ ..empty_metrics()
+ },
+ ),
+ ];
+ let refs: Vec<&ResultRow> = rows.iter().collect();
+ let mut md = String::new();
+ write_scan_resistance_section(&mut md, &refs);
+ assert!(md.contains("**S3-FIFO**"));
+ assert!(!md.contains("**LRU**"));
+ assert!(md.contains("0.975"));
+ }
+
+ #[test]
+ fn scan_resistance_section_renders_n_a_for_missing_score() {
+ let rows = [row(
+ "LIFO",
+ "Scan",
+ "scan_resistance",
+ Metrics {
+ scan_resistance: Some(ScanResistanceStats {
+ baseline_hit_rate: 0.001,
+ scan_hit_rate: 0.0,
+ recovery_hit_rate: 0.001,
+ resistance_score: None,
+ }),
+ ..empty_metrics()
+ },
+ )];
+ let refs: Vec<&ResultRow> = rows.iter().collect();
+ let mut md = String::new();
+ write_scan_resistance_section(&mut md, &refs);
+ assert!(md.contains("| **LIFO** |"));
+ assert!(
+ md.contains(" n/a |"),
+ "expected n/a placeholder, got:\n{md}"
+ );
+ }
+
+ #[test]
+ fn adaptation_section_renders_counts() {
+ let rows = [row(
+ "LRU",
+ "Shift",
+ "adaptation",
+ Metrics {
+ adaptation: Some(AdaptationStats {
+ stable_hit_rate: 0.62,
+ ops_to_50_percent: 1234,
+ ops_to_80_percent: 9999,
+ hit_rate_curve: Vec::new(),
+ window_size: 0,
+ }),
+ ..empty_metrics()
+ },
+ )];
+ let refs: Vec<&ResultRow> = rows.iter().collect();
+ let mut md = String::new();
+ write_adaptation_section(&mut md, &refs);
+ assert!(md.contains("| **LRU** | 62.00% | 1234 | 9999 |"));
+ }
+
+ #[test]
+ fn adaptation_section_renders_sparkline_when_curve_present() {
+ let rows = [
+ row(
+ "LRU",
+ "Shift",
+ "adaptation",
+ Metrics {
+ adaptation: Some(AdaptationStats {
+ stable_hit_rate: 0.85,
+ ops_to_50_percent: 500,
+ ops_to_80_percent: 1500,
+ hit_rate_curve: vec![0.0, 0.25, 0.5, 0.75, 1.0],
+ window_size: 256,
+ }),
+ ..empty_metrics()
+ },
+ ),
+ row(
+ "FIFO",
+ "Shift",
+ "adaptation",
+ Metrics {
+ adaptation: Some(AdaptationStats {
+ stable_hit_rate: 0.4,
+ ops_to_50_percent: 800,
+ ops_to_80_percent: 4000,
+ hit_rate_curve: Vec::new(),
+ window_size: 0,
+ }),
+ ..empty_metrics()
+ },
+ ),
+ ];
+ let refs: Vec<&ResultRow> = rows.iter().collect();
+ let mut md = String::new();
+ write_adaptation_section(&mut md, &refs);
+
+ assert!(
+ md.contains("| Curve |"),
+ "expected Curve column header, got:\n{md}",
+ );
+ assert!(
+ md.contains("`βββ
ββ`"),
+ "expected sparkline for LRU, got:\n{md}",
+ );
+ assert!(
+ md.contains("4000 | `` |"),
+ "expected empty sparkline cell for FIFO, got:\n{md}",
+ );
+ assert!(
+ md.contains("each cell is 256 ops"),
+ "expected window-size note, got:\n{md}",
+ );
+ }
+
+ #[test]
+ fn sparkline_maps_extremes_and_midpoints() {
+ assert_eq!(sparkline(&[]), "");
+ assert_eq!(sparkline(&[0.0, 1.0]), "ββ");
+ assert_eq!(sparkline(&[-0.5, 1.5]), "ββ");
+ assert_eq!(sparkline(&[0.5]).chars().count(), 1);
+ }
+
+ #[test]
+ fn sparkline_marks_nan_with_question_mark() {
+ let s = sparkline(&[f64::NAN, 0.0, f64::NAN, 1.0]);
+ assert_eq!(s, "?β?β");
+ assert!(s.contains('?'), "NaN must render as `?`, got {s:?}");
+ }
+
+ #[test]
+ fn pivot_section_keeps_first_on_duplicate_pair() {
+ let rows = [
+ row("LRU", "Zipf", "hit_rate", hit_metrics(0.10)),
+ row("LRU", "Zipf", "hit_rate", hit_metrics(0.99)),
+ ];
+ let refs: Vec<&ResultRow> = rows.iter().collect();
+ let mut md = String::new();
+ write_pivot_section(
+ &mut md,
+ "Hit Rate Comparison",
+ &refs,
+ |r| r.metrics.hit_stats.as_ref().map(|s| s.hit_rate),
+ |v| format!("{:.2}%", v * 100.0),
+ );
+ assert!(
+ md.contains("| **LRU** | 10.00% |"),
+ "expected first-wins (10.00%), got:\n{md}"
+ );
+ assert!(!md.contains("99.00%"));
+ }
+
+ #[test]
+ fn escape_md_cell_is_idempotent_for_safe_input() {
+ for s in ["LRU", "Zipf-1.0", "hit_rate", "S3-FIFO", ""] {
+ assert_eq!(escape_md_cell(s), s, "unexpected escape on {s:?}");
+ }
+ }
+
+ #[test]
+ fn escape_md_cell_neutralises_table_breaking_chars() {
+ assert_eq!(escape_md_cell("a|b"), "a\\|b");
+ assert_eq!(escape_md_cell("a\\b"), "a\\\\b");
+ assert_eq!(escape_md_cell("a`b"), "a\\`b");
+ assert_eq!(escape_md_cell("a\nb\rc"), "a b c");
+ }
+
+ #[test]
+ fn pivot_section_escapes_pipe_in_workload_and_policy_names() {
+ let mut artifact = BenchmarkArtifact::new(metadata());
+ artifact.add_result(row("a|b", "x|y", "hit_rate", hit_metrics(0.5)));
+ let md = generate_markdown(&artifact);
+ assert!(md.contains("**a\\|b**"), "policy pipe not escaped:\n{md}");
+ assert!(md.contains("x\\|y"), "workload pipe not escaped:\n{md}");
+ assert!(
+ !md.lines()
+ .any(|l| { l.starts_with("| **a|b**") || l.contains("| x|y |") }),
+ "raw unescaped pipe leaked into a table cell:\n{md}",
+ );
+ }
+
+ #[test]
+ fn generate_markdown_smoke() {
+ let mut artifact = BenchmarkArtifact::new(metadata());
+ artifact.add_result(row("LRU", "Zipf", "hit_rate", hit_metrics(0.5)));
+ artifact.add_result(row(
+ "LRU",
+ "Zipf",
+ "comprehensive",
+ Metrics {
+ throughput: Some(ThroughputStats {
+ duration_ms: 100.0,
+ ops_per_sec: 5_000_000.0,
+ gets_per_sec: 4_000_000.0,
+ inserts_per_sec: 1_000_000.0,
+ }),
+ latency: Some(LatencyStats {
+ sample_count: 1000,
+ min_ns: 10,
+ p50_ns: 50,
+ p95_ns: 200,
+ p99_ns: 500,
+ max_ns: 9999,
+ mean_ns: 75,
+ }),
+ ..empty_metrics()
+ },
+ ));
+ let md = generate_markdown(&artifact);
+ assert!(md.contains("# Benchmark Results"));
+ assert!(md.contains("## Hit Rate Comparison"));
+ assert!(md.contains("## Throughput (Million ops/sec)"));
+ assert!(md.contains("## Latency P99 (nanoseconds)"));
+ assert!(md.contains("5.00")); // 5M ops/sec
+ assert!(md.contains("500")); // p99
+ assert!(md.contains("schema v"));
+ }
+
+ #[test]
+ fn generate_markdown_handles_completely_empty_artifact() {
+ let artifact = BenchmarkArtifact::new(metadata());
+ let md = generate_markdown(&artifact);
+ assert!(md.contains("# Benchmark Results"));
+ assert!(md.contains("## Environment"));
+ assert!(md.contains("## Configuration"));
+ assert!(md.contains("## Policy Selection Guide"));
+ assert!(md.contains("schema v"));
+ assert!(!md.contains("## Hit Rate Comparison"));
+ assert!(!md.contains("## Scan Resistance"));
+ assert!(!md.contains("## Adaptation Speed"));
+ }
+
+ #[test]
+ fn generate_markdown_emits_no_data_when_rows_lack_metrics() {
+ let mut artifact = BenchmarkArtifact::new(metadata());
+ artifact.add_result(row("LRU", "Zipf", "hit_rate", empty_metrics()));
+ artifact.add_result(row("LRU", "Scan", "scan_resistance", empty_metrics()));
+ artifact.add_result(row("LRU", "Shift", "adaptation", empty_metrics()));
+ let md = generate_markdown(&artifact);
+ assert!(md.contains("## Hit Rate Comparison"));
+ assert!(md.contains("## Scan Resistance"));
+ assert!(md.contains("## Adaptation Speed"));
+ assert!(
+ md.matches("_No data._").count() >= 3,
+ "expected β₯3 `_No data._` markers (one per empty section), got:\n{md}",
+ );
+ }
+
+ #[test]
+ fn policy_guide_has_no_top_level_h1() {
+ // POLICY_GUIDE_MD is appended verbatim into index.md *after*
+ // the renderer's own `# CacheKit Benchmark Results` H1. A
+ // second top-level H1 in the guide would render two competing
+ // page titles in Jekyll's default theme and confuse static
+ // analyzers (TOC builders, GitHub's anchor algorithm). All
+ // headings inside the guide must be H2 or deeper.
+ for (idx, line) in POLICY_GUIDE_MD.lines().enumerate() {
+ assert!(
+ !line.starts_with("# "),
+ "policy_guide.md line {} starts a top-level H1: {line:?}; \
+ use `## ` or deeper instead",
+ idx + 1,
+ );
+ }
+ }
+}
diff --git a/bench-support/src/bin/render_docs/templates.rs b/bench-support/src/bin/render_docs/templates.rs
new file mode 100644
index 0000000..4dd9ec7
--- /dev/null
+++ b/bench-support/src/bin/render_docs/templates.rs
@@ -0,0 +1,992 @@
+//! ## Architecture
+//! Owns the on-disk asset templates (`charts_template.{html,js,css}`),
+//! their sentinels, the substitution engine that turns each template
+//! into a rendered string, and the build-time-derived metadata
+//! (schema version + renderer stamp) that gets woven in.
+//!
+//! ## Key Components
+//! - [`CHARTS_HTML`] / [`CHARTS_JS`] / [`CHARTS_CSS`]: bundled assets,
+//! loaded via `include_str!` so a stale checkout can't ship a
+//! surprise.
+//! - [`Substitution`] + [`apply_substitutions`]: count-checked
+//! sentinel replacement so a hand-edit that drops or duplicates a
+//! token is a render-time error, not a silent breakage.
+//! - [`render_charts_html`] / [`render_charts_js`]: the only two
+//! entry points the IO orchestrator calls.
+//! - [`renderer_stamp`]: provenance string woven into the rendered
+//! HTML's `` and the markdown footer.
+//!
+//! ## Performance Trade-offs
+//! Substitution does `String::replace` per sentinel (O(N) over the
+//! template). N is ~5 KiB and substitutions run once per render, so
+//! a streaming replacement is not worth the extra moving parts.
+//!
+//! ## Thread Safety
+//! All public functions are pure; the constants are `&'static str`.
+
+use std::error::Error;
+use std::fmt::Write as _;
+
+use bench_support::json_results::SCHEMA_VERSION;
+use bench_support::registry::PolicyMeta;
+
+// ============================================================================
+// Bundled assets
+// ============================================================================
+
+/// HTML shell for the charts page. Carries sentinels for the Chart.js
+/// version + SRI hash (substituted from [`CHART_JS_VERSION`] /
+/// [`CHART_JS_SRI`]) so a Chart.js bump only touches Rust constants.
+/// Runs under a strict CSP (no `unsafe-eval`, no `unsafe-inline`); the
+/// only inline content is the tiny `no-js` remover, whose body
+/// ([`NO_JS_REMOVER_BODY`]) is substituted into the template and whose
+/// CSP hash-source is computed at render time by
+/// [`compute_no_js_remover_hash`] β so the two cannot drift.
+pub(crate) const CHARTS_HTML: &str = include_str!("../charts_template.html");
+
+/// Behavior for the charts page; sibling script of [`CHARTS_HTML`].
+/// Carries sentinels for the policy color map and the expected schema
+/// major (substituted from [`bench_support::registry::POLICIES`] and
+/// [`SCHEMA_VERSION`]) so the JS can never drift from the Rust schema.
+pub(crate) const CHARTS_JS: &str = include_str!("../charts_template.js");
+
+/// Presentation for the charts page; sibling stylesheet of
+/// [`CHARTS_HTML`]. Hosts the `.no-js #loading` and `.hidden` rules
+/// that let the page run under `style-src 'self'` (no inline `