backhopper-core 0.30.0

Core types, snapshot I/O, store, and compatibility analysis for backhopper
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
// Copyright (C) 2026 Michael S. Klishin and Contributors
// SPDX-License-Identifier: Apache-2.0 OR MIT
// See LICENSE-APACHE and LICENSE-MIT for details.

//! Types and the pure scorer behind `siblings doctor`.
//!
//! A candidate moves through a one-way type-state pipeline:
//! `SiblingCandidate<Unscored>` carries raw diff facts, `score`
//! converts it into `SiblingCandidate<Scored>`, and only the scored
//! form serialises. The scorer itself is a pure function over
//! `CandidateFeatures`, so the labeled corpus gate and the proptests
//! run without a git repository.

use std::cmp::Ordering;
use std::fmt;
use std::num::NonZeroU32;

use serde::{Deserialize, Deserializer, Serialize};
use time::OffsetDateTime;

use crate::model::names::{CommitSha, GitRef, RelativePath, SeriesName, TagName};

/// A clamped `[0.0, 1.0]` ranking score. Total-ordered via
/// `f32::total_cmp` so candidates always sort; the constructor maps
/// NaN to zero.
#[derive(Debug, Clone, Copy, Serialize)]
#[cfg_attr(
    feature = "schemars",
    derive(schemars::JsonSchema),
    schemars(transparent)
)]
pub struct Confidence(f32);

impl Confidence {
    #[must_use]
    pub fn new(value: f32) -> Self {
        if value.is_nan() {
            return Self(0.0);
        }
        Self(value.clamp(0.0, 1.0))
    }

    #[must_use]
    pub fn value(self) -> f32 {
        self.0
    }
}

impl PartialEq for Confidence {
    fn eq(&self, other: &Self) -> bool {
        self.0.total_cmp(&other.0) == Ordering::Equal
    }
}

impl Eq for Confidence {}

impl PartialOrd for Confidence {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
        Some(self.cmp(other))
    }
}

impl Ord for Confidence {
    fn cmp(&self, other: &Self) -> Ordering {
        self.0.total_cmp(&other.0)
    }
}

impl fmt::Display for Confidence {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{:.2}", self.0)
    }
}

// route wire input through the clamping constructor
impl<'de> Deserialize<'de> for Confidence {
    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
        Ok(Self::new(f32::deserialize(deserializer)?))
    }
}

/// Recommended operator action, computed from threshold bands here,
/// never re-derived by consumers.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum SiblingDriftAction {
    Sweep,
    InvestigateInPlace,
    Ignore,
}

impl SiblingDriftAction {
    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Sweep => "sweep",
            Self::InvestigateInPlace => "investigate_in_place",
            Self::Ignore => "ignore",
        }
    }
}

/// How the since-point was derived. Serialized on the wire so consumers
/// can tell a defaulted window from an operator-chosen one.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum SinceDerivation {
    LastReleaseTag { tag: TagName, sha: CommitSha },
    ExplicitTag { tag: TagName, sha: CommitSha },
    ExplicitSha { sha: CommitSha },
}

impl SinceDerivation {
    #[must_use]
    pub fn sha(&self) -> &CommitSha {
        match self {
            Self::LastReleaseTag { sha, .. }
            | Self::ExplicitTag { sha, .. }
            | Self::ExplicitSha { sha } => sha,
        }
    }
}

/// Where the effective vocabulary came from. `Empty` is emitted (with
/// a warning) instead of silently producing a zero-candidate run.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum VocabularySource {
    FamilyDefault,
    File,
    Empty,
}

/// Compiled subject-prefilter vocabulary. Matching is case-insensitive
/// and word-boundary anchored when a term starts with a word
/// character, so `crash` hits `crashes` but not `scratch`; terms with
/// a non-word leading character (`_SUITE`) match as plain substrings.
#[derive(Debug, Clone)]
pub struct Vocabulary {
    terms: Vec<String>,
    set: regex::RegexSet,
}

impl Vocabulary {
    /// Compile `terms`, skipping blanks and case-insensitive
    /// duplicates while preserving first-seen order.
    pub fn compile(terms: &[String]) -> Result<Self, regex::Error> {
        let mut kept: Vec<String> = Vec::with_capacity(terms.len());
        for term in terms {
            let trimmed = term.trim();
            if trimmed.is_empty() {
                continue;
            }
            if kept.iter().any(|k| k.eq_ignore_ascii_case(trimmed)) {
                continue;
            }
            kept.push(trimmed.to_owned());
        }
        let patterns: Vec<String> = kept.iter().map(|t| term_pattern(t)).collect();
        let set = regex::RegexSet::new(&patterns)?;
        Ok(Self { terms: kept, set })
    }

    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.terms.is_empty()
    }

    #[must_use]
    pub fn len(&self) -> usize {
        self.terms.len()
    }

    #[must_use]
    pub fn terms(&self) -> &[String] {
        &self.terms
    }

    /// Count of distinct vocabulary terms the subject matches. Raw
    /// hit counts are deliberately not used: a keyword-stuffed
    /// subject must not outrank a real fix.
    #[must_use]
    pub fn distinct_matches(&self, subject: &str) -> u32 {
        u32::try_from(self.set.matches(subject).iter().count()).unwrap_or(u32::MAX)
    }
}

