use std::cmp::Ordering;
use std::fmt;
use super::{Direction, METRICS, MetricInfo, Scale, lookup};
#[derive(Debug, Clone, Copy)]
pub struct Threshold {
info: &'static MetricInfo,
limit: f64,
}
impl PartialEq for Threshold {
fn eq(&self, other: &Self) -> bool {
self.info.id == other.info.id && self.limit == other.limit
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub struct Checked {
pub threshold: Threshold,
pub warning: Option<LimitWarning>,
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum LimitError {
#[non_exhaustive]
UnknownMetric {
id: String,
},
#[non_exhaustive]
NotFinite {
id: &'static str,
limit: f64,
},
#[non_exhaustive]
Negative {
id: &'static str,
limit: f64,
},
#[non_exhaustive]
BadRatio {
ratio: f64,
},
}
impl fmt::Display for LimitError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UnknownMetric { id } => write!(f, "unknown metric {id:?}"),
Self::NotFinite { id, limit } if !matches!(super::scale(id), Some(Scale::Ratio)) => {
write!(f, "limit for {id:?} must be a finite number; got {limit}")
}
Self::NotFinite { id, limit } | Self::Negative { id, limit } => write!(
f,
"limit for {id:?} must be a finite non-negative number; got {limit}"
),
Self::BadRatio { ratio } => write!(f, "soft ratio must be in (0, 1]; got {ratio}"),
}
}
}
impl std::error::Error for LimitError {}
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub enum LimitWarning {
#[non_exhaustive]
OutOfDomain {
id: &'static str,
limit: f64,
min: f64,
max: f64,
},
#[non_exhaustive]
CannotFire {
id: &'static str,
limit: f64,
},
}
fn is_floor(id: &str) -> bool {
lookup(id).is_some_and(|m| m.direction == Direction::LowerIsWorse)
}
impl fmt::Display for LimitWarning {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match *self {
Self::OutOfDomain {
id,
limit,
min,
max,
} => {
let fires_on_all = if is_floor(id) {
limit > max
} else {
limit < min
};
let effect = if fires_on_all {
"it fires on every value"
} else {
"it can never fire"
};
write!(
f,
"limit {id}={limit} is outside the metric's range [{min}, {max}]; {effect}"
)
}
Self::CannotFire { id, limit } => {
let edge = if is_floor(id) { "floor" } else { "ceiling" };
write!(
f,
"limit {id}={limit} cannot fire: {limit} is the metric's {edge}"
)?;
if let Some(alt) = unbounded_sibling(id) {
write!(f, "; gate {alt} instead")?;
}
Ok(())
}
}
}
}
fn unbounded_sibling(id: &str) -> Option<&'static str> {
let info = lookup(id)?;
METRICS
.iter()
.find(|m| {
m.family == info.family
&& m.direction == info.direction
&& match (m.scale, m.direction) {
(Scale::Interval { min, .. }, Direction::LowerIsWorse) => min.is_infinite(),
(Scale::Interval { max, .. }, Direction::HigherIsWorse) => max.is_infinite(),
_ => false,
}
})
.map(|m| m.id)
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub enum Change {
Percent(f64),
Points(f64),
FromZero,
NaN,
}
impl Threshold {
pub fn check(id: &str, limit: f64) -> Result<Checked, LimitError> {
let info = lookup(id).ok_or_else(|| LimitError::UnknownMetric { id: id.to_owned() })?;
if !limit.is_finite() {
return Err(LimitError::NotFinite { id: info.id, limit });
}
let limit = if limit == 0.0 { 0.0 } else { limit };
if !matches!(info.scale, Scale::Interval { .. }) && limit < 0.0 {
return Err(LimitError::Negative { id: info.id, limit });
}
let threshold = Self { info, limit };
Ok(Checked {
warning: threshold.warning(),
threshold,
})
}
#[must_use]
pub fn id(&self) -> &'static str {
self.info.id
}
#[must_use]
pub fn info(&self) -> &'static MetricInfo {
self.info
}
#[must_use]
pub fn limit(&self) -> f64 {
self.limit
}
#[must_use]
pub fn warning(&self) -> Option<LimitWarning> {
match self.info.scale {
Scale::Interval { min, max, .. } => domain_warning(self.info, self.limit, min, max),
_ => None,
}
}
#[must_use]
pub fn breaches(&self, v: f64) -> bool {
match self.info.direction {
Direction::HigherIsWorse => v > self.limit,
Direction::LowerIsWorse => v < self.limit,
}
}
#[must_use]
pub fn excess(&self, v: f64) -> f64 {
match self.info.direction {
Direction::HigherIsWorse => v - self.limit,
Direction::LowerIsWorse => self.limit - v,
}
}
#[must_use]
pub fn severity(&self, v: f64) -> f64 {
match self.info.scale {
Scale::Interval { unit, .. } => 1.0 + self.excess(v) / unit,
_ => match self.info.direction {
Direction::HigherIsWorse if self.limit > 0.0 => v / self.limit,
Direction::LowerIsWorse if v > 0.0 => self.limit / v,
_ => f64::INFINITY,
},
}
}
#[must_use]
pub fn cmp_severity(&self, a: f64, b: f64) -> Ordering {
self.cmp_breach(a, self, b)
}
#[must_use]
pub fn cmp_breach(&self, a: f64, other: &Self, b: f64) -> Ordering {
self.severity(a)
.total_cmp(&other.severity(b))
.then_with(|| self.excess(a).total_cmp(&other.excess(b)))
}
#[must_use]
pub fn is_soft_ratio(ratio: f64) -> bool {
0.0 < ratio && ratio <= 1.0
}
pub fn soft_limit(&self, ratio: f64) -> Result<Self, LimitError> {
if !Self::is_soft_ratio(ratio) {
return Err(LimitError::BadRatio { ratio });
}
let step = |unit: f64| unit - ratio * unit;
let limit = match (self.info.scale, self.info.direction) {
(Scale::Interval { max, unit, .. }, Direction::LowerIsWorse) => {
(self.limit + step(unit)).min(max).max(self.limit)
}
(Scale::Interval { min, unit, .. }, Direction::HigherIsWorse) => {
(self.limit - step(unit)).max(min).min(self.limit)
}
(_, Direction::HigherIsWorse) => self.limit * ratio,
(_, Direction::LowerIsWorse) => (self.limit / ratio).min(f64::MAX),
};
Ok(Self {
info: self.info,
limit,
})
}
#[must_use]
pub fn change(&self, recorded: f64, v: f64) -> Change {
if v.is_nan() {
return Change::NaN;
}
let worse_by = match self.info.direction {
Direction::HigherIsWorse => v - recorded,
Direction::LowerIsWorse => recorded - v,
};
match self.info.scale {
Scale::Interval { .. } => Change::Points(worse_by),
_ if recorded == 0.0 => Change::FromZero,
_ => Change::Percent(worse_by / recorded.abs() * 100.0),
}
}
#[must_use]
pub fn drift(&self, recorded: f64, v: f64) -> f64 {
let delta = (recorded - v).abs();
match self.info.scale {
Scale::Interval { unit, .. } => delta / unit,
_ if delta == 0.0 => 0.0,
_ => delta / recorded.abs().max(v.abs()),
}
}
}
fn domain_warning(
info: &'static MetricInfo,
limit: f64,
min: f64,
max: f64,
) -> Option<LimitWarning> {
if limit < min || limit > max {
return Some(LimitWarning::OutOfDomain {
id: info.id,
limit,
min,
max,
});
}
let on_bad_edge = match info.direction {
Direction::LowerIsWorse => limit <= min,
Direction::HigherIsWorse => limit >= max,
};
on_bad_edge.then_some(LimitWarning::CannotFire { id: info.id, limit })
}
#[cfg(test)]
#[path = "threshold_tests.rs"]
mod tests;