1use crate::{ObservationRef, Observations, Scalar};
4use std::fmt;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
9#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
10pub enum Direction {
11 HigherIsBetter,
12 LowerIsBetter,
13}
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
18#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
19pub enum Comparator {
20 Equal,
21 NotEqual,
22 GreaterThan,
23 GreaterThanOrEqual,
24 LessThan,
25 LessThanOrEqual,
26}
27
28#[derive(Debug, Clone, PartialEq)]
30#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
31#[cfg_attr(feature = "serde", serde(tag = "kind", rename_all = "snake_case"))]
32pub enum Operand {
33 Literal { value: Scalar },
34 Observation { reference: ObservationRef },
35}
36
37impl Operand {
38 pub fn literal(value: impl Into<Scalar>) -> Self {
39 Self::Literal {
40 value: value.into(),
41 }
42 }
43
44 pub fn observation(condition: impl Into<String>, measurement: impl Into<String>) -> Self {
45 Self::Observation {
46 reference: ObservationRef::new(condition, measurement),
47 }
48 }
49}
50
51#[derive(Debug, Clone, PartialEq)]
53#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
54#[cfg_attr(feature = "serde", serde(tag = "kind", rename_all = "snake_case"))]
55pub enum Criterion {
56 Compare {
57 left: Operand,
58 comparator: Comparator,
59 right: Operand,
60 },
61 Improvement {
62 baseline: ObservationRef,
63 candidate: ObservationRef,
64 direction: Direction,
65 minimum_fraction: f64,
66 },
67 RegressionLimit {
68 baseline: ObservationRef,
69 candidate: ObservationRef,
70 direction: Direction,
71 maximum_fraction: f64,
72 },
73 All {
74 criteria: Vec<Criterion>,
75 },
76 Any {
77 criteria: Vec<Criterion>,
78 },
79 Not {
80 criterion: Box<Criterion>,
81 },
82}
83
84impl Criterion {
85 pub fn compare(left: Operand, comparator: Comparator, right: Operand) -> Self {
86 Self::Compare {
87 left,
88 comparator,
89 right,
90 }
91 }
92
93 pub fn improvement(
94 baseline: ObservationRef,
95 candidate: ObservationRef,
96 direction: Direction,
97 minimum_fraction: f64,
98 ) -> Self {
99 Self::Improvement {
100 baseline,
101 candidate,
102 direction,
103 minimum_fraction,
104 }
105 }
106
107 pub fn regression_limit(
108 baseline: ObservationRef,
109 candidate: ObservationRef,
110 direction: Direction,
111 maximum_fraction: f64,
112 ) -> Self {
113 Self::RegressionLimit {
114 baseline,
115 candidate,
116 direction,
117 maximum_fraction,
118 }
119 }
120
121 pub fn all(criteria: impl IntoIterator<Item = Criterion>) -> Self {
122 Self::All {
123 criteria: criteria.into_iter().collect(),
124 }
125 }
126
127 pub fn any(criteria: impl IntoIterator<Item = Criterion>) -> Self {
128 Self::Any {
129 criteria: criteria.into_iter().collect(),
130 }
131 }
132
133 pub fn negate(criterion: Criterion) -> Self {
134 Self::Not {
135 criterion: Box::new(criterion),
136 }
137 }
138
139 pub fn evaluate(&self, observations: &Observations) -> Result<CriterionResult, CriterionError> {
140 match self {
141 Self::Compare {
142 left,
143 comparator,
144 right,
145 } => {
146 let left = resolve(left, observations)?;
147 let right = resolve(right, observations)?;
148 let passed = compare(left, *comparator, right)?;
149 Ok(CriterionResult::leaf(
150 passed,
151 format!("comparison {:?}", comparator),
152 ))
153 }
154 Self::Improvement {
155 baseline,
156 candidate,
157 direction,
158 minimum_fraction,
159 } => {
160 require_fraction("minimum_fraction", *minimum_fraction)?;
161 let (before, after) = numeric_pair(baseline, candidate, observations)?;
162 let fraction = improvement_fraction(before, after, *direction)?;
163 Ok(CriterionResult::leaf(
164 fraction >= *minimum_fraction,
165 format!("improvement {fraction:.6} >= {minimum_fraction:.6}"),
166 ))
167 }
168 Self::RegressionLimit {
169 baseline,
170 candidate,
171 direction,
172 maximum_fraction,
173 } => {
174 require_fraction("maximum_fraction", *maximum_fraction)?;
175 let (before, after) = numeric_pair(baseline, candidate, observations)?;
176 let improvement = improvement_fraction(before, after, *direction)?;
177 let regression = (-improvement).max(0.0);
178 Ok(CriterionResult::leaf(
179 regression <= *maximum_fraction,
180 format!("regression {regression:.6} <= {maximum_fraction:.6}"),
181 ))
182 }
183 Self::All { criteria } => {
184 if criteria.is_empty() {
185 return Err(CriterionError::EmptyComposite("all"));
186 }
187 let children = criteria
188 .iter()
189 .map(|c| c.evaluate(observations))
190 .collect::<Result<Vec<_>, _>>()?;
191 Ok(CriterionResult::branch(
192 children.iter().all(|r| r.passed),
193 "all",
194 children,
195 ))
196 }
197 Self::Any { criteria } => {
198 if criteria.is_empty() {
199 return Err(CriterionError::EmptyComposite("any"));
200 }
201 let children = criteria
202 .iter()
203 .map(|c| c.evaluate(observations))
204 .collect::<Result<Vec<_>, _>>()?;
205 Ok(CriterionResult::branch(
206 children.iter().any(|r| r.passed),
207 "any",
208 children,
209 ))
210 }
211 Self::Not { criterion } => {
212 let child = criterion.evaluate(observations)?;
213 Ok(CriterionResult::branch(!child.passed, "not", vec![child]))
214 }
215 }
216 }
217}
218
219#[derive(Debug, Clone, PartialEq, Eq)]
221#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
222pub struct CriterionResult {
223 pub passed: bool,
224 pub detail: String,
225 pub children: Vec<CriterionResult>,
226}
227
228impl CriterionResult {
229 fn leaf(passed: bool, detail: impl Into<String>) -> Self {
230 Self {
231 passed,
232 detail: detail.into(),
233 children: Vec::new(),
234 }
235 }
236
237 fn branch(passed: bool, detail: impl Into<String>, children: Vec<CriterionResult>) -> Self {
238 Self {
239 passed,
240 detail: detail.into(),
241 children,
242 }
243 }
244}
245
246#[derive(Debug, Clone, PartialEq)]
248pub enum CriterionError {
249 MissingObservation(ObservationRef),
250 NonNumericObservation(ObservationRef),
251 NonFiniteNumber,
252 UndefinedRelativeChange,
253 InvalidFraction { name: &'static str, value: f64 },
254 IncompatibleOperands,
255 UnsupportedOrdering,
256 EmptyComposite(&'static str),
257}
258
259impl fmt::Display for CriterionError {
260 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
261 match self {
262 Self::MissingObservation(r) => {
263 write!(f, "missing observation {}:{}", r.condition, r.measurement)
264 }
265 Self::NonNumericObservation(r) => write!(
266 f,
267 "observation {}:{} is not numeric",
268 r.condition, r.measurement
269 ),
270 Self::NonFiniteNumber => write!(f, "a numeric value is not finite"),
271 Self::UndefinedRelativeChange => {
272 write!(f, "relative change is undefined for a zero baseline")
273 }
274 Self::InvalidFraction { name, value } => {
275 write!(f, "{name} must be finite and non-negative, got {value}")
276 }
277 Self::IncompatibleOperands => write!(f, "the operands have incompatible scalar types"),
278 Self::UnsupportedOrdering => write!(f, "ordering comparisons require numeric operands"),
279 Self::EmptyComposite(kind) => {
280 write!(f, "{kind} criterion must contain at least one criterion")
281 }
282 }
283 }
284}
285
286impl std::error::Error for CriterionError {}
287
288fn require_fraction(name: &'static str, value: f64) -> Result<(), CriterionError> {
289 if value.is_finite() && value >= 0.0 {
290 Ok(())
291 } else {
292 Err(CriterionError::InvalidFraction { name, value })
293 }
294}
295
296fn resolve<'a>(
297 operand: &'a Operand,
298 observations: &'a Observations,
299) -> Result<&'a Scalar, CriterionError> {
300 match operand {
301 Operand::Literal { value } => Ok(value),
302 Operand::Observation { reference } => observations
303 .get(reference)
304 .map(|m| &m.value)
305 .ok_or_else(|| CriterionError::MissingObservation(reference.clone())),
306 }
307}
308
309fn numeric_pair(
310 baseline: &ObservationRef,
311 candidate: &ObservationRef,
312 observations: &Observations,
313) -> Result<(f64, f64), CriterionError> {
314 let a = observations
315 .get(baseline)
316 .ok_or_else(|| CriterionError::MissingObservation(baseline.clone()))?;
317 let b = observations
318 .get(candidate)
319 .ok_or_else(|| CriterionError::MissingObservation(candidate.clone()))?;
320 let before = match &a.value {
321 Scalar::Number(v) if v.is_finite() => *v,
322 Scalar::Number(_) => return Err(CriterionError::NonFiniteNumber),
323 _ => return Err(CriterionError::NonNumericObservation(baseline.clone())),
324 };
325 let after = match &b.value {
326 Scalar::Number(v) if v.is_finite() => *v,
327 Scalar::Number(_) => return Err(CriterionError::NonFiniteNumber),
328 _ => return Err(CriterionError::NonNumericObservation(candidate.clone())),
329 };
330 Ok((before, after))
331}
332
333fn compare(left: &Scalar, comparator: Comparator, right: &Scalar) -> Result<bool, CriterionError> {
334 use Comparator::*;
335 match comparator {
336 Equal | NotEqual => {
337 let equal = match (left, right) {
338 (Scalar::Number(a), Scalar::Number(b)) => {
339 if !a.is_finite() || !b.is_finite() {
340 return Err(CriterionError::NonFiniteNumber);
341 }
342 a == b
343 }
344 (Scalar::Boolean(a), Scalar::Boolean(b)) => a == b,
345 (Scalar::Text(a), Scalar::Text(b)) => a == b,
346 _ => return Err(CriterionError::IncompatibleOperands),
347 };
348 Ok(if comparator == Equal { equal } else { !equal })
349 }
350 GreaterThan | GreaterThanOrEqual | LessThan | LessThanOrEqual => {
351 let (a, b) = match (left, right) {
352 (Scalar::Number(a), Scalar::Number(b)) => {
353 if !a.is_finite() || !b.is_finite() {
354 return Err(CriterionError::NonFiniteNumber);
355 }
356 (*a, *b)
357 }
358 (Scalar::Number(_), _) | (_, Scalar::Number(_)) => {
359 return Err(CriterionError::IncompatibleOperands);
360 }
361 _ => return Err(CriterionError::UnsupportedOrdering),
362 };
363 Ok(match comparator {
364 GreaterThan => a > b,
365 GreaterThanOrEqual => a >= b,
366 LessThan => a < b,
367 LessThanOrEqual => a <= b,
368 Equal | NotEqual => unreachable!(),
369 })
370 }
371 }
372}
373
374fn improvement_fraction(
375 before: f64,
376 after: f64,
377 direction: Direction,
378) -> Result<f64, CriterionError> {
379 if before == 0.0 {
380 return Err(CriterionError::UndefinedRelativeChange);
381 }
382 let magnitude = before.abs();
383 Ok(match direction {
384 Direction::HigherIsBetter => (after - before) / magnitude,
385 Direction::LowerIsBetter => (before - after) / magnitude,
386 })
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392 use crate::{Measurement, Observation};
393
394 fn observations(before: f64, after: f64) -> Observations {
395 Observations::new()
396 .with(Observation::new(
397 "baseline",
398 Measurement::new("latency", before),
399 ))
400 .with(Observation::new(
401 "candidate",
402 Measurement::new("latency", after),
403 ))
404 }
405
406 #[test]
407 fn lower_is_better_improvement_passes() {
408 let c = Criterion::improvement(
409 ObservationRef::new("baseline", "latency"),
410 ObservationRef::new("candidate", "latency"),
411 Direction::LowerIsBetter,
412 0.05,
413 );
414 assert!(c.evaluate(&observations(100.0, 90.0)).unwrap().passed);
415 }
416
417 #[test]
418 fn lower_is_better_improvement_fails() {
419 let c = Criterion::improvement(
420 ObservationRef::new("baseline", "latency"),
421 ObservationRef::new("candidate", "latency"),
422 Direction::LowerIsBetter,
423 0.05,
424 );
425 assert!(!c.evaluate(&observations(100.0, 97.0)).unwrap().passed);
426 }
427
428 #[test]
429 fn regression_limit_passes_on_improvement() {
430 let c = Criterion::regression_limit(
431 ObservationRef::new("baseline", "latency"),
432 ObservationRef::new("candidate", "latency"),
433 Direction::LowerIsBetter,
434 0.02,
435 );
436 assert!(c.evaluate(&observations(100.0, 90.0)).unwrap().passed);
437 }
438
439 #[test]
440 fn regression_limit_catches_regression() {
441 let c = Criterion::regression_limit(
442 ObservationRef::new("baseline", "latency"),
443 ObservationRef::new("candidate", "latency"),
444 Direction::LowerIsBetter,
445 0.02,
446 );
447 assert!(!c.evaluate(&observations(100.0, 103.0)).unwrap().passed);
448 }
449
450 #[test]
451 fn all_composes() {
452 let o = observations(100.0, 90.0).with(Observation::new(
453 "candidate",
454 Measurement::new("tests_pass", true),
455 ));
456 let c = Criterion::all([
457 Criterion::improvement(
458 ObservationRef::new("baseline", "latency"),
459 ObservationRef::new("candidate", "latency"),
460 Direction::LowerIsBetter,
461 0.05,
462 ),
463 Criterion::compare(
464 Operand::observation("candidate", "tests_pass"),
465 Comparator::Equal,
466 Operand::literal(true),
467 ),
468 ]);
469 assert!(c.evaluate(&o).unwrap().passed);
470 }
471
472 #[test]
473 fn missing_observation_is_error() {
474 let c = Criterion::compare(
475 Operand::observation("candidate", "missing"),
476 Comparator::Equal,
477 Operand::literal(true),
478 );
479 assert!(matches!(
480 c.evaluate(&Observations::new()),
481 Err(CriterionError::MissingObservation(_))
482 ));
483 }
484
485 #[test]
486 fn zero_baseline_refuses_relative_change() {
487 let c = Criterion::improvement(
488 ObservationRef::new("baseline", "latency"),
489 ObservationRef::new("candidate", "latency"),
490 Direction::LowerIsBetter,
491 0.05,
492 );
493 assert_eq!(
494 c.evaluate(&observations(0.0, 1.0)).unwrap_err(),
495 CriterionError::UndefinedRelativeChange
496 );
497 }
498}