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}