1use serde_json::Value;
6
7#[derive(Debug, Clone, Copy, PartialEq)]
9pub enum Op {
10 Lt,
12 Le,
14 Gt,
16 Ge,
18}
19
20#[derive(Debug, Clone)]
22pub struct Operand {
23 pub item: String,
25 pub backend: String,
27 pub metric: String,
29}
30
31#[derive(Debug, Clone)]
34pub struct Claim {
35 pub item: String,
37 pub backend: String,
39 pub metric: String,
41 pub minus: Option<Operand>,
43 pub op: Op,
45 pub bound: f64,
47}
48
49pub fn parse(expr: &str) -> Result<Claim, String> {
53 let malformed = || format!("claim must be `item.backend.metric <op> value`, got {expr:?}");
56 let (rest, rhs) = expr
57 .trim()
58 .rsplit_once(char::is_whitespace)
59 .ok_or_else(malformed)?;
60 let (lhs, op) = rest
61 .trim_end()
62 .rsplit_once(char::is_whitespace)
63 .ok_or_else(malformed)?;
64 let lhs = lhs.trim_end();
65 let op = match op {
66 "<" => Op::Lt,
67 "<=" => Op::Le,
68 ">" => Op::Gt,
69 ">=" => Op::Ge,
70 other => return Err(format!("unsupported operator {other:?}")),
71 };
72
73 if lhs.ends_with(" -") {
74 return Err(format!(
75 "claim difference needs a right operand: `A.backend.metric - B.backend.metric`, got {expr:?}"
76 ));
77 }
78 let (first, second) = split_difference(lhs).map_or((lhs, None), |(a, b)| (a, Some(b)));
79 let (item, backend, metric) = split_operand(first)?;
80 let minus = second.and_then(|text| {
81 let (item, backend, metric) = split_operand(text).ok()?;
82 Some(Operand {
83 item: item.to_string(),
84 backend: backend.to_string(),
85 metric: metric.to_string(),
86 })
87 });
88
89 let unit_at = rhs
90 .find(|c: char| !c.is_ascii_digit() && c != '.' && c != '_')
91 .unwrap_or(rhs.len());
92 let (num, unit) = rhs.split_at(unit_at);
93 let value: f64 = if num.contains('_') {
94 num.replace('_', "").parse()
95 } else {
96 num.parse()
97 }
98 .map_err(|_| format!("invalid number {num:?}"))?;
99 let factor = match unit {
100 "" => 1.0,
101 "ns" => 1.0,
102 "us" | "µs" => 1e3,
103 "ms" => 1e6,
104 "s" => 1e9,
105 other => return Err(format!("unknown unit {other:?}")),
106 };
107
108 Ok(Claim {
109 item: item.to_string(),
110 backend: backend.to_string(),
111 metric: metric.to_string(),
112 minus,
113 op,
114 bound: value * factor,
115 })
116}
117
118const BACKENDS: [&str; 6] = [
120 "perfcnt",
121 "walltime",
122 "alloc",
123 "asyncexec",
124 "callgrind",
125 "buildcost",
126];
127
128fn split_difference(lhs: &str) -> Option<(&str, &str)> {
132 let known =
133 |text: &str| split_operand(text).is_ok_and(|(_, backend, _)| BACKENDS.contains(&backend));
134 lhs.rmatch_indices(" - ").find_map(|(at, sep)| {
135 let (a, b) = (lhs[..at].trim_end(), lhs[at + sep.len()..].trim_start());
136 (known(a) && known(b)).then_some((a, b))
137 })
138}
139
140fn split_operand(text: &str) -> Result<(&str, &str, &str), String> {
142 text.rsplit_once('.')
143 .and_then(|(rest, metric)| Some((rest.rsplit_once('.')?, metric)))
144 .map(|((item, backend), metric)| (item.trim_start(), backend, metric))
145 .filter(|(_, backend, metric)| {
146 !backend.contains(char::is_whitespace) && !metric.contains(char::is_whitespace)
147 })
148 .ok_or_else(|| format!("left side must be item.backend.metric, got {text:?}"))
149}
150
151pub fn values(claim: &Claim, baseline: &Value) -> Result<(f64, Option<f64>), String> {
154 let first = measured(baseline, &claim.item, &claim.backend, &claim.metric)?;
155 let second = match &claim.minus {
156 Some(m) => Some(measured(baseline, &m.item, &m.backend, &m.metric)?),
157 None => None,
158 };
159 Ok((first, second))
160}
161
162pub fn evaluate(claim: &Claim, baseline: &Value) -> Result<(bool, f64), String> {
164 let (first, second) = values(claim, baseline).map_err(|e| {
165 if claim.minus.is_none() && claim.item.contains(" - ") {
166 format!(
167 "{e} (read as one item; for a difference both sides need a known backend: {})",
168 BACKENDS.join(", ")
169 )
170 } else {
171 e
172 }
173 })?;
174 let actual = first - second.unwrap_or(0.0);
175 let holds = match claim.op {
176 Op::Lt => actual < claim.bound,
177 Op::Le => actual <= claim.bound,
178 Op::Gt => actual > claim.bound,
179 Op::Ge => actual >= claim.bound,
180 };
181 Ok((holds, actual))
182}
183
184fn measured(baseline: &Value, item: &str, backend: &str, metric: &str) -> Result<f64, String> {
185 baseline["items"][item][backend][metric]
186 .as_f64()
187 .ok_or_else(|| {
188 format!(
189 "no measurement for {item}.{backend}.{metric} in baseline — run `cargo soothfast measure --save-baseline`"
190 )
191 })
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197 use serde_json::json;
198
199 #[test]
200 fn parses_units_and_ops() {
201 let c = parse("demo::f.walltime.median_ns < 1ms").unwrap();
202 assert_eq!(c.item, "demo::f");
203 assert_eq!(c.backend, "walltime");
204 assert_eq!(c.metric, "median_ns");
205 assert_eq!(c.bound, 1e6);
206 assert!(matches!(c.op, Op::Lt));
207 assert_eq!(parse("a::b.alloc.allocs <= 2").unwrap().bound, 2.0);
208 }
209
210 #[test]
211 fn imported_ids_may_hold_dots_and_spaces() {
212 let c = parse("pytest::t.py::test_parse.walltime.median_ns < 1us").unwrap();
213 assert_eq!(c.item, "pytest::t.py::test_parse");
214 assert_eq!(
215 (c.backend.as_str(), c.metric.as_str()),
216 ("walltime", "median_ns")
217 );
218 let v = parse("vitest::src/a.bench.ts > sum > loop.walltime.p99_ns <= 5ms").unwrap();
219 assert_eq!(v.item, "vitest::src/a.bench.ts > sum > loop");
220 assert_eq!(v.metric, "p99_ns");
221 assert!(parse("walltime.median_ns < 1").is_err());
222 assert!(parse("< 1").is_err());
223 }
224
225 #[test]
226 fn evaluates_against_baseline() {
227 let baseline = json!({ "items": { "demo::f": { "perfcnt": { "instructions": 100.0 } } } });
228 let c = parse("demo::f.perfcnt.instructions < 200").unwrap();
229 assert_eq!(evaluate(&c, &baseline).unwrap(), (true, 100.0));
230 let c = parse("demo::f.perfcnt.instructions < 50").unwrap();
231 assert_eq!(evaluate(&c, &baseline).unwrap(), (false, 100.0));
232 let missing = parse("demo::g.perfcnt.instructions < 50").unwrap();
233 assert!(evaluate(&missing, &baseline).is_err());
234 }
235
236 #[test]
237 fn a_difference_of_two_items_parses_both_sides() {
238 let c = parse("a::f.walltime.median_ns - b::g.walltime.median_ns < 2us").unwrap();
239 assert_eq!(c.item, "a::f");
240 let m = c.minus.unwrap();
241 assert_eq!(
242 (m.item.as_str(), m.backend.as_str(), m.metric.as_str()),
243 ("b::g", "walltime", "median_ns")
244 );
245 assert_eq!(c.bound, 2e3);
246 let v =
247 parse("x.bench.ts > a > b.walltime.p99_ns - y.py::t.walltime.p99_ns <= 1ms").unwrap();
248 assert_eq!(v.item, "x.bench.ts > a > b");
249 assert_eq!(v.minus.unwrap().item, "y.py::t");
250 assert!(parse("a.walltime.median_ns - b.walltime < 2us").is_err());
251 let err = parse("a.walltime.median_ns - < 2us").unwrap_err();
252 assert!(err.contains("right operand"), "{err}");
253 }
254
255 #[test]
256 fn an_imported_id_with_a_dash_is_one_operand_or_one_side() {
257 let single = parse("vitest::f.bench.ts > d - n.walltime.p99_ns < 5ms").unwrap();
258 assert_eq!(single.item, "vitest::f.bench.ts > d - n");
259 assert!(single.minus.is_none());
260 let diff =
261 parse("x.walltime.p99_ns - vitest::f.bench.ts > d - n.walltime.p99_ns < 5ms").unwrap();
262 assert_eq!(diff.item, "x");
263 assert_eq!(diff.minus.unwrap().item, "vitest::f.bench.ts > d - n");
264 }
265
266 #[test]
267 fn a_dash_in_a_single_operand_hints_at_the_difference_form() {
268 let c = parse("a.walltime.median_ns - b.nope.median_ns < 2us").unwrap();
269 let err = evaluate(&c, &json!({})).unwrap_err();
270 assert!(err.contains("read as one item"), "{err}");
271 assert!(err.contains("perfcnt, walltime"), "{err}");
272 }
273
274 #[test]
275 fn a_difference_is_evaluated_and_names_the_missing_side() {
276 let baseline = json!({ "items": {
277 "a": { "walltime": { "median_ns": 5000.0 } },
278 "b": { "walltime": { "median_ns": 4000.0 } },
279 } });
280 let c = parse("a.walltime.median_ns - b.walltime.median_ns < 2us").unwrap();
281 assert_eq!(evaluate(&c, &baseline).unwrap(), (true, 1000.0));
282 let c = parse("a.walltime.median_ns - b.walltime.median_ns > 2us").unwrap();
283 assert_eq!(evaluate(&c, &baseline).unwrap(), (false, 1000.0));
284 let c = parse("a.walltime.median_ns - gone.walltime.median_ns < 2us").unwrap();
285 assert!(
286 evaluate(&c, &baseline)
287 .unwrap_err()
288 .contains("gone.walltime.median_ns")
289 );
290 let c = parse("gone.walltime.median_ns - b.walltime.median_ns < 2us").unwrap();
291 assert!(
292 evaluate(&c, &baseline)
293 .unwrap_err()
294 .contains("gone.walltime.median_ns")
295 );
296 }
297
298 #[test]
299 fn rejects_malformed() {
300 assert!(parse("demo::f.walltime < 5").is_err());
301 assert!(parse("demo::f.walltime.median == 5").is_err());
302 assert!(parse("demo::f.walltime.median < 5parsecs").is_err());
303 }
304}