fn term_pattern(term: &str) -> String {
    let escaped = regex::escape(term);
    let starts_with_word = term
        .chars()
        .next()
        .is_some_and(|c| c.is_ascii_alphanumeric());
    if starts_with_word {
        format!("(?i)\\b{escaped}")
    } else {
        format!("(?i){escaped}")
    }
}

/// The scorer's full input: everything extracted from one walked
/// commit. Serialisable so the labeled corpus stores features, not
/// SHAs, and the F1 gate runs hermetically in CI.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct CandidateFeatures {
    pub subject: String,
    pub touched_paths: Vec<RelativePath>,
    pub lines_added: u32,
    pub lines_removed: u32,
    pub parent_count: NonZeroU32,
}

/// Scoring knobs. Calibrated together with `ActionThresholds` against
/// the labeled corpus; changes ship as releases, and the crate version
/// is a cache freshness key, so a stale cache cannot serve old scores.
#[derive(Debug, Clone, PartialEq)]
pub struct ScoreWeights {
    /// Total changed lines at which the size factor halves.
    pub line_half_life: f32,
    /// Vocabulary factor for exactly one distinct term matched.
    pub vocabulary_single: f32,
    /// Vocabulary factor for exactly two distinct terms matched.
    pub vocabulary_double: f32,
    /// Test-path factor floor: applied when no touched path looks
    /// test-related; the factor climbs linearly to 1.0 as the
    /// test-path share grows.
    pub test_path_floor: f32,
}

impl Default for ScoreWeights {
    fn default() -> Self {
        Self {
            line_half_life: 200.0,
            vocabulary_single: 0.7,
            vocabulary_double: 0.9,
            test_path_floor: 0.5,
        }
    }
}

/// Confidence bands for the recommended action. Half-open:
/// `confidence >= sweep_min` is `Sweep`, then
/// `confidence >= investigate_min` is `InvestigateInPlace`, the rest
/// is `Ignore`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActionThresholds {
    pub sweep_min: Confidence,
    pub investigate_min: Confidence,
}

impl Default for ActionThresholds {
    fn default() -> Self {
        Self {
            sweep_min: Confidence::new(0.75),
            investigate_min: Confidence::new(0.40),
        }
    }
}

impl ActionThresholds {
    #[must_use]
    pub fn action_for(&self, confidence: Confidence) -> SiblingDriftAction {
        if confidence >= self.sweep_min {
            SiblingDriftAction::Sweep
        } else if confidence >= self.investigate_min {
            SiblingDriftAction::InvestigateInPlace
        } else {
            SiblingDriftAction::Ignore
        }
    }
}

/// Per-factor breakdown serialised under `--explain` for operator
/// trust and corpus tuning.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct ScoreComponents {
    pub line_count_factor: f32,
    pub vocabulary_terms_matched: u32,
    pub test_path_factor: f32,
}

/// What the pure scorer returns: the clamped confidence plus the
/// factor breakdown it was computed from.
#[derive(Debug, Clone, PartialEq)]
pub struct ScoreOutcome {
    pub confidence: Confidence,
    pub components: ScoreComponents,
}

/// Score one candidate's features. Pure: same inputs, same score.
/// Age is deliberately not a factor; the oldest unswept fix is the
/// most overdue, not the least relevant.
#[must_use]
pub fn score_features(
    features: &CandidateFeatures,
    vocabulary: &Vocabulary,
    weights: &ScoreWeights,
) -> ScoreOutcome {
    let total_lines = features.lines_added.saturating_add(features.lines_removed);
    let line_count_factor = line_count_factor(total_lines, weights);
    let matched = vocabulary.distinct_matches(&features.subject);
    let vocabulary_factor = vocabulary_factor(matched, weights);
    let test_path_factor = test_path_factor(&features.touched_paths, weights);
    let confidence = Confidence::new(line_count_factor * vocabulary_factor * test_path_factor);
    ScoreOutcome {
        confidence,
        components: ScoreComponents {
            line_count_factor,
            vocabulary_terms_matched: matched,
            test_path_factor,
        },
    }
}

// smooth decay: 1.0 at zero lines, 0.5 at the half-life
fn line_count_factor(total_lines: u32, weights: &ScoreWeights) -> f32 {
    let half_life = weights.line_half_life.max(1.0);
    1.0 / (1.0 + (total_lines as f32) / half_life)
}

fn vocabulary_factor(distinct_matches: u32, weights: &ScoreWeights) -> f32 {
    match distinct_matches {
        0 => 0.0,
        1 => weights.vocabulary_single,
        2 => weights.vocabulary_double,
        _ => 1.0,
    }
}

