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};
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.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
77#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
78pub struct Ratio {
79    pub hits: usize,
80    pub total: usize,
81}
82
83/// A break the verdict did not flag.
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
86pub struct MissedBreak {
87    pub fingerprint: VerdictFingerprint,
88    pub break_class: Option<ResolverClass>,
89    pub outcome: BuildOutcome,
90    /// `Some(true)` a resolver bug (the producer checked the class),
91    /// `Some(false)` a coverage gap, `None` when unknown: the row recorded
92    /// no coverage, or the break carried no class.
93    pub is_resolver_bug: Option<bool>,
94}
95
96/// The fold's output.
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
99pub struct EvalReport {
100    pub rows: usize,
101    /// Of rows whose verdict said "nothing here", the share that built
102    /// clean: the number that authorises lightening verification.
103    pub vacuous_trust: Ratio,
104    /// Of real breaks, the share the verdict flagged.
105    pub recall: Ratio,
106    /// Of flagged rows, the share that were real breaks.
107    pub precision: Ratio,
108    /// Break count per symbol class, for ranking what to build next.
109    pub breaks_by_class: Vec<(ResolverClass, usize)>,
110    /// Breaks a "nothing here" verdict missed, the harvest worklist.
111    pub missed_breaks: Vec<MissedBreak>,
112}
113
114/// Fold the paired corpus into accuracy rates and a missed-break list.
115#[must_use]
116pub fn evaluate_corpus(entries: &[CorpusEntry]) -> EvalReport {
117    let mut vacuous_trust = Ratio { hits: 0, total: 0 };
118    let mut recall = Ratio { hits: 0, total: 0 };
119    let mut precision = Ratio { hits: 0, total: 0 };
120    let mut by_class: BTreeMap<ResolverClass, usize> = BTreeMap::new();
121    let mut missed = Vec::new();
122
123    for e in entries {
124        let broke = e.outcome.is_break();
125        let flagged = e.verdict.flagged();
126        if !flagged {
127            vacuous_trust.total += 1;
128            if !broke {
129                vacuous_trust.hits += 1;
130            }
131        }
132        if broke {
133            recall.total += 1;
134            if flagged {
135                recall.hits += 1;
136            }
137            let break_class = e.outcome.break_class();
138            if let Some(class) = break_class {
139                *by_class.entry(class).or_insert(0) += 1;
140            }
141            if !flagged {
142                missed.push(MissedBreak {
143                    fingerprint: e.fingerprint.clone(),
144                    break_class,
145                    outcome: e.outcome,
146                    is_resolver_bug: resolver_bug(break_class, e.coverage.as_ref()),
147                });
148            }
149        }
150        if flagged {
151            precision.total += 1;
152            if broke {
153                precision.hits += 1;
154            }
155        }
156    }
157
158    EvalReport {
159        rows: entries.len(),
160        vacuous_trust,
161        recall,
162        precision,
163        breaks_by_class: by_class.into_iter().collect(),
164        missed_breaks: missed,
165    }
166}
167
168/// Bug-vs-gap against the row's own coverage: unknown when the class or
169/// the coverage is absent.
170fn resolver_bug(class: Option<ResolverClass>, coverage: Option<&ResolverCoverage>) -> Option<bool> {
171    match (class, coverage) {
172        (Some(class), Some(coverage)) => Some(coverage.is_checked(class)),
173        _ => None,
174    }
175}
176
177/// An apply conflict a forecast predicts: one path, its conflict kind.
178/// Normalized to `RelativePath` by the producer, unlike
179/// `PredictedConflict`, which keeps a lossless `PathBuf`.
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
182pub struct ForecastedConflict {
183    pub path: RelativePath,
184    pub kind: ApplyConflictKind,
185}
186
187/// How the pick actually applied. `OutOfBand` landings correlate with
188/// hard conflicts, so dropping them would bias recall toward easy picks.
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
190#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
191#[serde(tag = "kind", rename_all = "snake_case")]
192pub enum ObservedApply {
193    Clean,
194    /// Still a conflict when `paths` is empty: normalization may have
195    /// dropped every path.
196    Conflicted {
197        paths: BTreeSet<RelativePath>,
198    },
199    OutOfBand,
200}
201
202/// One paired row: what triage predicted for a candidate and how the
203/// apply went.
204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
205#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
206pub struct ForecastEntry {
207    pub sha: CommitSha,
208    /// One conflict per path, the shape `predicted_conflicts` emits;
209    /// duplicate paths would inflate the `path_overlap` denominator.
210    pub predicted: Vec<ForecastedConflict>,
211    pub observed: ObservedApply,
212    /// Observed paths that did not normalize to `RelativePath`: counted
213    /// so the report states what the producer dropped.
214    #[serde(default)]
215    pub unconvertible_paths: usize,
216}
217
218/// Observed conflicting paths the forecast did not predict, the
219/// worklist row. Path-level, so a pick whose forecast called one path
220/// and missed another still surfaces the missed path.
221#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
222#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
223pub struct ForecastMiss {
224    pub sha: CommitSha,
225    /// Observed conflicting paths with no matched prediction.
226    pub missed_paths: BTreeSet<RelativePath>,
227    /// The entry's full prediction, for routing the miss. Empty means
228    /// the forecast saw nothing at all on this pick.
229    pub predicted: Vec<ForecastedConflict>,
230}
231
232/// The forecast fold's output. The rates are entry-level: a hit is an
233/// entry that predicted any conflict and observed any conflict; which
234/// paths matched is graded by `path_overlap` and `false_negatives`.
235/// Forecasts are computed at triage against the then-current target
236/// tip, so false negatives partly measure tip drift, not forecast error.
237#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
238#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
239pub struct ForecastReport {
240    pub entries: usize,
241    /// Of conflict-predicting entries, the share that observed a conflict.
242    pub precision: Ratio,
243    /// Of conflict-observing entries, the share the forecast predicted.
244    pub recall: Ratio,
245    /// Of predicted paths on true-positive entries, the share matched by
246    /// an observed conflicting path.
247    pub path_overlap: Ratio,
248    /// Entries excluded from both rate denominators: when this count
249    /// dominates, the rates are not trustworthy.
250    pub out_of_band: usize,
251    /// Observed paths the producer could not normalize, summed.
252    pub unconvertible_paths: usize,
253    /// Observed conflicting paths with no matched prediction, per entry,
254    /// including entries the rates count as hits.
255    pub false_negatives: Vec<ForecastMiss>,
256}
257
258/// Fold paired forecast rows into accuracy rates and a missed-conflict
259/// worklist.
260#[must_use]
261pub fn evaluate_forecasts(entries: &[ForecastEntry]) -> ForecastReport {
262    let mut precision = Ratio { hits: 0, total: 0 };
263    let mut recall = Ratio { hits: 0, total: 0 };
264    let mut path_overlap = Ratio { hits: 0, total: 0 };
265    let mut out_of_band = 0;
266    let mut unconvertible_paths = 0;
267    let mut false_negatives = Vec::new();
268
269    for e in entries {
270        unconvertible_paths += e.unconvertible_paths;
271        let observed_paths = match &e.observed {
272            ObservedApply::OutOfBand => {
273                out_of_band += 1;
274                continue;
275            }
276            ObservedApply::Clean => None,
277            ObservedApply::Conflicted { paths } => Some(paths),
278        };
279        let predicted = !e.predicted.is_empty();
280        // the variant decides, never the path count: an all-unconvertible
281        // conflict still counts
282        let observed = observed_paths.is_some();
283        if predicted {
284            precision.total += 1;
285            if observed {
286                precision.hits += 1;
287            }
288        }
289        if observed {
290            recall.total += 1;
291            if predicted {
292                recall.hits += 1;
293            }
294        }
295        let Some(paths) = observed_paths else {
296            continue;
297        };
298        let prediction_names = |o: &RelativePath| {
299            e.predicted
300                .iter()
301                .any(|p| paths_match(p.path.as_str(), o.as_str()))
302        };
303        if predicted {
304            path_overlap.total += e.predicted.len();
305            path_overlap.hits += e
306                .predicted
307                .iter()
308                .filter(|p| {
309                    paths
310                        .iter()
311                        .any(|o| paths_match(p.path.as_str(), o.as_str()))
312                })
313                .count();
314        }
315        let missed: BTreeSet<RelativePath> = paths
316            .iter()
317            .filter(|o| !prediction_names(o))
318            .cloned()
319            .collect();
320        if !missed.is_empty() {
321            false_negatives.push(ForecastMiss {
322                sha: e.sha.clone(),
323                missed_paths: missed,
324                predicted: e.predicted.clone(),
325            });
326        }
327    }
328
329    ForecastReport {
330        entries: entries.len(),
331        precision,
332        recall,
333        path_overlap,
334        out_of_band,
335        unconvertible_paths,
336        false_negatives,
337    }
338}
339
340/// Whether two paths recorded relative to different roots name the same
341/// file: equal, or one is a `/`-boundary suffix of the other. Many-to-one
342/// by design: a bare `Makefile` matches any path ending in `/Makefile`.
343#[must_use]
344pub fn paths_match(a: &str, b: &str) -> bool {
345    has_path_suffix(a, b) || has_path_suffix(b, a)
346}
347
348fn has_path_suffix(haystack: &str, needle: &str) -> bool {
349    haystack == needle
350        || (haystack.len() > needle.len()
351            && haystack.ends_with(needle)
352            && haystack.as_bytes()[haystack.len() - needle.len() - 1] == b'/')
353}