metrale_bench/hardware/
policy.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: The hardware verdicts: may a benchmark start ([`precheck`]), and may
4//! the numbers of a finished run be believed ([`postcheck`]).
5//!
6//! Owner: bench hardware.
7//! Invariants:
8//! - No I/O; the only environment read is [`PolicyOptions::from_env`].
9//! - A Correctness run is never refused, and a Speed run is refused only while the
10//!   kill switch is off.
11//! - [`Validity::Valid`] only when all three thermal counters were read on both
12//!   captures and none advanced.
13//!
14//! # Record first, gate second
15//!
16//! The absolute-temperature ceilings warn by default and refuse only under
17//! `METRALE_HW_TEMP_GATE=1`: they are calibrations, and a ceiling that refuses a
18//! healthy box teaches operators to set the kill switch. The two checks that need
19//! no threshold refuse a Speed run by default: a thermal throttle reason asserted
20//! before the run, and more than [`MAX_FOREIGN_COMPUTE_APPS`] foreign GPU compute
21//! processes. Everything is recorded either way.
22//!
23//! # Sensitivity, not benchmark id
24//!
25//! The policy keys on [`Sensitivity`], which every benchmark declares on its
26//! [`crate::BenchmarkDescriptor`]. There is no list of benchmark ids here: the
27//! registry is the one source of which benchmarks exist.
28
29use serde::{Deserialize, Serialize};
30
31use super::state::{HardwareState, HardwareStateDelta};
32
33/// 2026-09-26: Whether this benchmark's number is a speed number.
34///
35/// The split is about what thermal state can corrupt: a throttled box produces a
36/// slower wall time and the same tool call. So a hot box may refuse a Speed run
37/// and never refuses a Correctness run.
38#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "kebab-case")]
40pub enum Sensitivity {
41    /// 2026-09-26: Wall time, TTFT, TPOT, tok/s, or a Σwall bound.
42    Speed,
43    /// 2026-09-26: Accuracy, fidelity, state integrity: hardware state is recorded,
44    /// never gated on.
45    Correctness,
46}
47
48/// 2026-09-26: The pre-run decision.
49#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "kebab-case")]
51pub enum Decision {
52    /// 2026-09-26: No concern was raised. An unreadable throttle-reason list, an
53    /// unlistable compute-process list or undeclared ceilings yield
54    /// [`Decision::Warn`] instead; an unreadable temperature raises no concern.
55    Proceed,
56    /// 2026-09-26: Recorded, not blocking.
57    Warn,
58    /// 2026-09-26: The run must not start.
59    Refuse,
60}
61
62/// 2026-09-26: Whether the numbers a completed run produced may be believed.
63#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "kebab-case")]
65pub enum Validity {
66    /// 2026-09-26: Speed-sensitive; all three thermal counters were read on both
67    /// captures and none advanced.
68    Valid,
69    /// 2026-09-26: Speed-sensitive; no readable counter advanced, but at least one
70    /// could not be read on both captures. Not a pass.
71    Unknown,
72    /// 2026-09-26: Speed-sensitive, and a thermal counter advanced during the run.
73    Invalid,
74    /// 2026-09-26: Correctness run: thermal state does not bear on the number.
75    NotApplicable,
76}
77
78/// 2026-09-26: A verdict plus every concern that fed it, gating or not.
79#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
80pub struct Precheck {
81    pub decision: Decision,
82    #[serde(default, skip_serializing_if = "Vec::is_empty")]
83    pub concerns: Vec<String>,
84}
85
86#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
87pub struct Postcheck {
88    pub validity: Validity,
89    #[serde(default, skip_serializing_if = "Vec::is_empty")]
90    pub concerns: Vec<String>,
91}
92
93/// 2026-09-26: The two operator switches, read from the environment only in
94/// [`PolicyOptions::from_env`], and the box class's temperature ceilings.
95#[derive(Clone, Copy, Debug, Default, PartialEq)]
96pub struct PolicyOptions {
97    /// 2026-09-26: `METRALE_NO_HW_PRECHECK=1`: never refuse. Every concern is still
98    /// recorded, a line saying the refusal was suppressed is added, and
99    /// [`postcheck`], which ignores the options, still marks a throttled run invalid.
100    pub kill_switch: bool,
101    /// 2026-09-26: `METRALE_HW_TEMP_GATE=1`: an exceeded temperature ceiling refuses
102    /// instead of warning.
103    pub absolute_temp_gate: bool,
104    /// 2026-09-26: The box class's temperature ceilings (`kernels/<hw>/HARDWARE.toml`
105    /// `[benchmarks.limits.thermal]`: `gpu_ceiling_c`, `chassis_park_c`). `None`
106    /// (a class that declares none, or a caller without a repository) adds a
107    /// concern that the temperatures were not judged.
108    pub ceilings: Option<TempCeilings>,
109}
110
111/// 2026-09-26: The two absolute-temperature lines a pre-run capture is judged against.
112#[derive(Clone, Copy, Debug, PartialEq)]
113pub struct TempCeilings {
114    /// 2026-09-26: GPU die (`temperature.gpu`), °C.
115    pub gpu_c: f64,
116    /// 2026-09-26: Hottest chassis zone, °C.
117    pub chassis_c: f64,
118}
119
120impl TempCeilings {
121    /// 2026-09-26: The ceilings a class declares.
122    #[must_use]
123    pub fn of(thermal: &super::limits::ThermalEnvelope) -> Self {
124        Self {
125            gpu_c: thermal.gpu_ceiling_c,
126            chassis_c: thermal.chassis_park_c,
127        }
128    }
129}
130
131/// 2026-09-26: Env var that suppresses refusal. Read only by [`PolicyOptions::from_env`].
132pub const KILL_SWITCH_ENV: &str = "METRALE_NO_HW_PRECHECK";
133/// 2026-09-26: Env var that makes the absolute-temperature ceilings refuse.
134pub const TEMP_GATE_ENV: &str = "METRALE_HW_TEMP_GATE";
135
136/// 2026-09-26: GPU compute processes, other than this one, that a Speed run
137/// tolerates: one, the server of the model under test. A second means something
138/// else is resident.
139pub const MAX_FOREIGN_COMPUTE_APPS: usize = 1;
140
141impl PolicyOptions {
142    pub fn from_env() -> Self {
143        Self {
144            kill_switch: flag(KILL_SWITCH_ENV),
145            absolute_temp_gate: flag(TEMP_GATE_ENV),
146            ceilings: None,
147        }
148    }
149}
150
151/// 2026-09-26: `1`/`true`/`yes`, trimmed and case-insensitive. Anything else,
152/// including an empty value, is off.
153fn flag(name: &str) -> bool {
154    std::env::var(name)
155        .is_ok_and(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
156}
157
158/// 2026-09-26: Worst wins, so a Refuse cannot be argued down by a later Proceed.
159fn worst(a: Decision, b: Decision) -> Decision {
160    match (a, b) {
161        (Decision::Refuse, _) | (_, Decision::Refuse) => Decision::Refuse,
162        (Decision::Warn, _) | (_, Decision::Warn) => Decision::Warn,
163        _ => Decision::Proceed,
164    }
165}
166
167/// 2026-09-26: May this benchmark start?
168///
169/// A Correctness run always may: a Refuse is downgraded to [`Decision::Warn`], with
170/// every concern kept and one more that says so.
171pub fn precheck(
172    sensitivity: Sensitivity,
173    before: &HardwareState,
174    options: PolicyOptions,
175) -> Precheck {
176    let mut concerns = Vec::new();
177    let mut decision = Decision::Proceed;
178    let mut gate = |d: Decision, why: String| {
179        concerns.push(why);
180        decision = worst(decision, d);
181    };
182
183    // 2026-09-26: Refuses by default: a thermal reason is asserted now.
184    match before.throttle_active.thermal() {
185        Some(true) => gate(
186            Decision::Refuse,
187            "a thermal throttle reason is ACTIVE before the run started".to_string(),
188        ),
189        Some(false) => {}
190        None => gate(
191            Decision::Warn,
192            "throttle reasons could not be read — the box is not known to be unthrottled"
193                .to_string(),
194        ),
195    }
196
197    // 2026-09-26: Refuses by default: another resident compute process.
198    match before.foreign_compute_apps() {
199        Some(n) if n > MAX_FOREIGN_COMPUTE_APPS => gate(
200            Decision::Refuse,
201            format!(
202                "{n} GPU compute processes other than this one (at most \
203                 {MAX_FOREIGN_COMPUTE_APPS} expected — the model under test)"
204            ),
205        ),
206        Some(_) => {}
207        None => gate(
208            Decision::Warn,
209            "GPU compute processes could not be listed — contention is unknown".to_string(),
210        ),
211    }
212
213    // 2026-09-26: Warns by default; refuses only under METRALE_HW_TEMP_GATE=1.
214    let temp_level = if options.absolute_temp_gate {
215        Decision::Refuse
216    } else {
217        Decision::Warn
218    };
219    match options.ceilings {
220        Some(c) => {
221            if let Some(t) = before.gpu_temp_c.filter(|t| *t > c.gpu_c) {
222                gate(
223                    temp_level,
224                    format!("GPU die {t:.0} °C is above the {:.0} °C ceiling", c.gpu_c),
225                );
226            }
227            if let Some(t) = before.hottest_chassis_c().filter(|t| *t > c.chassis_c) {
228                gate(
229                    temp_level,
230                    format!(
231                        "hottest chassis zone {t:.0} °C is above the {:.0} °C ceiling",
232                        c.chassis_c
233                    ),
234                );
235            }
236        }
237        // 2026-09-26: Warns, never refuses: a missing ceiling is not a cool box.
238        None => gate(
239            Decision::Warn,
240            "no temperature ceilings are declared for this box class, so the capture's \
241             temperatures were recorded and not judged"
242                .to_string(),
243        ),
244    }
245
246    // 2026-09-26: A Correctness run records everything above and blocks on none of it.
247    if sensitivity == Sensitivity::Correctness && decision == Decision::Refuse {
248        decision = Decision::Warn;
249        concerns.push(
250            "correctness gate — recorded and proceeding; accuracy is not thermally sensitive"
251                .to_string(),
252        );
253    }
254    if options.kill_switch && decision == Decision::Refuse {
255        decision = Decision::Warn;
256        concerns.push(format!(
257            "{KILL_SWITCH_ENV}=1 — REFUSAL SUPPRESSED BY OPERATOR. The concerns above stand and \
258             are recorded with the run; the kill switch only lets it start."
259        ));
260    }
261    Precheck { decision, concerns }
262}
263
264/// 2026-09-26: May a finished run's numbers be believed? Reads the delta, not the
265/// absolute state, and ignores `_options`.
266pub fn postcheck(
267    sensitivity: Sensitivity,
268    delta: &HardwareStateDelta,
269    _options: PolicyOptions,
270) -> Postcheck {
271    let mut concerns = Vec::new();
272    if let Some(f) = delta.thermal_throttle_fraction().filter(|f| *f > 0.0) {
273        // 2026-09-26: A partial sum is a floor, so it is printed as one.
274        concerns.push(if delta.thermal_counters_complete() {
275            format!("thermally throttled for {:.2}% of the run", f * 100.0)
276        } else {
277            format!(
278                "thermally throttled for AT LEAST {:.2}% of the run — one or more throttle \
279                 counters were unreadable, so the true figure can only be higher",
280                f * 100.0
281            )
282        });
283    }
284    if let Some(d) = delta.hottest_chassis_delta_c {
285        concerns.push(format!("hottest chassis zone moved {d:+.0} °C"));
286    }
287
288    if sensitivity == Sensitivity::Correctness {
289        return Postcheck {
290            validity: Validity::NotApplicable,
291            concerns,
292        };
293    }
294    let validity = match delta.thermal_throttle_advanced() {
295        Some(true) => {
296            // 2026-09-26: An unread counter prints as `unreadable`, never as a
297            // measured 0 µs.
298            fn counter(v: Option<u64>) -> String {
299                v.map_or_else(|| "unreadable".to_string(), |us| format!("{us} µs"))
300            }
301            concerns.push(format!(
302                "a thermal throttle counter advanced during the run (sw {}, hw {}, \
303                 brake {}) — this speed number is not comparable",
304                counter(delta.sw_thermal_us),
305                counter(delta.hw_thermal_us),
306                counter(delta.hw_power_brake_us),
307            ));
308            Validity::Invalid
309        }
310        Some(false) => Validity::Valid,
311        None => {
312            concerns.push(
313                "throttle counters were unreadable on at least one capture — this run is not \
314                 known to have been unthrottled"
315                    .to_string(),
316            );
317            Validity::Unknown
318        }
319    };
320    Postcheck { validity, concerns }
321}
322
323#[cfg(test)]
324#[path = "policy_tests.rs"]
325mod tests;