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