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/// One parsed `soothfast:claim` expression: a measured metric of an item,
21/// compared against a fixed bound.
22#[derive(Debug, Clone)]
23pub struct Claim {
24    /// Fully qualified item path (`demo::lcg_checksum`).
25    pub item: String,
26    /// Metric backend the value comes from (`perfcnt`, `walltime`, `alloc`).
27    pub backend: String,
28    /// Metric name within the backend (`instructions`, `p99`, `allocs`).
29    pub metric: String,
30    /// How the measured value is compared to `bound`.
31    pub op: Op,
32    /// Claimed limit, in the metric's raw unit (durations normalized to ns).
33    pub bound: f64,
34}
35
36/// Parse `demo::lcg_checksum.perfcnt.instructions < 25000` (units on the
37/// bound: ns/us/ms/s multiply into ns; bare numbers stay raw).
38pub fn parse(expr: &str) -> Result<Claim, String> {
39    let tokens: Vec<&str> = expr.split_whitespace().collect();
40    let [lhs, op, rhs] = tokens.as_slice() else {
41        return Err(format!(
42            "claim must be `item.backend.metric <op> value`, got {expr:?}"
43        ));
44    };
45    let op = match *op {
46        "<" => Op::Lt,
47        "<=" => Op::Le,
48        ">" => Op::Gt,
49        ">=" => Op::Ge,
50        other => return Err(format!("unsupported operator {other:?}")),
51    };
52
53    // Item ids contain `::` but never `.` — split lhs on dots.
54    let parts: Vec<&str> = lhs.split('.').collect();
55    if parts.len() != 3 {
56        return Err(format!(
57            "left side must be item.backend.metric, got {lhs:?}"
58        ));
59    }
60
61    let unit_at = rhs
62        .find(|c: char| !c.is_ascii_digit() && c != '.' && c != '_')
63        .unwrap_or(rhs.len());
64    let (num, unit) = rhs.split_at(unit_at);
65    let value: f64 = num
66        .replace('_', "")
67        .parse()
68        .map_err(|_| format!("invalid number {num:?}"))?;
69    let factor = match unit {
70        "" => 1.0,
71        "ns" => 1.0,
72        "us" | "µs" => 1e3,
73        "ms" => 1e6,
74        "s" => 1e9,
75        other => return Err(format!("unknown unit {other:?}")),
76    };
77
78    Ok(Claim {
79        item: parts[0].to_string(),
80        backend: parts[1].to_string(),
81        metric: parts[2].to_string(),
82        op,
83        bound: value * factor,
84    })
85}
86
87/// Evaluate against a baseline document; returns (holds, actual value).
88pub fn evaluate(claim: &Claim, baseline: &Value) -> Result<(bool, f64), String> {
89    let actual = baseline["items"][&claim.item][&claim.backend][&claim.metric]
90        .as_f64()
91        .ok_or_else(|| {
92            format!(
93                "no measurement for {}.{}.{} in baseline — run `cargo soothfast measure --save-baseline`",
94                claim.item, claim.backend, claim.metric
95            )
96        })?;
97    let holds = match claim.op {
98        Op::Lt => actual < claim.bound,
99        Op::Le => actual <= claim.bound,
100        Op::Gt => actual > claim.bound,
101        Op::Ge => actual >= claim.bound,
102    };
103    Ok((holds, actual))
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use serde_json::json;
110
111    #[test]
112    fn parses_units_and_ops() {
113        let c = parse("demo::f.walltime.median_ns < 1ms").unwrap();
114        assert_eq!(c.item, "demo::f");
115        assert_eq!(c.backend, "walltime");
116        assert_eq!(c.metric, "median_ns");
117        assert_eq!(c.bound, 1e6);
118        assert!(matches!(c.op, Op::Lt));
119        assert_eq!(parse("a::b.alloc.allocs <= 2").unwrap().bound, 2.0);
120    }
121
122    #[test]
123    fn evaluates_against_baseline() {
124        let baseline = json!({ "items": { "demo::f": { "perfcnt": { "instructions": 100.0 } } } });
125        let c = parse("demo::f.perfcnt.instructions < 200").unwrap();
126        assert_eq!(evaluate(&c, &baseline).unwrap(), (true, 100.0));
127        let c = parse("demo::f.perfcnt.instructions < 50").unwrap();
128        assert_eq!(evaluate(&c, &baseline).unwrap(), (false, 100.0));
129        let missing = parse("demo::g.perfcnt.instructions < 50").unwrap();
130        assert!(evaluate(&missing, &baseline).is_err());
131    }
132
133    #[test]
134    fn rejects_malformed() {
135        assert!(parse("demo::f.walltime < 5").is_err());
136        assert!(parse("demo::f.walltime.median == 5").is_err());
137        assert!(parse("demo::f.walltime.median < 5parsecs").is_err());
138    }
139}