Skip to main content

backhopper_core/model/
evaluation.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//! Ergonomic, borrowed view over a `SeriesVerdict` and its `Diagnostics`.
6//!
7//! The check verbs and `check batch` carry the same verdict and diagnostic
8//! payloads in different envelopes. This view holds the query and finding
9//! accessors once so both paths read identically: a `BatchResult` row reaches
10//! it through [`BatchResult::evaluation`], and the driver's single-check
11//! `SeriesEvaluation` through its own `view`.
12
13use std::collections::{BTreeMap, BTreeSet};
14use std::path::PathBuf;
15
16use serde::{Deserialize, Serialize};
17
18use crate::model::batch::BatchResult;
19use crate::model::eval::{BuildOutcome, CorpusEntry, PredictedConflict};
20use crate::model::names::{MacroName, ModuleName, ProjectName, RelativePath};
21use crate::model::pin::Pin;
22use crate::model::resolver_coverage::ResolverCoverage;
23use crate::model::summary::VerdictKind;
24use crate::model::verdict::{
25    ApplyConflictKind, Diagnostics, IncludeDirective, PinVerdict, Reason, SeriesEvaluation,
26    SeriesVerdict, SnapshotSide, TestCallSite, Verdict, non_self_tracked,
27};
28
29/// Aggregate verdict across the pins of a series.
30#[non_exhaustive]
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
32#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
33#[serde(rename_all = "snake_case")]
34pub enum AggregateVerdict {
35    /// At least one pin compatible, none worse.
36    Compatible,
37    /// Some pin requires adaptation, none incompatible.
38    RequiresAdaptation,
39    /// At least one pin incompatible.
40    Incompatible,
41    /// All pins inapplicable.
42    Inapplicable,
43    /// The series has no pins.
44    Empty,
45}
46
47impl AggregateVerdict {
48    /// True when the verdict told the operator to adapt or stop. The
49    /// other arms read as "nothing here", `Empty` included.
50    #[must_use]
51    pub fn flagged(self) -> bool {
52        matches!(self, Self::Incompatible | Self::RequiresAdaptation)
53    }
54
55    /// Snake-case wire form, matching the `#[serde(rename_all)]` projection.
56    #[must_use]
57    pub fn as_str(self) -> &'static str {
58        match self {
59            Self::Compatible => "compatible",
60            Self::RequiresAdaptation => "requires_adaptation",
61            Self::Incompatible => "incompatible",
62            Self::Inapplicable => "inapplicable",
63            Self::Empty => "empty",
64        }
65    }
66}
67
68/// Borrowed accessors over a `SeriesVerdict` and its `Diagnostics`.
69///
70/// Methods take `self` by value; the view is `Copy`, so a temporary like
71/// `row.evaluation().worst_verdict()` is cheap and the returned iterators
72/// borrow the underlying data rather than the view.
73#[derive(Debug, Clone, Copy)]
74pub struct SeriesEvaluationView<'a> {
75    results: &'a SeriesVerdict,
76    diagnostics: &'a Diagnostics,
77}
78
79impl<'a> SeriesEvaluationView<'a> {
80    /// Construct a view over a verdict and its diagnostics.
81    #[must_use]
82    pub fn new(results: &'a SeriesVerdict, diagnostics: &'a Diagnostics) -> Self {
83        Self {
84            results,
85            diagnostics,
86        }
87    }
88
89    /// Worst aggregate verdict across pins. `Empty` when the series has no
90    /// pins.
91    #[must_use]
92    pub fn worst_verdict(self) -> AggregateVerdict {
93        if self.results.results.is_empty() {
94            return AggregateVerdict::Empty;
95        }
96        let s = &self.results.summary;
97        if s.incompatible > 0 {
98            AggregateVerdict::Incompatible
99        } else if s.requires_adaptation > 0 {
100            AggregateVerdict::RequiresAdaptation
101        } else if s.compatible > 0 {
102            AggregateVerdict::Compatible
103        } else if s.inapplicable > 0 {
104            AggregateVerdict::Inapplicable
105        } else {
106            AggregateVerdict::Empty
107        }
108    }
109
110    /// `true` if any pin carries a blocking reason.
111    #[must_use]
112    pub fn has_blocking_reason(self) -> bool {
113        self.results
114            .results
115            .iter()
116            .any(|p| matches!(&p.verdict, Verdict::Incompatible { .. }))
117    }
118
119    /// Iterate over pin verdicts whose verdict matches `verdict`.
120    pub fn pins_in(self, verdict: VerdictKind) -> impl Iterator<Item = &'a PinVerdict> {
121        self.results
122            .results
123            .iter()
124            .filter(move |p| VerdictKind::from(&p.verdict) == verdict)
125    }
126
127    /// Every [`Reason`] across all pins, flattened.
128    pub fn reasons(self) -> impl Iterator<Item = &'a Reason> {
129        self.results.results.iter().flat_map(pin_reasons)
130    }
131
132    /// All [`Reason`] values attached to the pin matching `pin`.
133    pub fn reasons_for(self, pin: &Pin) -> impl Iterator<Item = &'a Reason> {
134        self.results
135            .results
136            .iter()
137            .filter(move |p| &p.pin == pin)
138            .flat_map(pin_reasons)
139    }
140
141    /// Saturating sum of `tracked_refs` over non-self pins.
142    #[must_use]
143    pub fn tracked_refs(self, self_projects: &BTreeSet<ProjectName>) -> u32 {
144        non_self_tracked(&self.results.results, self_projects)
145    }
146
147    /// First pin whose project name matches `project`, if any.
148    #[must_use]
149    pub fn pin_by_project(self, project: &ProjectName) -> Option<&'a PinVerdict> {
150        self.results
151            .results
152            .iter()
153            .find(|p| &p.pin.project == project)
154    }
155
156    /// Always-on diagnostic counterpart of `Reason::TestModuleSymbolMissing`
157    /// from `018_ci_signal_gaps`: a per-`_SUITE.erl` map of
158    /// `helper_module -> call_site_count`. Empty when the patch did not touch
159    /// a SUITE that references an unresolved helper.
160    #[must_use]
161    pub fn missing_test_modules(self) -> &'a BTreeMap<RelativePath, BTreeMap<ModuleName, usize>> {
162        &self.diagnostics.missing_test_modules
163    }
164
165    /// Every `(pin, reason)` pair across the evaluation, the shared
166    /// source the typed finding iterators below filter and shape.
167    fn pin_reason_pairs(self) -> impl Iterator<Item = (&'a PinVerdict, &'a Reason)> {
168        self.results
169            .results
170            .iter()
171            .flat_map(|pin| pin_reasons(pin).map(move |r| (pin, r)))
172    }
173
174    /// Iterate every `Reason::TestModuleSymbolMissing` flattened to
175    /// `(pin, suite_path, missing_module, call_sites)`, so consumers surface
176    /// the signal without re-deriving it from the `Reason` enum.
177    pub fn test_module_symbol_missing(
178        self,
179    ) -> impl Iterator<Item = TestModuleSymbolMissingFinding<'a>> {
180        self.pin_reason_pairs().filter_map(|(pin, r)| match r {
181            Reason::TestModuleSymbolMissing {
182                suite_path,
183                missing_module,
184                call_sites,
185            } => Some(TestModuleSymbolMissingFinding {
186                pin,
187                suite_path,
188                missing_module,
189                call_sites: call_sites.as_slice(),
190            }),
191            _ => None,
192        })
193    }
194
195    /// Iterate every `Reason::HeaderFileMissing` flattened to
196    /// `(pin, source_path, include_directive, attempted_paths)`.
197    pub fn header_file_missing(self) -> impl Iterator<Item = HeaderFileMissingFinding<'a>> {
198        self.pin_reason_pairs().filter_map(|(pin, r)| match r {
199            Reason::HeaderFileMissing {
200                source_path,
201                include_directive,
202                attempted_paths,
203            } => Some(HeaderFileMissingFinding {
204                pin,
205                source_path,
206                include_directive,
207                attempted_paths: attempted_paths.as_slice(),
208            }),
209            _ => None,
210        })
211    }
212
213    /// Iterate every `Reason::BehaviourModuleMissing` flattened to
214    /// `(pin, source_path, behaviour)`.
215    pub fn behaviour_module_missing(
216        self,
217    ) -> impl Iterator<Item = BehaviourModuleMissingFinding<'a>> {
218        self.pin_reason_pairs().filter_map(|(pin, r)| match r {
219            Reason::BehaviourModuleMissing {
220                source_path,
221                behaviour,
222            } => Some(BehaviourModuleMissingFinding {
223                pin,
224                source_path,
225                behaviour,
226            }),
227            _ => None,
228        })
229    }
230
231    /// Iterate every `Reason::VersionedMachineSnapshotMissing` flattened to
232    /// `(pin, module, side)`.
233    pub fn versioned_machine_snapshot_missing(
234        self,
235    ) -> impl Iterator<Item = VersionedMachineSnapshotMissingFinding<'a>> {
236        self.pin_reason_pairs().filter_map(|(pin, r)| match r {
237            Reason::VersionedMachineSnapshotMissing { module, side } => {
238                Some(VersionedMachineSnapshotMissingFinding {
239                    pin,
240                    module,
241                    side: *side,
242                })
243            }
244            _ => None,
245        })
246    }
247
248    /// Iterate every `Reason::WireConstantBindingsMissing` flattened to
249    /// `(pin, module, macros, side)`.
250    pub fn wire_constant_bindings_missing(
251        self,
252    ) -> impl Iterator<Item = WireConstantBindingsMissingFinding<'a>> {
253        self.pin_reason_pairs().filter_map(|(pin, r)| match r {
254            Reason::WireConstantBindingsMissing {
255                module,
256                macros,
257                side,
258            } => Some(WireConstantBindingsMissingFinding {
259                pin,
260                module,
261                macros: macros.as_slice(),
262                side: *side,
263            }),
264            _ => None,
265        })
266    }
267}
268
269/// Owned projection of one batch row into a [`SeriesEvaluation`], moving
270/// every shared field across. The batch-only fields (`commit`, `series`,
271/// `parent_count`, `verdict_fingerprint`) are dropped. A consumer that
272/// needs one row's evaluation without a field-by-field repack uses this;
273/// a new shared field is carried here or it fails to compile.
274impl From<BatchResult> for SeriesEvaluation {
275    fn from(row: BatchResult) -> Self {
276        Self {
277            verdict: row.verdict,
278            diagnostics: row.diagnostics,
279            patch_facts: row.patch_facts,
280            touched_paths: row.touched_paths,
281            pr_commits: row.pr_commits,
282            apply: row.apply,
283            target_findings: row.target_findings,
284        }
285    }
286}
287
288impl BatchResult {
289    /// A borrowed [`SeriesEvaluationView`] over this row's verdict and
290    /// diagnostics, exposing the same accessors as a single-check evaluation.
291    /// The batch-only fields (`patch_facts`, `touched_paths`, `pr_commits`,
292    /// `parent_count`) stay on the row.
293    #[must_use]
294    pub fn evaluation(&self) -> SeriesEvaluationView<'_> {
295        SeriesEvaluationView::new(&self.verdict, &self.diagnostics)
296    }
297
298    /// Project this row into a corpus entry. backhopper fills the
299    /// fingerprint and aggregate verdict; the caller supplies the observed
300    /// outcome and the round's coverage. `None` when the row is not
301    /// measurable: no fingerprint (un-joinable), or an empty verdict (no
302    /// pins, so nothing was predicted).
303    #[must_use]
304    pub fn to_corpus_entry(
305        &self,
306        observed: BuildOutcome,
307        coverage: Option<&ResolverCoverage>,
308    ) -> Option<CorpusEntry> {
309        let fingerprint = self.verdict_fingerprint.clone()?;
310        let verdict = self.evaluation().worst_verdict();
311        if verdict == AggregateVerdict::Empty {
312            return None;
313        }
314        Some(CorpusEntry {
315            fingerprint,
316            verdict,
317            outcome: observed,
318            coverage: coverage.cloned(),
319        })
320    }
321
322    /// The risky apply conflicts this row predicts, one per path at its
323    /// highest severity: the union of the row's apply forecast and the
324    /// reason-derived conflicts. The reason arm keeps envelopes that
325    /// predate the forecast working; the forecast arm carries the rows
326    /// whose reasons were dropped on inapplicable pins. Path stays a
327    /// `PathBuf`: a predictor never drops a risky path, so relativizing
328    /// is the caller's call.
329    #[must_use]
330    pub fn predicted_conflicts(&self) -> Vec<PredictedConflict> {
331        let mut by_path: BTreeMap<PathBuf, ApplyConflictKind> = BTreeMap::new();
332        let mut record = |path: PathBuf, kind: ApplyConflictKind| {
333            by_path
334                .entry(path)
335                .and_modify(|seen| *seen = (*seen).max(kind))
336                .or_insert(kind);
337        };
338        for (path, kind) in self
339            .evaluation()
340            .reasons()
341            .filter_map(Reason::apply_conflict)
342        {
343            record(path.to_path_buf(), kind);
344        }
345        if let Some(forecast) = &self.apply {
346            for (path, kind) in forecast.conflicts() {
347                record(PathBuf::from(path.as_str()), kind);
348            }
349        }
350        by_path
351            .into_iter()
352            .map(|(path, kind)| PredictedConflict { path, kind })
353            .collect()
354    }
355}
356
357/// Borrowed view of one `Reason::TestModuleSymbolMissing` with its owning pin.
358#[derive(Debug, Clone, Copy)]
359pub struct TestModuleSymbolMissingFinding<'a> {
360    /// The pin whose verdict carries this reason.
361    pub pin: &'a PinVerdict,
362    /// The `_SUITE.erl` path the suite is at on the source side.
363    pub suite_path: &'a RelativePath,
364    /// The unresolved helper module the suite references.
365    pub missing_module: &'a ModuleName,
366    /// Every call site inside the suite that reaches the missing helper.
367    pub call_sites: &'a [TestCallSite],
368}
369
370/// Borrowed view of one `Reason::HeaderFileMissing` with its owning pin.
371#[derive(Debug, Clone, Copy)]
372pub struct HeaderFileMissingFinding<'a> {
373    /// The pin whose verdict carries this reason.
374    pub pin: &'a PinVerdict,
375    /// The touched `.erl` or `.hrl` that declared the unresolved include.
376    pub source_path: &'a RelativePath,
377    /// The include directive verbatim.
378    pub include_directive: &'a IncludeDirective,
379    /// Target paths the resolver tried in order.
380    pub attempted_paths: &'a [RelativePath],
381}
382
383/// Borrowed view of one `Reason::BehaviourModuleMissing` with its owning pin.
384#[derive(Debug, Clone, Copy)]
385pub struct BehaviourModuleMissingFinding<'a> {
386    /// The pin whose verdict carries this reason.
387    pub pin: &'a PinVerdict,
388    /// The touched `.erl` that declared `-behaviour(behaviour)`.
389    pub source_path: &'a RelativePath,
390    /// The unresolved behaviour module name.
391    pub behaviour: &'a ModuleName,
392}
393
394/// Borrowed view of one `Reason::VersionedMachineSnapshotMissing` with its
395/// owning pin.
396#[derive(Debug, Clone, Copy)]
397pub struct VersionedMachineSnapshotMissingFinding<'a> {
398    /// The pin whose verdict carries this reason.
399    pub pin: &'a PinVerdict,
400    /// The declared versioned-machine module the data is missing for.
401    pub module: &'a ModuleName,
402    /// Which snapshot side lacks the data.
403    pub side: SnapshotSide,
404}
405
406/// Borrowed view of one `Reason::WireConstantBindingsMissing` with its owning
407/// pin.
408#[derive(Debug, Clone, Copy)]
409pub struct WireConstantBindingsMissingFinding<'a> {
410    /// The pin whose verdict carries this reason.
411    pub pin: &'a PinVerdict,
412    /// The declared module whose wire-constant bindings are missing.
413    pub module: &'a ModuleName,
414    /// The macros that lack a binding on `side`, sorted alphabetically.
415    pub macros: &'a [MacroName],
416    /// Which snapshot side lacks the bindings.
417    pub side: SnapshotSide,
418}
419
420fn pin_reasons(pin: &PinVerdict) -> impl Iterator<Item = &Reason> {
421    pin.verdict.reasons().iter()
422}