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};
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)]
77#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
78pub struct Ratio {
79 pub hits: usize,
80 pub total: usize,
81}
82
83#[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 pub is_resolver_bug: Option<bool>,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
99pub struct EvalReport {
100 pub rows: usize,
101 pub vacuous_trust: Ratio,
104 pub recall: Ratio,
106 pub precision: Ratio,
108 pub breaks_by_class: Vec<(ResolverClass, usize)>,
110 pub missed_breaks: Vec<MissedBreak>,
112}
113
114#[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
168fn 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#[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#[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 Conflicted {
197 paths: BTreeSet<RelativePath>,
198 },
199 OutOfBand,
200}
201
202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
205#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
206pub struct ForecastEntry {
207 pub sha: CommitSha,
208 pub predicted: Vec<ForecastedConflict>,
211 pub observed: ObservedApply,
212 #[serde(default)]
215 pub unconvertible_paths: usize,
216}
217
218#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
222#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
223pub struct ForecastMiss {
224 pub sha: CommitSha,
225 pub missed_paths: BTreeSet<RelativePath>,
227 pub predicted: Vec<ForecastedConflict>,
230}
231
232#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
238#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
239pub struct ForecastReport {
240 pub entries: usize,
241 pub precision: Ratio,
243 pub recall: Ratio,
245 pub path_overlap: Ratio,
248 pub out_of_band: usize,
251 pub unconvertible_paths: usize,
253 pub false_negatives: Vec<ForecastMiss>,
256}
257
258#[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 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#[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}