1use 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;
27const 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 (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
53pub 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 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 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 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 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 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 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 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 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 assert!(pick_primary(&snaps).is_none());
226 }
227
228 #[test]
229 fn recency_dominance_works_even_when_top_score_is_lower() {
230 let now = at(2026, 5, 1);
236 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 assert!(pick_primary(&snaps).is_none());
252 }
253}