Skip to main content

backhopper_core/model/
sibling_drift.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//! Types and the pure scorer behind `siblings doctor`.
6//!
7//! A candidate moves through a one-way type-state pipeline:
8//! `SiblingCandidate<Unscored>` carries raw diff facts, `score`
9//! converts it into `SiblingCandidate<Scored>`, and only the scored
10//! form serialises. The scorer itself is a pure function over
11//! `CandidateFeatures`, so the labeled corpus gate and the proptests
12//! run without a git repository.
13
14use std::cmp::Ordering;
15use std::fmt;
16use std::num::NonZeroU32;
17
18use serde::{Deserialize, Deserializer, Serialize};
19use time::OffsetDateTime;
20
21use crate::model::names::{CommitSha, GitRef, RelativePath, SeriesName, TagName};
22
23/// A clamped `[0.0, 1.0]` ranking score. Total-ordered via
24/// `f32::total_cmp` so candidates always sort; the constructor maps
25/// NaN to zero, so no surprising order exists to begin with.
26#[derive(Debug, Clone, Copy, Serialize)]
27#[cfg_attr(
28    feature = "schemars",
29    derive(schemars::JsonSchema),
30    schemars(transparent)
31)]
32pub struct Confidence(f32);
33
34impl Confidence {
35    #[must_use]
36    pub fn new(value: f32) -> Self {
37        if value.is_nan() {
38            return Self(0.0);
39        }
40        Self(value.clamp(0.0, 1.0))
41    }
42
43    #[must_use]
44    pub fn value(self) -> f32 {
45        self.0
46    }
47}
48
49impl PartialEq for Confidence {
50    fn eq(&self, other: &Self) -> bool {
51        self.0.total_cmp(&other.0) == Ordering::Equal
52    }
53}
54
55impl Eq for Confidence {}
56
57impl PartialOrd for Confidence {
58    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
59        Some(self.cmp(other))
60    }
61}
62
63impl Ord for Confidence {
64    fn cmp(&self, other: &Self) -> Ordering {
65        self.0.total_cmp(&other.0)
66    }
67}
68
69impl fmt::Display for Confidence {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        write!(f, "{:.2}", self.0)
72    }
73}
74
75// route wire input through the clamping constructor
76impl<'de> Deserialize<'de> for Confidence {
77    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
78        Ok(Self::new(f32::deserialize(deserializer)?))
79    }
80}
81
82/// Recommended operator action, computed from threshold bands here,
83/// never re-derived by consumers.
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
85#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
86#[serde(rename_all = "snake_case")]
87pub enum SiblingDriftAction {
88    Sweep,
89    InvestigateInPlace,
90    Ignore,
91}
92
93impl SiblingDriftAction {
94    #[must_use]
95    pub fn as_str(self) -> &'static str {
96        match self {
97            Self::Sweep => "sweep",
98            Self::InvestigateInPlace => "investigate_in_place",
99            Self::Ignore => "ignore",
100        }
101    }
102}
103
104/// How the since-point was derived. Travels on the wire so consumers
105/// can tell a defaulted window from an operator-chosen one.
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
108#[serde(tag = "kind", rename_all = "snake_case")]
109pub enum SinceDerivation {
110    LastReleaseTag { tag: TagName, sha: CommitSha },
111    ExplicitTag { tag: TagName, sha: CommitSha },
112    ExplicitSha { sha: CommitSha },
113}
114
115impl SinceDerivation {
116    #[must_use]
117    pub fn sha(&self) -> &CommitSha {
118        match self {
119            Self::LastReleaseTag { sha, .. }
120            | Self::ExplicitTag { sha, .. }
121            | Self::ExplicitSha { sha } => sha,
122        }
123    }
124}
125
126/// Where the effective vocabulary came from. `Empty` is emitted (with
127/// a warning) instead of silently producing a zero-candidate run.
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
129#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
130#[serde(rename_all = "snake_case")]
131pub enum VocabularySource {
132    FamilyDefault,
133    File,
134    Empty,
135}
136
137/// Compiled subject-prefilter vocabulary. Matching is case-insensitive
138/// and word-boundary anchored when a term starts with a word
139/// character, so `crash` hits `crashes` but not `scratch`; terms with
140/// a non-word leading character (`_SUITE`) match as plain substrings.
141#[derive(Debug, Clone)]
142pub struct Vocabulary {
143    terms: Vec<String>,
144    set: regex::RegexSet,
145}
146
147impl Vocabulary {
148    /// Compile `terms`, skipping blanks and case-insensitive
149    /// duplicates while preserving first-seen order.
150    pub fn compile(terms: &[String]) -> Result<Self, regex::Error> {
151        let mut kept: Vec<String> = Vec::with_capacity(terms.len());
152        for term in terms {
153            let trimmed = term.trim();
154            if trimmed.is_empty() {
155                continue;
156            }
157            if kept.iter().any(|k| k.eq_ignore_ascii_case(trimmed)) {
158                continue;
159            }
160            kept.push(trimmed.to_owned());
161        }
162        let patterns: Vec<String> = kept.iter().map(|t| term_pattern(t)).collect();
163        let set = regex::RegexSet::new(&patterns)?;
164        Ok(Self { terms: kept, set })
165    }
166
167    #[must_use]
168    pub fn is_empty(&self) -> bool {
169        self.terms.is_empty()
170    }
171
172    #[must_use]
173    pub fn len(&self) -> usize {
174        self.terms.len()
175    }
176
177    #[must_use]
178    pub fn terms(&self) -> &[String] {
179        &self.terms
180    }
181
182    /// Count of distinct vocabulary terms the subject matches. Raw
183    /// hit counts are deliberately not used: a keyword-stuffed
184    /// subject must not outrank a real fix.
185    #[must_use]
186    pub fn distinct_matches(&self, subject: &str) -> u32 {
187        u32::try_from(self.set.matches(subject).iter().count()).unwrap_or(u32::MAX)
188    }
189}
190
191fn term_pattern(term: &str) -> String {
192    let escaped = regex::escape(term);
193    let starts_with_word = term
194        .chars()
195        .next()
196        .is_some_and(|c| c.is_ascii_alphanumeric());
197    if starts_with_word {
198        format!("(?i)\\b{escaped}")
199    } else {
200        format!("(?i){escaped}")
201    }
202}
203
204/// The scorer's full input: everything extracted from one walked
205/// commit. Serialisable so the labeled corpus stores features, not
206/// SHAs, and the F1 gate runs hermetically in CI.
207#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
208#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
209pub struct CandidateFeatures {
210    pub subject: String,
211    pub touched_paths: Vec<RelativePath>,
212    pub lines_added: u32,
213    pub lines_removed: u32,
214    pub parent_count: NonZeroU32,
215}
216
217/// Scoring knobs. Calibrated together with `ActionThresholds` against
218/// the labeled corpus; changes ship as releases, and the crate version
219/// is a cache freshness key, so a stale cache cannot serve old scores.
220#[derive(Debug, Clone, PartialEq)]
221pub struct ScoreWeights {
222    /// Total changed lines at which the size factor halves.
223    pub line_half_life: f32,
224    /// Vocabulary factor for exactly one distinct term matched.
225    pub vocabulary_single: f32,
226    /// Vocabulary factor for exactly two distinct terms matched.
227    pub vocabulary_double: f32,
228    /// Test-path factor floor: applied when no touched path looks
229    /// test-related; the factor climbs linearly to 1.0 as the
230    /// test-path share grows.
231    pub test_path_floor: f32,
232}
233
234impl Default for ScoreWeights {
235    fn default() -> Self {
236        Self {
237            line_half_life: 200.0,
238            vocabulary_single: 0.7,
239            vocabulary_double: 0.9,
240            test_path_floor: 0.5,
241        }
242    }
243}
244
245/// Confidence bands for the recommended action. Half-open:
246/// `confidence >= sweep_min` is `Sweep`, then
247/// `confidence >= investigate_min` is `InvestigateInPlace`, the rest
248/// is `Ignore`.
249#[derive(Debug, Clone, PartialEq, Eq)]
250pub struct ActionThresholds {
251    pub sweep_min: Confidence,
252    pub investigate_min: Confidence,
253}
254
255impl Default for ActionThresholds {
256    fn default() -> Self {
257        Self {
258            sweep_min: Confidence::new(0.75),
259            investigate_min: Confidence::new(0.40),
260        }
261    }
262}
263
264impl ActionThresholds {
265    #[must_use]
266    pub fn action_for(&self, confidence: Confidence) -> SiblingDriftAction {
267        if confidence >= self.sweep_min {
268            SiblingDriftAction::Sweep
269        } else if confidence >= self.investigate_min {
270            SiblingDriftAction::InvestigateInPlace
271        } else {
272            SiblingDriftAction::Ignore
273        }
274    }
275}
276
277/// Per-factor breakdown serialised under `--explain` for operator
278/// trust and corpus tuning.
279#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
280#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
281pub struct ScoreComponents {
282    pub line_count_factor: f32,
283    pub vocabulary_terms_matched: u32,
284    pub test_path_factor: f32,
285}
286
287/// What the pure scorer returns: the clamped confidence plus the
288/// factor breakdown it was computed from.
289#[derive(Debug, Clone, PartialEq)]
290pub struct ScoreOutcome {
291    pub confidence: Confidence,
292    pub components: ScoreComponents,
293}
294
295/// Score one candidate's features. Pure: same inputs, same score.
296/// Age is deliberately not a factor; the oldest unswept fix is the
297/// most overdue, not the least relevant.
298#[must_use]
299pub fn score_features(
300    features: &CandidateFeatures,
301    vocabulary: &Vocabulary,
302    weights: &ScoreWeights,
303) -> ScoreOutcome {
304    let total_lines = features.lines_added.saturating_add(features.lines_removed);
305    let line_count_factor = line_count_factor(total_lines, weights);
306    let matched = vocabulary.distinct_matches(&features.subject);
307    let vocabulary_factor = vocabulary_factor(matched, weights);
308    let test_path_factor = test_path_factor(&features.touched_paths, weights);
309    let confidence = Confidence::new(line_count_factor * vocabulary_factor * test_path_factor);
310    ScoreOutcome {
311        confidence,
312        components: ScoreComponents {
313            line_count_factor,
314            vocabulary_terms_matched: matched,
315            test_path_factor,
316        },
317    }
318}
319
320// smooth decay: 1.0 at zero lines, 0.5 at the half-life
321fn line_count_factor(total_lines: u32, weights: &ScoreWeights) -> f32 {
322    let half_life = weights.line_half_life.max(1.0);
323    1.0 / (1.0 + (total_lines as f32) / half_life)
324}
325
326fn vocabulary_factor(distinct_matches: u32, weights: &ScoreWeights) -> f32 {
327    match distinct_matches {
328        0 => 0.0,
329        1 => weights.vocabulary_single,
330        2 => weights.vocabulary_double,
331        _ => 1.0,
332    }
333}
334
335fn test_path_factor(paths: &[RelativePath], weights: &ScoreWeights) -> f32 {
336    let floor = weights.test_path_floor.clamp(0.0, 1.0);
337    if paths.is_empty() {
338        return floor;
339    }
340    let test_count = paths.iter().filter(|p| is_test_path(p)).count();
341    let share = (test_count as f32) / (paths.len() as f32);
342    floor + (1.0 - floor) * share
343}
344
345/// True for paths that look test-related: a `test`/`tests` directory
346/// component, a Common Test suite file, or a CT helper application.
347#[must_use]
348pub fn is_test_path(path: &RelativePath) -> bool {
349    let s = path.as_str();
350    let file_is_suite = s
351        .rsplit('/')
352        .next()
353        .is_some_and(|name| name.contains("_SUITE"));
354    if file_is_suite {
355        return true;
356    }
357    s.split('/')
358        .any(|c| c == "test" || c == "tests" || c.contains("ct_helpers"))
359}
360
361mod sealed {
362    pub trait Sealed {}
363}
364
365/// Marker trait for the candidate pipeline states. Sealed: the only
366/// states are `Unscored` and `Scored`.
367pub trait ScoreState: sealed::Sealed {}
368
369/// Pre-scoring state: raw diff size facts, no confidence yet. Not
370/// serialisable, so an unscored candidate can never reach a wire
371/// formatter.
372#[derive(Debug, Clone, PartialEq, Eq)]
373pub struct Unscored {
374    pub lines_added: u32,
375    pub lines_removed: u32,
376}
377
378/// Post-scoring state: the wire fields every consumer reads.
379#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
380#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
381pub struct Scored {
382    pub confidence: Confidence,
383    pub action: SiblingDriftAction,
384    /// Present in memory after every scoring pass; the CLI strips it
385    /// from output unless `--explain` was given.
386    #[serde(default, skip_serializing_if = "Option::is_none")]
387    pub score_components: Option<ScoreComponents>,
388}
389
390impl sealed::Sealed for Unscored {}
391impl ScoreState for Unscored {}
392impl sealed::Sealed for Scored {}
393impl ScoreState for Scored {}
394
395/// One sibling-branch commit moving through the doctor pipeline.
396/// `state` is flattened on the wire, so a scored candidate serialises
397/// with `confidence` and `action` as plain row fields.
398#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
399#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
400pub struct SiblingCandidate<S: ScoreState> {
401    pub sha: CommitSha,
402    pub subject: String,
403    /// Committer timestamp: when the commit landed on the source
404    /// branch, which is what "how overdue is this sweep" measures.
405    #[cfg_attr(feature = "schemars", schemars(with = "String"))]
406    #[serde(with = "time::serde::rfc3339")]
407    pub committed_at: OffsetDateTime,
408    /// Whole days between `committed_at` and the run; recomputed on
409    /// cache hits so a cached row never reports a stale age.
410    pub age_days: u32,
411    pub touched_paths: Vec<RelativePath>,
412    pub parent_count: NonZeroU32,
413    #[serde(flatten)]
414    pub state: S,
415}
416
417impl SiblingCandidate<Unscored> {
418    /// Consume the unscored candidate and produce the scored form.
419    /// The only way to obtain `SiblingCandidate<Scored>` outside
420    /// deserialisation.
421    #[must_use]
422    pub fn score(
423        self,
424        vocabulary: &Vocabulary,
425        weights: &ScoreWeights,
426        thresholds: &ActionThresholds,
427    ) -> SiblingCandidate<Scored> {
428        let features = CandidateFeatures {
429            subject: self.subject,
430            touched_paths: self.touched_paths,
431            lines_added: self.state.lines_added,
432            lines_removed: self.state.lines_removed,
433            parent_count: self.parent_count,
434        };
435        let outcome = score_features(&features, vocabulary, weights);
436        SiblingCandidate {
437            sha: self.sha,
438            subject: features.subject,
439            committed_at: self.committed_at,
440            age_days: self.age_days,
441            touched_paths: features.touched_paths,
442            parent_count: self.parent_count,
443            state: Scored {
444                confidence: outcome.confidence,
445                action: thresholds.action_for(outcome.confidence),
446                score_components: Some(outcome.components),
447            },
448        }
449    }
450}
451
452impl SiblingCandidate<Scored> {
453    #[must_use]
454    pub fn confidence(&self) -> Confidence {
455        self.state.confidence
456    }
457
458    #[must_use]
459    pub fn action(&self) -> SiblingDriftAction {
460        self.state.action
461    }
462
463    /// Drop the per-factor breakdown (the non-`--explain` shape).
464    #[must_use]
465    pub fn without_components(mut self) -> Self {
466        self.state.score_components = None;
467        self
468    }
469}
470
471/// The `siblings doctor` envelope payload.
472#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
473#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
474pub struct SiblingDoctorReport {
475    pub series: SeriesName,
476    pub target_branch: GitRef,
477    pub source_branches: Vec<GitRef>,
478    pub since: SinceDerivation,
479    pub vocabulary_source: VocabularySource,
480    /// Candidates dropped because they already cascaded to the
481    /// target (`-x` trailer or patch-id match): the visible proof
482    /// the suppression filter ran.
483    pub suppressed_count: u32,
484    /// First-parent commits walked across every source branch.
485    pub walked_count: u32,
486    /// Ranked best-first; truncated to `--top`.
487    pub candidates: Vec<SiblingCandidate<Scored>>,
488}