use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::model::evaluation::AggregateVerdict;
use crate::model::fingerprint::VerdictFingerprint;
use crate::model::names::{CommitSha, RelativePath};
use crate::model::resolver_coverage::{ResolverClass, ResolverCoverage};
use crate::model::verdict::ApplyConflictKind;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum BuildOutcome {
BuiltClean,
CompilationFailed { class: Option<ResolverClass> },
ApplyConflicted,
TestRegressed,
}
impl BuildOutcome {
#[must_use]
pub fn is_break(self) -> bool {
!matches!(self, Self::BuiltClean)
}
#[must_use]
pub fn break_class(self) -> Option<ResolverClass> {
match self {
Self::CompilationFailed { class } => class,
Self::BuiltClean | Self::ApplyConflicted | Self::TestRegressed => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct CorpusEntry {
pub fingerprint: VerdictFingerprint,
pub verdict: AggregateVerdict,
pub outcome: BuildOutcome,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub coverage: Option<ResolverCoverage>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct PredictedConflict {
pub path: PathBuf,
pub kind: ApplyConflictKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct Ratio {
pub hits: usize,
pub total: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct MissedBreak {
pub fingerprint: VerdictFingerprint,
pub break_class: Option<ResolverClass>,
pub outcome: BuildOutcome,
pub is_resolver_bug: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct EvalReport {
pub rows: usize,
pub vacuous_trust: Ratio,
pub recall: Ratio,
pub precision: Ratio,
pub breaks_by_class: Vec<(ResolverClass, usize)>,
pub missed_breaks: Vec<MissedBreak>,
}
#[must_use]
pub fn evaluate_corpus(entries: &[CorpusEntry]) -> EvalReport {
let mut vacuous_trust = Ratio { hits: 0, total: 0 };
let mut recall = Ratio { hits: 0, total: 0 };
let mut precision = Ratio { hits: 0, total: 0 };
let mut by_class: BTreeMap<ResolverClass, usize> = BTreeMap::new();
let mut missed = Vec::new();
for e in entries {
let broke = e.outcome.is_break();
let flagged = e.verdict.flagged();
if !flagged {
vacuous_trust.total += 1;
if !broke {
vacuous_trust.hits += 1;
}
}
if broke {
recall.total += 1;
if flagged {
recall.hits += 1;
}
let break_class = e.outcome.break_class();
if let Some(class) = break_class {
*by_class.entry(class).or_insert(0) += 1;
}
if !flagged {
missed.push(MissedBreak {
fingerprint: e.fingerprint.clone(),
break_class,
outcome: e.outcome,
is_resolver_bug: resolver_bug(break_class, e.coverage.as_ref()),
});
}
}
if flagged {
precision.total += 1;
if broke {
precision.hits += 1;
}
}
}
EvalReport {
rows: entries.len(),
vacuous_trust,
recall,
precision,
breaks_by_class: by_class.into_iter().collect(),
missed_breaks: missed,
}
}
fn resolver_bug(class: Option<ResolverClass>, coverage: Option<&ResolverCoverage>) -> Option<bool> {
match (class, coverage) {
(Some(class), Some(coverage)) => Some(coverage.is_checked(class)),
_ => None,
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct ForecastedConflict {
pub path: RelativePath,
pub kind: ApplyConflictKind,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ObservedApply {
Clean,
Conflicted {
paths: BTreeSet<RelativePath>,
},
OutOfBand,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct ForecastEntry {
pub sha: CommitSha,
pub predicted: Vec<ForecastedConflict>,
pub observed: ObservedApply,
#[serde(default)]
pub unconvertible_paths: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct ForecastMiss {
pub sha: CommitSha,
pub missed_paths: BTreeSet<RelativePath>,
pub predicted: Vec<ForecastedConflict>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct ForecastReport {
pub entries: usize,
pub precision: Ratio,
pub recall: Ratio,
pub path_overlap: Ratio,
pub out_of_band: usize,
pub unconvertible_paths: usize,
pub false_negatives: Vec<ForecastMiss>,
}
#[must_use]
pub fn evaluate_forecasts(entries: &[ForecastEntry]) -> ForecastReport {
let mut precision = Ratio { hits: 0, total: 0 };
let mut recall = Ratio { hits: 0, total: 0 };
let mut path_overlap = Ratio { hits: 0, total: 0 };
let mut out_of_band = 0;
let mut unconvertible_paths = 0;
let mut false_negatives = Vec::new();
for e in entries {
unconvertible_paths += e.unconvertible_paths;
let observed_paths = match &e.observed {
ObservedApply::OutOfBand => {
out_of_band += 1;
continue;
}
ObservedApply::Clean => None,
ObservedApply::Conflicted { paths } => Some(paths),
};
let predicted = !e.predicted.is_empty();
let observed = observed_paths.is_some();
if predicted {
precision.total += 1;
if observed {
precision.hits += 1;
}
}
if observed {
recall.total += 1;
if predicted {
recall.hits += 1;
}
}
let Some(paths) = observed_paths else {
continue;
};
let prediction_names = |o: &RelativePath| {
e.predicted
.iter()
.any(|p| paths_match(p.path.as_str(), o.as_str()))
};
if predicted {
path_overlap.total += e.predicted.len();
path_overlap.hits += e
.predicted
.iter()
.filter(|p| {
paths
.iter()
.any(|o| paths_match(p.path.as_str(), o.as_str()))
})
.count();
}
let missed: BTreeSet<RelativePath> = paths
.iter()
.filter(|o| !prediction_names(o))
.cloned()
.collect();
if !missed.is_empty() {
false_negatives.push(ForecastMiss {
sha: e.sha.clone(),
missed_paths: missed,
predicted: e.predicted.clone(),
});
}
}
ForecastReport {
entries: entries.len(),
precision,
recall,
path_overlap,
out_of_band,
unconvertible_paths,
false_negatives,
}
}
#[must_use]
pub fn paths_match(a: &str, b: &str) -> bool {
has_path_suffix(a, b) || has_path_suffix(b, a)
}
fn has_path_suffix(haystack: &str, needle: &str) -> bool {
haystack == needle
|| (haystack.len() > needle.len()
&& haystack.ends_with(needle)
&& haystack.as_bytes()[haystack.len() - needle.len() - 1] == b'/')
}