Эх сурвалжийг харах

add sm121 report: recipe bench report table with json support

- # Tested recipes (72)

| Model | Quant | Recipe | Engine | Nodes×TP | Reports | Last bench | Points | Score /100 |
|---|---|---|---|---|---|---|---|---|
|  | — | dsv4-vision-aiden-373 | vllm | 2×2 | 1 | 2026-09-07 00:24 | — | 91 |
| Intel/GLM-5.3-Flash-W4A16-AutoRound | W4A16 | glm53-flash-intel-w4a16 | vllm | 2×2 | 0 | — | — | — |
| LibertAIDAI/GLM-5.3-Flash-NVFP4 | NVFP4 | glm53-flash-aiden-2x | vllm | 2×2 | 3 | 2026-09-07 | 159/176 | 90.4 |
| LibertAIDAI/GLM-5.3-Flash-NVFP4 | NVFP4 | glm53-flash-nvfp4 | vllm | 2×2 | 0 | — | — | — |
| Mapika/GLM-5.2-NVFP4 | NVFP4 | glm52 | vllm | 4×4 | 0 | — | — | — |
| Mapika/MiniMax-M3-NVFP4 | NVFP4 | minimax-m3 | sglang | 4×4 | 0 | — | — | — |
| Mapika/MiniMax-M3-NVFP4 | NVFP4 | minimax-m3-nvfp4 | vllm | 4×4 | 0 | — | — | — |
| MiniMaxAI/MiniMax-M3-MXFP8 | MXFP8 | minimax-m3-mxfp8 | vllm | 4×4 | 0 | — | — | — |
| Pilcothink/Qwen3.8-27B-MixedInt4-AutoRound | INT4 | qwen38-27b-int4 | vllm | 1×1 | 0 | — | — | — |
| QuantTrio/GLM-5.2-Int4-Int8Mix | INT8 | glm52-int4-int8 | vllm | 4×4 | 0 | — | — | — |
| Qwen/Qwen-AgentWorld-35B-A3B | — | qwen-agentworld-35b | vllm | 1×1 | 0 | — | — | — |
| Qwen/Qwen3.8-27B | — | qwen38-27b | vllm | 1×1 | 0 | — | — | — |
| Qwen/Qwen3.8-27B-FP8 | FP8 | qwen38-27b-fp8 | vllm | 1×1 | 0 | — | — | — |
| Qwen/Qwen3.8-27B-FP8 | FP8 | qwen38-27b-fp8-dspark | vllm | 1×1 | 0 | — | — | — |
| Qwen/Qwen3.8-Flash-Next-FP8 | FP8 | qwen38-flash-next-fp8 | vllm | 2×2 | 0 | — | — | — |
| RadixArk/Qwen3.8-27B-NVFP4 | NVFP4 | qwen38-27b-nvfp4-sglang-dflash2 | sglang | 1×1 | 0 | — | — | — |
| RadixArk/Qwen3.8-27B-NVFP4 | NVFP4 | qwen38-27b-nvfp4-sglang-dspark | sglang | 1×1 | 0 | — | — | — |
| RadixArk/Qwen3.8-Flash-Next-NVFP4 | NVFP4 | qwen38-flash-next-nvfp4 | sglang | 2×2 | 0 | — | — | — |
| RedHatAI/GLM-5.3-Flash-NVFP4 | NVFP4 | glm53-flash-nvfp4-redhat | vllm | 2×2 | 1 | 2026-09-06 23:58 | — | 89 |
| RedHatAI/Qwen3.8-27B-NVFP4 | NVFP4 | qwen38-27b-nvfp4 | vllm | 1×1 | 0 | — | — | — |
| aidendle94/GLM-5.2-Hybrid-FP8-MXFP4 | MXFP4 | glm52-hybrid-fp8-mxfp4 | vllm | 4×4 | 0 | — | — | — |
| aidendle94/GLM-5.2-MXFP4-Experts-GPTQ | MXFP4 | glm52-mxfp4-experts-gptq | vllm | 4×4 | 0 | — | — | — |
| aidendle94/GLM-5.2-MXFP4-Experts-GPTQ | MXFP4 | glm52-nf4-lloyd-experts-gptq | vllm | 4×4 | 0 | — | — | — |
| brandonmusic/GLM-5.3-Flash-tr3-4bpw | EXL3 | glm53-flash-exl3 | vllm | 2×2 | 0 | — | — | — |
| deepseek-ai/DeepSeek-V4-Flash-0731 | — | deepseek-v4-flash | vllm | 2×2 | 0 | — | — | — |
| deepseek-ai/DeepSeek-V4-Flash-0731 | — | deepseek-v4-flash-dspark38 | vllm | 2×2 | 0 | — | — | — |
| deepseek-ai/DeepSeek-V4-Flash-0731 | — | deepseek-v4-flash-eugr | vllm | 2×2 | 0 | — | — | — |
| deepseek-ai/DeepSeek-V4-Flash-0731 | — | deepseek-v4-flash-eugr-agent | vllm | 2×2 | 0 | — | — | — |
| deepseek-ai/DeepSeek-V4-Flash-0731 | — | deepseek-v4-flash-src | vllm | 2×2 | 0 | — | — | — |
| deepseek-ai/DeepSeek-V4-Flash-Vision-Exp | — | dsv4-flash-vision | vllm | 2×2 | 0 | — | — | — |
| deepseek-ai/DeepSeek-V4-Flash-Vision-Exp | — | dsv4-flash-vision-eugr | vllm | 2×2 | 0 | — | — | — |
| deepseek-ai/DeepSeek-V4-Flash-Vision-Exp | — | dsv4-vision-aiden-2x | vllm | 2×2 | 0 | — | — | — |
| deepseek-ai/DeepSeek-V4-Flash-Vision-Exp | — | dsv4-vision-aiden-4x | vllm | 4×4 | 0 | — | — | — |
| deepseek-ai/DeepSeek-V4-Flash-Vision-Exp | — | dsv4-vision-exp-vavrusa | vllm | 2×2 | 0 | — | — | — |
| google/gemma-4-12B-it | — | gemma4-12b | vllm | 1×1 | 0 | — | — | — |
| local-inference-lab/GLM-5.3-Flash-NVFP4 | NVFP4 | glm53-flash-aiden-4x-labdrafter | vllm | 4×4 | 0 | — | — | — |
| local-inference-lab/GLM-5.3-Flash-NVFP4 | NVFP4 | glm53-flash-dflash2-mxfp8 | vllm | 2×2 | 0 | — | — | — |
| local-inference-lab/GLM-5.3-Flash-NVFP4 | NVFP4 | glm53-flash-nvfp4-lab | vllm | 2×2 | 0 | — | — | — |
| local-inference-lab/GLM-5.3-Flash-NVFP4 | NVFP4 | glm53-flash-nvfp4-lab-0rand | vllm | 2×2 | 0 | — | — | — |
| local-inference-lab/GLM-5.3-Flash-NVFP4 | NVFP4 | glm53-flash-nvfp4-lab-dflash2 | vllm | 2×2 | 0 | — | — | — |
| local-inference-lab/GLM-5.3-Flash-NVFP4 | NVFP4 | glm53-flash-nvfp4-r17abi | vllm | 2×2 | 2 | 2026-09-06 22:24 | — | 87 |
| local-inference-lab/GLM-5.3-Flash-NVFP4 | NVFP4 | glm53-flash-nvfp4-r18 | vllm | 2×2 | 0 | — | — | — |
| local-inference-lab/GLM-5.3-Flash-NVFP4 | NVFP4 | glm53-flash-nvfp4-r18-4x | vllm | 4×4 | 1 | 2026-09-06 03:13 | — | 89 |
| local-inference-lab/GLM-5.3-Flash-NVFP4 | NVFP4 | glm53-flash-nvfp4-r18-4x-dcp4 | vllm | 4×4 | 1 | 2026-09-06 04:33 | — | 91 |
| local-inference-lab/GLM-5.3-Flash-NVFP4 | NVFP4 | glm53-flash-nvfp4-r18-dflash2 | vllm | 2×2 | 0 | — | — | — |
| local-inference-lab/GLM-5.3-Flash-NVFP4 | NVFP4 | glm53-flash-r27-4x-dcp4 | vllm | 4×4 | 7 | 2026-09-08 06:28 | — | 91 |
| local-inference-lab/GLM-5.3-Flash-NVFP4 | NVFP4 | glm53-flash-sparkrun | vllm | 2×2 | 0 | — | — | — |
| local-inference-lab/GLM-5.3-Flash-NVFP4 | NVFP4KV | glm53-flash-nvfp4-r18-2x-nvfp4kv | vllm | 2×2 | 1 | 2026-09-06 12:54 | — | 88 |
| local-inference-lab/GLM-5.3-Flash-NVFP4 | NVFP4KV | glm53-flash-nvfp4-r18-4x-nvfp4kv | vllm | 4×4 | 1 | 2026-09-06 06:20 | — | 91 |
| local-inference-lab/GLM-5.3-Flash-NVFP4-4p67 | NVFP4 | glm53-flash-nvfp4-4p67 | vllm | 2×2 | 0 | — | — | — |
| local-inference-lab/GLM-5.3-Flash-NVFP4-Spark | NVFP4 | glm53-flash-nvfp4-eugr | vllm | 2×2 | 1 | 2026-09-06 01:10 | — | 81 |
| local-inference-lab/GLM-5.3-Flash-NVFP4-Spark | NVFP4 | glm53-flash-nvfp4-eugr-4x | vllm | 4×4 | 0 | — | — | — |
| local-inference-lab/GLM-5.3-Flash-NVFP4-Spark | NVFP4 | glm53-flash-nvfp4-spark | vllm | 2×2 | 0 | — | — | — |
| local-inference-lab/GLM-5.3-Flash-NVFP4-Spark | NVFP4 | glm53-flash-r27-spark | vllm | 2×2 | 8 | 2026-09-08 11:39 | — | 92 |
| local-inference-lab/GLM-5.3-Flash-NVFP4-Spark | NVFP4KV | glm53-flash-nvfp4-r18-2x-nvfp4kv-spark | vllm | 2×2 | 1 | 2026-09-06 13:50 | — | 90 |
| local-inference-lab/GLM-5.3-Flash-NVFP4-Spark | NVFP4KV | glm53-flash-nvfp4-r18-4x-nvfp4kv-spark | vllm | 4×4 | 0 | — | — | — |
| local-inference-lab/GLM-5.3-Flash-NVFP4-Spark | NVFP4KV | glm53-flash-r18-2x-nvfp4kv-spark-eager | vllm | 2×2 | 1 | 2026-09-07 07:30 | — | 90 |
| local-inference-lab/GLM-5.3-NVFP4-Spark | NVFP4 | glm53-full-nvfp4-spark-aiden | vllm | 4×4 | 0 | — | — | — |
| local-inference-lab/GLM-5.3-NVFP4-Spark | NVFP4 | glm53-full-nvfp4-spark-lab | vllm | 4×4 | 0 | — | — | — |
| lukealonso/GLM-5.1-NVFP4 | NVFP4 | glm51 | vllm | 4×1 | 0 | — | — | — |
| lukealonso/MiMo-V2.5-NVFP4 | NVFP4 | mimo-v2.5-nvfp4 | vllm | 2×2 | 0 | — | — | — |
| madeby561/GLM-5.2-MXFP8-NVFP4-NF3-Hybrid | NVFP4 | glm52-nf3-hybrid-context | vllm | 4×4 | 0 | — | — | — |
| madeby561/GLM-5.2-MXFP8-NVFP4-NF3-Hybrid | NVFP4 | glm52-nf3-hybrid-fast | vllm | 4×4 | 0 | — | — | — |
| noop | — | test-composed | vllm | 1×1 | 0 | — | — | — |
| noop | — | test-mods | vllm | 1×1 | 0 | — | — | — |
| noop | — | test-sleep | vllm | 1×1 | 0 | — | — | — |
| nota-ai/Solar-Open2-250B-Nota-NVFP4 | NVFP4 | solar-open2-nvfp4 | vllm | 2×2 | 0 | — | — | — |
| poolside/Laguna-M.1-NVFP4 | NVFP4 | laguna-m1-nvfp4 | vllm | 2×2 | 0 | — | — | — |
| poolside/Laguna-S-2.1-NVFP4 | NVFP4 | laguna-s2.1-nvfp4 | vllm | 1×1 | 0 | — | — | — |
| unsloth/Qwen3.8-27B-NVFP4 | NVFP4 | qwen38-27b-nvfp4-unsloth | vllm | 1×1 | 0 | — | — | — |
| unsloth/Qwen3.8-27B-NVFP4 | NVFP4 | qwen38-27b-nvfp4-unsloth-dspark | vllm | 1×1 | 0 | — | — | — |
| zai-org/GLM-5.3-Flash | — | glm53-flash-aiden-4x | vllm | 4×4 | 1 | 2026-09-07 06:42 | — | 93 | — table of all repo recipes with their committed
  benchmark reports (recipes/<lane>/reports/): model, quant, engine,
  nodes, latest score + points, report count
