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)]
23pub struct Claim {
24 pub item: String,
26 pub backend: String,
28 pub metric: String,
30 pub op: Op,
32 pub bound: f64,
34}
35
36pub 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 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
87pub 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}