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

bench run package: per-run recipe copy + provenance manifest (recipe/config sha, launcher git sha, tool version, per-node image digest+build label); report shows provenance chip + Provenance column with drift flag

Alexander Korolev 1 місяць тому
батько
коміт
081a0e6
10 змінених файлів з 590 додано та 11 видалено
  1. 10 0
      AGENTS.md
  2. 1 0
      locales/en/main.ftl
  3. 1 0
      locales/ru/main.ftl
  4. 16 0
      src/bench.rs
  5. 343 0
      src/bench_artifacts.rs
  6. 1 0
      src/main.rs
  7. 31 9
      src/report/mod.rs
  8. 8 1
      src/report/page.html
  9. 178 0
      src/report/provenance.rs
  10. 1 1
      src/report/ui_tests.rs

+ 10 - 0
AGENTS.md

@@ -30,6 +30,16 @@ scratch-cwd/absolute-json/log juggling, polls the API for readiness first,
 and prints the final score + perf rows. Defaults: seed 42, hardmode, perf,
 concurrency 1,2; served name/port from the recipe.
 
+**Run package (versioning per run):** every bench run also lands two more
+files under the same stem — `<stem>.recipe.toml` (a byte-exact copy of the
+recipe file as it stood at bench time) and `<stem>.manifest.json` (recipe +
+config SHA-256, launcher git commit, tool-eval-bench version, and the served
+image's identity per node — docker id + `sm121.checksum` build label,
+captured over SSH at bench time). `sm121 report` reads the manifests back:
+each run shows a provenance chip (`⚙ img@<buildsum12>·rcp@<sha12>`, ⚠ when
+the recipe has since drifted from the copy) and the markdown table gets a
+Provenance column. Old runs without a package show `—` / no chip.
+
 `sm121 web` — local axum + SSE dashboard: LED board (cluster-level health,
 non-blocking `extra_entities` as ⚙ badges), themes, serve/stop/power jobs
 with type-the-name power gate, live log-less jobs window fed by docker

+ 1 - 0
locales/en/main.ftl