- the tool-eval-bench  output is parsed as the authoritative
  score source (scores.final_score / total_points / max_points); .md/.log
  text parsing remains the fallback for older reports
- a bench run's  triplet is grouped into a
  single entry (JSON wins for the score, .md for the time); old
  md-only/log-only reports are unchanged
-  starts an axum page (search, group by model/quant/engine,
  column pick, pagination, per-report file links) and  opens a
  browser; i18n keys in en+ru
Alexander Korolev 1 сар өмнө
parent
commit
89894dff34

+ 13 - 0
locales/en/main.ftl

@@ -204,3 +204,16 @@ health-col-nodes = Nodes
 health-status-online = ● online
 health-status-offline = ✗ offline
 health-container-line = { $name }  { $status }  { $image }
+
+# sm121 report
+report-title = Tested recipes ({ $count })
+report-serving = Interactive recipe report: { $url }  (Ctrl-C to stop)
+report-col-model = Model
+report-col-quant = Quant
+report-col-recipe = Recipe
+report-col-engine = Engine
+report-col-nodes = Nodes×TP
+report-col-reports = Reports
+report-col-last = Last bench
+report-col-points = Points
+report-col-score = Score /100

+ 13 - 0
locales/ru/main.ftl

@@ -215,3 +215,16 @@ health-col-nodes = Ноды
 health-status-online = ● онлайн
 health-status-offline = ✗ офлайн
 health-container-line = { $name }  { $status }  { $image }
+
+# sm121 report
+report-title = Тестируемые рецепты ({ $count })
+report-serving = Интерактивный отчёт по рецептам: { $url }  (Ctrl-C для остановки)
+report-col-model = Модель
+report-col-quant = Квант
+report-col-recipe = Рецепт
+report-col-engine = Движок
+report-col-nodes = Узлы×TP
+report-col-reports = Отчёты
+report-col-last = Последний бенч
+report-col-points = Очки
+report-col-score = Счёт /100

