use std::collections::BTreeMap;
use std::fmt;
use crate::law_input::{InputType, MetricExpr};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Verdict {
Supports,
NoEvidence {
metric: String,
},
Breaks {
metric: String,
expected: String,
measured: String,
},
OutOfRange {
key: String,
},
ParameterUpdate {
key: String,
was: Vec<String>,
now: Vec<String>,
},
Undecided {
reason: String,
},
}
impl fmt::Display for Verdict {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Supports => write!(f, "supports"),
Self::NoEvidence { metric } => {
write!(f, "no evidence: nothing measured for `{metric}`")
}
Self::Breaks {
metric,
expected,
measured,
} => write!(
f,
"breaks: `{metric}` expected {expected}, measured {measured}"
),
Self::OutOfRange { key } => {
write!(
f,
"out of range: `{key}` is not in the measurement (drop the row)"
)
}
Self::ParameterUpdate { key, was, now } => write!(
f,
"parameter update: `{key}` moved from {} to {}",
was.join(" "),
now.join(" ")
),
Self::Undecided { reason } => write!(f, "undecided: {reason}"),
}
}
}
impl Verdict {
#[must_use]
pub const fn passed(&self) -> bool {
matches!(self, Self::Supports)
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Clause {
Evidence {
metric: String,
},
Expect {
metric: String,
value: f64,
tolerance: f64,
},
Range {
key: String,
values: Vec<String>,
},
}
#[derive(Debug, Clone, PartialEq)]
pub struct AuditLaw {
name: String,
clauses: Vec<Clause>,
inputs: Vec<(String, InputType)>,
metrics: Vec<(String, MetricExpr)>,
at_least: Vec<(String, f64)>,
}
impl AuditLaw {
#[must_use]
pub(crate) const fn new(name: String, clauses: Vec<Clause>) -> Self {
Self {
name,
clauses,
inputs: Vec::new(),
metrics: Vec::new(),
at_least: Vec::new(),
}
}
#[must_use]
pub fn with_metric(mut self, name: &str, expr: MetricExpr) -> Self {
self.metrics.push((name.to_owned(), expr));
self
}
#[must_use]
pub fn metrics(&self) -> &[(String, MetricExpr)] {
&self.metrics
}
#[must_use]
pub fn with_at_least(mut self, name: &str, n: f64) -> Self {
self.at_least.push((name.to_owned(), n));
self
}
#[must_use]
pub fn at_least(&self) -> &[(String, f64)] {
&self.at_least
}
#[must_use]
pub fn with_input(mut self, name: &str, ty: InputType) -> Self {
self.inputs.push((name.to_owned(), ty));
self
}
#[must_use]
pub fn inputs(&self) -> &[(String, InputType)] {
&self.inputs
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub fn clauses(&self) -> &[Clause] {
&self.clauses
}
#[must_use]
pub fn evaluate(&self, measured: &Measurements) -> Verdict {
self.check_evidence(measured)
.or_else(|| self.check_ranges(measured))
.or_else(|| self.check_expectations(measured))
.unwrap_or(Verdict::Supports)
}
fn check_evidence(&self, m: &Measurements) -> Option<Verdict> {
self.clauses.iter().find_map(|c| match c {
Clause::Evidence { metric } if !is_evidence(m.number(metric)) => {
Some(Verdict::NoEvidence {
metric: metric.clone(),
})
}
_ => None,
})
}
fn check_ranges(&self, m: &Measurements) -> Option<Verdict> {
self.clauses.iter().find_map(|c| {
let Clause::Range { key, values } = c else {
return None;
};
match m.range(key) {
None => Some(Verdict::OutOfRange { key: key.clone() }),
Some(now) if now != values.as_slice() => Some(Verdict::ParameterUpdate {
key: key.clone(),
was: values.clone(),
now: now.to_vec(),
}),
Some(_) => None,
}
})
}
fn check_expectations(&self, m: &Measurements) -> Option<Verdict> {
self.clauses.iter().find_map(|c| {
let Clause::Expect {
metric,
value,
tolerance,
} = c
else {
return None;
};
let Some(got) = m.number(metric) else {
return Some(Verdict::Undecided {
reason: format!("`{metric}` is not in the measurement"),
});
};
if !got.is_finite() {
return Some(Verdict::Undecided {
reason: format!("`{metric}` is not a finite number"),
});
}
if (got - value).abs() > *tolerance {
return Some(Verdict::Breaks {
metric: metric.clone(),
expected: if *tolerance == 0.0 {
format!("{value}")
} else {
format!("{value} ± {tolerance}")
},
measured: format!("{got}"),
});
}
None
})
}
}
fn is_evidence(value: Option<f64>) -> bool {
value.is_some_and(|v| v.is_finite() && v > 0.0)
}
#[must_use]
pub(crate) fn sorted_values(values: &[&str]) -> Vec<String> {
let mut v: Vec<String> = values.iter().map(|s| (*s).to_owned()).collect();
v.sort_by(|a, b| version_key(a).cmp(&version_key(b)).then_with(|| a.cmp(b)));
v.dedup();
v
}
fn version_key(v: &str) -> Vec<(u8, u64, String)> {
v.split(['.', '-', '+'])
.map(|c| {
c.parse::<u64>()
.map_or_else(|_| (1, 0, c.to_owned()), |n| (0, n, String::new()))
})
.collect()
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Measurements {
numbers: BTreeMap<String, f64>,
ranges: BTreeMap<String, Vec<String>>,
}
impl Measurements {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_number(mut self, metric: &str, value: f64) -> Self {
self.numbers.insert(metric.to_owned(), value);
self
}
#[must_use]
pub fn with_range(mut self, key: &str, values: &[&str]) -> Self {
self.ranges.insert(key.to_owned(), sorted_values(values));
self
}
#[must_use]
pub fn number(&self, metric: &str) -> Option<f64> {
self.numbers.get(metric).copied()
}
#[must_use]
pub fn range(&self, key: &str) -> Option<&[String]> {
self.ranges.get(key).map(Vec::as_slice)
}
}