@@ -211,6 +211,7 @@ report-serving = Interactive recipe report: { $url }  (live updates; Ctrl-C to s
 report-col-model = Model
 report-col-quant = Quant
 report-col-recipe = Recipe
+report-col-prov = Provenance
 report-col-engine = Engine
 report-col-nodes = Nodes×TP
 report-col-reports = Reports

+ 1 - 0
locales/ru/main.ftl

@@ -222,6 +222,7 @@ report-serving = Интерактивный отчёт по рецептам: {
 report-col-model = Модель
 report-col-quant = Квант
 report-col-recipe = Рецепт
+report-col-prov = Происхождение
 report-col-engine = Движок
 report-col-nodes = Узлы×TP
 report-col-reports = Отчёты

+ 16 - 0
src/bench.rs

@@ -289,6 +289,7 @@ pub async fn run(config: &Config, a: &Args, dry_run: bool) -> Result<()> {
     let base_url = format!("http://{}:{}/v1", head.ssh_ip, port);
     let stamp = utc_stamp();
     let label = sanitize(&a.label);
+    let stem = format!("{stamp}_{label}");
     let reports_dir = file
         .parent()
         .ok_or_else(|| anyhow::anyhow!("no parent dir for {}", file.display()))?
@@ -322,6 +323,10 @@ pub async fn run(config: &Config, a: &Args, dry_run: bool) -> Result<()> {
         println!("bench: cwd {}", scratch.display());
         println!("bench: {} {}", bin, args.join(" "));
         println!("bench: reports -> {}", reports_dir.display());
+        println!(
+            "bench: run package -> {}.recipe.toml + {}.manifest.json",
+            stem, stem
+        );
         return Ok(());
     }
 
@@ -361,6 +366,17 @@ pub async fn run(config: &Config, a: &Args, dry_run: bool) -> Result<()> {
             println!("{line}");
         }
     }
+    crate::bench_artifacts::write_run_package(&crate::bench_artifacts::RunPackage {
+        config,
+        cluster: &cluster,
+        recipe: &recipe,
+        recipe_file: &file,
+        stem: stem.as_str(),
+        reports_dir: &reports_dir,
+        bench_args: &args,
+        bench_json: &j,
+    })
+    .await?;
     eprintln!("bench: reports landed in {}", reports_dir.display());
     Ok(())
 }

+ 343 - 0
src/bench_artifacts.rs

@@ -0,0 +1,343 @@
+//! Versioned run package for `sm121 bench`.
+//!
+//! Every bench run lands next to its reports in the recipe's `reports/`
+//! dir (same stem as the report triplet):
+//!
+//! - `<stem>.recipe.toml` — a byte-exact copy of the recipe file the
+//!   serve was launched from, so the run's recipe is pinned even after
+//!   the recipe (or its image pins) move on.
+//! - `<stem>.manifest.json` — provenance: recipe + config SHA-256, the
+//!   launcher's git commit, the bench tool's version, and the served
+//!   image's identity per node (docker id + `sm121.checksum` build
+//!   label, captured over SSH at bench time).
+//!
+//! `sm121 report` reads the manifests back (see [`crate::report`]) and
+//! shows each run's provenance — and flags a run whose recipe has since
+//! changed (drift).
+
+use std::fs;
+use std::path::Path;
+
+use anyhow::Result;
+use sha2::{Digest, Sha256};
+
+use crate::config::{Cluster, Config, Recipe};
+use crate::ssh::run_ssh;
+
+/// SHA-256 of a byte slice as a 64-char lowercase hex string.
+pub fn sha256_hex(bytes: &[u8]) -> String {
+    let digest = Sha256::digest(bytes);
+    digest.iter().map(|b| format!("{b:02x}")).collect()
+}
+
+/// First 12 chars — the display width for hashes in the report UI.
+pub fn short12(s: &str) -> String {
+    s.chars().take(12).collect()
+}
+
+/// UTC "YYYY-MM-DDTHH:MM:SSZ" for the manifest's capture time.
+fn utc_now_iso() -> String {
+    let secs = std::time::SystemTime::now()
+        .duration_since(std::time::UNIX_EPOCH)
+        .map(|d| d.as_secs())
+        .unwrap_or(0);
+    let (y, m, d) = civil_date(secs as i64);
+    let rem = (secs % 86400) as u32;
+    format!(
+        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
+        y,
+        m,
+        d,
+        rem / 3600,
+        rem % 3600 / 60,
+        rem % 60
+    )
+}
+
+/// Days-since-civil conversion (Hansen/Clayton), same as `bench::civil_date`.
+fn civil_date(z: i64) -> (u32, u32, u32) {
+    let z = z + 719_468;
+    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
+    let doe = z - era * 146_097;
+    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
+    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
+    let mp = (5 * doy + 2) / 153;
+    let day = doy - (153 * mp + 2) / 5 + 1;
+    let month = if mp < 10 { mp + 3 } else { mp - 9 };
+    let year = era * 400 + yoe + if month <= 2 { 1 } else { 0 };
+    (year as u32, month as u32, day as u32)
+}
+
+#[derive(serde::Serialize, serde::Deserialize, Default, Debug, Clone)]
+pub struct ManifestRecipe {
+    pub name: String,
+    /// Path of the recipe file as resolved by the launcher.
+    pub file: String,
+    pub sha256: String,
+}
+
+#[derive(serde::Serialize, serde::Deserialize, Default, Debug, Clone)]
+pub struct ManifestCluster {
+    pub name: String,
+    /// ssh_ip per node (node order from the cluster definition).
+    pub nodes: Vec<String>,
+}
+
+#[derive(serde::Serialize, serde::Deserialize, Default, Debug, Clone)]
+pub struct ManifestConfig {
+    pub file: String,
+    pub sha256: String,
+}
+
+#[derive(serde::Serialize, serde::Deserialize, Default, Debug, Clone)]
+pub struct ManifestLauncher {
+    /// Canonical path of the `sm121` binary that ran the bench.
+    pub bin: String,
+    /// `git rev-parse HEAD` of the repo the binary was built in (cwd),
+    /// null when the cwd is not a git checkout.
+    pub git_sha: Option<String>,
+}
+
+#[derive(serde::Serialize, serde::Deserialize, Default, Debug, Clone)]
+pub struct ManifestBench {
+    pub tool: String,
+    /// `tool_eval_bench_version` from the run's JSON report.
+    pub version: Option<String>,
+    /// The exact `tool-eval-bench` arguments (base-url, model, seed, …).
+    pub args: Vec<String>,
+    pub final_score: Option<u32>,
+    /// `total/max` points, e.g. "163/176".
+    pub points: Option<String>,
+}
+
+/// One per bench run; the top-level shape of `<stem>.manifest.json`.
+#[derive(serde::Serialize, serde::Deserialize, Default, Debug, Clone)]
+pub struct Manifest {
+    pub schema_version: u32,
+    /// `<YYYY-MM-DD>_<label>` run stem (report triplet base name).
+    pub run: String,
+    /// UTC ISO-8601 capture time.
+    pub created_utc: String,
+    pub recipe: ManifestRecipe,
+    pub cluster: ManifestCluster,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub config: Option<ManifestConfig>,
+    pub launcher: ManifestLauncher,
+    pub bench: ManifestBench,
+    /// Docker image name → `{ "build_checksum": "…", "nodes": { name →
+    /// { "id", "sm121_checksum" } } }`. Nodes without the image are
+    /// absent from `nodes`.
+    pub images: serde_json::Map<String, serde_json::Value>,
+}
+
+/// `docker image inspect` the served image on every cluster node and
+/// collect its id + `sm121.checksum` label. Nodes where the image is
+/// missing (not in the launch set) are skipped, not errors.
+pub async fn capture_images(config: &Config, cluster: &Cluster, image: &str) -> serde_json::Value {
+    let mut nodes = serde_json::Map::new();
+    let mut build_checksum = String::new();
+    for n in &cluster.nodes {
+        let cmd = format!(
+            "docker image inspect --format '{{{{.Id}}}}|{{{{index .Config.Labels \"sm121.checksum\"}}}}' {image}"
+        );
+        let Ok(out) = run_ssh(config, n, cmd, false).await else {
+            continue;
+        };
+        let out = out.trim();
+        let (id, sum) = out
+            .split_once('|')
+            .map(|(a, b)| (a.to_string(), b.to_string()))
+            .unwrap_or_else(|| (out.to_string(), String::new()));
+        if build_checksum.is_empty() && !sum.is_empty() {
+            build_checksum = sum.clone();
+        }
+        nodes.insert(
+            n.name.clone(),
+            serde_json::json!({ "id": id, "sm121_checksum": sum }),
+        );
+    }
+    serde_json::json!({ "build_checksum": build_checksum, "nodes": nodes })
+}
+
+/// Inputs for [`write_run_package`] (bundled: the signature would
+/// otherwise trip `too_many_arguments`).
+pub struct RunPackage<'a> {
+    pub config: &'a Config,
+    pub cluster: &'a Cluster,
+    pub recipe: &'a Recipe,
+    pub recipe_file: &'a Path,
+    /// `<YYYY-MM-DD>_<label>` run stem (report triplet base name).
+    pub stem: &'a str,
+    pub reports_dir: &'a Path,
+    pub bench_args: &'a [String],
+    pub bench_json: &'a serde_json::Value,
+}
+
+/// Write the run package (`<stem>.recipe.toml` + `<stem>.manifest.json`)
+/// into `reports_dir`. The recipe copy is the bytes of `recipe_file` as
+/// read at bench time; image identity is captured live per node.
+pub async fn write_run_package(p: &RunPackage<'_>) -> Result<()> {
+    let RunPackage {
+        config,
+        cluster,
+        recipe,
+        recipe_file,
+        stem,
+        reports_dir,
+        bench_args,
+        bench_json,
+    } = p;
+    let bytes = fs::read(recipe_file)?;
+    let recipe_sha = sha256_hex(&bytes);
+    fs::write(reports_dir.join(format!("{stem}.recipe.toml")), &bytes)?;
+
+    let cfg_path = crate::gconfig::resolve_config_path("config.toml");
+    let manifest_config = fs::read_to_string(&cfg_path).ok().map(|s| ManifestConfig {
+        file: cfg_path.display().to_string(),
+        sha256: sha256_hex(s.as_bytes()),
+    });
+
+    let git_sha = std::process::Command::new("git")
+        .args(["rev-parse", "HEAD"])
+        .output()
+        .ok()
+        .filter(|o| o.status.success())
+        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
+
+    let scores = &bench_json["scores"];
+    let points = scores["total_points"]
+        .as_u64()
+        .and_then(|t| scores["max_points"].as_u64().map(|m| format!("{t}/{m}")));
+
+    let mut images = serde_json::Map::new();
+    if !recipe.docker_image.is_empty() {
+        images.insert(
+            recipe.docker_image.clone(),
+            capture_images(config, cluster, &recipe.docker_image).await,
+        );
+    }
+
+    let m = Manifest {
+        schema_version: 1,
+        run: stem.to_string(),
+        created_utc: utc_now_iso(),
+        recipe: ManifestRecipe {
+            name: recipe.name.clone(),
+            file: recipe_file.display().to_string(),
+            sha256: recipe_sha.clone(),
+        },
+        cluster: ManifestCluster {
+            name: cluster.name.clone(),
+            nodes: cluster.nodes.iter().map(|n| n.ssh_ip.clone()).collect(),
+        },
+        config: manifest_config,
+        launcher: ManifestLauncher {
+            bin: std::env::current_exe()
+                .map(|p| p.display().to_string())
+                .unwrap_or_default(),
+            git_sha,
+        },
+        bench: ManifestBench {
+            tool: "tool-eval-bench".into(),
+            version: bench_json
+                .get("tool_eval_bench_version")
+                .and_then(|v| v.as_str())
+                .map(String::from),
+            args: bench_args.to_vec(),
+            final_score: bench_json["final_score"].as_u64().map(|v| v as u32),
+            points,
+        },
+        images,
+    };
+    let manifest_path = reports_dir.join(format!("{stem}.manifest.json"));
+    fs::write(
+        &manifest_path,
+        format!("{}\n", serde_json::to_string_pretty(&m)?),
+    )?;
+    eprintln!(
+        "bench: run package: {} + {}",
+        reports_dir.join(format!("{stem}.recipe.toml")).display(),
+        manifest_path.display()
+    );
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn sha256_hex_known_value() {
+        assert_eq!(
+            sha256_hex(b"abc"),
+            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
+        );
+    }
+
+    #[test]
+    fn short12_truncates() {
+        assert_eq!(short12("deadbeefcafe"), "deadbeefcafe");
+        assert_eq!(short12("deadbeefcafe42"), "deadbeefcafe");
+    }
+
+    #[test]
+    fn run_package_writes_copy_and_manifest() {
+        let dir = std::env::temp_dir().join(format!("sm121-artifacts-{}-x", std::process::id()));
+        fs::create_dir_all(dir.join("reports")).unwrap();
+        let recipe_file = dir.join("recipe.toml");
+        fs::write(
+            &recipe_file,
+            "engine = \"vllm\"\nname = \"X\"\nmodel = \"m\"\n",
+        )
+        .unwrap();
+
+        let config: Config = toml::from_str("ssh_user = \"u\"\n").unwrap();
+        let cluster = Cluster {
+            name: "c".into(),
+            nodes: vec![], // no nodes → no SSH, no image capture
+            master_ib_ip: None,
+            extra_entities: vec![],
+        };
+        let recipe: Recipe = toml::from_str(
+            "engine = \"vllm\"\nname = \"X\"\nmodel = \"m\"\ndocker_image = \"img:tag\"\n",
+        )
+        .unwrap();
+        let bench_json: serde_json::Value =
+            serde_json::from_str(r#"{"tool_eval_bench_version": "1.2.3", "final_score": 93, "scores": {"total_points": 163, "max_points": 176}}"#)
+                .unwrap();
+
+        let rt = tokio::runtime::Runtime::new().unwrap();
+        rt.block_on(write_run_package(&RunPackage {
+            config: &config,
+            cluster: &cluster,
+            recipe: &recipe,
+            recipe_file: &recipe_file,
+            stem: "2026-01-01_test",
+            reports_dir: &dir.join("reports"),
+            bench_args: &["--base-url".into(), "x".into()],
+            bench_json: &bench_json,
+        }))
+        .unwrap();
+
+        let copy = fs::read_to_string(dir.join("reports/2026-01-01_test.recipe.toml")).unwrap();
+        assert_eq!(copy, "engine = \"vllm\"\nname = \"X\"\nmodel = \"m\"\n");
+        let m: Manifest = serde_json::from_str(
+            &fs::read_to_string(dir.join("reports/2026-01-01_test.manifest.json")).unwrap(),
+        )
+        .unwrap();
+        assert_eq!(m.schema_version, 1);
+        assert_eq!(m.run, "2026-01-01_test");
+        assert_eq!(
+            m.recipe.sha256,
+            sha256_hex(b"engine = \"vllm\"\nname = \"X\"\nmodel = \"m\"\n")
+        );
+        assert_eq!(m.bench.version.as_deref(), Some("1.2.3"));
+        assert_eq!(m.bench.points.as_deref(), Some("163/176"));
+        assert_eq!(m.bench.final_score, Some(93));
+        // no nodes → the image entry exists but has an empty node map
+        let img = &m.images["img:tag"];
+        assert_eq!(img["build_checksum"], "");
+        assert!(img["nodes"].as_object().unwrap().is_empty());
+        fs::remove_dir_all(&dir).ok();
+    }
+}

+ 1 - 0
src/main.rs

@@ -9,6 +9,7 @@ mod after_stop;
 mod ansi;
 mod before_serve;
 mod bench;
+mod bench_artifacts;
 mod builtin_scripts;
 mod cli;
 mod cluster_state;

+ 31 - 9
src/report/mod.rs

@@ -11,6 +11,7 @@
 //! recipe's `reports/` folder (`<YYYY-MM-DD>_<label>.md|.log`, the
 //! `sm121 publish`-style campaign reports).
 
+use crate::bench_artifacts::sha256_hex;
 use crate::config::{Config, Recipe};
 use anyhow::Result;
 use serde::{Deserialize, Serialize};
@@ -18,9 +19,11 @@ use std::collections::HashSet;
 use std::path::Path;
 
 pub(crate) mod parse;
+pub(crate) mod provenance;
 pub(crate) mod quality;
 pub(crate) mod serve;
 pub(crate) use parse::{PerfCell, bench_complete, parse_bench_json, perf_from};
+pub(crate) use provenance::Provenance;
 
 const PAGE_TEMPLATE: &str = include_str!("page.html");
 
@@ -53,6 +56,10 @@ pub(crate) struct ReportEntry {
     /// llama-benchy decode speeds (empty when the report has no perf box).
     #[serde(default)]
     perf: Vec<PerfCell>,
+    /// Run package provenance (recipe copy + image identity); empty fields
+    /// when the run predates the run package.
+    #[serde(default)]
+    provenance: Provenance,
 }
 
 #[derive(Serialize, Deserialize)]
@@ -199,13 +206,16 @@ fn slash_pair(s: &str) -> Option<(u32, u32)> {
     None
 }
 
-fn scan_reports(recipe_dir: &Path) -> Vec<ReportEntry> {
+fn scan_reports(recipe_dir: &Path, current_recipe_sha: &str) -> 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).
+    // Run-package files ride on their run's stem too: `<stem>.manifest.json`
+    // and `<stem>.recipe.toml` belong to the run they describe, not to a
+    // group of their own.
     let mut groups: std::collections::BTreeMap<String, Vec<String>> =
         std::collections::BTreeMap::new();
     for entry in rd.flatten() {
@@ -213,11 +223,17 @@ fn scan_reports(recipe_dir: &Path) -> Vec<ReportEntry> {
         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,
+        let stem = if let Some(s) = file.strip_suffix(".manifest.json") {
+            s.to_string()
+        } else if let Some(s) = file.strip_suffix(".recipe.toml") {
+            s.to_string()
+        } else {
+            match file.rsplit_once('.') {
+                Some((s, "md" | "json" | "log")) => s.to_string(),
+                _ => continue,
+            }
         };
-        groups.entry(stem.to_string()).or_default().push(file);
+        groups.entry(stem).or_default().push(file);
     }
     let read = |files: &[String], ext: &str| -> Option<String> {
         files
@@ -273,6 +289,9 @@ fn scan_reports(recipe_dir: &Path) -> Vec<ReportEntry> {
             .or_else(|| files.first())
             .cloned()
             .unwrap_or_default();
+        let provenance = read(&files, "manifest.json")
+            .and_then(|c| provenance::from_manifest(&c, current_recipe_sha))
+            .unwrap_or_default();
         out.push(ReportEntry {
             date,
             time,
@@ -284,6 +303,7 @@ fn scan_reports(recipe_dir: &Path) -> Vec<ReportEntry> {
             ratio,
             done,
             perf,
+            provenance,
         });
     }
     out.sort_by(|a, b| (&a.date, &a.label).cmp(&(&b.date, &b.label)));
@@ -318,7 +338,7 @@ pub(crate) fn collect(config: &Config) -> Vec<ReportRow> {
                 continue;
             };
             let quant = detect_quant(&name, &r.model);
-            let reports = scan_reports(&path);
+            let reports = scan_reports(&path, &sha256_hex(toml_str.as_bytes()));
             let incomplete = quality::incomplete(&reports);
             rows.push(ReportRow {
                 name,
@@ -377,10 +397,11 @@ fn markdown(rows: &[ReportRow]) -> String {
         fluent_i18n::t!("report-title", { "count" => rows.len() })
     );
     s.push_str(&format!(
-        "| {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |\n",
+        "| {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |\n",
         t("report-col-model"),
         t("report-col-quant"),
         t("report-col-recipe"),
+        t("report-col-prov"),
         t("report-col-engine"),
         t("report-col-nodes"),
         t("report-col-reports"),
@@ -390,16 +411,17 @@ fn markdown(rows: &[ReportRow]) -> String {
         t("report-col-tps1"),
         t("report-col-tps2"),
     ));
-    s.push_str("|---|---|---|---|---|---|---|---|---|---|---|\n");
+    s.push_str("|---|---|---|---|---|---|---|---|---|---|---|---|\n");
     for r in rows {
         let last = r.reports.last();
         let latest = latest_score(r);
         let perf = latest_perf(r);
         s.push_str(&format!(
-            "| {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |\n",
+            "| {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |\n",
             r.model,
             r.quant,
             r.name,
+            provenance::cell(r),
             r.engine,
             r.nodes
                 .map(|n| format!("{n}×{}", r.tp))

+ 8 - 1
src/report/page.html

@@ -21,6 +21,7 @@
   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; }
+  .prov { font-family: ui-monospace, monospace; font-size: 10px; opacity: .8; white-space: nowrap; }
   a.open:hover { background: color-mix(in srgb, currentColor 12%, canvas); }
   tr.row { cursor: pointer; }
   td.recipe { position: relative; }
@@ -411,9 +412,15 @@ function render() {
         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>`
           : "";
+        // run package provenance chip: image build-checksum · recipe sha
+        // (⚠ = the recipe has since changed — this run's copy is pinned)
+        const pr = e.provenance;
+        const chip = pr && (pr.image_sum || pr.recipe_sha)
+          ? `<span class="prov" title="recipe ${esc(pr.recipe_sha)}${pr.recipe_drift ? " (CHANGED SINCE THIS RUN — see the run's recipe.toml copy)" : ""} · image ${esc(pr.image || "—")} @ ${esc(pr.image_sum || "—")} (docker id ${esc(pr.image_id || "—")}) · tool ${esc(pr.tool || "—")} · launcher ${esc(pr.launcher || "—")}">⚙ ${esc((pr.image_sum || "–").slice(0, 12))}·${esc(pr.recipe_sha || "–")}${pr.recipe_drift ? "⚠" : ""}</span> `
+          : "";
         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}`;
+        `${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}${chip}`;
       }).join("<br>");
       out.push(`<tr class="hist"><td colspan="${vis.length}">${r.reports.length ? toml + " " + lines : "no reports"}</td></tr>`);
     }

+ 178 - 0
src/report/provenance.rs

@@ -0,0 +1,178 @@
+//! Run-package provenance for the report table: one bench run's recipe
+//! copy + served-image identity, parsed from `<stem>.manifest.json`
+//! (written by `sm121 bench`, see [`crate::bench_artifacts`]).
+//!
+//! The report also flags **drift**: the manifest's recipe SHA-256 vs the
+//! current `recipe.toml` — a run whose recipe has since changed shows ⚠,
+//! so "what did this bench actually test?" is always answerable.
+
+use crate::bench_artifacts::{Manifest, short12};
+use crate::report::ReportRow;
+use serde::{Deserialize, Serialize};
+
+/// Run package provenance for one bench run. Empty fields for runs that
+/// predate the run package.
+#[derive(Serialize, Deserialize, Default, Debug, Clone)]
+pub struct Provenance {
+    /// 12-char SHA-256 of the recipe copy captured at bench time.
+    pub recipe_sha: String,
+    /// True when the current recipe.toml's SHA-256 differs from the one
+    /// captured at bench time — the report may no longer represent the
+    /// recipe as it stands now.
+    pub recipe_drift: bool,
+    /// Docker image name from the manifest.
+    pub image: String,
+    /// `sm121.checksum` build label of the image (short hex).
+    pub image_sum: String,
+    /// Docker image id (first 12 hex chars) as seen on a launch node.
+    pub image_id: String,
+    /// tool-eval-bench version string from the run's JSON.
+    pub tool: String,
+    /// Launcher git commit (first 12 hex chars) that ran the bench.
+    pub launcher: String,
+}
+
+/// Parse a `<stem>.manifest.json` body into provenance; `None` when the
+/// body is not a valid manifest (the entry then falls back to the empty
+/// shape, as for runs without a package).
+pub fn from_manifest(content: &str, current_recipe_sha: &str) -> Option<Provenance> {
+    let m: Manifest = serde_json::from_str(content).ok()?;
+    let (image, image_sum, image_id) = m
+        .images
+        .iter()
+        .next()
+        .map(|(name, val)| {
+            let sum = val
+                .get("build_checksum")
+                .and_then(|v| v.as_str())
+                .unwrap_or_default();
+            let id = val
+                .get("nodes")
+                .and_then(|v| v.as_object())
+                .and_then(|n| n.values().next())
+                .and_then(|n| n.get("id"))
+                .and_then(|v| v.as_str())
+                .map(|id| short12(id.strip_prefix("sha256:").unwrap_or(id)))
+                .unwrap_or_default();
+            (name.clone(), sum.to_string(), id)
+        })
+        .unwrap_or_default();
+    Some(Provenance {
+        recipe_sha: short12(&m.recipe.sha256),
+        recipe_drift: m.recipe.sha256 != current_recipe_sha,
+        image,
+        image_sum,
+        image_id,
+        tool: m.bench.version.unwrap_or_default(),
+        launcher: m
+            .launcher
+            .git_sha
+            .as_deref()
+            .map(short12)
+            .unwrap_or_default(),
+    })
+}
+
+/// Latest report entry carrying a run package (newest first); the row's
+/// provenance cell mirrors what the run's chip shows in the web UI.
+pub fn latest(r: &ReportRow) -> Option<&Provenance> {
+    r.reports
+        .iter()
+        .rev()
+        .find(|e| !e.provenance.recipe_sha.is_empty() || !e.provenance.image_sum.is_empty())
+        .map(|e| &e.provenance)
+}
+
+/// `img@<buildsum>·rcp@<sha12>` (+ ⚠ on drift) for the markdown table.
+pub fn cell(r: &ReportRow) -> String {
+    latest(r)
+        .map(|p| {
+            let sum = p.image_sum.chars().take(12).collect::<String>();
+            let mut s = format!("img@{sum}·rcp@{}", p.recipe_sha);
+            if p.recipe_drift {
+                s.push_str(" ⚠");
+            }
+            s
+        })
+        .unwrap_or_else(|| "—".into())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::config::Config;
+    use crate::report::collect;
+    use crate::report::tests::fixture;
+    use std::fs;
+
+    #[test]
+    fn manifest_parses_and_drifts() {
+        use crate::bench_artifacts::sha256_hex;
+        let dir = fixture();
+        let recipe_path = dir.join("test-recipe-nvfp4/recipe.toml");
+        let recipe = fs::read_to_string(&recipe_path).unwrap();
+        // run package for the roce-run stem: manifest + recipe copy
+        let stem = "2026-01-04_roce-run";
+        fs::write(
+            dir.join(format!("test-recipe-nvfp4/reports/{stem}.manifest.json")),
+            format!(
+                "{{\"schema_version\":1,\"run\":\"{stem}\",\"created_utc\":\"x\",\"recipe\":{{\"name\":\"Test Recipe\",\"file\":\"x\",\"sha256\":\"{}\"}},\"cluster\":{{\"name\":\"c\",\"nodes\":[]}},\"launcher\":{{\"bin\":\"b\",\"git_sha\":\"deadbeefcafe42\"}},\"bench\":{{\"tool\":\"tool-eval-bench\",\"version\":\"2.6.1.dev99\",\"args\":[]}},\"images\":{{\"img:tag\":{{\"build_checksum\":\"8bb87348\",\"nodes\":{{\"dgx1\":{{\"id\":\"sha256:abcdef1234567890\",\"sm121_checksum\":\"8bb87348\"}}}}}}}}}}",
+                sha256_hex(recipe.as_bytes())
+            ),
+        )
+        .unwrap();
+        fs::write(
+            dir.join(format!("test-recipe-nvfp4/reports/{stem}.recipe.toml")),
+            &recipe,
+        )
+        .unwrap();
+        let mk_config = || {
+            toml::from_str::<Config>(&format!("recipe_sources = [\"{}\"]", dir.to_str().unwrap()))
+                .unwrap()
+        };
+
+        // no drift: manifest sha == current recipe sha
+        let rows = collect(&mk_config());
+        let row = rows.iter().find(|r| r.name == "test-recipe-nvfp4").unwrap();
+        let e = row.reports.iter().find(|e| e.label == "roce-run").unwrap();
+        assert_eq!(e.provenance.image_sum, "8bb87348");
+        assert_eq!(e.provenance.image, "img:tag");
+        assert_eq!(e.provenance.image_id, "abcdef123456");
+        assert_eq!(e.provenance.tool, "2.6.1.dev99");
+        assert_eq!(e.provenance.launcher, "deadbeefcafe");
+        assert_eq!(
+            e.provenance.recipe_sha,
+            &sha256_hex(recipe.as_bytes())[..12]
+        );
+        assert!(!e.provenance.recipe_drift);
+        // run-package files ride on the run's stem (links in the UI)
+        assert!(e.files.contains(&format!("{stem}.manifest.json")));
+        assert!(e.files.contains(&format!("{stem}.recipe.toml")));
+        // the score still comes from the REPORT json, not the manifest
+        assert_eq!(e.pct, Some(92.0));
+        // the markdown row carries the provenance cell
+        assert!(cell(row).starts_with("img@8bb87348·rcp@"));
+
+        // drift: the recipe changed since the bench
+        fs::write(&recipe_path, recipe + "port = 30001\n").unwrap();
+        let rows = collect(&mk_config());
+        let row = rows.iter().find(|r| r.name == "test-recipe-nvfp4").unwrap();
+        let e = row.reports.iter().find(|e| e.label == "roce-run").unwrap();
+        assert!(e.provenance.recipe_drift, "recipe changed after the run");
+        assert!(cell(row).ends_with('⚠'));
+        fs::remove_dir_all(&dir).ok();
+    }
+
+    #[test]
+    fn bad_manifest_yields_empty_provenance() {
+        assert!(from_manifest("not json", "x").is_none());
+        // complete manifest, empty images + drifted sha
+        let m = from_manifest(
+            r#"{"schema_version":1,"run":"r","created_utc":"x","recipe":{"name":"n","file":"f","sha256":"a"},"cluster":{"name":"c","nodes":[]},"launcher":{"bin":"b"},"bench":{"tool":"t","args":[]},"images":{}}"#,
+            "b",
+        )
+        .unwrap();
+        assert!(m.recipe_drift, "a != b");
+        assert!(m.image.is_empty());
+    }
+}

+ 1 - 1
src/report/ui_tests.rs

@@ -170,7 +170,7 @@ fn markdown_lists_rows_and_scores() {
     )];
     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 |"
+        "| 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()));