Skip to main content

backhopper_core/model/
clearance.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//! Round-level roll-up of a `check batch` result set. Derived on read
6//! from the rows, never serialized: it carries no schema version and
7//! no wire field is added.
8//!
9//! The point is trust. A renderer cannot report any outcome without
10//! going through the matching arm and stating what did or did not
11//! happen. `Clean` states analyzable surface was touched and passed;
12//! `ZeroDomain` states backhopper's dep and symbol domain did not
13//! intersect the round at all; `Findings` names what needs review.
14
15use std::collections::{BTreeMap, BTreeSet};
16
17use crate::model::apply::PathApplyOutcome;
18use crate::model::batch::BatchResult;
19use crate::model::names::{ProjectName, RelativePath};
20use crate::model::resolver_coverage::ResolverClass;
21use crate::model::verdict::{BumpStatus, SeriesSummary, Verdict, exit, non_self_tracked};
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum RoundClearance {
25    /// No dep-API risk; at least one candidate touched analyzable surface.
26    Clean(ClearanceFacts),
27    /// Every candidate was inapplicable; backhopper's dep and symbol domain
28    /// did not intersect this round.
29    ZeroDomain(ClearanceFacts),
30    /// A reviewer should look: a blocking verdict, tracked-dep references,
31    /// or a snapshot-missing pin bump.
32    Findings(ClearanceFacts),
33}
34
35/// The facts behind a clearance, shared by both arms.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct ClearanceFacts {
38    /// Distinct commits across the result set.
39    pub candidates: usize,
40    /// Distinct series across the result set.
41    pub series: usize,
42    /// Total `(commit, series)` rows.
43    pub rows: usize,
44    /// Tracked-dep references summed over non-self pins, saturating.
45    pub tracked: u32,
46    /// Per-pin verdict counts summed over rows, matching the batch
47    /// totals line.
48    pub verdicts: SeriesSummary,
49    pub reasons: ReasonHistogram,
50    pub bumps: BumpSummary,
51    /// Apply-axis roll-up. `rows_with_forecast == 0` means no row
52    /// carried a forecast, and the renderer says so.
53    pub apply: ApplyRollup,
54    /// Symbol-axis roll-up. `rows_with_findings == 0` means no row
55    /// carried the field, and the renderer says so.
56    pub target: TargetFindingsRollup,
57    /// Union of every row's `suggested_suites`, sorted and deduplicated.
58    pub suites: Vec<String>,
59    /// `exit::OK` or `exit::NEEDS_ATTENTION`, the value `check batch`
60    /// returns for the round.
61    pub exit_code: i32,
62}
63
64/// Inapplicability reasons tallied by their stable `as_str()` label.
65/// Keyed on the label, not the value, so `OutOfScopeFor { project }`
66/// rows with different projects group under one count.
67#[derive(Debug, Clone, Default, PartialEq, Eq)]
68pub struct ReasonHistogram(BTreeMap<&'static str, u32>);
69
70impl ReasonHistogram {
71    fn record(&mut self, label: &'static str) {
72        *self.0.entry(label).or_insert(0) += 1;
73    }
74
75    #[must_use]
76    pub fn is_empty(&self) -> bool {
77        self.0.is_empty()
78    }
79
80    #[must_use]
81    pub fn total(&self) -> u32 {
82        self.0.values().copied().sum()
83    }
84
85    #[must_use]
86    pub fn count(&self, label: &str) -> u32 {
87        self.0.get(label).copied().unwrap_or(0)
88    }
89
90    /// Label and count pairs, descending by count then ascending by
91    /// label, so the dominant reason reads first.
92    #[must_use]
93    pub fn ranked(&self) -> Vec<(&'static str, u32)> {
94        let mut ranked: Vec<(&'static str, u32)> = self.0.iter().map(|(k, n)| (*k, *n)).collect();
95        ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
96        ranked
97    }
98}
99
100/// Distinct dep-pin bumps across the round, classified by snapshot
101/// status. Deduplicated by `(commit, dep, to)` so a bump seen under
102/// several series counts once.
103#[derive(Debug, Clone, Default, PartialEq, Eq)]
104pub struct BumpSummary {
105    pub total: u32,
106    pub snapshot_present: u32,
107    /// The risk flag: a tracked dep moved to a version with no
108    /// snapshot to vet against.
109    pub snapshot_missing: u32,
110    pub untracked: u32,
111    /// Status unfilled, the value a cache hit carries.
112    pub unassessed: u32,
113    /// Bumps that introduce a pin rather than change one (`from` absent).
114    pub introduced: u32,
115}
116
117impl BumpSummary {
118    #[must_use]
119    pub fn is_empty(&self) -> bool {
120        self.total == 0
121    }
122}
123
124/// Apply-axis facts summed over the round's rows.
125#[derive(Debug, Clone, Default, PartialEq, Eq)]
126pub struct ApplyRollup {
127    /// Rows that carried an apply forecast at all.
128    pub rows_with_forecast: usize,
129    /// Rows whose forecast predicts at least one conflict.
130    pub conflicted_rows: usize,
131    /// Distinct conflicting paths across the round, sorted.
132    pub conflicted_paths: Vec<RelativePath>,
133    /// Conflict counts by kind label, summed over rows.
134    pub by_kind: BTreeMap<&'static str, u32>,
135    /// Paths stated unassessed, summed over rows; coverage, not risk.
136    pub unassessed_paths: u32,
137}
138
139impl ApplyRollup {
140    #[must_use]
141    pub fn has_conflict(&self) -> bool {
142        self.conflicted_rows > 0
143    }
144}
145
146/// Symbol-axis facts summed over the round's rows.
147#[derive(Debug, Clone, Default, PartialEq, Eq)]
148pub struct TargetFindingsRollup {
149    /// Rows that carried the findings field at all.
150    pub rows_with_findings: usize,
151    /// Rows whose findings are non-empty.
152    pub finding_rows: usize,
153    /// Finding counts by resolver class, the ranking view.
154    pub by_class: Vec<(ResolverClass, usize)>,
155    /// Findings with no resolver class, counted so the class view
156    /// never hides a reason.
157    pub unclassified: usize,
158}
159
160impl TargetFindingsRollup {
161    #[must_use]
162    pub fn has_findings(&self) -> bool {
163        self.finding_rows > 0
164    }
165}
166
167impl RoundClearance {
168    #[must_use]
169    pub fn from_results(results: &[BatchResult], self_projects: &BTreeSet<ProjectName>) -> Self {
170        let mut commits = BTreeSet::new();
171        let mut series = BTreeSet::new();
172        let mut tracked: u32 = 0;
173        let mut verdicts = SeriesSummary::default();
174        let mut reasons = ReasonHistogram::default();
175        let mut suites = BTreeSet::new();
176        let mut bumps = BumpSummary::default();
177        let mut seen_bumps = BTreeSet::new();
178        let mut snapshot_missing_bump = false;
179        let mut apply = ApplyRollup::default();
180        let mut conflicted_paths = BTreeSet::new();
181        let mut target = TargetFindingsRollup::default();
182        let mut target_by_class: BTreeMap<ResolverClass, usize> = BTreeMap::new();
183
184        for row in results {
185            commits.insert(&row.commit);
186            series.insert(&row.series);
187
188            verdicts.compatible += row.verdict.summary.compatible;
189            verdicts.requires_adaptation += row.verdict.summary.requires_adaptation;
190            verdicts.incompatible += row.verdict.summary.incompatible;
191            verdicts.inapplicable += row.verdict.summary.inapplicable;
192
193            tracked = tracked.saturating_add(non_self_tracked(&row.verdict.results, self_projects));
194            for pin in &row.verdict.results {
195                if let Verdict::Inapplicable { reason } = &pin.verdict {
196                    reasons.record(reason.as_str());
197                }
198            }
199
200            for suite in &row.diagnostics.suggested_suites {
201                suites.insert(suite.clone());
202            }
203
204            if let Some(forecast) = &row.apply {
205                apply.rows_with_forecast += 1;
206                if forecast.has_conflict() {
207                    apply.conflicted_rows += 1;
208                }
209                for (path, outcome) in &forecast.paths {
210                    match outcome {
211                        PathApplyOutcome::Conflict { kind } => {
212                            conflicted_paths.insert(path.clone());
213                            *apply.by_kind.entry(kind.as_str()).or_insert(0) += 1;
214                        }
215                        PathApplyOutcome::Unassessed { .. } => apply.unassessed_paths += 1,
216                        PathApplyOutcome::CleanExact | PathApplyOutcome::CleanDrifted { .. } => {}
217                    }
218                }
219            }
220
221            if let Some(findings) = &row.target_findings {
222                target.rows_with_findings += 1;
223                if !findings.is_empty() {
224                    target.finding_rows += 1;
225                }
226                for (class, n) in findings.by_class() {
227                    *target_by_class.entry(class).or_insert(0) += n;
228                }
229                target.unclassified += findings.unclassified();
230            }
231
232            for bump in &row.diagnostics.pin_bumps {
233                if !seen_bumps.insert((&row.commit, &bump.dep, bump.to.as_str())) {
234                    continue;
235                }
236                bumps.total += 1;
237                if bump.from.is_none() {
238                    bumps.introduced += 1;
239                }
240                match &bump.status {
241                    Some(BumpStatus::SnapshotPresent) => bumps.snapshot_present += 1,
242                    Some(BumpStatus::SnapshotMissing { .. }) => {
243                        bumps.snapshot_missing += 1;
244                        snapshot_missing_bump = true;
245                    }
246                    Some(BumpStatus::Untracked) => bumps.untracked += 1,
247                    None => bumps.unassessed += 1,
248                }
249            }
250        }
251
252        apply.conflicted_paths = conflicted_paths.into_iter().collect();
253        target.by_class = target_by_class.into_iter().collect();
254
255        let blocking = verdicts.is_blocking();
256        // findings gate on non-empty: the same reasons on an
257        // applicable pin read at least requires-adaptation
258        let exit_code = if blocking || apply.has_conflict() || target.has_findings() {
259            exit::NEEDS_ATTENTION
260        } else {
261            exit::OK
262        };
263
264        let facts = ClearanceFacts {
265            candidates: commits.len(),
266            series: series.len(),
267            rows: results.len(),
268            tracked,
269            verdicts,
270            reasons,
271            bumps,
272            apply,
273            target,
274            suites: suites.into_iter().collect(),
275            exit_code,
276        };
277
278        let all_inapplicable = facts.verdicts.compatible == 0
279            && facts.verdicts.requires_adaptation == 0
280            && facts.verdicts.incompatible == 0
281            && facts.verdicts.inapplicable > 0;
282
283        if blocking
284            || tracked > 0
285            || snapshot_missing_bump
286            || facts.apply.has_conflict()
287            || facts.target.has_findings()
288        {
289            Self::Findings(facts)
290        } else if all_inapplicable {
291            Self::ZeroDomain(facts)
292        } else {
293            Self::Clean(facts)
294        }
295    }
296
297    #[must_use]
298    pub fn facts(&self) -> &ClearanceFacts {
299        match self {
300            Self::Clean(f) | Self::ZeroDomain(f) | Self::Findings(f) => f,
301        }
302    }
303
304    #[must_use]
305    pub fn is_clean(&self) -> bool {
306        matches!(self, Self::Clean(_) | Self::ZeroDomain(_))
307    }
308}