Skip to main content

backhopper_core/model/
eval.rs

1// Copyright (C) 2026 Michael S. Klishin and Contributors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3// See LICENSE-APACHE and LICENSE-MIT for details.
4
5//! The corpus fold: joins recorded verdicts to observed build outcomes
6//! and reports how trustworthy the verdict is. The fingerprint pairs the
7//! two; this is a plain fold over the paired rows, run as needed, not a
8//! stored subsystem.
9
10use std::collections::{BTreeMap, BTreeSet};
11use std::path::PathBuf;
12
13use serde::{Deserialize, Serialize};
14
15use crate::model::evaluation::AggregateVerdict;
16use crate::model::fingerprint::VerdictFingerprint;
17use crate::model::names::{CommitSha, RelativePath, vocabulary};
18use crate::model::resolver_coverage::{ResolverClass, ResolverCoverage};
19use crate::model::verdict::ApplyConflictKind;
20
21/// What the build did once the pick landed. Only `CompilationFailed` carries
22/// a class: an apply conflict and a test regression are not symbol-class
23/// breaks. The class is the symbol that actually broke, which the
24/// consumer reads from the build error, not from the verdict.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
27#[serde(rename_all = "snake_case")]
28pub enum BuildOutcome {
29    BuiltClean,
30    CompilationFailed { class: Option<ResolverClass> },
31    ApplyConflicted,
32    TestRegressed,
33}
34
35impl BuildOutcome {
36    #[must_use]
37    pub fn is_break(self) -> bool {
38        !matches!(self, Self::BuiltClean)
39    }
40
41    /// The symbol class the break fell in, for a compile failure that
42    /// named one.
43    #[must_use]
44    pub fn break_class(self) -> Option<ResolverClass> {
45        match self {
46            Self::CompilationFailed { class } => class,
47            Self::BuiltClean | Self::ApplyConflicted | Self::TestRegressed => None,
48        }
49    }
50}
51
52/// One paired row: a verdict, its fingerprint, and the outcome it was
53/// meant to predict. `coverage` is the producing binary's claims, so the
54/// fold routes bug-vs-gap against the row, not the running binary.
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
57pub struct CorpusEntry {
58    pub fingerprint: VerdictFingerprint,
59    pub verdict: AggregateVerdict,
60    pub outcome: BuildOutcome,
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub coverage: Option<ResolverCoverage>,
63}
64
65/// A risky apply conflict a verdict predicts: one path, its conflict
66/// kind. The path is the reason's own `PathBuf`, never dropped.
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
69pub struct PredictedConflict {
70    pub path: PathBuf,
71    pub kind: ApplyConflictKind,
72}
73
74/// A measured rate kept as `hit/total` so the sample size is never lost
75/// behind a bare percentage. `hits <= total` always: every mutator
76/// grows `total` by at least as much as `hits` in the same step.
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
78#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
79pub struct Ratio {
80    hits: usize,
81    total: usize,
82}
83
84impl Ratio {
85    #[must_use]
86    pub const fn zero() -> Self {
87        Self { hits: 0, total: 0 }
88    }
89
90    pub fn hit(&mut self) {
91        self.hits += 1;
92        self.total += 1;
93    }
94
95    pub fn miss(&mut self) {
96        self.total += 1;
97    }
98
99    #[must_use]
100    pub fn hits(&self) -> usize {
101        self.hits
102    }
103
104    #[must_use]
105    pub fn total(&self) -> usize {
106        self.total
107    }
108
109    #[must_use]
110    pub fn rate(&self) -> Option<f64> {
111        if self.total == 0 {
112            None
113        } else {
114            Some(self.hits as f64 / self.total as f64)
115        }
116    }
117
118    /// Count `hits` matches out of `total` candidates in one step, for
119    /// a fold over a whole slice. Private: `hit` and `miss` are the only
120    /// public way to grow a ratio.
121    fn extend(&mut self, hits: usize, total: usize) {
122        debug_assert!(hits <= total, "a ratio's hits cannot exceed its total");
123        self.hits += hits;
124        self.total += total;
125    }
126}
127
128vocabulary!(
129    /// Who is responsible for a break the verdict did not flag: the
130    /// resolver (it checked the class and still missed it), a coverage gap
131    /// (it never checked the class), or unknown (the row recorded no
132    /// coverage, or the break carried no class).
133    pub enum BreakAttribution: "break attribution" {
134        ResolverBug => "resolver_bug",
135        CoverageGap => "coverage_gap",
136        Unknown => "unknown",
137    }
138);
139
140/// A break the verdict did not flag.
141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
142#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
143pub struct MissedBreak {
144    pub fingerprint: VerdictFingerprint,
145    pub break_class: Option<ResolverClass>,
146    pub outcome: BuildOutcome,
147    pub attribution: BreakAttribution,
148}
149
150/// The fold's output.
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
152#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
153pub struct EvalReport {
154    pub rows: usize,
155    /// Of rows whose verdict said "nothing here", the share that built
156    /// clean: the number that authorises lightening verification.
157    pub vacuous_trust: Ratio,
158    /// Of real breaks, the share the verdict flagged.
159    pub recall: Ratio,
160    /// Of flagged rows, the share that were real breaks.
161    pub precision: Ratio,
162    /// Break count per symbol class, for ranking what to build next.
163    pub breaks_by_class: Vec<(ResolverClass, usize)>,
164    /// Breaks a "nothing here" verdict missed, the worklist.
165    pub missed_breaks: Vec<MissedBreak>,
166}
167
168/// Fold the paired corpus into accuracy rates and a missed-break list.
169#[must_use]
170pub fn evaluate_corpus(entries: &[CorpusEntry]) -> EvalReport {
171    let mut vacuous_trust = Ratio::zero();
172    let mut recall = Ratio::zero();
173    let mut precision = Ratio::zero();
174    let mut by_class: BTreeMap<ResolverClass, usize> = BTreeMap::new();
175    let mut missed = Vec::new();
176
177    for e in entries {
178        let broke = e.outcome.is_break();
179        let flagged = e.verdict.flagged();
180        if !flagged {
181            if broke {
182                vacuous_trust.miss();
183            } else {
184                vacuous_trust.hit();
185            }
186        }
187        if broke {
188            if flagged {
189                recall.hit();
190            } else {
191                recall.miss();
192            }
193            let break_class = e.outcome.break_class();
194            if let Some(class) = break_class {
195                *by_class.entry(class).or_insert(0) += 1;
196            }
197            if !flagged {
198                missed.push(MissedBreak {
199                    fingerprint: e.fingerprint.clone(),
200                    break_class,
201                    outcome: e.outcome,
202                    attribution: break_attribution(break_class, e.coverage.as_ref()),
203                });
204            }
205        }
206        if flagged {
207            if broke {
208                precision.hit();
209            } else {
210                precision.miss();
211            }
212        }
213    }
214
215    EvalReport {
216        rows: entries.len(),
217        vacuous_trust,
218        recall,
219        precision,
220        breaks_by_class: by_class.into_iter().collect(),
221        missed_breaks: missed,
222    }
223}
224
225/// Bug-vs-gap against the row's own coverage: unknown when the class or
226/// the coverage is absent.
227fn break_attribution(
228    class: Option<ResolverClass>,
229    coverage: Option<&ResolverCoverage>,
230) -> BreakAttribution {
231    match (class, coverage) {
232        (Some(class), Some(coverage)) if coverage.is_checked(class) => {
233            BreakAttribution::ResolverBug
234        }
235        (Some(_), Some(_)) => BreakAttribution::CoverageGap,
236        _ => BreakAttribution::Unknown,
237    }
238}
239
240/// An apply conflict a forecast predicts: one path, its conflict kind.
241/// Normalized to `RelativePath` by the producer, unlike
242/// `PredictedConflict`, which keeps a lossless `PathBuf`.
243#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
244#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
245pub struct ForecastedConflict {
246    pub path: RelativePath,
247    pub kind: ApplyConflictKind,
248}
249
250/// How the pick actually applied. `OutOfBand` landings correlate with
251/// hard conflicts, so dropping them would bias recall toward easy picks.
252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
254#[serde(tag = "kind", rename_all = "snake_case")]
255pub enum ObservedApply {
256    Clean,
257    /// Still a conflict when `paths` is empty: normalization may have
258    /// dropped every path.
259    Conflicted {
260        paths: BTreeSet<RelativePath>,
261    },
262    OutOfBand,
263}
264
265/// One paired row: what triage predicted for a candidate and how the
266/// apply went.
267#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
268#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
269pub struct ForecastEntry {
270    pub sha: CommitSha,
271    /// One conflict per path, the shape `predicted_conflicts` emits;
272    /// duplicate paths would inflate the `path_overlap` denominator.
273    pub predicted: Vec<ForecastedConflict>,
274    pub observed: ObservedApply,
275    /// Observed paths that did not normalize to `RelativePath`: counted
276    /// so the report states what the producer dropped.
277    #[serde(default)]
278    pub unconvertible_paths: usize,
279}
280
281/// Observed conflicting paths the forecast did not predict, the
282/// worklist row. Path-level, so a pick whose forecast called one path
283/// and missed another still surfaces the missed path.
284#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
285#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
286pub struct ForecastMiss {
287    pub sha: CommitSha,
288    /// Observed conflicting paths with no matched prediction.
289    pub missed_paths: BTreeSet<RelativePath>,
290    /// The entry's full prediction, for routing the miss. Empty means
291    /// the forecast predicted nothing at all on this pick.
292    pub predicted: Vec<ForecastedConflict>,
293}
294
295/// The forecast fold's output. The rates are entry-level: a hit is an
296/// entry that predicted any conflict and observed any conflict; which
297/// paths matched is graded by `path_overlap` and `false_negatives`.
298/// Forecasts are computed at triage against the then-current target
299/// tip, so false negatives partly measure tip drift, not forecast error.
300#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
301#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
302pub struct ForecastReport {
303    pub entries: usize,
304    /// Of conflict-predicting entries, the share that observed a conflict.
305    pub precision: Ratio,
306    /// Of conflict-observing entries, the share the forecast predicted.
307    pub recall: Ratio,
308    /// Of predicted paths on true-positive entries, the share matched by
309    /// an observed conflicting path.
310    pub path_overlap: Ratio,
311    /// Entries excluded from both rate denominators: when this count
312    /// dominates, the rates are not trustworthy.
313    pub out_of_band: usize,
314    /// Observed paths the producer could not normalize, summed.
315    pub unconvertible_paths: usize,
316    /// Observed conflicting paths with no matched prediction, per entry,
317    /// including entries the rates count as hits.
318    pub false_negatives: Vec<ForecastMiss>,
319}
320
321/// Fold paired forecast rows into accuracy rates and a missed-conflict
322/// worklist.
323#[must_use]
324pub fn evaluate_forecasts(entries: &[ForecastEntry]) -> ForecastReport {
325    let mut precision = Ratio::zero();
326    let mut recall = Ratio::zero();
327    let mut path_overlap = Ratio::zero();
328    let mut out_of_band = 0;
329    let mut unconvertible_paths = 0;
330    let mut false_negatives = Vec::new();
331
332    for e in entries {
333        unconvertible_paths += e.unconvertible_paths;
334        let observed_paths = match &e.observed {
335            ObservedApply::OutOfBand => {
336                out_of_band += 1;
337                continue;
338            }
339            ObservedApply::Clean => None,
340            ObservedApply::Conflicted { paths } => Some(paths),
341        };
342        let predicted = !e.predicted.is_empty();
343        // the variant decides, not the path count: an all-unconvertible conflict still counts
344        let observed = observed_paths.is_some();
345        if predicted {
346            if observed {
347                precision.hit();
348            } else {
349                precision.miss();
350            }
351        }
352        if observed {
353            if predicted {
354                recall.hit();
355            } else {
356                recall.miss();
357            }
358        }
359        let Some(paths) = observed_paths else {
360            continue;
361        };
362        let prediction_names = |o: &RelativePath| {
363            e.predicted
364                .iter()
365                .any(|p| paths_match(p.path.as_str(), o.as_str()))
366        };
367        if predicted {
368            let matched = e
369                .predicted
370                .iter()
371                .filter(|p| {
372                    paths
373                        .iter()
374                        .any(|o| paths_match(p.path.as_str(), o.as_str()))
375                })
376                .count();
377            path_overlap.extend(matched, e.predicted.len());
378        }
379        let missed: BTreeSet<RelativePath> = paths
380            .iter()
381            .filter(|o| !prediction_names(o))
382            .cloned()
383            .collect();
384        if !missed.is_empty() {
385            false_negatives.push(ForecastMiss {
386                sha: e.sha.clone(),
387                missed_paths: missed,
388                predicted: e.predicted.clone(),
389            });
390        }
391    }
392
393    ForecastReport {
394        entries: entries.len(),
395        precision,
396        recall,
397        path_overlap,
398        out_of_band,
399        unconvertible_paths,
400        false_negatives,
401    }
402}
403
404/// Whether two paths recorded relative to different roots name the same
405/// file: equal, or one is a `/`-boundary suffix of the other. Many-to-one
406/// by design: a bare `Makefile` matches any path ending in `/Makefile`.
407#[must_use]
408pub fn paths_match(a: &str, b: &str) -> bool {
409    has_path_suffix(a, b) || has_path_suffix(b, a)
410}
411
412fn has_path_suffix(haystack: &str, needle: &str) -> bool {
413    haystack == needle
414        || (haystack.len() > needle.len()
415            && haystack.ends_with(needle)
416            && haystack.as_bytes()[haystack.len() - needle.len() - 1] == b'/')
417}