Просмотр исходного кода

report: three-tier medals (family/parent, model, per-TP) + Parent and Parent->Model grouping

- medals: p = best of the whole family, m = best of the model repo, t = best of (parent, tp); gold/silver/bronze per tier, scoreless recipes unranked, ties by name; badge shows earned slots joined by '|' with a per-tier tooltip
- grouping: new 'Parent -> Model' (two-level tree) and 'Parent' group modes alongside model/quant/engine
- medal filter: 'any' matches any tier, gold/silver/bronze match the family tier
Alexander Korolev 1 месяц назад
Родитель
Сommit
953108ee71
4 измененных файлов с 119 добавлено и 39 удалено
  1. 3 4
      src/report/mod.rs
  2. 19 5
      src/report/page.html
  3. 41 11
      src/report/quality.rs
  4. 56 19
      src/report/tests.rs

+ 3 - 4
src/report/mod.rs

@@ -90,10 +90,9 @@ pub(crate) struct ReportRow {
     /// Lifetime launch count from the .lock ledger.
     /// Lifetime launch count from the .lock ledger.
     #[serde(default)]
     #[serde(default)]
     runs: u64,
     runs: u64,
-    /// 1 gold / 2 silver / 3 bronze — best lanes per (parent, nodes), by
-    /// latest score. More nodes = more GPUs, so sizes don't compete.
+    /// Family / model / per-TP medal ranks (see [`quality::Medals`]).
     #[serde(default)]
     #[serde(default)]
-    medal: Option<u8>,
+    medals: quality::Medals,
     reports: Vec<ReportEntry>,
     reports: Vec<ReportEntry>,
 }
 }
 
 
@@ -368,7 +367,7 @@ pub(crate) fn collect(config: &Config, show_archived: bool) -> Vec<ReportRow> {
                 incomplete,
                 incomplete,
                 up: lock.running.as_ref().map(|r| r.start),
                 up: lock.running.as_ref().map(|r| r.start),
                 runs: lock.total,
                 runs: lock.total,
-                medal: None,
+                medals: quality::Medals::default(),
                 reports,
                 reports,
             });
             });
         }
         }

+ 19 - 5
src/report/page.html

@@ -31,6 +31,7 @@
   td.recipe:hover .copy { visibility: visible; }
   td.recipe:hover .copy { visibility: visible; }
   td.recipe .copy:hover { background: color-mix(in srgb, currentColor 12%, canvas); }
   td.recipe .copy:hover { background: color-mix(in srgb, currentColor 12%, canvas); }
   td.recipe .copied { color: green; font-weight: 600; }
   td.recipe .copied { color: green; font-weight: 600; }
+  td.recipe .medals { letter-spacing: 1px; }
   .half { -webkit-background-clip: text; background-clip: text; color: transparent; }
   .half { -webkit-background-clip: text; background-clip: text; color: transparent; }
   td.num { text-align: right; white-space: nowrap; }
   td.num { text-align: right; white-space: nowrap; }
   #count { color: gray; }
   #count { color: gray; }
@@ -58,6 +59,8 @@
   <input id="q" type="search" placeholder="Search recipes, models, images, labels…">
   <input id="q" type="search" placeholder="Search recipes, models, images, labels…">
   <label>Group:
   <label>Group:
     <select id="group">
     <select id="group">
+      <option value="pm">Parent → Model</option>
+      <option value="parent">Parent</option>
       <option value="mq">Model → Quant</option>
       <option value="mq">Model → Quant</option>
       <option value="model">Model</option>
       <option value="model">Model</option>
       <option value="quant">Quant</option>
       <option value="quant">Quant</option>
@@ -103,7 +106,15 @@ const COLS = [
   ["tps1", "Tok/s c1"], ["tps2", "Tok/s c2"], ["parent", "Parent"],
   ["tps1", "Tok/s c1"], ["tps2", "Tok/s c2"], ["parent", "Parent"],
 ];
 ];
 const NUM = new Set(["nodes", "tp", "port", "n", "pct", "tps1", "tps2", "stars", "state", "uptime", "runs"]);
 const NUM = new Set(["nodes", "tp", "port", "n", "pct", "tps1", "tps2", "stars", "state", "uptime", "runs"]);