+ 31 - 0
src/cli.rs

@@ -249,6 +249,21 @@ pub enum Commands {
         open: bool,
     },
 
+    /// Table of all tested recipes and their benchmark history
+    Report {
+        /// Serve the interactive HTML table (default: print Markdown)
+        #[arg(long)]
+        serve: bool,
+
+        /// Port for --serve
+        #[arg(long, default_value_t = 3101)]
+        port: u16,
+
+        /// Open the browser (implies --serve)
+        #[arg(long)]
+        open: bool,
+    },
+
     /// Build Docker image for a recipe on a remote node
     Build {
         #[arg(long, required = true, help = "Recipe name")]
@@ -461,4 +476,20 @@ mod tests {
     fn health_format_rejects_invalid_value() {
         assert!(health_format(&["--format", "bogus"]).is_err());
     }
+
+    #[test]
+    fn report_flag_defaults_and_overrides() {
+        let cli = Cli::try_parse_from(["sm121", "report"]).unwrap();
+        let Commands::Report { serve, port, open } = cli.command else {
+            unreachable!()
+        };
+        assert!(!serve && !open);
+        assert_eq!(port, 3101);
+        let cli = Cli::try_parse_from(["sm121", "report", "--open", "--port", "4000"]).unwrap();
+        let Commands::Report { open, port, .. } = cli.command else {
+            unreachable!()
+        };
+        assert!(open);
+        assert_eq!(port, 4000);
+    }
 }

+ 4 - 0
src/main.rs

@@ -28,6 +28,7 @@ mod power;
 mod pre_serve;
 mod publish;
 mod registry;
+mod report;
 mod ssh;
 mod tmux;
 mod web;
@@ -229,6 +230,9 @@ async fn main() -> Result<()> {
         Commands::Power { action } => {
             handle_power(&config, action, dry_run).await?;
         }
+        Commands::Report { serve, port, open } => {
+            crate::report::run(&config, serve || open, port, open).await?;
+        }
     }
 
     Ok(())

+ 491 - 0
src/report/mod.rs

@@ -0,0 +1,491 @@
+//! `sm121 report` — one table of all tested recipes and their benchmark
+//! history. Default renders a Markdown table to stdout; `--serve` (implied
+//! by `--open`) serves an interactive HTML version (tree grouping, column
+//! visibility, sorting, search, pagination) on a local port.
+//!
+//! Data sources are read-only and local: the recipe dirs from
+//! [`crate::config::recipe_dirs`] (each `<name>/recipe.toml`) plus each
+//! recipe's `reports/` folder (`<YYYY-MM-DD>_<label>.md|.log`, the
+//! `sm121 publish`-style campaign reports).
+
+use crate::config::{Config, Recipe};
+use anyhow::Result;
+use serde::{Deserialize, Serialize};
+use std::collections::HashSet;
+use std::path::Path;
+
+const PAGE_TEMPLATE: &str = include_str!("page.html");
+
+#[derive(Serialize, Deserialize)]
+pub(crate) struct ReportEntry {
+    date: String,
+    /// `HH:MM` run time (from the bench `Date`/`Run ID` timestamp), null when
+    /// the file carries no timestamp.
+    time: Option<String>,
+    label: String,
+    /// Primary file in the recipe's `reports/` dir (for the open button):
+    /// the human `.md` report when present, else the `.json`, else the log.
+    file: String,
+    /// All files of this run (`<date>_<label>.{md,json,log}`); the run is
+    /// grouped by stem so one bench never shows as three entries.
+    files: Vec<String>,
+    /// Normalized 0–100 quality score (Final Score / box summary / the
+    /// parenthesized value in `## Score: 159/176 (90.4)`).
+    pct: Option<f64>,
+    /// Raw `points/max` (e.g. "159/176") — only hardmode reports carry one;
+    /// tool-eval-bench's `Final Score: 81 / 100` is already normalized.
+    points: Option<String>,
+    /// points/max as a ratio, for sorting the Points column (null when absent).
+    ratio: Option<f64>,
+}
+
+#[derive(Serialize, Deserialize)]
+pub(crate) struct ReportRow {
+    name: String,
+    title: String,
+    model: String,
+    quant: String,
+    engine: String,
+    image: String,
+    nodes: Option<u32>,
+    tp: u32,
+    deploy: String,
+    port: u16,
+    reports: Vec<ReportEntry>,
+}
+
+/// Quantization tokens, longest first so `nvfp4kv` wins over `nvfp4`, etc.
+const QUANTS: &[&str] = &[
+    "nvfp4kv", "nvfp4", "mxfp8", "mxfp4", "w4a16", "dcp4", "exl3", "int8", "int4", "nf4", "fp8",
+    "awq", "gptq", "bf16", "fp16",
+];
+
+fn detect_quant(name: &str, model: &str) -> String {
+    let hay = format!("{name} {model}").to_lowercase();
+    // ponytail: token-scan heuristic over dir+model names; per-recipe `quant`
+    // field if recipes ever grow one.
+    for q in QUANTS {
+        if hay.contains(q) {
+            return q.to_uppercase();
+        }
+    }
+    "—".to_string()
+}
+
+/// Parse the score headers used by campaign reports and raw bench logs:
+/// `## Score: 159/176 (90.4)`, `- **Final Score**: **81** / 100`, and the
+/// box-drawn log summary `│    Score:  88 / 100  │`. Lines must start with
+/// a score prefix (after stripping markdown bullets / box-drawing chars),
+/// so prose and embedded tool transcripts can't produce false positives.
+/// Returns `(points, max, normalized_pct)`.
+fn parse_score(content: &str) -> Option<(u32, u32, Option<f64>)> {
+    for line in content.lines() {
+        let l = line.trim_start_matches(|c: char| {
+            matches!(c, '#' | '-' | '•' | '|' | '│' | '┃') || c.is_whitespace()
+        });
+        let is_score = l.starts_with("**Final Score**") || l.starts_with("Score:");
+        if is_score && let Some((p, m)) = slash_pair(l) {
+            let pct = paren_pct(l)
+                // `Final Score` / log box are already out of 100
+                .or_else(|| (m == 100).then_some(p as f64))
+                .or_else(|| Some(p as f64 / m as f64 * 100.0));
+            return Some((p, m, pct));
+        }
+    }
+    None
+}
+
+/// First parenthesized number, e.g. `(90.4)` in `## Score: 159/176 (90.4)`.
+fn paren_pct(s: &str) -> Option<f64> {
+    let open = s.find('(')?;
+    let close = open + s[open..].find(')')?;
+    s[open + 1..close].trim().parse().ok()
+}
+
+/// First `YYYY-MM-DDTHH:MM` (bench `**Date**` line) or `YYYY-MM-DDTHH-MM`
+/// (Run ID / full-report path) timestamp; returns `HH:MM`.
+fn extract_time(content: &str) -> Option<String> {
+    for line in content.lines() {
+        let b = line.as_bytes();
+        for i in 0..b.len().saturating_sub(15) {
+            let d = |j: usize| b[i + j].is_ascii_digit();
+            if d(0)
+                && d(1)
+                && d(2)
+                && d(3)
+                && b[i + 4] == b'-'
+                && d(5)
+                && d(6)
+                && b[i + 7] == b'-'
+                && d(8)
+                && d(9)
+                && b[i + 10] == b'T'
+                && d(11)
+                && d(12)
+                && (b[i + 13] == b':' || b[i + 13] == b'-')
+                && d(14)
+                && d(15)
+            {
+                let mut t = line[i + 11..i + 16].to_string();
+                if t.as_bytes()[2] == b'-' {
+                    t.replace_range(2..3, ":");
+                }
+                return Some(t);
+            }
+        }
+    }
+    None
+}
+
+/// First `N…/M` pair in `s`, tolerating `*` and spaces around both sides
+/// (bold markdown). Plain digit scan — no regex dependency.
+fn slash_pair(s: &str) -> Option<(u32, u32)> {
+    let b = s.as_bytes();
+    let mut i = 0;
+    while i < b.len() {
+        if !b[i].is_ascii_digit() {
+            i += 1;
+            continue;
+        }
+        let start = i;
+        while i < b.len() && b[i].is_ascii_digit() {
+            i += 1;
+        }
+        let mut j = i;
+        while j < b.len() && matches!(b[j], b' ' | b'*') {
+            j += 1;
+        }
+        if j < b.len() && b[j] == b'/' {
+            let mut k = j + 1;
+            while k < b.len() && matches!(b[k], b' ' | b'*') {
+                k += 1;
+            }
+            let dstart = k;
+            while k < b.len() && b[k].is_ascii_digit() {
+                k += 1;
+            }
+            if let (Ok(n), Ok(m)) = (s[start..i].parse(), s[dstart..k].parse()) {
+                return Some((n, m));
+            }
+        }
+    }
+    None
+}
+
+/// Parse the tool-eval-bench `--json-file` output (authoritative for
+/// `sm121 bench` runs): `scores.final_score` (0–100), `scores.total_points`
+/// / `max_points` (hardmode). Returns `(pct, points, ratio)`; `points` is
+/// suppressed when max is 100 (already normalized, same rule as the text
+/// parsers).
+fn parse_bench_json(content: &str) -> Option<(f64, Option<String>, Option<f64>)> {
+    let v: serde_json::Value = serde_json::from_str(content).ok()?;
+    let s = v.get("scores")?;
+    let pct = s.get("final_score").and_then(|x| x.as_f64())?;
+    let tp = s.get("total_points").and_then(|x| x.as_u64());
+    let mp = s.get("max_points").and_then(|x| x.as_u64());
+    let (points, ratio) = match (tp, mp) {
+        (Some(p), Some(m)) if m > 0 && m != 100 => {
+            (Some(format!("{p}/{m}")), Some(p as f64 / m as f64))
+        }
+        _ => (None, None),
+    };
+    Some((pct, points, ratio))
+}
+
+fn scan_reports(recipe_dir: &Path) -> Vec<ReportEntry> {
+    let reports = recipe_dir.join("reports");
+    let Ok(rd) = std::fs::read_dir(&reports) else {
+        return vec![];
+    };
+    // Group files by run stem: `<YYYY-MM-DD>_<label>.{md,json,log}` → one
+    // entry per bench run (a run's three files are one report, not three).
+    let mut groups: std::collections::BTreeMap<String, Vec<String>> =
+        std::collections::BTreeMap::new();
+    for entry in rd.flatten() {
+        let path = entry.path();
+        let Some(file) = path.file_name().and_then(|f| f.to_str()).map(String::from) else {
+            continue;
+        };
+        let (stem, _ext) = match file.rsplit_once('.') {
+            Some((s, e)) if matches!(e, "md" | "json" | "log") => (s, e),
+            _ => continue,
+        };
+        groups.entry(stem.to_string()).or_default().push(file);
+    }
+    let read = |files: &[String], ext: &str| -> Option<String> {
+        files
+            .iter()
+            .find(|f| f.ends_with(&format!(".{ext}")))
+            .and_then(|f| std::fs::read_to_string(reports.join(f)).ok())
+    };
+    let mut out = Vec::new();
+    for (stem, mut files) in groups {
+        files.sort();
+        let (date, label) = match stem.split_once('_') {
+            Some((d, l)) if d.len() == 10 && d.as_bytes()[4] == b'-' => {
+                (d.to_string(), l.to_string())
+            }
+            _ => (String::new(), stem.clone()),
+        };
+        let json = read(&files, "json");
+        let md = read(&files, "md");
+        let log = read(&files, "log");
+        // Score: the bench JSON is authoritative; fall back to the .md text,
+        // then the raw .log (old reports are md/log only).
+        let (pct, points, ratio) = match json.as_deref().and_then(parse_bench_json) {
+            Some((p, pts, rt)) => (Some(p), pts, rt),
+            None => md
+                .as_deref()
+                .or(log.as_deref())
+                .and_then(parse_score)
+                .map(|(p, m, pct)| {
+                    if m != 100 {
+                        (pct, Some(format!("{p}/{m}")), Some(p as f64 / m as f64))
+                    } else {
+                        (pct, None, None)
+                    }
+                })
+                .unwrap_or((None, None, None)),
+        };
+        let time = md
+            .as_deref()
+            .and_then(extract_time)
+            .or_else(|| log.as_deref().and_then(extract_time));
+        let file = files
+            .iter()
+            .find(|f| f.ends_with(".md"))
+            .or_else(|| files.iter().find(|f| f.ends_with(".json")))
+            .or_else(|| files.first())
+            .cloned()
+            .unwrap_or_default();
+        out.push(ReportEntry {
+            date,
+            time,
+            label,
+            file,
+            files,
+            pct,
+            points,
+            ratio,
+        });
+    }
+    out.sort_by(|a, b| (&a.date, &a.label).cmp(&(&b.date, &b.label)));
+    out
+}
+
+/// Collect all discoverable recipes (cwd + recipe_sources) with their
+/// report history. First dir wins on name collisions (same precedence as
+/// `load_recipe`).
+pub(crate) fn collect(config: &Config) -> Vec<ReportRow> {
+    let mut rows = Vec::new();
+    let mut seen = HashSet::new();
+    for dir in crate::config::recipe_dirs(config) {
+        let Ok(entries) = std::fs::read_dir(&dir) else {
+            continue;
+        };
+        for entry in entries.flatten() {
+            let path = entry.path();
+            if !path.is_dir() {
+                continue;
+            }
+            let Some(name) = entry.file_name().to_str().map(String::from) else {
+                continue;
+            };
+            if !seen.insert(name.clone()) {
+                continue;
+            }
+            let Ok(toml_str) = std::fs::read_to_string(path.join("recipe.toml")) else {
+                continue;
+            };
+            let Ok(r) = toml::from_str::<Recipe>(&toml_str) else {
+                continue;
+            };
+            let quant = detect_quant(&name, &r.model);
+            let reports = scan_reports(&path);
+            rows.push(ReportRow {
+                name,
+                title: r.name,
+                model: r.model,
+                quant,
+                engine: r.engine,
+                image: r.docker_image,
+                nodes: r.required_nodes,
+                tp: r.tensor_parallel_size,
+                deploy: format!("{:?}", r.deploy).to_lowercase(),
+                port: r.port,
+                reports,
+            });
+        }
+    }
+    rows.sort_by(|a, b| (&a.model, &a.quant, &a.name).cmp(&(&b.model, &b.quant, &b.name)));
+    rows
+}
+
+fn latest_score(r: &ReportRow) -> Option<&ReportEntry> {
+    r.reports
+        .iter()
+        .rev()
+        .find(|e| e.pct.is_some() || e.points.is_some())
+}
+
+/// Normalized score, whole numbers without decimals (81, not 81.0).
+fn fmt_pct(pct: f64) -> String {
+    if (pct - pct.trunc()).abs() < 1e-9 {
+        format!("{}", pct as i64)
+    } else {
+        format!("{pct:.1}")
+    }
+}
+
+fn markdown(rows: &[ReportRow]) -> String {
+    let mut s = format!(
+        "# {}\n\n",
+        fluent_i18n::t!("report-title", { "count" => rows.len() })
+    );
+    s.push_str(&format!(
+        "| {} | {} | {} | {} | {} | {} | {} | {} | {} |\n",
+        t("report-col-model"),
+        t("report-col-quant"),
+        t("report-col-recipe"),
+        t("report-col-engine"),
+        t("report-col-nodes"),
+        t("report-col-reports"),
+        t("report-col-last"),
+        t("report-col-points"),
+        t("report-col-score"),
+    ));
+    s.push_str("|---|---|---|---|---|---|---|---|---|\n");
+    for r in rows {
+        let last = r.reports.last();
+        let latest = latest_score(r);
+        s.push_str(&format!(
+            "| {} | {} | {} | {} | {} | {} | {} | {} | {} |\n",
+            r.model,
+            r.quant,
+            r.name,
+            r.engine,
+            r.nodes
+                .map(|n| format!("{n}×{}", r.tp))
+                .unwrap_or_else(|| format!("1×{}", r.tp)),
+            r.reports.len(),
+            last.map(|e| match &e.time {
+                Some(t) => format!("{} {t}", e.date),
+                None => e.date.clone(),
+            })
+            .unwrap_or("—".into()),
+            latest
+                .and_then(|e| e.points.clone())
+                .unwrap_or_else(|| "—".into()),
+            latest
+                .and_then(|e| e.pct)
+                .map(fmt_pct)
+                .unwrap_or_else(|| "—".into()),
+        ));
+    }
+    s
+}
+
+fn t(key: &str) -> String {
+    fluent_i18n::t!(key)
+}
+
+/// Load a report file for the `/r/<recipe>/<file>` route. Both path parts
+/// must be plain names (no traversal) — the route is loopback-only, but the
+/// resolve walks every recipe dir, so stay strict.
+fn load_report(config: &Config, recipe: &str, file: &str) -> Option<String> {
+    let plain = |s: &str| {
+        !s.contains("..")
+            && s.chars()
+                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
+    };
+    if !plain(recipe) || !plain(file) {
+        return None;
+    }
+    crate::config::recipe_dirs(config)
+        .into_iter()
+        .find_map(|dir| {
+            let p = dir.join(recipe).join("reports").join(file);
+            p.is_file()
+                .then(|| std::fs::read_to_string(p).ok())
+                .flatten()
+        })
+}
+
+/// `GET /r/<recipe>/<file>` — full report text in a minimal pre page.
+async fn report_file(
+    axum::extract::State(state): axum::extract::State<std::sync::Arc<Config>>,
+    axum::extract::Path((recipe, file)): axum::extract::Path<(String, String)>,
+) -> axum::response::Response {
+    use axum::http::StatusCode;
+    use axum::response::IntoResponse;
+    match load_report(&state, &recipe, &file) {
+        Some(text) => axum::response::Html(format!(
+            "<!doctype html><meta charset=\"utf-8\"><title>{recipe} — {file}</title>\
+             <pre style=\"font:12px/1.45 ui-monospace,monospace;white-space:pre-wrap;\
+             word-break:break-word;margin:16px;\">{}</pre>",
+            text.replace('&', "&amp;")
+                .replace('<', "&lt;")
+                .replace('>', "&gt;")
+        ))
+        .into_response(),
+        None => (
+            StatusCode::NOT_FOUND,
+            axum::response::Html(
+                "<!doctype html><meta charset=\"utf-8\"><title>404</title>report not found"
+                    .to_string(),
+            ),
+        )
+            .into_response(),
+    }
+}
+
+/// The interactive page: template with `{{DATA}}` replaced by the JSON
+/// rows (`<` escaped so `</script>` in data can't break out).
+fn html_page(rows: &[ReportRow]) -> String {
+    let json = serde_json::to_string(rows).unwrap_or_else(|_| "[]".into());
+    PAGE_TEMPLATE.replace("{{DATA}}", &json.replace('<', "\\u003c"))
+}
+
+/// Entry point: Markdown to stdout, or the interactive page on
+/// `http://127.0.0.1:<port>/` (`--open` also spawns the browser).
+pub async fn run(config: &Config, serve: bool, port: u16, open: bool) -> Result<()> {
+    let rows = collect(config);
+    if !serve {
+        print!("{}", markdown(&rows));
+        return Ok(());
+    }
+    let page = html_page(&rows);
+    let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)).await?;
+    let addr = listener.local_addr()?;
+    let app = axum::Router::new()
+        .route(
+            "/",
+            axum::routing::get(move || {
+                let page = page.clone();
+                async move { axum::response::Html(page) }
+            }),
+        )
+        .route("/r/{recipe}/{file}", axum::routing::get(report_file))
+        .with_state(std::sync::Arc::new(config.clone()));
+    if open {
+        let url = format!("http://{addr}/");
+        tokio::spawn(async move {
+            tokio::time::sleep(std::time::Duration::from_millis(400)).await;
+            let prog = if cfg!(target_os = "macos") {
+                "open"
+            } else {
+                "xdg-open"
+            };
+            let _ = std::process::Command::new(prog).arg(&url).spawn();
+        });
+    }
+    println!(
+        "{}",
+        fluent_i18n::t!("report-serving", { "url" => format!("http://{addr}/") })
+    );
+    axum::serve(listener, app).await?;
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests;

