provenance.rs 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. //! Run-package provenance for the report table: one bench run's recipe
  2. //! copy + served-image identity, parsed from `<stem>.manifest.json`
  3. //! (written by `sm121 bench`, see [`crate::bench_artifacts`]).
  4. //!
  5. //! The report also flags **drift**: the manifest's recipe SHA-256 vs the
  6. //! current `recipe.toml` — a run whose recipe has since changed shows ⚠,
  7. //! so "what did this bench actually test?" is always answerable.
  8. use crate::bench_artifacts::{Manifest, short12};
  9. use crate::report::ReportRow;
  10. use serde::{Deserialize, Serialize};
  11. /// Run package provenance for one bench run. Empty fields for runs that
  12. /// predate the run package.
  13. #[derive(Serialize, Deserialize, Default, Debug, Clone)]
  14. pub struct Provenance {
  15. /// 12-char SHA-256 of the recipe copy captured at bench time.
  16. pub recipe_sha: String,
  17. /// True when the current recipe.toml's SHA-256 differs from the one
  18. /// captured at bench time — the report may no longer represent the
  19. /// recipe as it stands now.
  20. pub recipe_drift: bool,
  21. /// Docker image name from the manifest.
  22. pub image: String,
  23. /// `sm121.checksum` build label of the image (short hex).
  24. pub image_sum: String,
  25. /// Docker image id (first 12 hex chars) as seen on a launch node.
  26. pub image_id: String,
  27. /// tool-eval-bench version string from the run's JSON.
  28. pub tool: String,
  29. /// Launcher git commit (first 12 hex chars) that ran the bench.
  30. pub launcher: String,
  31. }
  32. /// Parse a `<stem>.manifest.json` body into provenance; `None` when the
  33. /// body is not a valid manifest (the entry then falls back to the empty
  34. /// shape, as for runs without a package).
  35. pub fn from_manifest(content: &str, current_recipe_sha: &str) -> Option<Provenance> {
  36. let m: Manifest = serde_json::from_str(content).ok()?;
  37. let (image, image_sum, image_id) = m
  38. .images
  39. .iter()
  40. .next()
  41. .map(|(name, val)| {
  42. let sum = val
  43. .get("build_checksum")
  44. .and_then(|v| v.as_str())
  45. .unwrap_or_default();
  46. let id = val
  47. .get("nodes")
  48. .and_then(|v| v.as_object())
  49. .and_then(|n| n.values().next())
  50. .and_then(|n| n.get("id"))
  51. .and_then(|v| v.as_str())
  52. .map(|id| short12(id.strip_prefix("sha256:").unwrap_or(id)))
  53. .unwrap_or_default();
  54. (name.clone(), sum.to_string(), id)
  55. })
  56. .unwrap_or_default();
  57. Some(Provenance {
  58. recipe_sha: short12(&m.recipe.sha256),
  59. recipe_drift: m.recipe.sha256 != current_recipe_sha,
  60. image,
  61. image_sum,
  62. image_id,
  63. tool: m.bench.version.unwrap_or_default(),
  64. launcher: m
  65. .launcher
  66. .git_sha
  67. .as_deref()
  68. .map(short12)
  69. .unwrap_or_default(),
  70. })
  71. }
  72. /// Latest report entry carrying a run package (newest first); the row's
  73. /// provenance cell mirrors what the run's chip shows in the web UI.
  74. pub fn latest(r: &ReportRow) -> Option<&Provenance> {
  75. r.reports
  76. .iter()
  77. .rev()
  78. .find(|e| !e.provenance.recipe_sha.is_empty() || !e.provenance.image_sum.is_empty())
  79. .map(|e| &e.provenance)
  80. }
  81. /// `img@<buildsum>·rcp@<sha12>` (+ ⚠ on drift) for the markdown table.
  82. pub fn cell(r: &ReportRow) -> String {
  83. latest(r)
  84. .map(|p| {
  85. let sum = p.image_sum.chars().take(12).collect::<String>();
  86. let mut s = format!("img@{sum}·rcp@{}", p.recipe_sha);
  87. if p.recipe_drift {
  88. s.push_str(" ⚠");
  89. }
  90. s
  91. })
  92. .unwrap_or_else(|| "—".into())
  93. }
  94. #[cfg(test)]
  95. mod tests {
  96. use super::*;
  97. use crate::config::Config;
  98. use crate::report::collect;
  99. use crate::report::tests::fixture;
  100. use std::fs;
  101. #[test]
  102. fn manifest_parses_and_drifts() {
  103. use crate::bench_artifacts::sha256_hex;
  104. let dir = fixture();
  105. let recipe_path = dir.join("test-recipe-nvfp4/recipe.toml");
  106. let recipe = fs::read_to_string(&recipe_path).unwrap();
  107. // run package for the roce-run stem: manifest + recipe copy
  108. let stem = "2026-01-04_roce-run";
  109. fs::write(
  110. dir.join(format!("test-recipe-nvfp4/reports/{stem}.manifest.json")),
  111. format!(
  112. "{{\"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\"}}}}}}}}}}",
  113. sha256_hex(recipe.as_bytes())
  114. ),
  115. )
  116. .unwrap();
  117. fs::write(
  118. dir.join(format!("test-recipe-nvfp4/reports/{stem}.recipe.toml")),
  119. &recipe,
  120. )
  121. .unwrap();
  122. let mk_config = || {
  123. toml::from_str::<Config>(&format!("recipe_sources = [\"{}\"]", dir.to_str().unwrap()))
  124. .unwrap()
  125. };
  126. // no drift: manifest sha == current recipe sha
  127. let rows = collect(&mk_config(), false);
  128. let row = rows.iter().find(|r| r.name == "test-recipe-nvfp4").unwrap();
  129. let e = row.reports.iter().find(|e| e.label == "roce-run").unwrap();
  130. assert_eq!(e.provenance.image_sum, "8bb87348");
  131. assert_eq!(e.provenance.image, "img:tag");
  132. assert_eq!(e.provenance.image_id, "abcdef123456");
  133. assert_eq!(e.provenance.tool, "2.6.1.dev99");
  134. assert_eq!(e.provenance.launcher, "deadbeefcafe");
  135. assert_eq!(
  136. e.provenance.recipe_sha,
  137. &sha256_hex(recipe.as_bytes())[..12]
  138. );
  139. assert!(!e.provenance.recipe_drift);
  140. // run-package files ride on the run's stem (links in the UI)
  141. assert!(e.files.contains(&format!("{stem}.manifest.json")));
  142. assert!(e.files.contains(&format!("{stem}.recipe.toml")));
  143. // the score still comes from the REPORT json, not the manifest
  144. assert_eq!(e.pct, Some(92.0));
  145. // the markdown row carries the provenance cell
  146. assert!(cell(row).starts_with("img@8bb87348·rcp@"));
  147. // drift: the recipe changed since the bench
  148. fs::write(&recipe_path, recipe + "port = 30001\n").unwrap();
  149. let rows = collect(&mk_config(), false);
  150. let row = rows.iter().find(|r| r.name == "test-recipe-nvfp4").unwrap();
  151. let e = row.reports.iter().find(|e| e.label == "roce-run").unwrap();
  152. assert!(e.provenance.recipe_drift, "recipe changed after the run");
  153. assert!(cell(row).ends_with('⚠'));
  154. fs::remove_dir_all(&dir).ok();
  155. }
  156. #[test]
  157. fn bad_manifest_yields_empty_provenance() {
  158. assert!(from_manifest("not json", "x").is_none());
  159. // complete manifest, empty images + drifted sha
  160. let m = from_manifest(
  161. 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":{}}"#,
  162. "b",
  163. )
  164. .unwrap();
  165. assert!(m.recipe_drift, "a != b");
  166. assert!(m.image.is_empty());
  167. }
  168. }