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;