+ 189 - 0
src/report/page.html

@@ -0,0 +1,189 @@
+<!doctype html>
+<html>
+<head>
+<meta charset="utf-8">
+<title>sm121 — tested recipes</title>
+<style>
+  :root { color-scheme: light dark; }
+  body { font: 13px/1.45 system-ui, sans-serif; margin: 16px; }
+  h1 { font-size: 18px; margin: 0 0 10px; }
+  #bar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; }
+  #q { flex: 1 1 220px; max-width: 380px; padding: 4px 8px; }
+  select, button, #cols summary { padding: 3px 8px; cursor: pointer; }
+  #cols { position: relative; display: inline-block; }
+  #colbox { position: absolute; z-index: 5; background: canvas; border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
+            padding: 6px 10px; max-height: 60vh; overflow: auto; white-space: nowrap; }
+  #colbox label { display: block; }
+  table { border-collapse: collapse; width: 100%; }
+  th, td { border: 1px solid color-mix(in srgb, currentColor 20%, transparent); padding: 3px 8px; text-align: left; vertical-align: top; }
+  th { cursor: pointer; user-select: none; white-space: nowrap; position: sticky; top: 0; background: canvas; }
+  tr.g1 td { font-weight: 700; background: color-mix(in srgb, currentColor 8%, canvas); cursor: pointer; }
+  tr.g2 td { font-style: italic; background: color-mix(in srgb, currentColor 4%, canvas); cursor: pointer; }
+  tr.hist td { font-family: ui-monospace, monospace; font-size: 12px; background: color-mix(in srgb, currentColor 3%, canvas); }
+  a.open { color: inherit; text-decoration: none; border: 1px solid color-mix(in srgb, currentColor 30%, transparent); border-radius: 3px; padding: 0 4px; }
+  a.open:hover { background: color-mix(in srgb, currentColor 12%, canvas); }
+  tr.row { cursor: pointer; }
+  td.num { text-align: right; white-space: nowrap; }
+  #count { color: gray; }
+  .dim { color: gray; }
+</style>
+</head>
+<body>
+<h1>sm121 — tested recipes &amp; benchmark history</h1>
+<div id="bar">
+  <input id="q" type="search" placeholder="Search recipes, models, images, labels…">
+  <label>Group:
+    <select id="group">
+      <option value="mq" selected>Model → Quant</option>
+      <option value="model">Model</option>
+      <option value="quant">Quant</option>
+      <option value="engine">Engine</option>
+      <option value="">None (flat)</option>
+    </select>
+  </label>
+  <details id="cols"><summary>Columns ▾</summary><div id="colbox"></div></details>
+  <label>Rows/page:
+    <select id="size"><option>25</option><option selected>50</option><option>100</option><option value="0">All</option></select>
+  </label>
+  <button id="prev">‹</button><span id="count"></span><button id="next">›</button>
+</div>
+<table id="tbl"><thead></thead><tbody></tbody></table>
+<script>
+"use strict";
+const DATA = {{DATA}};
+const COLS = [
+  ["name", "Recipe"], ["title", "Title"], ["model", "Model"], ["quant", "Quant"],
+  ["engine", "Engine"], ["image", "Image"], ["nodes", "Nodes"], ["tp", "TP"],
+  ["deploy", "Deploy"], ["port", "Port"], ["n", "Reports"], ["last", "Last bench"],
+  ["points", "Points"], ["pct", "Score /100"],
+];
+const NUM = new Set(["nodes", "tp", "port", "n", "pct"]);
+
+// latest report matching a predicate, for the points / score columns
+function latest(r, f) { for (let i = r.reports.length - 1; i >= 0; i--) if (f(r.reports[i])) return r.reports[i]; return null; }
+function cell(r, k) {
+  if (k === "n") return r.reports.length;
+  if (k === "last") return r.reports.length ? r.reports[r.reports.length - 1].date : "";
+  if (k === "points") { const e = latest(r, e => e.points); return e ? e.points : ""; }
+  if (k === "pct") { const e = latest(r, e => e.pct != null); return e ? e.pct : ""; }
+  return r[k];
+}
+
+const S = { q: "", group: "mq", sort: "name", dir: 1, hidden: new Set(["title", "image", "port"]),
+            page: 0, size: 50, closed: new Set(), openRow: null };
+
+const esc = s => String(s).replace(/[&<>"]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
+
+function filtered() {
+  const q = S.q.toLowerCase();
+  return DATA.filter(r => !q || (r.name + " " + r.title + " " + r.model + " " + r.quant + " " +
+    r.engine + " " + r.image + " " + r.deploy + " " +
+    r.reports.map(e => e.date + " " + e.label).join(" ")).toLowerCase().includes(q));
+}
+function sorted(rows) {
+  const gk = { mq: r => r.model + "\0" + r.quant, model: r => r.model,
+               quant: r => r.quant, engine: r => r.engine }[S.group];
+  const k = S.sort, d = S.dir;
+  const val = r => {
+    if (k === "points") { const e = latest(r, e => e.ratio != null); return e ? e.ratio : -1; }
+    const v = cell(r, k); return NUM.has(k) || k === "last" ? v : String(v).toLowerCase();
+  };
+  return rows.slice().sort((a, b) => {
+    if (gk) { const ga = gk(a), gb = gk(b); if (ga !== gb) return ga.localeCompare(gb); }
+    let x = val(a), y = val(b);
+    if (x === y) return a.name.localeCompare(b.name);
+    if (typeof x === "number" && typeof y === "number") return (x - y) * d;
+    return String(x).localeCompare(String(y)) * d;
+  });
+}
+function gkey(r, level) {
+  if (S.group === "mq") return level === 1 ? r.model : r.model + "\u0001" + r.quant;
+  const f = { model: "model", quant: "quant", engine: "engine" }[S.group];
+  return level === 1 ? r[f] : null;
+}
+
+function render() {
+  // visible columns
+  const vis = COLS.filter(c => !S.hidden.has(c[0]));
+  document.querySelector("thead").innerHTML = "<tr>" + vis.map(c => {
+    const arrow = S.sort === c[0] ? (S.dir > 0 ? " ▲" : " ▼") : "";
+    return `<th data-k="${c[0]}">${esc(c[1])}${arrow}</th>`;
+  }).join("") + "</tr>";
+
+  const all = sorted(filtered());
+  const size = +document.getElementById("size").value;
+  const pages = size ? Math.max(1, Math.ceil(all.length / size)) : 1;
+  if (S.page >= pages) S.page = pages - 1;
+  const rows = size ? all.slice(S.page * size, S.page * size + size) : all;
+  document.getElementById("count").textContent = size
+    ? `${all.length ? S.page * size + 1 : 0}–${Math.min(all.length, S.page * size + size)} / ${all.length}` : `${all.length}`;
+
+  // group tree over the current page slice
+  const out = [];
+  let g1 = null, g2 = null;
+  for (const r of rows) {
+    if (S.group) {
+      const k1 = gkey(r, 1);
+      if (k1 !== g1) {
+        g1 = k1; g2 = null;
+        const closed = S.closed.has("1\0" + k1);
+        out.push(`<tr class="g1" data-g="${encodeURIComponent("1\0" + k1)}"><td colspan="${vis.length}">${closed ? "▶" : "▼"} ${esc(k1)} <span class="dim">(${all.filter(x => gkey(x, 1) === k1).length})</span></td></tr>`);
+      }
+      if (S.closed.has("1\0" + k1)) continue;
+      const k2 = gkey(r, 2);
+      if (k2 !== null) {
+        if (k2 !== g2) {
+          g2 = k2;
+          const closed = S.closed.has("2\0" + k2);
+          out.push(`<tr class="g2" data-g="${encodeURIComponent("2\0" + k2)}"><td colspan="${vis.length}">${closed ? "▶" : "▼"} ${esc(r.quant)} <span class="dim">(${all.filter(x => gkey(x, 2) === k2).length})</span></td></tr>`);
+        }
+        if (S.closed.has("2\0" + k2)) continue;
+      }
+    }
+    out.push("<tr class=\"row\" data-n=\"" + esc(r.name) + "\">" + vis.map(c => {
+      const v = cell(r, c[0]);
+      const lr = r.reports.length ? r.reports[r.reports.length - 1] : null;
+      const tip = c[0] === "last" && lr
+        ? ` title="${esc(lr.date + (lr.time ? " " + lr.time : ""))}"` : "";
+      return `<td${NUM.has(c[0]) ? " class=\"num\"" : ""}${tip}>${v === "" || v == null ? '<span class="dim">—</span>' : esc(v)}</td>`;
+    }).join("") + "</tr>");
+    if (S.openRow === r.name)
+      out.push(`<tr class="hist"><td colspan="${vis.length}">${r.reports.length ? r.reports.map(e => {
+        const fl = e.files && e.files.length ? e.files : [e.file];
+        return `<span>${fl.map(f =>
+          `<a class="open" href="/r/${encodeURIComponent(r.name)}/${encodeURIComponent(f)}" target="_blank" title="open ${esc(f)}">${esc(f.split('.').pop())}</a>`).join(" ")}</span> ` +
+        `${esc(e.date)}${e.time ? "·" + esc(e.time) : ""} ${esc(e.label)} — ${e.pct != null ? esc(e.pct) + " /100" : '<span class="dim">no score</span>'}${e.points ? " (" + esc(e.points) + ")" : ""}`;
+      }).join("<br>") : "no reports"}</td></tr>`);
+  }
+  document.querySelector("tbody").innerHTML = out.join("");
+}
+
+document.querySelector("thead").addEventListener("click", e => {
+  const th = e.target.closest("th"); if (!th) return;
+  const k = th.dataset.k;
+  if (S.sort === k) S.dir = -S.dir; else { S.sort = k; S.dir = 1; }
+  render();
+});
+document.querySelector("tbody").addEventListener("click", e => {
+  const g = e.target.closest("tr.g1,tr.g2");
+  if (g) { const k = decodeURIComponent(g.dataset.g); S.closed.has(k) ? S.closed.delete(k) : S.closed.add(k); render(); return; }
+  const row = e.target.closest("tr.row");
+  if (row) { S.openRow = S.openRow === row.dataset.n ? null : row.dataset.n; render(); }
+});
+document.getElementById("q").addEventListener("input", e => { S.q = e.target.value; S.page = 0; render(); });
+document.getElementById("group").addEventListener("change", e => { S.group = e.target.value; S.closed.clear(); S.page = 0; render(); });
+document.getElementById("size").addEventListener("change", () => { S.page = 0; render(); });
+document.getElementById("prev").addEventListener("click", () => { if (S.page > 0) { S.page--; render(); } });
+document.getElementById("next").addEventListener("click", () => { S.page++; render(); });
+// column checkboxes
+document.getElementById("colbox").innerHTML = COLS.map(c =>
+  `<label><input type="checkbox" data-k="${c[0]}" ${S.hidden.has(c[0]) ? "" : "checked"}> ${esc(c[1])}</label>`).join("");
+document.getElementById("colbox").addEventListener("change", e => {
+  const k = e.target.dataset.k; if (!k) return;
+  e.target.checked ? S.hidden.delete(k) : S.hidden.add(k);
+  render();
+});
+render();
+</script>
+</body>
+</html>

+ 281 - 0
src/report/tests.rs

@@ -0,0 +1,281 @@
+use super::{
+    ReportRow, collect, detect_quant, extract_time, fmt_pct, html_page, load_report, markdown,
+    parse_score, slash_pair,
+};
+use crate::config::Config;
+use std::fs;
+use std::path::PathBuf;
+
+#[test]
+fn slash_pair_tolerates_bold_and_spaces() {
+    assert_eq!(slash_pair("**81** / 100"), Some((81, 100)));
+    assert_eq!(
+        slash_pair("Score: 159/176 (90.4) — 74 pass"),
+        Some((159, 176))
+    );
+    assert_eq!(slash_pair("no pair here"), None);
+}
+
+#[test]
+fn parse_score_handles_both_header_styles() {
+    let bench = "- **Final Score**: **81** / 100\n- more\n";
+    assert_eq!(parse_score(bench), Some((81, 100, Some(81.0))));
+    let campaign = "intro\n## Score: 159/176 (90.4) — 74 pass / 11 partial\n";
+    assert_eq!(parse_score(campaign), Some((159, 176, Some(90.4))));
+    assert_eq!(parse_score("# no score in here\n"), None);
+}
+
+#[test]
+fn parse_score_reads_log_summary_box() {
+    // raw tool-eval-bench log: the score box sits deep in the file
+    let log = "│    Quality:        88/100                                                    │\n\
+               │    Score:  88 / 100                                                          │\n\
+               │    • Final score: (total points / max points) × 100                          │\n";
+    assert_eq!(parse_score(log), Some((88, 100, Some(88.0))));
+}
+
+#[test]
+fn parse_score_ignores_transcript_noise() {
+    // embedded tool transcripts contain "score" but no leading score header
+    let noise = "tool_result={\"relevance_score\": 0.95, \"url\": \"x/1\"}\n\
+                 Note: my initial search returned no matches, would you like more?\n\
+                 │    Deployability:  73/100  (α=0.7)                                               │\n\
+                 │    Responsiveness: 39/100  (median turn: 4.1s)                                   │\n";
+    assert_eq!(parse_score(noise), None);
+}
+
+#[test]
+fn fmt_pct_whole_and_fractional() {
+    assert_eq!(fmt_pct(81.0), "81");
+    assert_eq!(fmt_pct(90.4), "90.4");
+}
+
+#[test]
+fn extract_time_reads_both_timestamp_shapes() {
+    let md = "- **Run ID**: `2026-09-06T01-10-29.307431Z_edbd57fa`\n\
+              - **Date**: `2026-09-06T01:58:31.440817+00:00`\n";
+    assert_eq!(extract_time(md), Some("01:10".into()));
+    assert_eq!(
+        extract_time("x 2026-09-07T23:05:00 y\n"),
+        Some("23:05".into())
+    );
+    assert_eq!(extract_time("# no time here\n"), None);
+    assert_eq!(extract_time("2026-09-07 at 12:30 o'clock\n"), None);
+    assert_eq!(extract_time("2026-09-07T12:3 short\n"), None);
+}
+
+#[test]
+fn detect_quant_prefers_longest_token() {
+    assert_eq!(detect_quant("glm52-hybrid-fp8-mxfp4", "x"), "MXFP4");
+    assert_eq!(detect_quant("x", "unsloth/Qwen3.8-27B-NVFP4"), "NVFP4");
+    assert_eq!(detect_quant("glm53-flash-r18-4x-nvfp4kv", ""), "NVFP4KV");
+    assert_eq!(detect_quant("qwen38-27b-int4", ""), "INT4");
+    assert_eq!(detect_quant("deepseek-v4-flash-dspark38", "m"), "—");
+}
+
+fn fixture() -> PathBuf {
+    use std::sync::atomic::{AtomicU32, Ordering};
+    static N: AtomicU32 = AtomicU32::new(0);
+    // unique per call — tests run in parallel and clean up after themselves
+    let dir = std::env::temp_dir().join(format!(
+        "sm121-report-{}-{}",
+        std::process::id(),
+        N.fetch_add(1, Ordering::Relaxed)
+    ));
+    let r = dir.join("test-recipe-nvfp4");
+    fs::create_dir_all(r.join("reports")).unwrap();
+    fs::write(
+        r.join("recipe.toml"),
+        "engine = \"vllm\"\nname = \"Test Recipe\"\nmodel = \"org/Test-Model-NVFP4\"\nrequired_nodes = 2\n",
+    )
+    .unwrap();
+    fs::write(
+        r.join("reports/2026-01-02_demo-run.md"),
+        "# demo\n## Score: 159/176 (90.4)\n",
+    )
+    .unwrap();
+    fs::write(
+        r.join("reports/2026-01-01_raw.log"),
+        "noise\nrun 2026-01-01T08-30-00Z\n│    Score:  88 / 100 │\n",
+    )
+    .unwrap();
+    fs::write(
+        r.join("reports/2026-01-03_hardmode.md"),
+        "# hm\n## Score: 159/176 (90.4) — 74 pass / 11 partial\n",
+    )
+    .unwrap();
+    // a full `sm121 bench` run triplet: one bench run must yield ONE entry
+    fs::write(
+        r.join("reports/2026-01-04_roce-run.json"),
+        r#"{"scores": {"final_score": 92, "total_points": 162, "max_points": 176}}"#,
+    )
+    .unwrap();
+    fs::write(
+        r.join("reports/2026-01-04_roce-run.md"),
+        "- **Run ID**: `2026-01-04T11:08:03.183685Z_ef01052a`\n- **Final Score**: **92** / 100\n",
+    )
+    .unwrap();
+    fs::write(
+        r.join("reports/2026-01-04_roce-run.log"),
+        "noise\n│    Score:  92 / 100 │\n",
+    )
+    .unwrap();
+    dir
+}
+
+#[test]
+fn collect_carries_file_and_time() {
+    let dir = fixture();
+    let config: Config =
+        toml::from_str(&format!("recipe_sources = [\"{}\"]", dir.to_str().unwrap())).unwrap();
+    let rows = collect(&config);
+    fs::remove_dir_all(&dir).ok();
+    let row = rows.iter().find(|r| r.name == "test-recipe-nvfp4").unwrap();
+    assert_eq!(row.reports[2].file, "2026-01-03_hardmode.md");
+    assert_eq!(row.reports[2].time, None, "fixture .md has no timestamp");
+    assert_eq!(
+        row.reports[0].time,
+        Some("08:30".into()),
+        "from Run ID in .log"
+    );
+}
+
+#[test]
+fn load_report_resolves_and_rejects_traversal() {
+    let dir = fixture();
+    let config: Config =
+        toml::from_str(&format!("recipe_sources = [\"{}\"]", dir.to_str().unwrap())).unwrap();
+    let ok = load_report(&config, "test-recipe-nvfp4", "2026-01-03_hardmode.md");
+    fs::remove_dir_all(&dir).ok();
+    assert!(ok.unwrap().contains("159/176"));
+    let dir = fixture();
+    let config: Config =
+        toml::from_str(&format!("recipe_sources = [\"{}\"]", dir.to_str().unwrap())).unwrap();
+    fs::remove_dir_all(&dir).ok();
+    assert_eq!(
+        load_report(&config, "test-recipe-nvfp4", "../../etc/passwd"),
+        None
+    );
+    assert_eq!(load_report(&config, "..", "x.md"), None);
+    assert_eq!(
+        load_report(&config, "test-recipe-nvfp4", "missing.md"),
+        None
+    );
+}
+
+#[test]
+fn collect_reads_recipe_reports_and_dedups() {
+    let dir = fixture();
+    let config: Config =
+        toml::from_str(&format!("recipe_sources = [\"{}\"]", dir.to_str().unwrap())).unwrap();
+    let rows = collect(&config);
+    fs::remove_dir_all(&dir).ok();
+    let row = rows.iter().find(|r| r.name == "test-recipe-nvfp4").unwrap();
+    assert_eq!(row.title, "Test Recipe");
+    assert_eq!(row.quant, "NVFP4");
+    assert_eq!(row.nodes, Some(2));
+    assert_eq!(row.reports.len(), 4);
+    assert_eq!(row.reports[0].label, "raw");
+    assert_eq!(row.reports[0].pct, Some(88.0));
+    assert_eq!(row.reports[0].points, None);
+    assert_eq!(row.reports[1].label, "demo-run");
+    assert_eq!(row.reports[1].pct, Some(90.4));
+    assert_eq!(row.reports[1].points.as_deref(), Some("159/176"));
+    assert_eq!(row.reports[1].ratio, Some(159.0 / 176.0));
+    assert_eq!(row.reports[2].label, "hardmode");
+    assert_eq!(row.reports[2].points.as_deref(), Some("159/176"));
+    assert_eq!(row.reports[2].pct, Some(90.4));
+    assert_eq!(row.reports[2].ratio, Some(159.0 / 176.0));
+}
+
+#[test]
+fn bench_triplet_groups_into_one_entry_with_json_authority() {
+    let dir = fixture();
+    let config: Config =
+        toml::from_str(&format!("recipe_sources = [\"{}\"]", dir.to_str().unwrap())).unwrap();
+    let rows = collect(&config);
+    fs::remove_dir_all(&dir).ok();
+    let row = rows.iter().find(|r| r.name == "test-recipe-nvfp4").unwrap();
+    let e = &row.reports[3];
+    assert_eq!(e.label, "roce-run");
+    // the JSON score wins over the md/log text (same value here — 92)
+    assert_eq!(e.pct, Some(92.0));
+    assert_eq!(e.points.as_deref(), Some("162/176"));
+    assert_eq!(e.ratio, Some(162.0 / 176.0));
+    // primary file = the human .md; all three files are listed
+    assert_eq!(e.file, "2026-01-04_roce-run.md");
+    assert_eq!(
+        e.files,
+        vec![
+            "2026-01-04_roce-run.json",
+            "2026-01-04_roce-run.log",
+            "2026-01-04_roce-run.md"
+        ]
+    );
+    // time comes from the md Run ID (11:08)
+    assert_eq!(e.time, Some("11:08".into()));
+}
+
+#[test]
+fn parse_bench_json_reads_scores_block() {
+    let v = r#"{"scores": {"final_score": 88, "total_points": 155, "max_points": 176}}"#;
+    assert_eq!(
+        super::parse_bench_json(v),
+        Some((88.0, Some("155/176".into()), Some(155.0 / 176.0)))
+    );
+    // max_points 100 → points suppressed (already normalized), pct kept
+    let v = r#"{"scores": {"final_score": 81.0, "total_points": 81, "max_points": 100}}"#;
+    assert_eq!(super::parse_bench_json(v), Some((81.0, None, None)));
+    // missing block / garbage
+    assert_eq!(super::parse_bench_json("{}"), None);
+    assert_eq!(super::parse_bench_json("not json"), None);
+    assert_eq!(super::parse_bench_json(r#"{"scores": {}}"#), None);
+}
+
+fn row(name: &str, model: &str, quant: &str, score: Option<serde_json::Value>) -> ReportRow {
+    let reports = score.unwrap_or(
+        serde_json::json!([{"file": "f.md", "files": ["f.md"], "date": "", "label": ""}]),
+    );
+    serde_json::from_value(serde_json::json!({
+        "name": name, "title": name, "model": model, "quant": quant,
+        "engine": "vllm", "image": "img:1", "nodes": 1, "tp": 1,
+        "deploy": "docker", "port": 30000, "reports": reports,
+    }))
+    .unwrap()
+}
+
+#[test]
+fn markdown_lists_rows_and_scores() {
+    let rows = vec![row(
+        "r-a",
+        "m/one",
+        "NVFP4",
+        Some(serde_json::json!([
+            {"file": "f.md", "files": ["f.md"], "date": "2026-01-02", "label": "l", "pct": 90.4, "points": "159/176", "ratio": 0.9034},
+        ])),
+    )];
+    let md = markdown(&rows);
+    assert!(md.contains("| m/one | NVFP4 | r-a | vllm | 1×1 | 1 | 2026-01-02 | 159/176 | 90.4 |"));
+    let empty = markdown(&[]);
+    assert!(empty.contains(fluent_i18n::t!("report-title", { "count" => 0 }).as_str()));
+}
+
+#[test]
+fn html_page_embeds_data_escaped() {
+    let rows = vec![row("r-a", "m/one", "NVFP4", None)];
+    let page = html_page(&rows);
+    assert!(page.contains("r-a"));
+    assert!(!page.contains("{{DATA}}"));
+    // a model name containing </script> cannot break out of the JSON island
+    let evil = vec![row("x", "a</script>b", "NVFP4", None)];
+    assert!(!html_page(&evil).contains("</script>b"));
+}
+
+#[test]
+fn report_keys_fall_back_outside_en() {
+    let _ = fluent_i18n::set_locale(Some("ru"));
+    let s = fluent_i18n::t!("report-title", { "count" => 3 });
+    let _ = fluent_i18n::set_locale(None);
+    assert!(s.contains('3'), "fallback broken, got: {s}");
+    assert_ne!(s, "report-title", "missing fluent fallback, got: {s}");
+}