1use 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#[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 #[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#[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#[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#[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 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 pub enum BreakAttribution: "break attribution" {
134 ResolverBug => "resolver_bug",
135 CoverageGap => "coverage_gap",
136 Unknown => "unknown",
137 }
138);
139
140#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
152#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
153pub struct EvalReport {
154 pub rows: usize,
155 pub vacuous_trust: Ratio,
158 pub recall: Ratio,
160 pub precision: Ratio,
162 pub breaks_by_class: Vec<(ResolverClass, usize)>,
164 pub missed_breaks: Vec<MissedBreak>,
166}
167
168#[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
225fn 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#[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#[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 Conflicted {
260 paths: BTreeSet<RelativePath>,
261 },
262 OutOfBand,
263}
264
265#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
268#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
269pub struct ForecastEntry {
270 pub sha: CommitSha,
271 pub predicted: Vec<ForecastedConflict>,
274 pub observed: ObservedApply,
275 #[serde(default)]
278 pub unconvertible_paths: usize,
279}
280
281#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
285#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
286pub struct ForecastMiss {
287 pub sha: CommitSha,
288 pub missed_paths: BTreeSet<RelativePath>,
290 pub predicted: Vec<ForecastedConflict>,
293}
294
295#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
301#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
302pub struct ForecastReport {
303 pub entries: usize,
304 pub precision: Ratio,
306 pub recall: Ratio,
308 pub path_overlap: Ratio,
311 pub out_of_band: usize,
314 pub unconvertible_paths: usize,
316 pub false_negatives: Vec<ForecastMiss>,
319}
320
321#[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 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#[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}