fn test_path_factor(paths: &[RelativePath], weights: &ScoreWeights) -> f32 {
    let floor = weights.test_path_floor.clamp(0.0, 1.0);
    if paths.is_empty() {
        return floor;
    }
    let test_count = paths.iter().filter(|p| is_test_path(p)).count();
    let share = (test_count as f32) / (paths.len() as f32);
    floor + (1.0 - floor) * share
}

/// True for paths that look test-related: a `test`/`tests` directory
/// component, a Common Test suite file, or a CT helper application.
#[must_use]
pub fn is_test_path(path: &RelativePath) -> bool {
    let s = path.as_str();
    let file_is_suite = s
        .rsplit('/')
        .next()
        .is_some_and(|name| name.contains("_SUITE"));
    if file_is_suite {
        return true;
    }
    s.split('/')
        .any(|c| c == "test" || c == "tests" || c.contains("ct_helpers"))
}

mod sealed {
    pub trait Sealed {}
}

/// Marker trait for the candidate pipeline states. Sealed: the only
/// states are `Unscored` and `Scored`.
pub trait ScoreState: sealed::Sealed {}

/// Pre-scoring state: raw diff size facts, no confidence yet. Not
/// serialisable, so an unscored candidate can never reach a wire
/// formatter.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Unscored {
    pub lines_added: u32,
    pub lines_removed: u32,
}

/// Post-scoring state: the wire fields every consumer reads.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct Scored {
    pub confidence: Confidence,
    pub action: SiblingDriftAction,
    /// Present in memory after every scoring pass; the CLI strips it
    /// from output unless `--explain` was given.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub score_components: Option<ScoreComponents>,
}

impl sealed::Sealed for Unscored {}
impl ScoreState for Unscored {}
impl sealed::Sealed for Scored {}
impl ScoreState for Scored {}

/// One sibling-branch commit moving through the doctor pipeline.
/// `state` is flattened on the wire, so a scored candidate serialises
/// with `confidence` and `action` as plain row fields.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct SiblingCandidate<S: ScoreState> {
    pub sha: CommitSha,
    pub subject: String,
    /// Committer timestamp: when the commit landed on the source
    /// branch, which is what "how overdue is this sweep" measures.
    #[cfg_attr(feature = "schemars", schemars(with = "String"))]
    #[serde(with = "time::serde::rfc3339")]
    pub committed_at: OffsetDateTime,
    /// Whole days between `committed_at` and the run; recomputed on
    /// cache hits so a cached row never reports a stale age.
    pub age_days: u32,
    pub touched_paths: Vec<RelativePath>,
    pub parent_count: NonZeroU32,
    #[serde(flatten)]
    pub state: S,
}

impl SiblingCandidate<Unscored> {
    /// Consume the unscored candidate and produce the scored form.
    /// The only way to obtain `SiblingCandidate<Scored>` outside
    /// deserialisation.
    #[must_use]
    pub fn score(
        self,
        vocabulary: &Vocabulary,
        weights: &ScoreWeights,
        thresholds: &ActionThresholds,
    ) -> SiblingCandidate<Scored> {
        let features = CandidateFeatures {
            subject: self.subject,
            touched_paths: self.touched_paths,
            lines_added: self.state.lines_added,
            lines_removed: self.state.lines_removed,
            parent_count: self.parent_count,
        };
        let outcome = score_features(&features, vocabulary, weights);
        SiblingCandidate {
            sha: self.sha,
            subject: features.subject,
            committed_at: self.committed_at,
            age_days: self.age_days,
            touched_paths: features.touched_paths,
            parent_count: self.parent_count,
            state: Scored {
                confidence: outcome.confidence,
                action: thresholds.action_for(outcome.confidence),
                score_components: Some(outcome.components),
            },
        }
    }
}

impl SiblingCandidate<Scored> {
    #[must_use]
    pub fn confidence(&self) -> Confidence {
        self.state.confidence
    }

    #[must_use]
    pub fn action(&self) -> SiblingDriftAction {
        self.state.action
    }

    /// Drop the per-factor breakdown (the non-`--explain` shape).
    #[must_use]
    pub fn without_components(mut self) -> Self {
        self.state.score_components = None;
        self
    }
}

/// The `siblings doctor` envelope payload.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct SiblingDoctorReport {
    pub series: SeriesName,
    pub target_branch: GitRef,
    pub source_branches: Vec<GitRef>,
    pub since: SinceDerivation,
    pub vocabulary_source: VocabularySource,
    /// Candidates dropped because they already cascaded to the
    /// target (`-x` trailer or patch-id match): the visible proof
    /// the suppression filter ran.
    pub suppressed_count: u32,
    /// First-parent commits walked across every source branch.
    pub walked_count: u32,
    /// Ranked best-first; truncated to `--top`.
    pub candidates: Vec<SiblingCandidate<Scored>>,
}