1use 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#[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
75impl<'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#[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#[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#[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#[derive(Debug, Clone)]
142pub struct Vocabulary {
143 terms: Vec<String>,
144 set: regex::RegexSet,
145}
146
147impl Vocabulary {
148 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 #[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#[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#[derive(Debug, Clone, PartialEq)]
221pub struct ScoreWeights {
222 pub line_half_life: f32,
224 pub vocabulary_single: f32,
226 pub vocabulary_double: f32,
228 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#[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#[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#[derive(Debug, Clone, PartialEq)]
290pub struct ScoreOutcome {
291 pub confidence: Confidence,
292 pub components: ScoreComponents,
293}
294
295#[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
320fn 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#[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
365pub trait ScoreState: sealed::Sealed {}
368
369#[derive(Debug, Clone, PartialEq, Eq)]
373pub struct Unscored {
374 pub lines_added: u32,
375 pub lines_removed: u32,
376}
377
378#[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 #[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#[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 #[cfg_attr(feature = "schemars", schemars(with = "String"))]
406 #[serde(with = "time::serde::rfc3339")]
407 pub committed_at: OffsetDateTime,
408 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 #[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 #[must_use]
465 pub fn without_components(mut self) -> Self {
466 self.state.score_components = None;
467 self
468 }
469}
470
471#[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 pub suppressed_count: u32,
484 pub walked_count: u32,
486 pub candidates: Vec<SiblingCandidate<Scored>>,
488}