Skip to main content

axioval_engine/
refinement.rs

1//! Rule-level refinement of a capability's outcomes: severities graded by
2//! how far a value misses its bound.
3//!
4//! A capability decides what is found; a rule instance may refine how it is
5//! reported. The runtime applies the refinement after the capability ran, so
6//! every capability that reports what refinement needs gets it without a
7//! parameter of its own.
8
9use axioval_ir::contract::{
10    self as schema, CategoryLevel, Selector, SeverityBand, SeverityOverride,
11};
12use axioval_ir::{NotEvaluatedReason, Object, Severity};
13
14use crate::{CapabilityEvaluation, CompiledRule, RuleContext, SelectorVerdict};
15
16/// How far a measured value misses the bound it fails, relative to that
17/// bound, as an interval sure to hold the exact relative deviation.
18///
19/// Both ends are at least zero; the upper end may be infinite (a bound of
20/// zero, or a value known only from one side).
21#[derive(Clone, Copy, Debug, PartialEq)]
22pub struct Deviation {
23    lower: f64,
24    upper: f64,
25}
26
27impl Deviation {
28    /// A relative deviation in `[lower, upper]`; `None` unless
29    /// `0 <= lower <= upper` (NaN refused).
30    #[must_use]
31    pub fn try_new(lower: f64, upper: f64) -> Option<Self> {
32        (lower >= 0.0 && lower <= upper).then_some(Self { lower, upper })
33    }
34
35    /// The shortfall of a value in `[lower, upper]` below `minimum`, relative
36    /// to `minimum`: `(minimum - value) / |minimum|`, widened outwards so it
37    /// holds the exact quotient. A bound of zero makes any shortfall
38    /// infinitely large.
39    #[must_use]
40    pub fn below(minimum: f64, lower: f64, upper: f64) -> Self {
41        Self::relative(minimum - upper, minimum - lower, minimum)
42    }
43
44    /// The excess of a value in `[lower, upper]` over `maximum`, relative to
45    /// `maximum`: `(value - maximum) / |maximum|`, widened outwards.
46    #[must_use]
47    pub fn above(maximum: f64, lower: f64, upper: f64) -> Self {
48        Self::relative(lower - maximum, upper - maximum, maximum)
49    }
50
51    fn relative(least: f64, most: f64, bound: f64) -> Self {
52        let scale = bound.abs();
53        let (lower, upper) = if scale == 0.0 {
54            let infinite = |miss: f64| if miss > 0.0 { f64::INFINITY } else { 0.0 };
55            (infinite(least), infinite(most))
56        } else {
57            // One rounding in the difference and one in the quotient: a
58            // step outwards on each end keeps the exact value inside.
59            (
60                (least / scale).next_down().next_down(),
61                (most / scale).next_up().next_up(),
62            )
63        };
64        let lower = if lower.is_nan() { 0.0 } else { lower.max(0.0) };
65        let upper = if upper.is_nan() {
66            f64::INFINITY
67        } else {
68            upper.max(lower)
69        };
70        Self { lower, upper }
71    }
72
73    /// The larger of two deviations, as a finding naming several failing
74    /// values is graded by the one missing most: an interval sure to hold
75    /// the larger exact value.
76    #[must_use]
77    pub fn worst(self, other: Self) -> Self {
78        Self {
79            lower: self.lower.max(other.lower),
80            upper: self.upper.max(other.upper),
81        }
82    }
83
84    /// The smaller of two deviations, as a value that may meet any of
85    /// several alternative limits misses by as little as the nearest one.
86    #[must_use]
87    pub fn least(self, other: Self) -> Self {
88        Self {
89            lower: self.lower.min(other.lower),
90            upper: self.upper.min(other.upper),
91        }
92    }
93
94    /// The least the exact deviation may be.
95    #[must_use]
96    pub fn lower(&self) -> f64 {
97        self.lower
98    }
99
100    /// The most the exact deviation may be.
101    #[must_use]
102    pub fn upper(&self) -> f64 {
103        self.upper
104    }
105}
106
107/// What a rule instance asks of its outcomes beyond the capability's
108/// verdicts. Empty for a rule that declares nothing.
109#[derive(Clone, Debug, Default, PartialEq)]
110pub struct RuleRefinement {
111    /// Severity bands over the relative deviation, ascending.
112    pub severity_bands: Vec<SeverityBand>,
113    /// Severities chosen by the objects a finding involves, first match
114    /// first.
115    pub severity_overrides: Vec<SeverityOverride>,
116    /// Nested categories headed before findings, outermost first.
117    pub categories: Vec<CategoryLevel>,
118}
119
120impl RuleRefinement {
121    /// Whether the rule declares nothing.
122    #[must_use]
123    pub fn is_empty(&self) -> bool {
124        self.severity_bands.is_empty()
125            && self.severity_overrides.is_empty()
126            && self.categories.is_empty()
127    }
128
129    /// Whether applying the rule's declarations needs an [`OutcomeRefiner`]:
130    /// everything but severity bands reads the model.
131    #[must_use]
132    pub fn needs_refiner(&self) -> bool {
133        !self.severity_overrides.is_empty() || !self.categories.is_empty()
134    }
135}
136
137/// Trusted code that applies what a rule instance declares about its
138/// outcomes and that needs the model to apply: selectors, properties and
139/// relationships (severity overrides, categories).
140///
141/// The runtime calls it after the capability ran and after severity bands
142/// were applied, for every rule the plan refines. The host installs it with
143/// the capabilities ([`crate::CapabilityRegistry::with_refiner`]); a rule
144/// needing one compiles only against a registry that has one, so no
145/// declaration is ever silently ignored.
146pub trait OutcomeRefiner: Send + Sync {
147    /// Refines one rule's outcomes in place. What cannot be decided becomes
148    /// a not-evaluated outcome, never a default.
149    fn refine(
150        &self,
151        context: &RuleContext<'_>,
152        rule: &CompiledRule,
153        refining: &Refining<'_>,
154        evaluation: &mut CapabilityEvaluation,
155    );
156
157    /// How many objects `rule`'s applicability selector surely selects;
158    /// objects it cannot decide are not counted.
159    fn selected(&self, context: &RuleContext<'_>, rule: &CompiledRule) -> usize;
160
161    /// Whether `selector` selects `object`, for the runtime's own reads of
162    /// the model: the selection of a rule whose outcomes another rule reads
163    /// per object.
164    ///
165    /// The default decides nothing, so a refiner that does not evaluate
166    /// selectors leaves every such object undecided rather than selected or
167    /// not.
168    fn evaluate_selector(
169        &self,
170        context: &RuleContext<'_>,
171        selector: &Selector,
172        object: &Object,
173    ) -> SelectorVerdict {
174        let _ = (context, selector, object);
175        SelectorVerdict::Undecided(
176            NotEvaluatedReason::MissingService,
177            "the host's outcome refiner evaluates no selectors".into(),
178        )
179    }
180}
181
182/// What one call of an [`OutcomeRefiner`] applies: the rule's declarations
183/// and the host's location policy.
184#[derive(Clone, Copy, Debug)]
185pub struct Refining<'a> {
186    /// What the rule instance declares; empty for a rule declaring nothing.
187    pub refinement: &'a RuleRefinement,
188    /// How the host locates outcomes; `None` when it does not.
189    pub locations: Option<&'a LocationPolicy>,
190}
191
192/// How outcomes are located by storey and space; a host's choice, never a
193/// package's.
194#[derive(Clone, Copy, Debug, Eq, PartialEq)]
195pub enum LocationMethod {
196    /// The storeys each object lies in, climbed to along the containment
197    /// path; no spaces.
198    Storeys,
199    /// The nearest storeys and spaces each object lies in, climbed to along
200    /// the containment path.
201    Containers,
202    /// Storeys as for [`Self::Containers`]; spaces are those whose body
203    /// contains or meets each object, through the geometry-derived
204    /// `axioval:derived.contained-in-space` relationship.
205    Geometry,
206}
207
208/// A host's location policy: the method, and what storeys and spaces are in
209/// its sources' own vocabulary.
210///
211/// Storeys and spaces are objects of the named kinds (compared ignoring
212/// case). The containment path's steps are climbed in any order and any
213/// number of times, stopping at each storey or space reached, as a
214/// `related` selector's steps read. An object that is itself a storey or
215/// space is located in itself. `name` is the property (set, name) whose
216/// value names a place, read natively.
217#[derive(Clone, Debug, Eq, PartialEq)]
218pub struct LocationPolicy {
219    pub method: LocationMethod,
220    pub storey_kinds: Vec<String>,
221    pub space_kinds: Vec<String>,
222    pub containment: Vec<String>,
223    pub name: Option<(String, String)>,
224}
225
226/// Checks a rule's bands: each threshold finite and positive, strictly
227/// ascending, at least one band.
228pub(crate) fn validate_bands(bands: &[SeverityBand]) -> Result<(), String> {
229    let mut previous = 0.0;
230    for (index, band) in bands.iter().enumerate() {
231        if !band.below.is_finite() || band.below <= previous {
232            return Err(format!(
233                "severity band {index} must lie below a finite threshold above {previous}"
234            ));
235        }
236        previous = band.below;
237    }
238    Ok(())
239}
240
241/// The severity `deviation` grades to under `bands`, with `beyond` for a
242/// deviation at or past the last band, and whether the interval reached
243/// bands of different severities.
244///
245/// Band `i` holds deviations in `[below(i-1), below(i))` (from zero for the
246/// first). An interval takes the most severe band it may reach.
247pub(crate) fn grade(
248    bands: &[SeverityBand],
249    beyond: &Severity,
250    deviation: Deviation,
251) -> (Severity, bool) {
252    let mut reached: Vec<Severity> = Vec::new();
253    let mut from = 0.0;
254    for band in bands {
255        if deviation.lower < band.below && deviation.upper >= from {
256            reached.push(report_severity(&band.severity));
257        }
258        from = band.below;
259    }
260    if deviation.upper >= from {
261        reached.push(beyond.clone());
262    }
263    // `Severity` orders the most severe first.
264    let worst = reached
265        .iter()
266        .min()
267        .cloned()
268        .unwrap_or_else(|| beyond.clone());
269    let mixed = reached.iter().any(|severity| *severity != worst);
270    (worst, mixed)
271}
272
273/// A package severity as a report severity.
274#[must_use]
275pub fn report_severity(severity: &schema::Severity) -> Severity {
276    match severity {
277        schema::Severity::Error => Severity::Error,
278        schema::Severity::Warning => Severity::Warning,
279        schema::Severity::Info => Severity::Info,
280    }
281}
282
283/// A severity as messages spell it.
284pub(crate) fn label(severity: &Severity) -> &'static str {
285    match severity {
286        Severity::Error => "error",
287        Severity::Warning => "warning",
288        Severity::Info => "info",
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    fn bands() -> Vec<SeverityBand> {
297        vec![
298            SeverityBand {
299                below: 0.05,
300                severity: schema::Severity::Info,
301            },
302            SeverityBand {
303                below: 0.2,
304                severity: schema::Severity::Warning,
305            },
306        ]
307    }
308
309    #[test]
310    fn a_deviation_takes_its_band_and_the_rule_severity_beyond() {
311        let grade = |lower, upper| grade(&bands(), &Severity::Error, Deviation { lower, upper }).0;
312        assert_eq!(grade(0.03, 0.03), Severity::Info);
313        assert_eq!(grade(0.1, 0.1), Severity::Warning);
314        assert_eq!(grade(0.3, 0.3), Severity::Error);
315        assert_eq!(grade(0.05, 0.05), Severity::Warning);
316        assert_eq!(grade(0.2, f64::INFINITY), Severity::Error);
317    }
318
319    #[test]
320    fn a_straddling_deviation_takes_its_most_severe_band() {
321        let (severity, mixed) = grade(
322            &bands(),
323            &Severity::Error,
324            Deviation {
325                lower: 0.03,
326                upper: 0.1,
327            },
328        );
329        assert_eq!(severity, Severity::Warning);
330        assert!(mixed);
331        let (severity, mixed) = grade(
332            &bands(),
333            &Severity::Error,
334            Deviation {
335                lower: 0.1,
336                upper: 0.25,
337            },
338        );
339        assert_eq!(severity, Severity::Error);
340        assert!(mixed);
341    }
342
343    #[test]
344    fn a_relative_deviation_holds_the_exact_quotient() {
345        let shortfall = Deviation::below(10.0, 7.0, 7.0);
346        assert!(shortfall.lower() <= 0.3 && 0.3 <= shortfall.upper());
347        let excess = Deviation::above(2.0, 2.5, 3.0);
348        assert!(excess.lower() <= 0.25 && 0.5 <= excess.upper());
349        assert!(Deviation::below(0.0, -1.0, -1.0).upper().is_infinite());
350        assert!(
351            Deviation::above(1.0, 2.0, f64::INFINITY)
352                .upper()
353                .is_infinite()
354        );
355    }
356
357    #[test]
358    fn bands_must_ascend_from_zero() {
359        assert!(validate_bands(&bands()).is_ok());
360        let mut descending = bands();
361        descending.reverse();
362        assert!(validate_bands(&descending).is_err());
363        assert!(
364            validate_bands(&[SeverityBand {
365                below: 0.0,
366                severity: schema::Severity::Info
367            }])
368            .is_err()
369        );
370    }
371}