Skip to main content

soothfast_docs/
claims.rs

1//! Quantitative-claims ledger: `item.backend.metric <op> value[unit]`
2//! evaluated against a measurement baseline. Numbers in prose become gated
3//! facts instead of aspirations.
4
5use serde_json::Value;
6
7/// Comparison operator of a claim expression (`<`, `<=`, `>`, `>=`).
8#[derive(Debug, Clone, Copy, PartialEq)]
9pub enum Op {
10    /// `<`
11    Lt,
12    /// `<=` (also written `≤`)
13    Le,
14    /// `>`
15    Gt,
16    /// `>=` (also written `≥`)
17    Ge,
18}
19
20/// A metric of one item: `item.backend.metric`.
21#[derive(Debug, Clone)]
22pub struct Operand {
23    /// Fully qualified item path (`demo::lcg_checksum`).
24    pub item: String,
25    /// Metric backend the value comes from (`perfcnt`, `walltime`, `alloc`).
26    pub backend: String,
27    /// Metric name within the backend (`instructions`, `p99`, `allocs`).
28    pub metric: String,
29}
30
31/// One parsed `soothfast:claim` expression: a measured metric of an item,
32/// or the difference of two items' metrics, compared against a fixed bound.
33#[derive(Debug, Clone)]
34pub struct Claim {
35    /// Fully qualified item path (`demo::lcg_checksum`).
36    pub item: String,
37    /// Metric backend the value comes from (`perfcnt`, `walltime`, `alloc`).
38    pub backend: String,
39    /// Metric name within the backend (`instructions`, `p99`, `allocs`).
40    pub metric: String,
41    /// Metric subtracted from the one above (`A.b.m - B.b.m < 2us`).
42    pub minus: Option<Operand>,
43    /// How the measured value is compared to `bound`.
44    pub op: Op,
45    /// Claimed limit, in the metric's raw unit (durations normalized to ns).
46    pub bound: f64,
47}
48
49/// Parse `demo::lcg_checksum.perfcnt.instructions < 25000` (units on the
50/// bound: ns/us/ms/s multiply into ns; bare numbers stay raw). Two operands
51/// joined by ` - ` claim their difference.
52pub fn parse(expr: &str) -> Result<Claim, String> {
53    // Imported item ids may hold spaces (`file > group > name`), so the
54    // operator and bound are the last two tokens and the rest is the items.
55    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
118/// Backends a measurement baseline records under an item.
119const BACKENDS: [&str; 6] = [
120    "perfcnt",
121    "walltime",
122    "alloc",
123    "asyncexec",
124    "callgrind",
125    "buildcost",
126];
127
128/// The ` - ` that separates two operands, trying split points from the
129/// right. An imported id may hold ` - ` itself, so a split counts only when
130/// both sides are operands with a known backend.
131fn 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
140/// Backend and metric never hold a dot; an imported item id may.
141fn 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
151/// The measured value of the claim's item and, for a difference, of the item
152/// subtracted from it.
153pub 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
162/// Evaluate against a baseline document; returns (holds, actual value).
163pub 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}