Skip to main content

big_code_analysis/metric_catalog/
threshold.rs

1//! A validated metric limit and the value arithmetic it implies
2//! (issue #1621).
3//!
4//! Every question a threshold surface asks of a value — does it breach,
5//! by how much, how does it rank against another breach, where does the
6//! soft tier sit, how far has it moved from its baseline record — has
7//! an answer that depends on the metric's [`Direction`] *and* its
8//! [`Scale`]. [`Threshold`] answers all of them in one place so the
9//! front-ends stop re-deriving them with ratio arithmetic that is
10//! meaningless on the interval-scale `mi.*` family.
11
12use std::cmp::Ordering;
13use std::fmt;
14
15use super::{Direction, METRICS, MetricInfo, Scale, lookup};
16
17/// A metric limit that has been checked against the metric's
18/// [`Scale`]: the metric id is known, the limit is finite, and a
19/// ratio-scale limit is non-negative.
20///
21/// Built with [`Threshold::check`]. Fields are private so every
22/// `Threshold` in existence has passed that validation; read them back
23/// through [`id`](Self::id), [`info`](Self::info) and
24/// [`limit`](Self::limit).
25///
26/// Equality compares the metric id and the limit (`f64` `==`).
27#[derive(Debug, Clone, Copy)]
28pub struct Threshold {
29    info: &'static MetricInfo,
30    limit: f64,
31}
32
33impl PartialEq for Threshold {
34    fn eq(&self, other: &Self) -> bool {
35        self.info.id == other.info.id && self.limit == other.limit
36    }
37}
38
39/// A successfully validated [`Threshold`], together with any advisory
40/// the limit raised.
41///
42/// A limit that is legal but cannot do what its author presumably
43/// meant — outside the metric's domain, or sitting on the domain edge
44/// where it can never fire — is accepted with a [`LimitWarning`] rather
45/// than rejected, because the 2.x CLI already accepts such limits and
46/// tightening the grammar is reserved for a major release.
47#[derive(Debug, Clone, Copy, PartialEq)]
48#[non_exhaustive]
49pub struct Checked {
50    /// The validated threshold.
51    pub threshold: Threshold,
52    /// An advisory to surface to the user, if the limit raised one.
53    pub warning: Option<LimitWarning>,
54}
55
56/// Why a limit was rejected by [`Threshold::check`] or a soft ratio by
57/// [`Threshold::soft_limit`].
58///
59/// The [`Display`](fmt::Display) rendering is a bare message with no
60/// severity prefix; the presenting layer adds one.
61#[derive(Debug, Clone, PartialEq)]
62#[non_exhaustive]
63pub enum LimitError {
64    /// The metric id is not in [`METRICS`].
65    #[non_exhaustive]
66    UnknownMetric {
67        /// The id as given.
68        id: String,
69    },
70    /// The limit is NaN or infinite, which would silently disable a
71    /// strict comparison gate.
72    #[non_exhaustive]
73    NotFinite {
74        /// The metric id.
75        id: &'static str,
76        /// The rejected limit.
77        limit: f64,
78    },
79    /// A negative limit on a [`Scale::Ratio`] metric, whose values are
80    /// counts and cannot be negative.
81    #[non_exhaustive]
82    Negative {
83        /// The metric id.
84        id: &'static str,
85        /// The rejected limit.
86        limit: f64,
87    },
88    /// A soft-tier ratio outside `(0, 1]`, or not finite.
89    #[non_exhaustive]
90    BadRatio {
91        /// The rejected ratio.
92        ratio: f64,
93    },
94}
95
96impl fmt::Display for LimitError {
97    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98        match self {
99            Self::UnknownMetric { id } => write!(f, "unknown metric {id:?}"),
100            Self::NotFinite { id, limit } if !matches!(super::scale(id), Some(Scale::Ratio)) => {
101                write!(f, "limit for {id:?} must be a finite number; got {limit}")
102            }
103            // The wording the CLI's threshold parser used before #1621, kept
104            // so its tests and users' log greps survive the migration.
105            Self::NotFinite { id, limit } | Self::Negative { id, limit } => write!(
106                f,
107                "limit for {id:?} must be a finite non-negative number; got {limit}"
108            ),
109            Self::BadRatio { ratio } => write!(f, "soft ratio must be in (0, 1]; got {ratio}"),
110        }
111    }
112}
113
114impl std::error::Error for LimitError {}
115
116/// An advisory raised by a limit [`Threshold::check`] accepted.
117///
118/// The [`Display`](fmt::Display) rendering is a bare message with no
119/// severity prefix; the presenting layer adds one.
120#[derive(Debug, Clone, Copy, PartialEq)]
121#[non_exhaustive]
122pub enum LimitWarning {
123    /// The limit lies outside the metric's [`Scale::Interval`] domain,
124    /// so the gate either fires on every value or on none.
125    #[non_exhaustive]
126    OutOfDomain {
127        /// The metric id.
128        id: &'static str,
129        /// The configured limit.
130        limit: f64,
131        /// The domain's lower bound.
132        min: f64,
133        /// The domain's upper bound.
134        max: f64,
135    },
136    /// The limit sits on the domain edge at the metric's bad end — a
137    /// floor at the minimum or a ceiling at the maximum — so no value
138    /// can ever be strictly past it.
139    #[non_exhaustive]
140    CannotFire {
141        /// The metric id.
142        id: &'static str,
143        /// The configured limit.
144        limit: f64,
145    },
146}
147
148/// Whether metric `id`'s limit is a floor (lower is worse) rather than a
149/// ceiling.
150fn is_floor(id: &str) -> bool {
151    lookup(id).is_some_and(|m| m.direction == Direction::LowerIsWorse)
152}
153
154impl fmt::Display for LimitWarning {
155    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156        match *self {
157            Self::OutOfDomain {
158                id,
159                limit,
160                min,
161                max,
162            } => {
163                let fires_on_all = if is_floor(id) {
164                    limit > max
165                } else {
166                    limit < min
167                };
168                let effect = if fires_on_all {
169                    "it fires on every value"
170                } else {
171                    "it can never fire"
172                };
173                write!(
174                    f,
175                    "limit {id}={limit} is outside the metric's range [{min}, {max}]; {effect}"
176                )
177            }
178            Self::CannotFire { id, limit } => {
179                let edge = if is_floor(id) { "floor" } else { "ceiling" };
180                write!(
181                    f,
182                    "limit {id}={limit} cannot fire: {limit} is the metric's {edge}"
183                )?;
184                if let Some(alt) = unbounded_sibling(id) {
185                    write!(f, "; gate {alt} instead")?;
186                }
187                Ok(())
188            }
189        }
190    }
191}
192
193/// A metric in the same family whose domain is unbounded on the side a
194/// `CannotFire` limit sits on — the metric to gate instead
195/// (`mi.original` for `mi.visual_studio`). The unbounded-side test is
196/// not observable in today's catalog, where `mi.original` is also the
197/// family's first row; it keeps a future bounded row from being named.
198fn unbounded_sibling(id: &str) -> Option<&'static str> {
199    let info = lookup(id)?;
200    METRICS
201        .iter()
202        .find(|m| {
203            m.family == info.family
204                && m.direction == info.direction
205                && match (m.scale, m.direction) {
206                    (Scale::Interval { min, .. }, Direction::LowerIsWorse) => min.is_infinite(),
207                    (Scale::Interval { max, .. }, Direction::HigherIsWorse) => max.is_infinite(),
208                    _ => false,
209                }
210        })
211        .map(|m| m.id)
212}
213
214/// How a value has moved relative to its baseline record, oriented so
215/// a positive number means *worse*. Returned by [`Threshold::change`].
216#[derive(Debug, Clone, Copy, PartialEq)]
217#[non_exhaustive]
218pub enum Change {
219    /// A [`Scale::Ratio`] metric's change as a percentage of the record.
220    Percent(f64),
221    /// A [`Scale::Interval`] metric's change in the metric's own points;
222    /// a percentage of an arbitrary zero means nothing.
223    Points(f64),
224    /// A [`Scale::Ratio`] metric whose record was zero: any growth is an
225    /// unbounded percentage.
226    FromZero,
227    /// The current value is NaN.
228    NaN,
229}
230
231impl Threshold {
232    /// Validate `limit` for the metric `id`.
233    ///
234    /// # Errors
235    ///
236    /// - [`LimitError::UnknownMetric`] when `id` is not in [`METRICS`];
237    /// - [`LimitError::NotFinite`] for a NaN or infinite limit;
238    /// - [`LimitError::Negative`] for a negative limit on a
239    ///   [`Scale::Ratio`] metric.
240    ///
241    /// A [`Scale::Interval`] limit outside the domain, or on the domain
242    /// edge at the metric's bad end, is accepted with a
243    /// [`LimitWarning`] in [`Checked::warning`].
244    pub fn check(id: &str, limit: f64) -> Result<Checked, LimitError> {
245        let info = lookup(id).ok_or_else(|| LimitError::UnknownMetric { id: id.to_owned() })?;
246        if !limit.is_finite() {
247            return Err(LimitError::NotFinite { id: info.id, limit });
248        }
249        // `-0.0` passes every comparison as `0.0` but renders as `-0` and
250        // divides to `-inf`; store the zero a user meant.
251        let limit = if limit == 0.0 { 0.0 } else { limit };
252        if !matches!(info.scale, Scale::Interval { .. }) && limit < 0.0 {
253            return Err(LimitError::Negative { id: info.id, limit });
254        }
255        let threshold = Self { info, limit };
256        Ok(Checked {
257            warning: threshold.warning(),
258            threshold,
259        })
260    }
261
262    /// The metric id this threshold gates.
263    #[must_use]
264    pub fn id(&self) -> &'static str {
265        self.info.id
266    }
267
268    /// The catalog entry of the gated metric.
269    #[must_use]
270    pub fn info(&self) -> &'static MetricInfo {
271        self.info
272    }
273
274    /// The configured limit.
275    #[must_use]
276    pub fn limit(&self) -> f64 {
277        self.limit
278    }
279
280    /// The advisory this limit raises, if any: an interval-scale limit
281    /// outside its domain, or on the domain edge at the metric's bad end
282    /// where a strict comparison cannot fire. The same advisory
283    /// [`check`](Self::check) returns in [`Checked::warning`], asked of a
284    /// threshold already in hand — a soft-tier limit
285    /// [`soft_limit`](Self::soft_limit) derived, say.
286    #[must_use]
287    pub fn warning(&self) -> Option<LimitWarning> {
288        match self.info.scale {
289            Scale::Interval { min, max, .. } => domain_warning(self.info, self.limit, min, max),
290            _ => None,
291        }
292    }
293
294    /// Whether `v` breaches the limit: strictly greater for a
295    /// higher-is-worse metric, strictly less for a lower-is-worse one.
296    /// A value equal to the limit does not breach, and NaN never does.
297    #[must_use]
298    pub fn breaches(&self, v: f64) -> bool {
299        match self.info.direction {
300            Direction::HigherIsWorse => v > self.limit,
301            Direction::LowerIsWorse => v < self.limit,
302        }
303    }
304
305    /// The signed distance of `v` past the limit in the bad direction:
306    /// positive for a breach, zero on the limit, negative inside it.
307    #[must_use]
308    pub fn excess(&self, v: f64) -> f64 {
309        match self.info.direction {
310            Direction::HigherIsWorse => v - self.limit,
311            Direction::LowerIsWorse => self.limit - v,
312        }
313    }
314
315    /// How badly `v` breaches the limit, as a multiple where `1.0` is
316    /// "on the limit". Comparable across metrics, so a ranking of
317    /// offenders can sort on it directly.
318    ///
319    /// - [`Scale::Ratio`] ceiling with a positive limit: exactly
320    ///   `v / limit` (bitwise; the ratio users already read as
321    ///   "2× over"). A ratio-scale floor, which the catalog has none of
322    ///   today, inverts it to `limit / v`.
323    /// - [`Scale::Ratio`] with a zero limit (or a floor breached by a
324    ///   zero value): `f64::INFINITY`. Every such breach saturates;
325    ///   tie-break on
326    ///   [`excess`](Self::excess), as [`cmp_severity`](Self::cmp_severity)
327    ///   does.
328    /// - [`Scale::Interval`]: `1 + excess / unit`, one step per unit of
329    ///   badness past the limit. Finite for any finite `v`, whatever its
330    ///   sign or the limit's.
331    ///
332    /// Defined for a **breaching** value. Callers rank only breaches;
333    /// for a value that does not breach the result is unspecified.
334    #[must_use]
335    pub fn severity(&self, v: f64) -> f64 {
336        match self.info.scale {
337            Scale::Interval { unit, .. } => 1.0 + self.excess(v) / unit,
338            _ => match self.info.direction {
339                Direction::HigherIsWorse if self.limit > 0.0 => v / self.limit,
340                Direction::LowerIsWorse if v > 0.0 => self.limit / v,
341                _ => f64::INFINITY,
342            },
343        }
344    }
345
346    /// Order two breaching values of this metric: [`Ordering::Greater`]
347    /// means `a` is the worse breach. Compares
348    /// [`severity`](Self::severity) and breaks a tie (including two
349    /// saturated zero-limit severities) on [`excess`](Self::excess).
350    #[must_use]
351    pub fn cmp_severity(&self, a: f64, b: f64) -> Ordering {
352        self.cmp_breach(a, self, b)
353    }
354
355    /// Order the breach `a` of this threshold against the breach `b` of
356    /// `other`, which may gate a different metric or limit:
357    /// [`Ordering::Greater`] means `a` is the worse breach. The same key
358    /// as [`cmp_severity`](Self::cmp_severity) — severity, then excess —
359    /// so a ranking across metrics and a ranking within one agree.
360    #[must_use]
361    pub fn cmp_breach(&self, a: f64, other: &Self, b: f64) -> Ordering {
362        self.severity(a)
363            .total_cmp(&other.severity(b))
364            .then_with(|| self.excess(a).total_cmp(&other.excess(b)))
365    }
366
367    /// Whether `ratio` is a soft-tier band [`soft_limit`](Self::soft_limit)
368    /// accepts: finite and in `(0, 1]`. `1.0` is the identity; a ratio
369    /// above it would make the soft tier looser than the hard gate, and
370    /// `0`, a negative or NaN is a usage error.
371    #[must_use]
372    pub fn is_soft_ratio(ratio: f64) -> bool {
373        // NaN fails both comparisons, and an infinity one of them.
374        0.0 < ratio && ratio <= 1.0
375    }
376
377    /// The stricter soft-tier threshold for a band `ratio` in `(0, 1]`.
378    ///
379    /// - [`Scale::Ratio`]: `limit · ratio` for a ceiling,
380    ///   `limit / ratio` for a floor.
381    /// - [`Scale::Interval`]: the limit moved `(1 - ratio) · unit` into
382    ///   the healthy side — up for a floor, down for a ceiling — and
383    ///   clamped at the domain's healthy edge (`max` for a floor, `min`
384    ///   for a ceiling), though never past the hard limit itself, so a
385    ///   soft tier is never looser than its hard tier even for an
386    ///   out-of-domain limit. Multiplying a floor of `-50` would
387    ///   *loosen* it, which is why an interval metric cannot share the
388    ///   ratio arithmetic.
389    ///
390    /// `ratio == 1` returns the hard threshold unchanged. No rounding is
391    /// applied; presentation rounding is the caller's concern.
392    ///
393    /// # Errors
394    ///
395    /// [`LimitError::BadRatio`] when `ratio` is not finite or lies
396    /// outside `(0, 1]`.
397    pub fn soft_limit(&self, ratio: f64) -> Result<Self, LimitError> {
398        if !Self::is_soft_ratio(ratio) {
399            return Err(LimitError::BadRatio { ratio });
400        }
401        // `unit - ratio * unit` rounds once where `(1 - ratio) * unit`
402        // rounds twice: at 0.95 the latter gives 0.8550000000000009, a
403        // residue the CLI's sig-fig grid cannot absorb near zero, so a
404        // floor of 0 printed as 0.855001.
405        let step = |unit: f64| unit - ratio * unit;
406        let limit = match (self.info.scale, self.info.direction) {
407            (Scale::Interval { max, unit, .. }, Direction::LowerIsWorse) => {
408                (self.limit + step(unit)).min(max).max(self.limit)
409            }
410            (Scale::Interval { min, unit, .. }, Direction::HigherIsWorse) => {
411                (self.limit - step(unit)).max(min).min(self.limit)
412            }
413            (_, Direction::HigherIsWorse) => self.limit * ratio,
414            // A tiny ratio can overflow the quotient; keep it finite.
415            (_, Direction::LowerIsWorse) => (self.limit / ratio).min(f64::MAX),
416        };
417        Ok(Self {
418            info: self.info,
419            limit,
420        })
421    }
422
423    /// How `v` has moved from its baseline `recorded` value, positive
424    /// when worse. A [`Scale::Ratio`] metric reports a percentage of the
425    /// record (or [`Change::FromZero`] for a zero record); a
426    /// [`Scale::Interval`] metric reports points, since a percentage of
427    /// an arbitrary zero is meaningless. A NaN `v` is [`Change::NaN`].
428    #[must_use]
429    pub fn change(&self, recorded: f64, v: f64) -> Change {
430        if v.is_nan() {
431            return Change::NaN;
432        }
433        let worse_by = match self.info.direction {
434            Direction::HigherIsWorse => v - recorded,
435            Direction::LowerIsWorse => recorded - v,
436        };
437        match self.info.scale {
438            Scale::Interval { .. } => Change::Points(worse_by),
439            _ if recorded == 0.0 => Change::FromZero,
440            _ => Change::Percent(worse_by / recorded.abs() * 100.0),
441        }
442    }
443
444    /// A non-negative magnitude of the move from `recorded` to `v`. The
445    /// ratio form lies in `[0, 1]`; the interval form counts units and is
446    /// unbounded, so a ranking across the two scales favours a large
447    /// `mi.*` move.
448    ///
449    /// - [`Scale::Ratio`]: `|recorded - v| / max(|recorded|, |v|)`, in
450    ///   `[0, 1]`, and `0` when both are zero.
451    /// - [`Scale::Interval`]: `|recorded - v| / unit`, so a hundredth of
452    ///   a point near an arbitrary zero stays a hundredth of a step.
453    #[must_use]
454    pub fn drift(&self, recorded: f64, v: f64) -> f64 {
455        let delta = (recorded - v).abs();
456        match self.info.scale {
457            Scale::Interval { unit, .. } => delta / unit,
458            _ if delta == 0.0 => 0.0,
459            _ => delta / recorded.abs().max(v.abs()),
460        }
461    }
462}
463
464/// The advisory for an interval-scale limit: outside the domain, or on
465/// the edge at the bad end where a strict comparison cannot fire.
466fn domain_warning(
467    info: &'static MetricInfo,
468    limit: f64,
469    min: f64,
470    max: f64,
471) -> Option<LimitWarning> {
472    if limit < min || limit > max {
473        return Some(LimitWarning::OutOfDomain {
474            id: info.id,
475            limit,
476            min,
477            max,
478        });
479    }
480    let on_bad_edge = match info.direction {
481        Direction::LowerIsWorse => limit <= min,
482        Direction::HigherIsWorse => limit >= max,
483    };
484    on_bad_edge.then_some(LimitWarning::CannotFire { id: info.id, limit })
485}
486
487#[cfg(test)]
488#[path = "threshold_tests.rs"]
489mod tests;