use chrono::{DateTime, Utc};
use super::AgentSnapshot;
const FRESHNESS_DECAY_DAYS: f64 = 14.0;
const VOLUME_SATURATION_LOG10: f64 = 3.0;
const FRESHNESS_WEIGHT: f64 = 0.6;
const VOLUME_WEIGHT: f64 = 0.4;
const PRIMARY_THRESHOLD: f64 = 1.5;
const RECENCY_DOMINANCE_HOURS: i64 = 4;
pub fn compute_score(
last_used: Option<DateTime<Utc>>,
sessions: Option<u64>,
now: DateTime<Utc>,
) -> f64 {
let freshness = last_used
.map(|t| {
let days = (now - t).num_seconds() as f64 / 86_400.0;
(1.0 - days / FRESHNESS_DECAY_DAYS).clamp(0.0, 1.0)
})
.unwrap_or(0.0);
let volume = sessions
.map(|n| ((n as f64 + 1.0).log10() / VOLUME_SATURATION_LOG10).min(1.0))
.unwrap_or(0.0);
FRESHNESS_WEIGHT * freshness + VOLUME_WEIGHT * volume
}
pub fn pick_primary(snapshots: &[AgentSnapshot]) -> Option<&AgentSnapshot> {
let mut sorted: Vec<&AgentSnapshot> = snapshots.iter().filter(|s| s.score > 0.0).collect();
sorted.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
match sorted.as_slice() {
[] => None,
[only] => Some(*only),
[top, second, ..] => {
if let (Some(t1), Some(t2)) = (top.last_used, second.last_used) {
let hours_apart = (t1 - t2).num_hours();
if hours_apart >= RECENCY_DOMINANCE_HOURS {
return Some(*top);
}
}
if second.score == 0.0 || top.score / second.score >= PRIMARY_THRESHOLD {
Some(*top)
} else {
None
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn at(year: i32, month: u32, day: u32) -> DateTime<Utc> {
Utc.with_ymd_and_hms(year, month, day, 12, 0, 0).unwrap()
}
#[test]
fn never_used_no_sessions_zero() {
let now = at(2026, 5, 1);
assert_eq!(compute_score(None, None, now), 0.0);
}
#[test]
fn used_today_thousand_sessions_near_one() {
let now = at(2026, 5, 1);
let s = compute_score(Some(now), Some(1000), now);
assert!(s > 0.95 && s <= 1.0, "score = {s}");
}
#[test]
fn fourteen_days_old_zero_freshness_only_volume() {
let now = at(2026, 5, 1);
let s = compute_score(Some(at(2026, 4, 17)), Some(100), now);
assert!(s > 0.25 && s < 0.30, "score = {s}");
}
#[test]
fn used_today_no_sessions_only_freshness() {
let now = at(2026, 5, 1);
let s = compute_score(Some(now), None, now);
assert!((s - 0.6).abs() < 1e-9, "score = {s}");
}
#[test]
fn one_session_today_partial_score() {
let now = at(2026, 5, 1);
let s = compute_score(Some(now), Some(1), now);
assert!(s > 0.63 && s < 0.65, "score = {s}");
}
#[test]
fn future_timestamp_clamped_to_freshness_one() {
let now = at(2026, 5, 1);
let s = compute_score(Some(at(2026, 5, 5)), Some(10), now);
assert!(s > 0.6, "score = {s}");
}
fn snap(id: &'static str, score: f64) -> AgentSnapshot {
snap_with_recency(id, score, None)
}
fn snap_with_recency(
id: &'static str,
score: f64,
last_used: Option<DateTime<Utc>>,
) -> AgentSnapshot {
AgentSnapshot {
id,
display_name: id,
status: crate::agents::InstallStatus::Yes,
sessions: None,
last_used,
score,
paths_checked: vec![],
}
}
#[test]
fn primary_picks_top_when_gap_is_clear() {
let snaps = vec![
snap("claude", 0.95),
snap("codex", 0.20),
snap("gemini", 0.10),
];
assert_eq!(pick_primary(&snaps).unwrap().id, "claude");
}
#[test]
fn primary_returns_none_when_top_two_are_close_and_both_fresh() {
let now = at(2026, 5, 1);
let snaps = vec![
snap_with_recency("claude", 0.60, Some(now)),
snap_with_recency("copilot", 0.55, Some(now - chrono::Duration::minutes(30))),
];
assert!(pick_primary(&snaps).is_none(), "should defer to user");
}
#[test]
fn primary_handles_single_candidate() {
let snaps = vec![snap("claude", 0.30)];
assert_eq!(pick_primary(&snaps).unwrap().id, "claude");
}
#[test]
fn primary_handles_empty_or_zero_scores() {
assert!(pick_primary(&[]).is_none());
assert!(pick_primary(&[snap("x", 0.0)]).is_none());
}
#[test]
fn recency_dominance_overrides_close_score_gap() {
let now = at(2026, 5, 1);
let snaps = vec![
snap_with_recency("claude", 1.000, Some(now)),
snap_with_recency("copilot", 0.717, Some(now - chrono::Duration::hours(41))),
];
assert_eq!(pick_primary(&snaps).unwrap().id, "claude");
}
#[test]
fn recency_dominance_does_not_fire_within_4h_window() {
let now = at(2026, 5, 1);
let snaps = vec![
snap_with_recency("claude", 0.60, Some(now)),
snap_with_recency("copilot", 0.50, Some(now - chrono::Duration::hours(2))),
];
assert!(pick_primary(&snaps).is_none());
}
#[test]
fn recency_dominance_works_even_when_top_score_is_lower() {
let now = at(2026, 5, 1);
let snaps = vec![
snap_with_recency(
"stale_high_vol",
0.50,
Some(now - chrono::Duration::days(7)),
),
snap_with_recency("fresh_low_vol", 0.45, Some(now)),
];
assert!(pick_primary(&snaps).is_none());
}
}