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;