Skip to main content

devboy_core/agents/
score.rs

1//! Score formula and primary-selection logic.
2//!
3//! `score = 0.6 * freshness + 0.4 * volume`
4//! - `freshness = max(0, 1 - days_since_last_used / 14)` — decays to 0 at 14 days.
5//! - `volume    = min(1, log10(sessions + 1) / 3)` — saturates at 1000 sessions.
6//!
7//! `pick_primary` picks the top-scoring snapshot when one of:
8//! - **Recency dominance** — the top candidate was used at least
9//!   `RECENCY_DOMINANCE_HOURS` (4 h) more recently than the runner-up.
10//!   Just used Claude 5 seconds ago vs Copilot 2 days ago? Claude wins,
11//!   period — volume can't fight that.
12//! - **Score gap** — the top score is at least `PRIMARY_THRESHOLD` (1.5×)
13//!   the runner-up's. Used as fallback when both candidates are equally
14//!   fresh.
15//!
16//! Otherwise returns `None`, signalling that the caller should ask the user.
17
18use chrono::{DateTime, Utc};
19
20use super::AgentSnapshot;
21
22const FRESHNESS_DECAY_DAYS: f64 = 14.0;
23const VOLUME_SATURATION_LOG10: f64 = 3.0;
24const FRESHNESS_WEIGHT: f64 = 0.6;
25const VOLUME_WEIGHT: f64 = 0.4;
26const PRIMARY_THRESHOLD: f64 = 1.5;
27/// If the top candidate was used at least this many hours later than the
28/// runner-up, it wins regardless of score gap. ~half a workday — enough to
29/// imply "I switched to this one and stopped touching the other."
30const RECENCY_DOMINANCE_HOURS: i64 = 4;
31
32pub fn compute_score(
33    last_used: Option<DateTime<Utc>>,
34    sessions: Option<u64>,
35    now: DateTime<Utc>,
36) -> f64 {
37    let freshness = last_used
38        .map(|t| {
39            let days = (now - t).num_seconds() as f64 / 86_400.0;
40            // Clamp to [0, 1]: future timestamps (clock skew / odd mtimes)
41            // would otherwise produce >1.0; ancient timestamps go to 0.
42            (1.0 - days / FRESHNESS_DECAY_DAYS).clamp(0.0, 1.0)
43        })
44        .unwrap_or(0.0);
45
46    let volume = sessions
47        .map(|n| ((n as f64 + 1.0).log10() / VOLUME_SATURATION_LOG10).min(1.0))
48        .unwrap_or(0.0);
49
50    FRESHNESS_WEIGHT * freshness + VOLUME_WEIGHT * volume
51}
52
53/// Pick the primary candidate.
54///
55/// Two paths to "primary":
56/// 1. **Recency dominance**: top was used ≥ 4 h after the runner-up. Wins
57///    unconditionally — fresh activity beats accumulated volume.
58/// 2. **Score gap**: top's score is ≥ 1.5× the runner-up's. Used when both
59///    candidates were touched recently and the formula has to break ties on
60///    volume.
61///
62/// Returns `None` when neither path applies (caller should ask the user) or
63/// when no candidate has a positive score.
64pub fn pick_primary(snapshots: &[AgentSnapshot]) -> Option<&AgentSnapshot> {
65    let mut sorted: Vec<&AgentSnapshot> = snapshots.iter().filter(|s| s.score > 0.0).collect();
66    sorted.sort_by(|a, b| {
67        b.score
68            .partial_cmp(&a.score)
69            .unwrap_or(std::cmp::Ordering::Equal)
70    });
71
72    match sorted.as_slice() {
73        [] => None,
74        [only] => Some(*only),
75        [top, second, ..] => {
76            // Path 1: recency dominance.
77            if let (Some(t1), Some(t2)) = (top.last_used, second.last_used) {
78                let hours_apart = (t1 - t2).num_hours();
79                if hours_apart >= RECENCY_DOMINANCE_HOURS {
80                    return Some(*top);
81                }
82            }
83            // Path 2: score gap.
84            if second.score == 0.0 || top.score / second.score >= PRIMARY_THRESHOLD {
85                Some(*top)
86            } else {
87                None
88            }
89        }
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use chrono::TimeZone;
97
98    fn at(year: i32, month: u32, day: u32) -> DateTime<Utc> {
99        Utc.with_ymd_and_hms(year, month, day, 12, 0, 0).unwrap()
100    }
101
102    #[test]
103    fn never_used_no_sessions_zero() {
104        let now = at(2026, 5, 1);
105        assert_eq!(compute_score(None, None, now), 0.0);
106    }
107
108    #[test]
109    fn used_today_thousand_sessions_near_one() {
110        let now = at(2026, 5, 1);
111        let s = compute_score(Some(now), Some(1000), now);
112        assert!(s > 0.95 && s <= 1.0, "score = {s}");
113    }
114
115    #[test]
116    fn fourteen_days_old_zero_freshness_only_volume() {
117        let now = at(2026, 5, 1);
118        let s = compute_score(Some(at(2026, 4, 17)), Some(100), now);
119        // freshness ≈ 0, volume = log10(101)/3 ≈ 0.668, weighted * 0.4 ≈ 0.267
120        assert!(s > 0.25 && s < 0.30, "score = {s}");
121    }
122
123    #[test]
124    fn used_today_no_sessions_only_freshness() {
125        let now = at(2026, 5, 1);
126        let s = compute_score(Some(now), None, now);
127        assert!((s - 0.6).abs() < 1e-9, "score = {s}");
128    }
129
130    #[test]
131    fn one_session_today_partial_score() {
132        let now = at(2026, 5, 1);
133        let s = compute_score(Some(now), Some(1), now);
134        // freshness = 1 → 0.6, volume = log10(2)/3 ≈ 0.1004 → 0.0402, total ≈ 0.640
135        assert!(s > 0.63 && s < 0.65, "score = {s}");
136    }
137
138    #[test]
139    fn future_timestamp_clamped_to_freshness_one() {
140        let now = at(2026, 5, 1);
141        let s = compute_score(Some(at(2026, 5, 5)), Some(10), now);
142        // negative days → freshness clamped at... actually formula gives 1 - (negative)/14 > 1
143        // Specification says max(0, 1 - days/14), no upper clamp; that's fine — future dates
144        // are improbable but harmless.
145        assert!(s > 0.6, "score = {s}");
146    }
147
148    fn snap(id: &'static str, score: f64) -> AgentSnapshot {
149        snap_with_recency(id, score, None)
150    }
151
152    fn snap_with_recency(
153        id: &'static str,
154        score: f64,
155        last_used: Option<DateTime<Utc>>,
156    ) -> AgentSnapshot {
157        AgentSnapshot {
158            id,
159            display_name: id,
160            status: crate::agents::InstallStatus::Yes,
161            sessions: None,
162            last_used,
163            score,
164            paths_checked: vec![],
165        }
166    }
167
168    #[test]
169    fn primary_picks_top_when_gap_is_clear() {
170        let snaps = vec![
171            snap("claude", 0.95),
172            snap("codex", 0.20),
173            snap("gemini", 0.10),
174        ];
175        assert_eq!(pick_primary(&snaps).unwrap().id, "claude");
176    }
177
178    #[test]
179    fn primary_returns_none_when_top_two_are_close_and_both_fresh() {
180        let now = at(2026, 5, 1);
181        // Both used within an hour of each other → no recency dominance, gap < 1.5x.
182        let snaps = vec![
183            snap_with_recency("claude", 0.60, Some(now)),
184            snap_with_recency("copilot", 0.55, Some(now - chrono::Duration::minutes(30))),
185        ];
186        assert!(pick_primary(&snaps).is_none(), "should defer to user");
187    }
188
189    #[test]
190    fn primary_handles_single_candidate() {
191        let snaps = vec![snap("claude", 0.30)];
192        assert_eq!(pick_primary(&snaps).unwrap().id, "claude");
193    }
194
195    #[test]
196    fn primary_handles_empty_or_zero_scores() {
197        assert!(pick_primary(&[]).is_none());
198        assert!(pick_primary(&[snap("x", 0.0)]).is_none());
199    }
200
201    #[test]
202    fn recency_dominance_overrides_close_score_gap() {
203        // Real-world scenario: Claude used 1 second ago, Copilot used 41 hours
204        // ago. Volumes happen to be close (3243 vs 26 → log10 saturates), so
205        // the score gap is only 1.39× — under the 1.5× threshold. But the
206        // recency gap is 41 hours, way over 4 hours → Claude must win.
207        let now = at(2026, 5, 1);
208        let snaps = vec![
209            snap_with_recency("claude", 1.000, Some(now)),
210            snap_with_recency("copilot", 0.717, Some(now - chrono::Duration::hours(41))),
211        ];
212        assert_eq!(pick_primary(&snaps).unwrap().id, "claude");
213    }
214
215    #[test]
216    fn recency_dominance_does_not_fire_within_4h_window() {
217        // Both used within a few hours → fall back to score-gap rule.
218        let now = at(2026, 5, 1);
219        let snaps = vec![
220            snap_with_recency("claude", 0.60, Some(now)),
221            snap_with_recency("copilot", 0.50, Some(now - chrono::Duration::hours(2))),
222        ];
223        // 2h apart < 4h dominance threshold, score gap 0.60/0.50 = 1.2 < 1.5
224        // → no primary picked.
225        assert!(pick_primary(&snaps).is_none());
226    }
227
228    #[test]
229    fn recency_dominance_works_even_when_top_score_is_lower() {
230        // Edge case: a very fresh agent with low session count beats a
231        // higher-volume but stale agent. Score-sort puts the high-volume one
232        // first; recency dominance should override.
233        // Note: in practice this is rare because the score formula already
234        // weights freshness 0.6, but we want the rule to be robust.
235        let now = at(2026, 5, 1);
236        // Construct so that top by score is the stale one.
237        let snaps = vec![
238            snap_with_recency(
239                "stale_high_vol",
240                0.50,
241                Some(now - chrono::Duration::days(7)),
242            ),
243            snap_with_recency("fresh_low_vol", 0.45, Some(now)),
244        ];
245        // top by score = stale_high_vol (0.50). second = fresh_low_vol (0.45).
246        // top.last_used is *earlier* than second.last_used, so hours_apart is
247        // negative — dominance does NOT fire. Score gap 1.11× < 1.5× → None.
248        // This test documents the asymmetry: dominance is one-way (top must
249        // be more recent than second). This is intentional: "score-sorted top
250        // is more recent than runner-up" is the meaningful signal.
251        assert!(pick_primary(&snaps).is_none());
252    }
253}