metrale_bench/
serve_env.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: The `METRALE_*` environment a gate's server is measured under.
4//!
5//! A serve lever is a `METRALE_*` variable that is not a harness variable.
6//! Levers are not recipe keys, so they do not appear in the argv a leased
7//! server is fingerprinted by. Instead the recipe declares the levers it is
8//! measured under (its `env:` block, with the gate's BENCH.toml
9//! `[benchmarks.serve_env]` pin on top), and [`reconcile`] refuses a lever in
10//! the harness's own environment that nothing declared, and a declared lever
11//! it carries at another value. The set is fingerprinted into the serve
12//! identity (`serve_identity`) and disclosed on the gate record
13//! (`gate::GateRecord::serve_env`).
14//!
15//! It lives in this crate so both ends share one definition: the server
16//! fingerprints its own environment for `GET /serve-config`, and the harness
17//! computes what that fingerprint must be.
18//!
19//! Owner: bench (serve environment).
20//! Invariants:
21//! - [`declared`] returns only `Runtime`-class levers with non-empty values.
22//! - [`reconcile`] succeeds only when every present lever is declared at the
23//!   declared value; its `env` is then the declaration, whole.
24
25use std::collections::BTreeMap;
26
27use anyhow::{Result, bail};
28use sha2::{Digest, Sha256};
29
30/// 2026-09-26: The prefix every lever carries.
31pub use metrale_config::levers::PREFIX;
32
33/// 2026-09-26: Whether `name` is a serve lever: any `METRALE_*` name that is
34/// not a harness variable.
35///
36/// The harness variables are the table's `Class::Harness` entries
37/// (`metrale_config::levers`): they place, log or build for the harness.
38/// Every other name under the prefix counts, including one the table does
39/// not declare, so the harness names it in a refusal; `met` itself refuses
40/// to start with an undeclared one set (`levers::check` in `main.rs`).
41pub fn is_lever(name: &str) -> bool {
42    name.starts_with(PREFIX) && !metrale_config::levers::is_harness(name)
43}
44
45/// 2026-09-26: The levers in an environment, from any `(name, value)`
46/// iterator.
47pub fn levers<I, K, V>(env: I) -> BTreeMap<String, String>
48where
49    I: IntoIterator<Item = (K, V)>,
50    K: AsRef<str>,
51    V: AsRef<str>,
52{
53    env.into_iter()
54        .filter(|(k, _)| is_lever(k.as_ref()))
55        .map(|(k, v)| (k.as_ref().to_string(), v.as_ref().to_string()))
56        .collect()
57}
58
59/// 2026-09-26: The levers of this process: what the server fingerprints, and
60/// what the harness reconciles against the recipe before it serves.
61///
62/// Lossy on a non-UTF-8 name or value rather than skipping it: a skipped
63/// variable would be a lever the fingerprint omits.
64pub fn process_levers() -> BTreeMap<String, String> {
65    levers(std::env::vars_os().map(|(k, v)| {
66        (
67            k.to_string_lossy().into_owned(),
68            v.to_string_lossy().into_owned(),
69        )
70    }))
71}
72
73/// 2026-09-26: The SHA-256 of a lever set: `name=value` in key order, each
74/// NUL-terminated, so `A=1 B=2` and `A=1B=2` differ and the empty set has one
75/// fixed digest.
76#[must_use]
77pub fn fingerprint(levers: &BTreeMap<String, String>) -> String {
78    let mut h = Sha256::new();
79    for (k, v) in levers {
80        h.update(k.as_bytes());
81        h.update(b"=");
82        h.update(v.as_bytes());
83        h.update([0u8]);
84    }
85    metrale_closure::hex_lower(&h.finalize())
86}
87
88/// 2026-09-26: Validate one declaration (a recipe's `env:` block or a
89/// BENCH.toml `[benchmarks.serve_env]` table) into the lever set it means.
90///
91/// `owner` names the declaring file in every refusal.
92///
93/// # Errors
94/// A key that is not a `Runtime` lever (no prefix; a harness, build, dev or
95/// tool variable; or a name the lever table does not declare), or a value
96/// that is empty after trimming (how a shell spells "unset").
97pub fn declared(owner: &str, raw: &BTreeMap<String, String>) -> Result<BTreeMap<String, String>> {
98    let mut out = BTreeMap::new();
99    for (name, value) in raw {
100        if !name.starts_with(PREFIX) {
101            bail!(
102                "{owner} declares env {name}, which is not a METRALE_* serve lever. The gate \
103                 applies exactly the levers a recipe declares and nothing else, so a variable \
104                 the server does not read cannot be declared here."
105            );
106        }
107        match metrale_config::levers::lookup(name).map(|l| l.class) {
108            Some(metrale_config::levers::Class::Runtime) => {}
109            Some(metrale_config::levers::Class::Harness) => bail!(
110                "{owner} declares env {name}, which places, logs or builds rather than \
111                 measuring ({name} is a harness variable, not a serve lever) — it is the \
112                 box's to set, never the recipe's."
113            ),
114            Some(class) => bail!(
115                "{owner} declares env {name}, which the server never reads (it is a {} \
116                 variable), so declaring it here would change nothing that is measured.",
117                class.as_str()
118            ),
119            None => bail!(
120                "{owner} declares env {name}, which is not a declared lever: nothing reads it, \
121                 so it is a typo or a lever that no longer exists, and `met serve` refuses \
122                 to start with it set. `met dump-serve-options` lists every declared lever."
123            ),
124        }
125        if value.trim().is_empty() {
126            bail!(
127                "{owner} declares env {name} with an empty value. An exported-but-empty variable \
128                 is how a shell spells \"unset\": declare a value or drop the key."
129            );
130        }
131        out.insert(name.clone(), value.clone());
132    }
133    Ok(out)
134}
135
136/// 2026-09-26: The recipe's declaration under the gate entry's pin; the pin
137/// wins on a clash.
138#[must_use]
139pub fn merge_declared(
140    recipe: BTreeMap<String, String>,
141    baseline: BTreeMap<String, String>,
142) -> BTreeMap<String, String> {
143    let mut out = recipe;
144    out.extend(baseline);
145    out
146}
147
148/// 2026-09-26: What the harness must do to serve `declared` from an
149/// environment that already holds `present`.
150#[derive(Clone, Debug, PartialEq, Eq)]
151pub struct Reconciled {
152    /// 2026-09-26: The lever set the server runs under: the declaration,
153    /// whole.
154    pub env: BTreeMap<String, String>,
155    /// 2026-09-26: The declared levers the harness does not already carry:
156    /// what a child serve is given on top of the inherited environment, and
157    /// what an in-process serve refuses to start without.
158    pub missing: BTreeMap<String, String>,
159}
160
161/// 2026-09-26: Reconcile the recipe's declaration with the harness's own
162/// levers ([`process_levers`]).
163///
164/// # Errors
165/// A present lever that `declared` does not name: refused, not inherited and
166/// not stripped, because either would change what the gate measures without
167/// a trace. A declared lever present at another value: refused, because
168/// neither side may win silently.
169pub fn reconcile(
170    owner: &str,
171    declared: &BTreeMap<String, String>,
172    present: &BTreeMap<String, String>,
173) -> Result<Reconciled> {
174    let undeclared: Vec<String> = present
175        .iter()
176        .filter(|(k, _)| !declared.contains_key(*k))
177        .map(|(k, v)| format!("{k}={v}"))
178        .collect();
179    if !undeclared.is_empty() {
180        bail!(
181            "the harness environment carries {} serve lever(s) that {owner} does not declare: \
182             {}. A lever the recipe did not ask for changes what the gate measures \
183             (METRALE_FP8_ROWWISE=1 builds FP8 twin weights that a 0.70-util serve cannot \
184             boot with — #1242), so it is refused rather than inherited. Unset it — on a node \
185             agent, remove it from bench.yaml `env:`, which reaches EVERY gate on the box — or \
186             declare it in the recipe's `env:` (or the gate's [benchmarks.serve_env]) if the \
187             gate is measured under it.",
188            undeclared.len(),
189            undeclared.join(", ")
190        );
191    }
192    let mut missing = BTreeMap::new();
193    let mut contradicted = Vec::new();
194    for (k, want) in declared {
195        match present.get(k) {
196            Some(got) if got == want => {}
197            Some(got) => {
198                contradicted.push(format!("{k}: harness {got:?}, {owner} declares {want:?}"))
199            }
200            None => {
201                missing.insert(k.clone(), want.clone());
202            }
203        }
204    }
205    if !contradicted.is_empty() {
206        bail!(
207            "the harness environment contradicts {owner} on {} serve lever(s): {}. Neither value \
208             wins silently: unset the variable so the declaration applies, or change the \
209             declaration.",
210            contradicted.len(),
211            contradicted.join("; ")
212        );
213    }
214    Ok(Reconciled {
215        env: declared.clone(),
216        missing,
217    })
218}
219
220#[cfg(test)]
221#[path = "serve_env_tests.rs"]
222mod tests;