metrale_bench/gate/recipe_closure.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-10-02: The recipe a gate serves, as part of what its records attest.
4//!
5//! A gate serves the recipe its BENCH entry names, read from `recipes/<id>.yaml` in the tree
6//! under test ([`read_in_tree`]). A record carries the canonical content hash of what it served
7//! ([`GateRecord::served_recipe_sha256`]), and stands at a later commit only while that commit's
8//! recipe hashes the same ([`recipe_standing`]).
9//!
10//! The canonical form ([`canonical`]) is the parsed recipe (`metrale_config::recipe_yaml`), so
11//! comments, blank lines, quoting and key order do not count. It drops `metadata`, which no
12//! serve reads, and applies the run's serve overrides to `defaults`, so a recipe key that an
13//! override pins does not count either.
14//!
15//! Owner: bench gate.
16//! Invariants:
17//! - [`recipe_standing`] answers `Same` only when both hashes exist and agree. A recipe the
18//! tree does not have, one that does not parse, and a record without a served-recipe hash
19//! are `Changed`, never `Same`: such a record was served from a recipe outside the tree
20//! (a node's cached index), so nothing attests what it served.
21//! - A record with no `served_by` (an operator's own endpoint) served no recipe, so the
22//! recipe does not bear on its standing.
23
24use std::collections::BTreeMap;
25use std::path::Path;
26
27use anyhow::{Context, Result, bail};
28use metrale_config::recipe_yaml::{self, Yaml};
29use sha2::{Digest, Sha256};
30
31use super::record::GateRecord;
32
33/// 2026-10-02: The top-level recipe key no serve reads (description, maintainer, dates).
34const DISPLAY_ONLY: &str = "metadata";
35
36/// 2026-10-02: Where recipe `id` (`<family>/<stem>`) lives in a tree.
37pub fn recipe_path(id: &str) -> String {
38 format!("recipes/{id}.yaml")
39}
40
41/// 2026-10-02: The text of recipe `id` in the tree at `root`. A recipe the tree does not have
42/// is refused: a gate serves only the recipe committed beside its BENCH.toml, never a node's
43/// cached copy.
44pub fn read_in_tree(root: &Path, id: &str) -> Result<String> {
45 let path = root.join(recipe_path(id));
46 std::fs::read_to_string(&path).with_context(|| {
47 format!(
48 "recipe {id:?} is not in this tree at {}. A gate serves only the recipe committed \
49 in the tree under test; it never falls back to a cached recipe index",
50 path.display()
51 )
52 })
53}
54
55/// 2026-10-02: The served form of a recipe: parsed, without `metadata`, with `overrides`
56/// replacing or adding `defaults` keys.
57pub fn canonical(text: &str, overrides: &BTreeMap<String, String>) -> Result<Yaml> {
58 let Yaml::Map(mut top) = recipe_yaml::parse(text)? else {
59 bail!("a recipe must be a mapping");
60 };
61 top.remove(DISPLAY_ONLY);
62 let Some(Yaml::Map(defaults)) = top.get_mut("defaults") else {
63 bail!("a recipe's `defaults:` must be a mapping");
64 };
65 for (key, value) in overrides {
66 defaults.insert(key.clone(), Yaml::Scalar(value.clone()));
67 }
68 Ok(Yaml::Map(top))
69}
70
71/// 2026-10-02: Hex sha256 of [`canonical`], over a JSON rendering whose maps are in key order.
72pub fn content_sha256(text: &str, overrides: &BTreeMap<String, String>) -> Result<String> {
73 let rendered = serde_json::to_string(&json(&canonical(text, overrides)?))?;
74 Ok(Sha256::digest(rendered.as_bytes())
75 .iter()
76 .map(|b| format!("{b:02x}"))
77 .collect())
78}
79
80fn json(y: &Yaml) -> serde_json::Value {
81 match y {
82 Yaml::Scalar(s) => serde_json::Value::String(s.clone()),
83 Yaml::List(items) => serde_json::Value::Array(items.iter().map(json).collect()),
84 Yaml::Map(m) => serde_json::Value::Array(
85 m.iter()
86 .map(|(k, v)| serde_json::Value::Array(vec![k.clone().into(), json(v)]))
87 .collect(),
88 ),
89 }
90}
91
92/// 2026-10-02: Whether the recipe a record served is still what its gate serves.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub enum RecipeStanding {
95 /// 2026-10-02: Same recipe id, same canonical content; or no recipe was served.
96 Same,
97 /// 2026-10-02: Another recipe id, other content, or nothing to compare. Carries the
98 /// recipe path the verdict names.
99 Changed(String),
100}
101
102/// 2026-10-02: Compare what `record` served with what its gate serves in the tree at `root`:
103/// the recipe the BENCH entry for the record's (hardware, model) names, rendered with the
104/// record's serve overrides (which `check_record` requires to equal the entry's pins).
105///
106/// The record side is its `served_recipe_sha256`; a record without one is `Changed`.
107pub fn recipe_standing(root: &Path, record: &GateRecord) -> RecipeStanding {
108 let Some(served) = record.served_by.as_deref() else {
109 return RecipeStanding::Same;
110 };
111 let head_id = super::read_baseline(root, super::group::serve_baseline_id(&record.benchmark_id))
112 .ok()
113 .and_then(|b| {
114 b.resolve(&record.hardware.gate_key(), Some(&record.target_model))
115 .ok()
116 .and_then(|(_, entry)| entry.recipe.clone())
117 });
118 let Some(head_id) = head_id else {
119 return RecipeStanding::Changed(recipe_path(served));
120 };
121 let changed = || RecipeStanding::Changed(recipe_path(&head_id));
122 if head_id != served {
123 return changed();
124 }
125 let Ok(head) = read_in_tree(root, &head_id)
126 .and_then(|text| content_sha256(&text, &record.serve_overrides))
127 else {
128 return changed();
129 };
130 let Some(recorded) = &record.served_recipe_sha256 else {
131 return changed();
132 };
133 if *recorded == head {
134 RecipeStanding::Same
135 } else {
136 changed()
137 }
138}
139
140impl GateRecord {
141 /// 2026-10-02: Attach the canonical content hash of the recipe the gate served
142 /// ([`content_sha256`] over the recipe text and the run's serve overrides).
143 #[must_use]
144 pub fn with_served_recipe(mut self, sha256: String) -> Self {
145 self.served_recipe_sha256 = Some(sha256);
146 self
147 }
148}