metrale_bench/
params.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: Parameter schema: what a benchmark exposes for editing before
4//! it runs.
5//!
6//! A benchmark declares [`ParamSpec`]s; the TUI renders them, the user edits
7//! them as text, and the edited [`ParamValues`] reach `Benchmark::configure`
8//! before the first `next()`. [`ParamValues::defaults`] takes the starting
9//! values from the specs, and the TUI's edit form parses through
10//! [`ParamKind::parse`].
11//!
12//! Owner: bench (parameters).
13//! Invariants:
14//! - [`ParamKind::parse`] returns only values inside the kind's domain.
15//! - `ParamValues` accessors never substitute a value for a missing or
16//!   mistyped key.
17
18use std::collections::BTreeMap;
19
20use anyhow::{Result, anyhow, bail};
21
22/// 2026-09-26: The editing affordance and the validation domain for one parameter.
23#[derive(Clone, Debug, PartialEq)]
24pub enum ParamKind {
25    Bool,
26    Int {
27        min: i64,
28        max: i64,
29    },
30    Float {
31        min: f64,
32        max: f64,
33    },
34    /// 2026-09-26: Free text, not empty.
35    Text,
36    /// 2026-09-26: One of a fixed set, matched case-insensitively. The value
37    /// is carried as [`ParamValue::Text`].
38    Choice(&'static [&'static str]),
39    /// 2026-09-26: Comma-separated integers, at least one.
40    IntList {
41        min: i64,
42        max: i64,
43    },
44}
45
46/// 2026-09-26: A concrete parameter value.
47#[derive(Clone, Debug, PartialEq)]
48pub enum ParamValue {
49    Bool(bool),
50    Int(i64),
51    Float(f64),
52    Text(String),
53    IntList(Vec<i64>),
54}
55
56impl ParamValue {
57    /// 2026-09-26: Render for the edit box; [`ParamKind::parse`] reads it
58    /// back.
59    pub fn to_edit_string(&self) -> String {
60        match self {
61            ParamValue::Bool(b) => b.to_string(),
62            ParamValue::Int(i) => i.to_string(),
63            ParamValue::Float(v) => format!("{v}"),
64            ParamValue::Text(s) => s.clone(),
65            ParamValue::IntList(v) => v.iter().map(i64::to_string).collect::<Vec<_>>().join(", "),
66        }
67    }
68
69    fn type_name(&self) -> &'static str {
70        match self {
71            ParamValue::Bool(_) => "bool",
72            ParamValue::Int(_) => "int",
73            ParamValue::Float(_) => "float",
74            ParamValue::Text(_) => "text",
75            ParamValue::IntList(_) => "int-list",
76        }
77    }
78}
79
80impl ParamKind {
81    /// 2026-09-26: Parse an edited string into a value of this kind, enforcing
82    /// the domain. The TUI stores the error text against the field, so it
83    /// names the bound that was violated.
84    pub fn parse(&self, raw: &str) -> Result<ParamValue> {
85        let s = raw.trim();
86        match self {
87            ParamKind::Bool => match s.to_ascii_lowercase().as_str() {
88                "true" | "yes" | "on" | "1" => Ok(ParamValue::Bool(true)),
89                "false" | "no" | "off" | "0" => Ok(ParamValue::Bool(false)),
90                _ => bail!("expected true or false, got {s:?}"),
91            },
92            ParamKind::Int { min, max } => {
93                let v: i64 = s.parse().map_err(|_| anyhow!("not an integer: {s:?}"))?;
94                if v < *min || v > *max {
95                    bail!("must be between {min} and {max}, got {v}");
96                }
97                Ok(ParamValue::Int(v))
98            }
99            ParamKind::Float { min, max } => {
100                let v: f64 = s.parse().map_err(|_| anyhow!("not a number: {s:?}"))?;
101                if !v.is_finite() {
102                    bail!("must be finite, got {s:?}");
103                }
104                if v < *min || v > *max {
105                    bail!("must be between {min} and {max}, got {v}");
106                }
107                Ok(ParamValue::Float(v))
108            }
109            ParamKind::Text => {
110                if s.is_empty() {
111                    bail!("must not be empty");
112                }
113                Ok(ParamValue::Text(s.to_string()))
114            }
115            ParamKind::Choice(options) => options
116                .iter()
117                .find(|o| o.eq_ignore_ascii_case(s))
118                .map(|o| ParamValue::Text((*o).to_string()))
119                .ok_or_else(|| anyhow!("must be one of: {}", options.join(", "))),
120            ParamKind::IntList { min, max } => {
121                let mut out = Vec::new();
122                for part in s.split(',') {
123                    let p = part.trim();
124                    if p.is_empty() {
125                        continue;
126                    }
127                    let v: i64 = p.parse().map_err(|_| anyhow!("not an integer: {p:?}"))?;
128                    if v < *min || v > *max {
129                        bail!("every entry must be between {min} and {max}, got {v}");
130                    }
131                    out.push(v);
132                }
133                if out.is_empty() {
134                    bail!("must list at least one integer");
135                }
136                Ok(ParamValue::IntList(out))
137            }
138        }
139    }
140
141    /// 2026-09-26: Short hint rendered beside the field, e.g. `1–4096` or
142    /// `a / b / c`.
143    pub fn domain_hint(&self) -> String {
144        match self {
145            ParamKind::Bool => "true / false".into(),
146            ParamKind::Int { min, max } | ParamKind::IntList { min, max } => {
147                format!("{min}–{max}")
148            }
149            ParamKind::Float { min, max } => format!("{min}–{max}"),
150            ParamKind::Text => "text".into(),
151            ParamKind::Choice(options) => options.join(" / "),
152        }
153    }
154}
155
156/// 2026-09-26: One editable parameter.
157#[derive(Clone, Debug)]
158pub struct ParamSpec {
159    pub key: &'static str,
160    pub label: &'static str,
161    /// 2026-09-26: One line shown with the field. Say what the value does,
162    /// not its type.
163    pub help: &'static str,
164    pub kind: ParamKind,
165    pub default: ParamValue,
166}
167
168impl ParamSpec {
169    pub fn new(
170        key: &'static str,
171        label: &'static str,
172        help: &'static str,
173        kind: ParamKind,
174        default: ParamValue,
175    ) -> Self {
176        Self {
177            key,
178            label,
179            help,
180            kind,
181            default,
182        }
183    }
184}
185
186/// 2026-09-26: The edited values handed to `Benchmark::configure`.
187///
188/// Accessors return `Err` on a missing or mistyped key rather than falling
189/// back to a default: a benchmark that reads a key its schema never declared
190/// is a bug, and a substituted value would hide it behind a plausible number.
191/// `float` also accepts an `Int`.
192#[derive(Clone, Debug, Default, PartialEq)]
193pub struct ParamValues(pub(crate) BTreeMap<String, ParamValue>);
194
195impl ParamValues {
196    /// 2026-09-26: Starting values for a schema: each spec's `default`.
197    pub fn defaults(specs: &[ParamSpec]) -> Self {
198        Self(
199            specs
200                .iter()
201                .map(|s| (s.key.to_string(), s.default.clone()))
202                .collect(),
203        )
204    }
205
206    pub fn set(&mut self, key: impl Into<String>, value: ParamValue) {
207        self.0.insert(key.into(), value);
208    }
209
210    pub fn get(&self, key: &str) -> Option<&ParamValue> {
211        self.0.get(key)
212    }
213
214    fn require(&self, key: &str) -> Result<&ParamValue> {
215        self.0
216            .get(key)
217            .ok_or_else(|| anyhow!("parameter {key:?} was never set"))
218    }
219
220    fn mistyped(key: &str, want: &str, got: &ParamValue) -> anyhow::Error {
221        anyhow!("parameter {key:?} is {}, expected {want}", got.type_name())
222    }
223
224    pub fn bool(&self, key: &str) -> Result<bool> {
225        match self.require(key)? {
226            ParamValue::Bool(b) => Ok(*b),
227            other => Err(Self::mistyped(key, "bool", other)),
228        }
229    }
230
231    pub fn int(&self, key: &str) -> Result<i64> {
232        match self.require(key)? {
233            ParamValue::Int(i) => Ok(*i),
234            other => Err(Self::mistyped(key, "int", other)),
235        }
236    }
237
238    /// 2026-09-26: `int`, refused when negative.
239    pub fn usize(&self, key: &str) -> Result<usize> {
240        let v = self.int(key)?;
241        usize::try_from(v).map_err(|_| anyhow!("parameter {key:?} must not be negative, got {v}"))
242    }
243
244    pub fn float(&self, key: &str) -> Result<f64> {
245        match self.require(key)? {
246            ParamValue::Float(v) => Ok(*v),
247            // 2026-09-26: An integer in a float field is unambiguous.
248            ParamValue::Int(i) => Ok(*i as f64),
249            other => Err(Self::mistyped(key, "float", other)),
250        }
251    }
252
253    pub fn text(&self, key: &str) -> Result<&str> {
254        match self.require(key)? {
255            ParamValue::Text(s) => Ok(s.as_str()),
256            other => Err(Self::mistyped(key, "text", other)),
257        }
258    }
259
260    pub fn int_list(&self, key: &str) -> Result<&[i64]> {
261        match self.require(key)? {
262            ParamValue::IntList(v) => Ok(v.as_slice()),
263            other => Err(Self::mistyped(key, "int-list", other)),
264        }
265    }
266
267    /// 2026-09-26: Refuse an unknown key, a missing key, or a value that does
268    /// not parse-check against its spec. Headless runs call it before a run,
269    /// and every driver's `configure` except vision's and video's calls it
270    /// first, so a bad field is reported against the field rather than as a
271    /// mid-run failure.
272    pub fn validate_against(&self, specs: &[ParamSpec]) -> Result<()> {
273        for key in self.0.keys() {
274            if !specs.iter().any(|spec| spec.key == key) {
275                let known = specs.iter().map(|spec| spec.key).collect::<Vec<_>>();
276                bail!(
277                    "unknown parameter {key:?} — this benchmark takes: {}",
278                    known.join(", ")
279                );
280            }
281        }
282        for spec in specs {
283            let value = self.require(spec.key)?;
284            // 2026-09-26: Round-trip through the kind, so editing and
285            // validation share `ParamKind::parse` as the domain check.
286            spec.kind
287                .parse(&value.to_edit_string())
288                .map_err(|e| anyhow!("{}: {e}", spec.label))?;
289        }
290        Ok(())
291    }
292}
293
294#[cfg(test)]
295#[path = "params_tests.rs"]
296mod tests;