Переглянути джерело

report: parent_model category field (medals/tree rank under base model); medals by parent x nodes (gold/silver/bronze, filterable); half-star grades (color bands 0-20/40/60/80, half-star precision, sort by absolute score, both-scores hint); report viewer page (rendered md + raw toggle, highlighted toml/json, recipe.toml section once per recipe, opens in new tab)

Alexander Korolev 1 місяць тому
батько
коміт
48b7aa92f7

+ 1 - 0
AGENTS.md

@@ -108,6 +108,7 @@ and must move together — the served image must match the recipe revision.
 engine = "vllm"                     # or "sglang"
 docker_image = "img:tag"
 model = "org/model"
+parent_model = "org/base-model"     # optional; report category (medals/tree) ranks under the base model
 port = 30000                        # default
 required_nodes = 2                  # optional; also caps the launch set (N nodes, not all)
 solo = false                       # optional; single-node on first free node, no dist flags

+ 1 - 0
src/after_stop.rs

@@ -187,6 +187,7 @@ mod tests {
     fn test_recipe(after_stop: Vec<PreServeCommand>) -> Recipe {
         Recipe {
             build_profile: None,
+            parent_model: None,
             engine: "vllm".to_string(),
             name: "T".to_string(),
             docker_image: String::new(),

+ 1 - 0
src/before_serve.rs

@@ -355,6 +355,7 @@ mod tests {
     fn test_recipe() -> Recipe {
         Recipe {
             build_profile: None,
+            parent_model: None,
             engine: "vllm".to_string(),
             name: "Test".to_string(),
             docker_image: String::new(),

+ 7 - 0
src/config/recipe.rs

@@ -60,6 +60,13 @@ pub struct Recipe {
     pub docker_image: String,
     #[serde(default)]
     pub model: String,
+    /// Parent model this recipe's category ranks under (medals, tree
+    /// grouping). Optional — defaults to `model` itself. Set it when a
+    /// quantized/forked repo (`local-inference-lab/GLM-5.3-Flash-NVFP4-Spark`)
+    /// should compete inside its base model's category
+    /// (`zai-org/GLM-5.3-Flash`).
+    #[serde(default)]
+    pub parent_model: Option<String>,
     #[serde(default = "default_port")]
     pub port: u16,
     #[serde(default)]

+ 1 - 0
src/docker/command_tests.rs

@@ -79,6 +79,7 @@ fn run_with(recipe: &Recipe) -> String {
 fn base_recipe() -> Recipe {
     Recipe {
         build_profile: None,
+        parent_model: None,
         engine: "vllm".into(),
         name: "T".into(),
         docker_image: "img".into(),

+ 1 - 0
src/docker/mirror.rs

@@ -171,6 +171,7 @@ mod tests {
     fn trec() -> Recipe {
         Recipe {
             build_profile: None,
+            parent_model: None,
             engine: "vllm".into(),
             name: "T".into(),
             docker_image: "img".into(),

+ 1 - 0
src/docker/sglang_tests.rs

@@ -7,6 +7,7 @@ use super::*;
 fn sglang_recipe() -> Recipe {
     Recipe {
         build_profile: None,
+        parent_model: None,
         engine: "sglang".into(),
         name: "S".into(),
         docker_image: "sglang-img".into(),

+ 1 - 0
src/docker/tests.rs

@@ -47,6 +47,7 @@ fn tnccl() -> NcclConfig {
 fn trec() -> Recipe {
     Recipe {
         build_profile: None,
+        parent_model: None,
         engine: "vllm".into(),
         name: "T".into(),
         docker_image: "img".into(),

+ 1 - 0
src/doctor.rs

@@ -354,6 +354,7 @@ mod tests {
     fn test_recipe() -> Recipe {
         Recipe {
             build_profile: None,
+            parent_model: None,
             engine: "vllm".to_string(),
             name: "Test Model".to_string(),
             docker_image: "test/image:latest".to_string(),

+ 1 - 0
src/engine_flags.rs

@@ -111,6 +111,7 @@ mod tests {
     fn base_recipe() -> Recipe {
         Recipe {
             build_profile: None,
+            parent_model: None,
             engine: "vllm".into(),
             name: "T".into(),
             docker_image: "img".into(),

+ 33 - 2
src/report/mod.rs

@@ -18,8 +18,9 @@ use std::collections::HashSet;
 use std::path::Path;
 
 pub(crate) mod parse;
+pub(crate) mod quality;
 pub(crate) mod serve;
-pub(crate) use parse::{PerfCell, parse_bench_json, perf_from};
+pub(crate) use parse::{PerfCell, bench_complete, parse_bench_json, perf_from};
 
 const PAGE_TEMPLATE: &str = include_str!("page.html");
 
@@ -44,7 +45,13 @@ pub(crate) struct ReportEntry {
     points: Option<String>,
     /// points/max as a ratio, for sorting the Points column (null when absent).
     ratio: Option<f64>,
+    /// The bench covered the full scenario pool (max_points == 176, or a
+    /// text report that printed its final summary — those only print at
+    /// the end of a completed run).
+    #[serde(default)]
+    done: bool,
     /// llama-benchy decode speeds (empty when the report has no perf box).
+    #[serde(default)]
     perf: Vec<PerfCell>,
 }
 
@@ -53,6 +60,10 @@ pub(crate) struct ReportRow {
     name: String,
     title: String,
     model: String,
+    /// Ranking category: `parent_model` when the recipe declares one
+    /// (a quant/fork repo ranks inside its base model's category).
+    #[serde(default)]
+    parent: String,
     quant: String,
     engine: String,
     image: String,
@@ -60,6 +71,13 @@ pub(crate) struct ReportRow {
     tp: u32,
     deploy: String,
     port: u16,
+    /// Reports exist but none finished the full pool (shown as ◐).
+    #[serde(default)]
+    incomplete: bool,
+    /// 1 gold / 2 silver / 3 bronze — best lanes per (parent, nodes), by
+    /// latest score. More nodes = more GPUs, so sizes don't compete.
+    #[serde(default)]
+    medal: Option<u8>,
     reports: Vec<ReportEntry>,
 }
 
@@ -236,6 +254,13 @@ fn scan_reports(recipe_dir: &Path) -> Vec<ReportEntry> {
                 })
                 .unwrap_or((None, None, None)),
         };
+        // Done: the JSON says the pool was fully covered; a text-only
+        // report counts as done when it printed a final summary (those
+        // only appear at the end of a completed run).
+        let done = json
+            .as_deref()
+            .map(bench_complete)
+            .unwrap_or(pct.is_some() || points.is_some());
         let time = md
             .as_deref()
             .and_then(extract_time)
@@ -257,6 +282,7 @@ fn scan_reports(recipe_dir: &Path) -> Vec<ReportEntry> {
             pct,
             points,
             ratio,
+            done,
             perf,
         });
     }
@@ -293,10 +319,12 @@ pub(crate) fn collect(config: &Config) -> Vec<ReportRow> {
             };
             let quant = detect_quant(&name, &r.model);
             let reports = scan_reports(&path);
+            let incomplete = quality::incomplete(&reports);
             rows.push(ReportRow {
                 name,
                 title: r.name,
-                model: r.model,
+                model: r.model.clone(),
+                parent: r.parent_model.clone().unwrap_or_else(|| r.model.clone()),
                 quant,
                 engine: r.engine,
                 image: r.docker_image,
@@ -304,11 +332,14 @@ pub(crate) fn collect(config: &Config) -> Vec<ReportRow> {
                 tp: r.tensor_parallel_size,
                 deploy: format!("{:?}", r.deploy).to_lowercase(),
                 port: r.port,
+                incomplete,
+                medal: None,
                 reports,
             });
         }
     }
     rows.sort_by(|a, b| (&a.model, &a.quant, &a.name).cmp(&(&b.model, &b.quant, &b.name)));
+    quality::award_medals(&mut rows);
     rows
 }
 

+ 67 - 12
src/report/page.html

@@ -30,6 +30,7 @@
   td.recipe:hover .copy { visibility: visible; }
   td.recipe .copy:hover { background: color-mix(in srgb, currentColor 12%, canvas); }
   td.recipe .copied { color: green; font-weight: 600; }
+  .half { -webkit-background-clip: text; background-clip: text; color: transparent; }
   td.num { text-align: right; white-space: nowrap; }
   #count { color: gray; }
   #live { color: #2a9d2a; font-size: 11px; white-space: nowrap; }
@@ -66,6 +67,15 @@
   <label>TP:
     <select id="tp"><option value="">All</option></select>
   </label>
+  <label>Medals:
+    <select id="medal">
+      <option value="" selected>All</option>
+      <option value="m">🥇🥈🥉</option>
+      <option value="1">🥇 gold</option>
+      <option value="2">🥈 silver</option>
+      <option value="3">🥉 bronze</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>
@@ -86,10 +96,30 @@ 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"],
-  ["tps1", "Tok/s c1"], ["tps2", "Tok/s c2"],
+  ["points", "Points"], ["pct", "Score /100"], ["stars", "Bench"],
+  ["tps1", "Tok/s c1"], ["tps2", "Tok/s c2"], ["parent", "Parent"],
 ];
-const NUM = new Set(["nodes", "tp", "port", "n", "pct", "tps1", "tps2"]);
+const NUM = new Set(["nodes", "tp", "port", "n", "pct", "tps1", "tps2", "stars"]);
+const MEDAL = { 1: "🥇 ", 2: "🥈 ", 3: "🥉 " };
+
+// star grade of the latest completed bench: half-star precision (1 star =
+// 20 points), colored by score band — sort key is the ABSOLUTE score
+function starInfo(r) {
+  const e = latest(r, x => x.done && x.pct != null);
+  return e ? { pct: e.pct, s: Math.round(e.pct / 10) / 2 } : null;
+}
+const STAR_BAND = p => p < 20 ? "red" : p < 40 ? "brown" : p < 60 ? "orange" : p < 80 ? "gold" : "green";
+function starGlyphs(si) {
+  const col = STAR_BAND(si.pct);
+  let out = "";
+  for (let i = 1; i <= 5; i++) {
+    if (si.s >= i) out += `<span style="color:${col}">★</span>`;
+    else if (si.s >= i - 0.5)
+      out += `<span class="half" style="background:linear-gradient(90deg,${col} 50%,color-mix(in srgb,currentColor 25%,transparent) 50%)">★</span>`;
+    else out += '<span class="dim">☆</span>';
+  }
+  return out;
+}
 
 // decode speed (tg t/s) from a run's llama-benchy perf cells
 function tg(e, d, c) { const p = e && e.perf ? e.perf.find(p => p.d === d && p.c === c) : null; return p ? p.tg : null; }
@@ -102,6 +132,7 @@ function cell(r, k) {
   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 : ""; }
   if (k === "tps1" || k === "tps2") { const e = latest(r, e => e.perf && e.perf.length); return tg(e, 0, k === "tps1" ? 1 : 2) ?? ""; }
+  if (k === "stars") { const si = starInfo(r); return si ? si.pct : -1; } // sort by the absolute score
   return r[k];
 }
 
@@ -236,15 +267,17 @@ const S = {
   dir: P.get("dir") ? +P.get("dir") : 1,
   size: [25, 50, 100, 0].includes(size0) ? size0 : 50,
   page: Math.max(0, +(P.get("page") ?? 0)),
-  hidden: new Set((P.get("hidden") ?? "title,image,port").split(",").filter(Boolean)),
+  hidden: new Set((P.get("hidden") ?? "title,image,port,parent").split(",").filter(Boolean)),
   closed: new Set((P.get("closed") ?? "").split("|").map(decodeURIComponent).filter(Boolean)),
   openRow: P.get("open") ?? null,
+  medal: P.get("medal") ?? "",
   tp: tpParam != null && TPVALS.includes(+tpParam) ? tpParam : "",
 };
 document.getElementById("q").value = S.q;
 document.getElementById("group").value = S.group;
 document.getElementById("size").value = String(S.size);
 document.getElementById("tp").value = S.tp;
+document.getElementById("medal").value = S.medal;
 function syncUrl() {
   const p = new URLSearchParams();
   if (S.q) p.set("q", S.q);
@@ -258,6 +291,7 @@ function syncUrl() {
   const closed = [...S.closed].map(encodeURIComponent);
   if (closed.length) p.set("closed", closed.join("|"));
   if (S.openRow) p.set("open", S.openRow);
+  if (S.medal) p.set("medal", S.medal);
   if (S.tp) p.set("tp", S.tp);
   const qs = p.toString();
   history.replaceState(null, "", qs ? "?" + qs : location.pathname);
@@ -268,12 +302,13 @@ const esc = s => String(s).replace(/[&<>"]/g, c => ({"&":"&amp;","<":"&lt;",">":
 function filtered() {
   const q = S.q.toLowerCase();
   return DATA.filter(r => (!S.tp || String(r.tp) === S.tp) &&
-    (!q || (r.name + " " + r.title + " " + r.model + " " + r.quant + " " +
+    (!S.medal || (S.medal === "m" ? r.medal != null : String(r.medal) === S.medal)) &&
+    (!q || (r.name + " " + r.title + " " + r.model + " " + r.parent + " " + 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,
+  const gk = { mq: r => r.parent + "\0" + r.quant, model: r => r.parent,
                quant: r => r.quant, engine: r => r.engine }[S.group];
   const k = S.sort, d = S.dir;
   const val = r => {
@@ -340,13 +375,30 @@ function render() {
       const tip = c[0] === "last" && lr
         ? ` title="${esc(lr.date + (lr.time ? " " + lr.time : ""))}"` : "";
       const cls = (NUM.has(c[0]) ? "num" : "") + (c[0] === "name" ? " recipe" : "");
-      const body = v === "" || v == null
-        ? '<span class="dim">—</span>'
-        : esc(v) + (c[0] === "name" ? `<button class="copy" data-copy="${esc(r.name)}" title="copy recipe name">⧉</button>` : "");
+      let body;
+      if (c[0] === "stars") {
+        const si = starInfo(r);
+        const pe = si && latest(r, e => e.points); // both scores in the hint
+        body = !si
+          ? r.incomplete
+            ? '<span class="dim" title="bench started, not finished">◐</span>'
+            : '<span class="dim" title="no bench reports">—</span>'
+          : `<span title="${si.pct} /100${pe && pe.points ? " (" + pe.points + ")" : ""}">${starGlyphs(si)}</span>`;
+      } else if (v === "" || v == null) {
+        body = '<span class="dim">—</span>';
+      } else if (c[0] === "name") {
+        body = (MEDAL[r.medal] || "") + esc(v) +
+          `<button class="copy" data-copy="${esc(r.name)}" title="copy recipe name">⧉</button>`;
+      } else {
+        body = esc(v);
+      }
       return `<td${cls ? ` class="${cls}"` : ""}${tip}>${body}</td>`;
     }).join("") + "</tr>");
-    if (S.openRow === r.name)
-      out.push(`<tr class="hist"><td colspan="${vis.length}">${r.reports.length ? r.reports.map(e => {
+    if (S.openRow === r.name) {
+      // one toml section per recipe (the recipe.toml is not versioned);
+      // per-run report files stay on each line
+      const toml = `<a class="open" href="/r/${encodeURIComponent(r.name)}/recipe.toml" target="_blank" title="recipe.toml (highlighted)">⚙ toml</a>`;
+      const lines = r.reports.map(e => {
         const fl = e.files && e.files.length ? e.files : [e.file];
         const ts = (e.perf && e.perf.length)
           ? ` <span title="${esc(e.perf.map(p => "d" + p.d + " c" + p.c + ": " + p.tg + " t/s").join("  ·  "))}"> ${tg(e, 0, 1) ?? "—"} / ${tg(e, 0, 2) ?? "—"} t/s</span>`
@@ -354,7 +406,9 @@ function render() {
         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) + ")" : ""}${ts}`;
-      }).join("<br>") : "no reports"}</td></tr>`);
+      }).join("<br>");
+      out.push(`<tr class="hist"><td colspan="${vis.length}">${r.reports.length ? toml + " " + lines : "no reports"}</td></tr>`);
+    }
   }
   document.querySelector("tbody").innerHTML = out.join("");
 }
@@ -392,6 +446,7 @@ document.querySelector("tbody").addEventListener("click", e => {
 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("tp").addEventListener("change", e => { S.tp = e.target.value; S.page = 0; render(); });
+document.getElementById("medal").addEventListener("change", e => { S.medal = e.target.value; 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(); });

+ 15 - 0
src/report/parse.rs

@@ -33,6 +33,21 @@ pub(crate) fn parse_bench_json(content: &str) -> Option<(f64, Option<String>, Op
     Some((pct, points, ratio))
 }
 
+/// Did this bench cover the full scenario pool? The campaign's hardmode
+/// pool is 88 scenarios × 2 pt = 176 (`max_points` in the JSON). A run
+/// interrupted early writes fewer. Non-bench JSONs → false.
+pub(crate) fn bench_complete(content: &str) -> bool {
+    serde_json::from_str::<serde_json::Value>(content)
+        .ok()
+        .and_then(|v| {
+            v.get("scores")?
+                .get("max_points")?
+                .as_u64()
+                .map(|mp| mp == 176)
+        })
+        .unwrap_or(false)
+}
+
 /// First non-empty perf box among the given contents (md first, then log —
 /// older reports carry the box in the .md, `sm121 bench` runs in the .log).
 pub(crate) fn perf_from(md: Option<&str>, log: Option<&str>) -> Vec<PerfCell> {

+ 39 - 0
src/report/quality.rs

@@ -0,0 +1,39 @@
+//! Quality markers for collected recipes: bench-coverage stars and the
+//! per-category medal ranks.
+
+use super::{ReportEntry, ReportRow, latest_score};
+
+/// Reports exist but none finished the full scenario pool.
+pub(crate) fn incomplete(reports: &[ReportEntry]) -> bool {
+    !reports.is_empty() && !reports.iter().any(|e| e.done)
+}
+
+/// Gold/silver/bronze per (parent model, node count) category, ranked by
+/// latest score. Lanes of different node counts don't compete against each
+/// other (4x has more GPUs than 2x); recipes without any score don't take
+/// a medal. Ties break by recipe name (deterministic).
+pub(crate) fn award_medals(rows: &mut [ReportRow]) {
+    use std::cmp::Ordering;
+    let mut groups: std::collections::HashMap<(String, u32), Vec<usize>> =
+        std::collections::HashMap::new();
+    for (i, r) in rows.iter().enumerate() {
+        groups
+            .entry((r.parent.clone(), r.nodes.unwrap_or(1)))
+            .or_default()
+            .push(i);
+    }
+    for (_, mut idxs) in groups {
+        idxs.sort_by(|&a, &b| {
+            let pct = |i: usize| latest_score(&rows[i]).and_then(|e| e.pct);
+            pct(b)
+                .partial_cmp(&pct(a))
+                .unwrap_or(Ordering::Equal)
+                .then_with(|| rows[a].name.cmp(&rows[b].name))
+        });
+        for (rank, &i) in idxs.iter().enumerate().take(3) {
+            if latest_score(&rows[i]).is_some() {
+                rows[i].medal = Some(rank as u8 + 1);
+            }
+        }
+    }
+}

+ 21 - 14
src/report/serve.rs

@@ -122,9 +122,10 @@ async fn events_stream(
     Sse::new(first.chain(rest))
 }
 
-/// `GET /r/<recipe>/<file>` — full report text in a minimal pre page.
-/// Both path parts must be plain names (no traversal); the resolve walks
-/// every recipe dir, so stay strict.
+/// `GET /r/<recipe>/<file>` — full file text (report, log, json, or the
+/// recipe's own `recipe.toml`) served through the viewer page. Both path
+/// parts must be plain names (no traversal); the resolve walks every
+/// recipe dir, so stay strict.
 pub(crate) fn load_report(config: &Config, recipe: &str, file: &str) -> Option<String> {
     let plain = |s: &str| {
         !s.contains("..")
@@ -137,13 +138,24 @@ pub(crate) fn load_report(config: &Config, recipe: &str, file: &str) -> Option<S
     crate::config::recipe_dirs(config)
         .into_iter()
         .find_map(|dir| {
-            let p = dir.join(recipe).join("reports").join(file);
+            let base = dir.join(recipe);
+            // "recipe.toml" resolves from the recipe dir root, everything
+            // else from its reports/ subdir
+            let p = if file == "recipe.toml" {
+                base.join("recipe.toml")
+            } else {
+                base.join("reports").join(file)
+            };
             p.is_file()
                 .then(|| std::fs::read_to_string(p).ok())
                 .flatten()
         })
 }
 
+/// `GET /r/<recipe>/<file>` — file viewer page: rendered markdown (with a
+/// raw toggle), syntax-highlighted TOML/JSON, plain logs.
+const VIEW_TEMPLATE: &str = include_str!("view.html");
+
 async fn report_file(
     axum::extract::State(state): axum::extract::State<AppState>,
     axum::extract::Path((recipe, file)): Path<(String, String)>,
@@ -160,16 +172,11 @@ async fn report_file(
             let clean = String::from_utf8_lossy(&clean)
                 .replace("\r\n", "\n")
                 .replace('\r', "\n");
-            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>",
-                clean
-                    .replace('&', "&amp;")
-                    .replace('<', "&lt;")
-                    .replace('>', "&gt;")
-            ))
-            .into_response()
+            let meta = serde_json::json!({ "recipe": recipe, "file": file, "content": clean });
+            let meta = serde_json::to_string(&meta)
+                .unwrap_or_default()
+                .replace('<', "\\u003c");
+            axum::response::Html(VIEW_TEMPLATE.replace("{{META}}", &meta)).into_response()
         }
         None => (
             StatusCode::NOT_FOUND,

+ 76 - 43
src/report/tests.rs

@@ -1,7 +1,8 @@
+use super::parse::bench_complete;
+use super::quality::{award_medals, incomplete};
 use super::serve::load_report;
 use super::{
-    ReportRow, collect, detect_quant, extract_time, fmt_pct, html_page, markdown, parse_score,
-    slash_pair,
+    ReportRow, collect, detect_quant, extract_time, fmt_pct, html_page, parse_score, slash_pair,
 };
 use crate::config::Config;
 use std::fs;
@@ -143,6 +144,70 @@ fn collect_carries_file_and_time() {
     );
 }
 
+fn entry(pct: f64, done: bool) -> super::ReportEntry {
+    serde_json::from_value(serde_json::json!({
+        "date": "2026-01-02", "time": null, "label": "l", "file": "f.md",
+        "files": ["f.md"], "pct": pct, "points": null, "ratio": null,
+        "done": done, "perf": [],
+    }))
+    .unwrap()
+}
+
+fn qrow(name: &str, parent: &str, nodes: u32, pct: Option<f64>) -> ReportRow {
+    let reports = match pct {
+        Some(p) => serde_json::json!([
+            {"file": "f.md", "files": ["f.md"], "date": "2026-01-02", "label": "l", "pct": p, "done": true}
+        ]),
+        None => serde_json::json!([]),
+    };
+    serde_json::from_value(serde_json::json!({
+        "name": name, "title": name, "model": parent, "parent": parent,
+        "quant": "NVFP4", "engine": "vllm", "image": "img", "nodes": nodes,
+        "tp": nodes, "deploy": "docker", "port": 30000, "medal": null,
+        "reports": reports,
+    }))
+    .unwrap()
+}
+
+#[test]
+fn bench_complete_requires_full_pool() {
+    assert!(bench_complete(
+        r#"{"scores": {"final_score": 92, "total_points": 162, "max_points": 176}}"#
+    ));
+    // interrupted run: pool not fully covered
+    assert!(!bench_complete(
+        r#"{"scores": {"final_score": 40, "total_points": 70, "max_points": 88}}"#
+    ));
+    assert!(!bench_complete("not json"));
+}
+
+#[test]
+fn incomplete_flags_pool_not_finished() {
+    assert!(!incomplete(&[]));
+    assert!(incomplete(&[entry(0.0, false)]));
+    assert!(!incomplete(&[entry(0.0, false), entry(50.0, true)]));
+}
+
+#[test]
+fn medals_rank_within_parent_and_node_count() {
+    let mut rows = vec![
+        qrow("a", "M", 2, Some(90.0)),
+        qrow("b", "M", 2, Some(94.0)), // gold among 2-node M lanes
+        qrow("c", "M", 2, Some(88.0)), // bronze
+        qrow("d", "M", 4, Some(91.0)), // gold among 4-node lanes (own group)
+        qrow("e", "M", 2, None),       // no score — no medal
+        qrow("f", "N", 1, Some(50.0)), // gold of its own single-lane model
+    ];
+    award_medals(&mut rows);
+    let medal = |n: &str| rows.iter().find(|r| r.name == n).unwrap().medal;
+    assert_eq!(medal("b"), Some(1));
+    assert_eq!(medal("a"), Some(2));
+    assert_eq!(medal("c"), Some(3));
+    assert_eq!(medal("d"), Some(1));
+    assert_eq!(medal("e"), None);
+    assert_eq!(medal("f"), Some(1));
+}
+
 #[test]
 fn load_report_resolves_and_rejects_traversal() {
     let dir = fixture();
@@ -152,6 +217,15 @@ fn load_report_resolves_and_rejects_traversal() {
     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();
+    let toml = load_report(&config, "test-recipe-nvfp4", "recipe.toml");
+    fs::remove_dir_all(&dir).ok();
+    assert!(
+        toml.unwrap().contains("engine = \"vllm\""),
+        "recipe.toml served"
+    );
+    let dir = fixture();
     let config: Config =
         toml::from_str(&format!("recipe_sources = [\"{}\"]", dir.to_str().unwrap())).unwrap();
     fs::remove_dir_all(&dir).ok();
@@ -327,47 +401,6 @@ fn parse_perf_box_ignores_progress_spinner_soup() {
     );
 }
 
-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"], "perf": [], "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"], "perf": [{"d": 0, "c": 1, "tg": 27.4}, {"d": 0, "c": 2, "tg": 33.3}], "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 | 27.4 | 33.3 |"
-    ));
-    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 html_page_keeps_view_state_in_the_url() {
     // the full view (filter, grouping, sort, columns, paging) must be

+ 44 - 1
src/report/ui_tests.rs

@@ -3,7 +3,7 @@
 // shared helpers (fixture) live in tests.rs.
 
 use super::tests::fixture;
-use super::{collect, html_page, serve};
+use super::{ReportRow, collect, html_page, markdown, serve};
 use crate::config::Config;
 use std::fs;
 
@@ -143,3 +143,46 @@ async fn report_playwright_checks() {
     );
     eprintln!("{stdout}");
 }
+
+// ----- rendering (moved from tests.rs at the 500-line cap) -----
+
+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"], "perf": [], "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"], "perf": [{"d": 0, "c": 1, "tg": 27.4}, {"d": 0, "c": 2, "tg": 33.3}], "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 | 27.4 | 33.3 |"
+    ));
+    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"));
+}

+ 179 - 0
src/report/view.html

@@ -0,0 +1,179 @@
+<!doctype html>
+<html>
+<head>
+<meta charset="utf-8">
+<title>report</title>
+<style>
+  :root { color-scheme: light dark; }
+  body { font: 13px/1.5 system-ui, sans-serif; margin: 0; }
+  header { display: flex; gap: 10px; align-items: center; padding: 8px 16px;
+           border-bottom: 1px solid color-mix(in srgb, currentColor 20%, transparent); position: sticky; top: 0; background: canvas; z-index: 2; }
+  header a { color: inherit; }
+  #path { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+  #tabs { margin-left: auto; }
+  #tabs button { padding: 2px 10px; cursor: pointer; border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
+                 background: canvas; color: inherit; border-radius: 4px 0 0 4px; }
+  #tabs button + button { border-radius: 0 4px 4px 0; border-left: 0; }
+  #tabs button.on { background: color-mix(in srgb, currentColor 15%, canvas); font-weight: 600; }
+  pre { font: 12px/1.45 ui-monospace, monospace; white-space: pre-wrap; word-break: break-word; margin: 12px; }
+  article { max-width: 140ch; margin: 12px auto; padding: 0 16px; }
+  article h1, article h2, article h3 { margin: 1.1em 0 0.4em; line-height: 1.25; }
+  article table { border-collapse: collapse; margin: 0.6em 0; }
+  article th, article td { border: 1px solid color-mix(in srgb, currentColor 25%, transparent); padding: 3px 10px; }
+  article th { background: color-mix(in srgb, currentColor 8%, canvas); }
+  article code { font: 12px ui-monospace, monospace; background: color-mix(in srgb, currentColor 10%, canvas); padding: 1px 4px; border-radius: 3px; }
+  article pre { background: color-mix(in srgb, currentColor 8%, canvas); padding: 10px 12px; border-radius: 6px; }
+  article pre code { background: none; padding: 0; }
+  article blockquote { margin: 0.6em 0; padding: 4px 14px; border-left: 3px solid color-mix(in srgb, currentColor 35%, transparent); color: color-mix(in srgb, currentColor 75%, canvas); }
+  article li { margin: 2px 0; }
+  .cmt { color: light-dark(#8a8a8a, #9a9a9a); font-style: italic; }
+  .str { color: light-dark(#0a7d32, #8fd694); }
+  .key { color: light-dark(#0b5cad, #7ab8f5); }
+  .num { color: light-dark(#b35900, #f0a860); }
+  .kw  { color: light-dark(#7a2ea0, #c792ea); }
+</style>
+</head>
+<body>
+<header>
+  <span id="path"></span>
+  <span id="tabs"></span>
+</header>
+<div id="out"></div>
+<script>
+"use strict";
+const META = {{META}};               // {recipe, file, content}
+const EXT = (META.file.split(".").pop() || "").toLowerCase();
+const esc = s => String(s).replace(/[&<>"]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
+document.getElementById("path").textContent = META.recipe + " / " + META.file;
+document.title = META.recipe + " — " + META.file;
+
+// ---------- inline markdown (on escaped text) ----------
+function inline(s) {
+  return s
+    .replace(/`([^`]+)`/g, "<code>$1</code>")
+    .replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>")
+    .replace(/\*([^*]+)\*/g, "<i>$1</i>")
+    .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<a href="$2" target="_blank">$1</a>');
+}
+
+// ---------- minimal md block renderer: headings, tables, fences,
+// ---------- blockquotes, lists, hr, paragraphs (bench-report subset)
+function mdHtml(src) {
+  return "<article>" + mdBlocks(src.replace(/\r\n?/g, "\n").split("\n")) + "</article>";
+}
+function mdBlocks(lines) {
+  const out = [];
+  let i = 0;
+  const cells = r => r.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map(c => c.trim());
+  while (i < lines.length) {
+    const l = lines[i];
+    if (/^```/.test(l)) {
+      let j = i + 1;
+      while (j < lines.length && !/^```/.test(lines[j])) j++;
+      out.push("<pre><code>" + esc(lines.slice(i + 1, j).join("\n")) + "</code></pre>");
+      i = j + 1; continue;
+    }
+    if (/^\s*\|.*\|\s*$/.test(l) && i + 1 < lines.length && /^\s*\|[\s:|-]+\|\s*$/.test(lines[i + 1])) {
+      const align = cells(lines[i + 1]).map(c => (/:-+:$/.test(c) ? "center" : /-+:$/.test(c) ? "right" : "left"));
+      let rows = "<tr>" + cells(l).map((c, k) => `<th style="text-align:${align[k] || "left"}">${inline(esc(c))}</th>`).join("") + "</tr>";
+      i += 2;
+      while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) {
+        rows += "<tr>" + cells(lines[i]).map((c, k) => `<td style="text-align:${align[k] || "left"}">${inline(esc(c))}</td>`).join("") + "</tr>";
+        i++;
+      }
+      out.push(`<table>${rows}</table>`); continue;
+    }
+    const h = l.match(/^(#{1,6})\s+(.*)/);
+    if (h) { out.push(`<h${h[1].length}>${inline(esc(h[2]))}</h${h[1].length}>`); i++; continue; }
+    if (/^\s*(-{3,}|\*{3,})\s*$/.test(l)) { out.push("<hr>"); i++; continue; }
+    if (/^\s*>\s?/.test(l)) {
+      let j = i;
+      while (j < lines.length && /^\s*>\s?/.test(lines[j])) j++;
+      out.push("<blockquote>" + mdBlocks(lines.slice(i, j).map(x => x.replace(/^\s*>\s?/, ""))) + "</blockquote>");
+      i = j; continue;
+    }
+    const li = l.match(/^\s*([-*]|\d+\.)\s+/);
+    if (li) {
+      const ul = li[1].startsWith("-");
+      const re = ul ? /^\s*[-*]\s+/ : /^\s*\d+\.\s+/;
+      let j = i;
+      while (j < lines.length && re.test(lines[j])) j++;
+      out.push(`<${ul ? "ul" : "ol"}>` + lines.slice(i, j).map(x => `<li>${inline(esc(x.replace(re, "")))}</li>`).join("") + `</${ul ? "ul" : "ol"}>`);
+      i = j; continue;
+    }
+    if (!l.trim()) { i++; continue; }
+    let j = i;
+    while (j < lines.length && lines[j].trim() &&
+           !/^(#{1,6}\s|\s*[-*]\s|\s*\d+\.\s|\s*>|\s*\||```)/.test(lines[j])) j++;
+    out.push("<p>" + inline(esc(lines.slice(i, j).join(" "))) + "</p>");
+    i = j;
+  }
+  return out.join("\n");
+}
+
+// ---------- syntax highlighters (on escaped text; esc turns " into
+// ---------- &quot;, so raw triple-quotes show up as &quot;×3)
+const TQ = "&quot;&quot;&quot;";
+// strings, bools, numbers outside of strings — shared by kv values and
+// bare array-item lines
+function vals(v) {
+  const wrap = (cls, s) => `<span class="${cls}">${s}</span>`;
+  return v.split(/&quot;(?:[^&]|&(?!quot;))*?&quot;/).length === 1
+    ? v.replace(/(^|[\s,\[])(true|false)([\s,\]]|$)/g, '$1<span class="kw">$2</span>$3')
+       .replace(/\b\d[\d_]*(?:\.\d+)?\b/g, m => wrap("num", m))
+    : v.split(/(&quot;(?:[^&]|&(?!quot;))*?&quot;)/).map(p =>
+        p.startsWith("&quot;") ? wrap("str", p)
+        : p.replace(/(^|[\s,\[])(true|false)([\s,\]]|$)/g, '$1<span class="kw">$2</span>$3')
+           .replace(/\b\d[\d_]*(?:\.\d+)?\b/g, m => wrap("num", m))
+      ).join("");
+}
+function hlToml(escaped) {
+  let inStr = false;
+  return escaped.split("\n").map(line => {
+    const wrap = (cls, s) => `<span class="${cls}">${s}</span>`;
+    const q = line.split(TQ).length - 1;
+    if (inStr) {
+      if (q > 0) inStr = false; // closing """ on this line
+      return wrap("str", line);
+    }
+    if (q === 1) inStr = true;  // opening """ — value handled below
+    if (/^\s*#/.test(line)) return wrap("cmt", line);
+    const kv = line.match(/^(\s*)([A-Za-z0-9_.\[\]-]+)(\s*=\s*)(.*)$/);
+    if (kv) return kv[1] + wrap("key", kv[2]) + kv[3] + (q >= 1 ? wrap("str", kv[4]) : vals(kv[4]));
+    if (/^\s*\[/.test(line)) return wrap("key", line);
+    return vals(line);
+  }).join("\n");
+}
+
+function hlJson(src) {
+  let pretty = src;
+  try { pretty = JSON.stringify(JSON.parse(src), null, 2); } catch { /* keep raw */ }
+  return esc(pretty)
+    .replace(/(&quot;[^&]*?&quot;)(:)/g, '<span class="key">$1</span>$2')
+    .replace(/(: )&quot;((?:[^&]|&(?!quot;))*?)&quot;/g, '$1<span class="str">&quot;$2&quot;</span>')
+    .replace(/(: )(-?\d+\.?\d*(?:[eE][+-]?\d+)?)/g, '$1<span class="num">$2</span>')
+    .replace(/(: )(true|false|null)/g, '$1<span class="kw">$2</span>');
+}
+
+// ---------- render ----------
+let mode = EXT === "md" ? "rendered" : "raw";
+const HAS_TABS = EXT === "md";
+function paint() {
+  const out = document.getElementById("out");
+  if (mode === "rendered") out.innerHTML = mdHtml(META.content);
+  else {
+    const raw = esc(META.content.replace(/\r\n?/g, "\n"));
+    out.innerHTML = "<pre>" + (EXT === "toml" ? hlToml(raw) : EXT === "json" ? hlJson(META.content) : raw) + "</pre>";
+  }
+  if (HAS_TABS) {
+    document.getElementById("tabs").innerHTML =
+      `<button id="t-r" class="${mode === "rendered" ? "on" : ""}">Rendered</button>` +
+      `<button id="t-w" class="${mode === "raw" ? "on" : ""}">Raw</button>`;
+    document.getElementById("t-r").onclick = () => { mode = "rendered"; paint(); };
+    document.getElementById("t-w").onclick = () => { mode = "raw"; paint(); };
+  }
+}
+paint();
+</script>
+</body>
+</html>

+ 1 - 0
src/tmux/commands.rs

@@ -145,6 +145,7 @@ mod tests {
     fn trecipe() -> Recipe {
         Recipe {
             build_profile: None,
+            parent_model: None,
             engine: "vllm".into(),
             name: "T".into(),
             docker_image: "img".into(),