use axioval_ir::contract::{
self as schema, CategoryLevel, Selector, SeverityBand, SeverityOverride,
};
use axioval_ir::{NotEvaluatedReason, Object, Severity};
use crate::{CapabilityEvaluation, CompiledRule, RuleContext, SelectorVerdict};
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Deviation {
lower: f64,
upper: f64,
}
impl Deviation {
#[must_use]
pub fn try_new(lower: f64, upper: f64) -> Option<Self> {
(lower >= 0.0 && lower <= upper).then_some(Self { lower, upper })
}
#[must_use]
pub fn below(minimum: f64, lower: f64, upper: f64) -> Self {
Self::relative(minimum - upper, minimum - lower, minimum)
}
#[must_use]
pub fn above(maximum: f64, lower: f64, upper: f64) -> Self {
Self::relative(lower - maximum, upper - maximum, maximum)
}
fn relative(least: f64, most: f64, bound: f64) -> Self {
let scale = bound.abs();
let (lower, upper) = if scale == 0.0 {
let infinite = |miss: f64| if miss > 0.0 { f64::INFINITY } else { 0.0 };
(infinite(least), infinite(most))
} else {
(
(least / scale).next_down().next_down(),
(most / scale).next_up().next_up(),
)
};
let lower = if lower.is_nan() { 0.0 } else { lower.max(0.0) };
let upper = if upper.is_nan() {
f64::INFINITY
} else {
upper.max(lower)
};
Self { lower, upper }
}
#[must_use]
pub fn worst(self, other: Self) -> Self {
Self {
lower: self.lower.max(other.lower),
upper: self.upper.max(other.upper),
}
}
#[must_use]
pub fn least(self, other: Self) -> Self {
Self {
lower: self.lower.min(other.lower),
upper: self.upper.min(other.upper),
}
}
#[must_use]
pub fn lower(&self) -> f64 {
self.lower
}
#[must_use]
pub fn upper(&self) -> f64 {
self.upper
}
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct RuleRefinement {
pub severity_bands: Vec<SeverityBand>,
pub severity_overrides: Vec<SeverityOverride>,
pub categories: Vec<CategoryLevel>,
}
impl RuleRefinement {
#[must_use]
pub fn is_empty(&self) -> bool {
self.severity_bands.is_empty()
&& self.severity_overrides.is_empty()
&& self.categories.is_empty()
}
#[must_use]
pub fn needs_refiner(&self) -> bool {
!self.severity_overrides.is_empty() || !self.categories.is_empty()
}
}
pub trait OutcomeRefiner: Send + Sync {
fn refine(
&self,
context: &RuleContext<'_>,
rule: &CompiledRule,
refining: &Refining<'_>,
evaluation: &mut CapabilityEvaluation,
);
fn selected(&self, context: &RuleContext<'_>, rule: &CompiledRule) -> usize;
fn evaluate_selector(
&self,
context: &RuleContext<'_>,
selector: &Selector,
object: &Object,
) -> SelectorVerdict {
let _ = (context, selector, object);
SelectorVerdict::Undecided(
NotEvaluatedReason::MissingService,
"the host's outcome refiner evaluates no selectors".into(),
)
}
}
#[derive(Clone, Copy, Debug)]
pub struct Refining<'a> {
pub refinement: &'a RuleRefinement,
pub locations: Option<&'a LocationPolicy>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum LocationMethod {
Storeys,
Containers,
Geometry,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct LocationPolicy {
pub method: LocationMethod,
pub storey_kinds: Vec<String>,
pub space_kinds: Vec<String>,
pub containment: Vec<String>,
pub name: Option<(String, String)>,
}
pub(crate) fn validate_bands(bands: &[SeverityBand]) -> Result<(), String> {
let mut previous = 0.0;
for (index, band) in bands.iter().enumerate() {
if !band.below.is_finite() || band.below <= previous {
return Err(format!(
"severity band {index} must lie below a finite threshold above {previous}"
));
}
previous = band.below;
}
Ok(())
}
pub(crate) fn grade(
bands: &[SeverityBand],
beyond: &Severity,
deviation: Deviation,
) -> (Severity, bool) {
let mut reached: Vec<Severity> = Vec::new();
let mut from = 0.0;
for band in bands {
if deviation.lower < band.below && deviation.upper >= from {
reached.push(report_severity(&band.severity));
}
from = band.below;
}
if deviation.upper >= from {
reached.push(beyond.clone());
}
let worst = reached
.iter()
.min()
.cloned()
.unwrap_or_else(|| beyond.clone());
let mixed = reached.iter().any(|severity| *severity != worst);
(worst, mixed)
}
#[must_use]
pub fn report_severity(severity: &schema::Severity) -> Severity {
match severity {
schema::Severity::Error => Severity::Error,
schema::Severity::Warning => Severity::Warning,
schema::Severity::Info => Severity::Info,
}
}
pub(crate) fn label(severity: &Severity) -> &'static str {
match severity {
Severity::Error => "error",
Severity::Warning => "warning",
Severity::Info => "info",
}
}
#[cfg(test)]
mod tests {
use super::*;
fn bands() -> Vec<SeverityBand> {
vec![
SeverityBand {
below: 0.05,
severity: schema::Severity::Info,
},
SeverityBand {
below: 0.2,
severity: schema::Severity::Warning,
},
]
}
#[test]
fn a_deviation_takes_its_band_and_the_rule_severity_beyond() {
let grade = |lower, upper| grade(&bands(), &Severity::Error, Deviation { lower, upper }).0;
assert_eq!(grade(0.03, 0.03), Severity::Info);
assert_eq!(grade(0.1, 0.1), Severity::Warning);
assert_eq!(grade(0.3, 0.3), Severity::Error);
assert_eq!(grade(0.05, 0.05), Severity::Warning);
assert_eq!(grade(0.2, f64::INFINITY), Severity::Error);
}
#[test]
fn a_straddling_deviation_takes_its_most_severe_band() {
let (severity, mixed) = grade(
&bands(),
&Severity::Error,
Deviation {
lower: 0.03,
upper: 0.1,
},
);
assert_eq!(severity, Severity::Warning);
assert!(mixed);
let (severity, mixed) = grade(
&bands(),
&Severity::Error,
Deviation {
lower: 0.1,
upper: 0.25,
},
);
assert_eq!(severity, Severity::Error);
assert!(mixed);
}
#[test]
fn a_relative_deviation_holds_the_exact_quotient() {
let shortfall = Deviation::below(10.0, 7.0, 7.0);
assert!(shortfall.lower() <= 0.3 && 0.3 <= shortfall.upper());
let excess = Deviation::above(2.0, 2.5, 3.0);
assert!(excess.lower() <= 0.25 && 0.5 <= excess.upper());
assert!(Deviation::below(0.0, -1.0, -1.0).upper().is_infinite());
assert!(
Deviation::above(1.0, 2.0, f64::INFINITY)
.upper()
.is_infinite()
);
}
#[test]
fn bands_must_ascend_from_zero() {
assert!(validate_bands(&bands()).is_ok());
let mut descending = bands();
descending.reverse();
assert!(validate_bands(&descending).is_err());
assert!(
validate_bands(&[SeverityBand {
below: 0.0,
severity: schema::Severity::Info
}])
.is_err()
);
}
}