-const MEDAL = { 1: "🥇 ", 2: "🥈 ", 3: "🥉 " };
+const MEDAL = { 1: "🥇", 2: "🥈", 3: "🥉" };
+// three-tier medal badge: family | model | tp (slots shown only when earned)
+function medalBadge(r) {
+  const M = r.medals || {};
+  const g = v => MEDAL[v] || "";
+  const parts = [g(M.p), g(M.m), g(M.t)].filter(Boolean);
+  if (!parts.length) return "";
+  return `<span class="medals" title="family ${g(M.p) || "–"} · model ${g(M.m) || "–"} · tp${r.tp} ${g(M.t) || "–"}">${parts.join("|")}</span> `;
+}
 
 
 // run-ledger helpers: who is up right now, for how long (live — a 30 s tick
 // run-ledger helpers: who is up right now, for how long (live — a 30 s tick
 // re-renders while anything is running)
 // re-renders while anything is running)
@@ -342,13 +353,14 @@ function filtered() {
   const q = S.q.toLowerCase();
   const q = S.q.toLowerCase();
   return DATA.filter(r => (!S.tp || String(r.tp) === S.tp) &&
   return DATA.filter(r => (!S.tp || String(r.tp) === S.tp) &&
     (!S.uponly || r.up != null) &&
     (!S.uponly || r.up != null) &&
-    (!S.medal || (S.medal === "m" ? r.medal != null : String(r.medal) === S.medal)) &&
+    (!S.medal || (S.medal === "m" ? (r.medals && (r.medals.p || r.medals.m || r.medals.t)) : r.medals && String(r.medals.p) === S.medal)) &&
     (!q || (r.name + " " + r.title + " " + r.model + " " + r.parent + " " + r.quant + " " +
     (!q || (r.name + " " + r.title + " " + r.model + " " + r.parent + " " + r.quant + " " +
     r.engine + " " + r.image + " " + r.deploy + " " +
     r.engine + " " + r.image + " " + r.deploy + " " +
     r.reports.map(e => e.date + " " + e.label).join(" ")).toLowerCase().includes(q)));
     r.reports.map(e => e.date + " " + e.label).join(" ")).toLowerCase().includes(q)));
 }
 }
 function sorted(rows) {
 function sorted(rows) {
-  const gk = { mq: r => r.parent + "\0" + r.quant, model: r => r.parent,
+  const gk = { mq: r => r.parent + "\0" + r.quant, pm: r => r.parent + "\0" + r.model,
+               parent: r => r.parent, model: r => r.parent,
                quant: r => r.quant, engine: r => r.engine }[S.group];
                quant: r => r.quant, engine: r => r.engine }[S.group];
   const k = S.sort, d = S.dir;
   const k = S.sort, d = S.dir;
   const val = r => {
   const val = r => {
@@ -365,6 +377,8 @@ function sorted(rows) {
   });
   });
 }
 }
 function gkey(r, level) {
 function gkey(r, level) {
+  if (S.group === "pm") return level === 1 ? r.parent : r.parent + "\u0001" + r.model;
+  if (S.group === "parent") return level === 1 ? r.parent : null;
   if (S.group === "mq") return level === 1 ? r.model : r.model + "\u0001" + r.quant;
   if (S.group === "mq") return level === 1 ? r.model : r.model + "\u0001" + r.quant;
   const f = { model: "model", quant: "quant", engine: "engine" }[S.group];
   const f = { model: "model", quant: "quant", engine: "engine" }[S.group];
   return level === 1 ? r[f] : null;
   return level === 1 ? r[f] : null;
@@ -404,7 +418,7 @@ function render() {
         if (k2 !== g2) {
         if (k2 !== g2) {
           g2 = k2;
           g2 = k2;
           const closed = S.closed.has("2\0" + 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>`);
+          out.push(`<tr class="g2" data-g="${encodeURIComponent("2\0" + k2)}"><td colspan="${vis.length}">${closed ? "▶" : "▼"} ${esc(S.group === "pm" ? r.model : r.quant)} <span class="dim">(${all.filter(x => gkey(x, 2) === k2).length})</span></td></tr>`);
         }
         }
         if (S.closed.has("2\0" + k2)) continue;
         if (S.closed.has("2\0" + k2)) continue;
       }
       }
@@ -435,7 +449,7 @@ function render() {
       } else if (v === "" || v == null) {
       } else if (v === "" || v == null) {
         body = '<span class="dim">—</span>';
         body = '<span class="dim">—</span>';
       } else if (c[0] === "name") {
       } else if (c[0] === "name") {
-        body = (MEDAL[r.medal] || "") + esc(v) +
+        body = medalBadge(r) + esc(v) +
           `<button class="copy" data-copy="${esc(r.name)}" title="copy recipe name">⧉</button>`;
           `<button class="copy" data-copy="${esc(r.name)}" title="copy recipe name">⧉</button>`;
       } else {
       } else {
         body = esc(v);
         body = esc(v);

+ 41 - 11
src/report/quality.rs

@@ -1,6 +1,8 @@
 //! Quality markers for collected recipes: bench-coverage stars and the
 //! Quality markers for collected recipes: bench-coverage stars and the
 //! per-category medal ranks.
 //! per-category medal ranks.
 
 
+use serde::{Deserialize, Serialize};
+
 use super::render::latest_score;
 use super::render::latest_score;
 use super::{ReportEntry, ReportRow};
 use super::{ReportEntry, ReportRow};
 
 
@@ -9,20 +11,34 @@ pub(crate) fn incomplete(reports: &[ReportEntry]) -> bool {
     !reports.is_empty() && !reports.iter().any(|e| e.done)
     !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]) {
+/// The three medal tiers, gold/silver/bronze each (1/2/3), ranked by
+/// latest score — the full championship picture:
+/// - `p`: within the parent **family** (all models, all TP sizes),
+/// - `m`: within the parent's **model** repo (all TP sizes),
+/// - `t`: within **(parent, tp)** — lanes of different TP sizes don't
+///   compete (4x has more GPUs than 2x).
+///
+/// Recipes without any score don't take a medal; ties break by name.
+#[derive(Serialize, Deserialize, Clone, Copy, Default, PartialEq, Debug)]
+pub(crate) struct Medals {
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub(crate) p: Option<u8>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub(crate) m: Option<u8>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub(crate) t: Option<u8>,
+}
+
+/// Rank recipes (1..3) inside groups defined by `key`; scoreless recipes
+/// stay unranked.
+fn rank_by_key(rows: &[ReportRow], key: impl Fn(&ReportRow) -> String) -> Vec<Option<u8>> {
     use std::cmp::Ordering;
     use std::cmp::Ordering;
-    let mut groups: std::collections::HashMap<(String, u32), Vec<usize>> =
+    let mut groups: std::collections::HashMap<String, Vec<usize>> =
         std::collections::HashMap::new();
         std::collections::HashMap::new();
     for (i, r) in rows.iter().enumerate() {
     for (i, r) in rows.iter().enumerate() {
-        groups
-            .entry((r.parent.clone(), r.nodes.unwrap_or(1)))
-            .or_default()
-            .push(i);
+        groups.entry(key(r)).or_default().push(i);
     }
     }
+    let mut out = vec![None; rows.len()];
     for (_, mut idxs) in groups {
     for (_, mut idxs) in groups {
         idxs.sort_by(|&a, &b| {
         idxs.sort_by(|&a, &b| {
             let pct = |i: usize| latest_score(&rows[i]).and_then(|e| e.pct);
             let pct = |i: usize| latest_score(&rows[i]).and_then(|e| e.pct);
@@ -33,8 +49,22 @@ pub(crate) fn award_medals(rows: &mut [ReportRow]) {
         });
         });
         for (rank, &i) in idxs.iter().enumerate().take(3) {
         for (rank, &i) in idxs.iter().enumerate().take(3) {
             if latest_score(&rows[i]).is_some() {
             if latest_score(&rows[i]).is_some() {
-                rows[i].medal = Some(rank as u8 + 1);
+                out[i] = Some(rank as u8 + 1);
             }
             }
         }
         }
     }
     }
+    out
+}
+
+pub(crate) fn award_medals(rows: &mut [ReportRow]) {
+    let p = rank_by_key(rows, |r| r.parent.clone());
+    let m = rank_by_key(rows, |r| format!("{}\u{1}{}", r.parent, r.model));
+    let t = rank_by_key(rows, |r| format!("{}\u{1}tp{}", r.parent, r.tp));
+    for (i, r) in rows.iter_mut().enumerate() {
+        r.medals = Medals {
+            p: p[i],
+            m: m[i],
+            t: t[i],
+        };
+    }
 }
 }

+ 56 - 19
src/report/tests.rs

@@ -1,5 +1,5 @@
 use super::parse::bench_complete;
 use super::parse::bench_complete;
-use super::quality::{award_medals, incomplete};
+use super::quality::{Medals, award_medals, incomplete};
 use super::render::fmt_pct;
 use super::render::fmt_pct;
 use super::serve::load_report;
 use super::serve::load_report;
 use super::{ReportRow, collect, detect_quant, extract_time, html_page, parse_score, slash_pair};
 use super::{ReportRow, collect, detect_quant, extract_time, html_page, parse_score, slash_pair};
@@ -152,7 +152,7 @@ fn entry(pct: f64, done: bool) -> super::ReportEntry {
     .unwrap()
     .unwrap()
 }
 }
 
 
-fn qrow(name: &str, parent: &str, nodes: u32, pct: Option<f64>) -> ReportRow {
+fn qrow(name: &str, parent: &str, model: &str, tp: u32, pct: Option<f64>) -> ReportRow {
     let reports = match pct {
     let reports = match pct {
         Some(p) => serde_json::json!([
         Some(p) => serde_json::json!([
             {"file": "f.md", "files": ["f.md"], "date": "2026-01-02", "label": "l", "pct": p, "done": true}
             {"file": "f.md", "files": ["f.md"], "date": "2026-01-02", "label": "l", "pct": p, "done": true}
@@ -160,9 +160,9 @@ fn qrow(name: &str, parent: &str, nodes: u32, pct: Option<f64>) -> ReportRow {
         None => serde_json::json!([]),
         None => serde_json::json!([]),
     };
     };
     serde_json::from_value(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,
+        "name": name, "title": name, "model": model, "parent": parent,
+        "quant": "NVFP4", "engine": "vllm", "image": "img", "nodes": tp,
+        "tp": tp, "deploy": "docker", "port": 30000,
         "reports": reports,
         "reports": reports,
     }))
     }))
     .unwrap()
     .unwrap()
@@ -188,23 +188,60 @@ fn incomplete_flags_pool_not_finished() {
 }
 }
 
 
 #[test]
 #[test]
-fn medals_rank_within_parent_and_node_count() {
+fn medals_rank_family_model_and_tp_tiers() {
     let mut rows = vec![
     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
+        qrow("a", "M", "m1", 2, Some(90.0)),
+        qrow("b", "M", "m1", 2, Some(94.0)), // family gold, model gold, tp(2) gold
+        qrow("c", "M", "m1", 2, Some(88.0)), // family bronze, model bronze, tp(2) bronze
+        qrow("d", "M", "m2", 4, Some(91.0)), // family silver, model-m2 gold, tp(4) gold
+        qrow("e", "M", "m1", 2, None),       // no score — no medal anywhere
+        qrow("f", "N", "n1", 1, Some(50.0)), // own family/model/tp golds
     ];
     ];
     award_medals(&mut rows);
     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));
+    let got = |n: &str| rows.iter().find(|r| r.name == n).unwrap().medals;
+    assert_eq!(
+        got("b"),
+        Medals {
+            p: Some(1),
+            m: Some(1),
+            t: Some(1)
+        }
+    );
+    assert_eq!(
+        got("a"),
+        Medals {
+            p: Some(3),
+            m: Some(2),
+            t: Some(2)
+        }
+    );
+    // family rank 4 (d=91 outranks) — no family medal, but model/tp medals hold
+    assert_eq!(
+        got("c"),
+        Medals {
+            p: None,
+            m: Some(3),
+            t: Some(3)
+        }
+    );
+    assert_eq!(
+        got("d"),
+        Medals {
+            p: Some(2),
+            m: Some(1),
+            t: Some(1)
+        },
+        "family silver; tp(4) own group gold"
+    );
+    assert_eq!(got("e"), Medals::default());
+    assert_eq!(
+        got("f"),
+        Medals {
+            p: Some(1),
+            m: Some(1),
+            t: Some(1)
+        }
+    );
 }
 }
 
 
 #[test]
 #[test]