use crate::slots::SlotRecord;
use std::path::{Path, PathBuf};
#[derive(Default)]
pub struct Chooser {
last_pointer: Option<PathBuf>,
seen_once: bool,
}
impl Chooser {
pub fn choose(
&mut self,
pointer: Option<&Path>,
rotated: Option<&str>,
slots: &[SlotRecord],
) -> Option<SlotRecord> {
let now = pointer.map(Path::to_path_buf);
let changed = self.seen_once && now != self.last_pointer;
self.last_pointer = now;
self.seen_once = true;
let by_pointer = pointer.and_then(|p| slots.iter().find(|r| r.config_dir == p));
if changed {
if let Some(r) = by_pointer {
return Some(r.clone());
}
}
if let Some(name) = rotated {
if let Some(r) = slots.iter().find(|r| r.name == name) {
return Some(r.clone());
}
}
by_pointer.or_else(|| slots.first()).cloned()
}
}
pub fn over_threshold(five_h: Option<f64>, seven_d: Option<f64>, threshold: f64) -> bool {
let limit = (threshold * 100.0).clamp(0.0, 100.0);
[five_h, seven_d].into_iter().flatten().any(|p| p >= limit)
}
pub fn over_threshold_with(
five_h: Option<f64>,
seven_d: Option<f64>,
threshold: f64,
_credits_available: bool,
) -> bool {
over_threshold(five_h, seven_d, threshold)
}
pub fn headroom(five_h: Option<f64>, seven_d: Option<f64>) -> Option<f64> {
let worst = [five_h, seven_d]
.into_iter()
.flatten()
.fold(f64::NAN, f64::max);
worst.is_finite().then(|| (100.0 - worst).clamp(0.0, 100.0))
}
pub fn by_headroom<'a, T>(
items: &mut [T],
rank: impl Fn(&T) -> usize,
room: impl Fn(&T) -> Option<f64>,
) where
T: 'a,
{
items.sort_by(|a, b| {
rank(a).cmp(&rank(b)).then_with(|| {
match (room(a), room(b)) {
(Some(x), Some(y)) => y.total_cmp(&x), (Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
}
})
});
}
pub fn rotate_target(
current: &str,
slots: &[SlotRecord],
state: &std::collections::HashMap<String, crate::proxy::ratelimit::Quota>,
) -> Option<String> {
slots
.iter()
.filter(|r| r.name != current)
.find(|r| !state.get(&r.name).is_some_and(|q| q.rejected))
.map(|r| r.name.clone())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn slot(name: &str, dir: &str) -> crate::slots::SlotRecord {
crate::slots::SlotRecord {
tool: "claude-code".into(),
name: name.into(),
id: name.into(),
config_dir: PathBuf::from(dir),
adopted: false,
}
}
#[test]
fn a_changed_pointer_wins_over_a_rotation() {
let slots = vec![slot("rnd", "/s/rnd"), slot("bsgong", "/s/bsgong")];
let mut c = Chooser::default();
assert_eq!(
c.choose(Some(&PathBuf::from("/s/rnd")), None, &slots)
.unwrap()
.name,
"rnd"
);
assert_eq!(
c.choose(Some(&PathBuf::from("/s/rnd")), Some("bsgong"), &slots)
.unwrap()
.name,
"bsgong"
);
assert_eq!(
c.choose(Some(&PathBuf::from("/s/bsgong")), Some("bsgong"), &slots)
.unwrap()
.name,
"bsgong"
);
assert_eq!(
c.choose(Some(&PathBuf::from("/s/rnd")), Some("bsgong"), &slots)
.unwrap()
.name,
"rnd",
"an explicit new choice overrides the rotation"
);
}
#[test]
fn unknown_pointer_falls_back_and_no_slots_yields_nothing() {
let slots = vec![slot("rnd", "/s/rnd")];
let mut c = Chooser::default();
assert_eq!(
c.choose(Some(&PathBuf::from("/nope")), None, &slots)
.unwrap()
.name,
"rnd",
"an unresolvable pointer still serves the request"
);
assert!(c.choose(None, None, &[]).is_none(), "no slots -> no choice");
}
#[test]
fn rotation_skips_the_current_and_the_known_spent_accounts() {
use crate::proxy::ratelimit::Quota;
let slots = vec![
slot("rnd", "/s/rnd"),
slot("bsgong", "/s/b"),
slot("claude", "/s/c"),
];
let spent = |name: &str| {
(
name.to_string(),
Quota {
rejected: true,
..Default::default()
},
)
};
let mut state: std::collections::HashMap<String, Quota> =
[spent("rnd"), spent("bsgong")].into_iter().collect();
assert_eq!(
rotate_target("rnd", &slots, &state).as_deref(),
Some("claude"),
"the first account that is neither current nor spent"
);
state.extend([spent("claude")]);
assert_eq!(
rotate_target("rnd", &slots, &state),
None,
"every account spent -> nothing to rotate to"
);
}
#[test]
fn headroom_is_the_worst_window_not_the_best() {
assert_eq!(headroom(Some(2.0), Some(97.0)), Some(3.0));
assert_eq!(headroom(Some(40.0), None), Some(60.0));
assert_eq!(headroom(None, None), None, "unmeasured is not empty");
}
#[test]
fn candidates_sort_by_room_with_explicit_rank_winning() {
let mut v = vec![
("plenty", usize::MAX, Some(90.0)),
("scarce", usize::MAX, Some(5.0)),
("unknown", usize::MAX, None),
("pinned", 0, Some(1.0)),
];
by_headroom(&mut v, |t| t.1, |t| t.2);
assert_eq!(
v.iter().map(|t| t.0).collect::<Vec<_>>(),
vec!["pinned", "plenty", "scarce", "unknown"],
"a pinned account first, then most room, unmeasured last"
);
}
#[test]
fn over_threshold_only_fires_on_a_measured_window() {
assert!(over_threshold(Some(98.0), None, 0.98));
assert!(over_threshold(Some(99.5), None, 0.98));
assert!(
over_threshold(None, Some(100.0), 0.98),
"either window can trip it"
);
assert!(!over_threshold(Some(97.9), Some(50.0), 0.98));
assert!(!over_threshold(None, None, 0.98));
assert!(!over_threshold(Some(99.0), None, 1.0));
assert!(over_threshold(Some(100.0), None, 1.0));
}
#[test]
fn a_rotation_naming_an_unknown_account_is_ignored() {
let slots = vec![slot("rnd", "/s/rnd")];
let mut c = Chooser::default();
c.choose(Some(&PathBuf::from("/s/rnd")), None, &slots);
assert_eq!(
c.choose(Some(&PathBuf::from("/s/rnd")), Some("deleted"), &slots)
.unwrap()
.name,
"rnd",
"a rotation target removed meanwhile must not strand the request"
);
}
}
#[derive(Default)]
pub struct Sidelined {
marks: std::collections::HashMap<String, std::time::Instant>,
}
pub const SIDELINE_FOR: std::time::Duration = std::time::Duration::from_secs(600);
impl Sidelined {
pub fn mark(&mut self, name: &str, now: std::time::Instant) {
self.marks.insert(name.to_string(), now);
}
pub fn contains(&self, name: &str, now: std::time::Instant) -> bool {
self.marks
.get(name)
.is_some_and(|at| now.duration_since(*at) < SIDELINE_FOR)
}
pub fn clear(&mut self, name: &str) {
self.marks.remove(name);
}
pub fn active(&self, now: std::time::Instant) -> usize {
self.marks
.values()
.filter(|at| now.duration_since(**at) < SIDELINE_FOR)
.count()
}
}
#[cfg(test)]
mod sidelined_tests {
use super::*;
use std::time::Instant;
#[test]
fn a_refusal_holds_an_account_out_then_lets_it_back() {
let mut s = Sidelined::default();
let t0 = Instant::now();
s.mark("rnd", t0);
assert!(s.contains("rnd", t0), "held out right after the refusal");
assert!(
s.contains("rnd", t0 + SIDELINE_FOR - std::time::Duration::from_secs(1)),
"still held out inside the window"
);
assert!(
!s.contains("rnd", t0 + SIDELINE_FOR),
"and offered again once it lapses - a login fixed meanwhile must be usable"
);
assert_eq!(s.active(t0 + SIDELINE_FOR), 0);
}
#[test]
fn naming_an_account_puts_it_back_at_once() {
let mut s = Sidelined::default();
let t0 = Instant::now();
s.mark("rnd", t0);
s.clear("rnd");
assert!(!s.contains("rnd", t0));
}
}
#[cfg(test)]
mod extra_usage_tests {
use super::*;
#[test]
fn credits_do_not_keep_a_capped_account_in_front() {
for credits in [false, true] {
assert!(
over_threshold_with(Some(100.0), Some(55.0), 0.98, credits),
"capped is capped (credits: {credits})"
);
}
}
#[test]
fn credits_change_nothing_below_the_threshold() {
for credits in [false, true] {
assert!(!over_threshold_with(Some(10.0), Some(20.0), 0.98, credits));
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Strategy {
#[default]
Roomiest,
ConsumeFirst,
}
impl Strategy {
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"roomiest" | "best" => Some(Self::Roomiest),
"consume-first" | "consume_first" | "soonest" => Some(Self::ConsumeFirst),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Roomiest => "roomiest",
Self::ConsumeFirst => "consume-first",
}
}
}
pub fn order_by<T>(
items: &mut [T],
strategy: Strategy,
rank: impl Fn(&T) -> usize,
room: impl Fn(&T) -> Option<f64>,
resets_in: impl Fn(&T) -> Option<i64>,
) {
match strategy {
Strategy::Roomiest => by_headroom(items, rank, room),
Strategy::ConsumeFirst => items.sort_by(|a, b| {
rank(a).cmp(&rank(b)).then_with(|| {
let usable = |t: &T| room(t).is_none_or(|r| r > 0.0);
match (usable(a), usable(b)) {
(true, false) => return std::cmp::Ordering::Less,
(false, true) => return std::cmp::Ordering::Greater,
_ => {}
}
match (resets_in(a), resets_in(b)) {
(Some(x), Some(y)) => x.cmp(&y), (Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
}
})
}),
}
}
pub const HYSTERESIS_MARGIN: f64 = 10.0;
pub fn worth_moving_to(
current_room: Option<f64>,
candidate_room: Option<f64>,
margin: f64,
) -> bool {
match (current_room, candidate_room) {
(Some(now), Some(next)) => next - now >= margin,
_ => false,
}
}
#[cfg(test)]
mod strategy_tests {
use super::*;
type Acct = (&'static str, usize, Option<f64>, Option<i64>);
fn order(strategy: Strategy, mut v: Vec<Acct>) -> Vec<&'static str> {
order_by(&mut v, strategy, |a| a.1, |a| a.2, |a| a.3);
v.into_iter().map(|a| a.0).collect()
}
#[test]
fn roomiest_takes_the_account_with_the_most_left() {
let v = vec![
("tight", 0, Some(5.0), Some(60)),
("roomy", 0, Some(80.0), Some(9_000)),
];
assert_eq!(order(Strategy::Roomiest, v), ["roomy", "tight"]);
}
#[test]
fn consume_first_takes_the_window_about_to_reset() {
let v = vec![
("roomy", 0, Some(80.0), Some(9_000)),
("soon", 0, Some(30.0), Some(60)),
];
assert_eq!(order(Strategy::ConsumeFirst, v), ["soon", "roomy"]);
}
#[test]
fn consume_first_still_skips_an_account_with_nothing_in_it() {
let v = vec![
("empty", 0, Some(0.0), Some(30)),
("has-some", 0, Some(20.0), Some(9_000)),
];
assert_eq!(order(Strategy::ConsumeFirst, v), ["has-some", "empty"]);
}
#[test]
fn an_explicit_rank_outranks_both_strategies() {
for s in [Strategy::Roomiest, Strategy::ConsumeFirst] {
let v = vec![
("second", 1, Some(99.0), Some(10)),
("preferred", 0, Some(1.0), Some(99_999)),
];
assert_eq!(order(s, v)[0], "preferred", "{s:?}");
}
}
#[test]
fn unmeasured_accounts_sort_after_measured_ones() {
for s in [Strategy::Roomiest, Strategy::ConsumeFirst] {
let v = vec![
("unknown", 0, None, None),
("known", 0, Some(50.0), Some(500)),
];
assert_eq!(order(s, v)[0], "known", "{s:?}");
}
}
#[test]
fn the_names_round_trip() {
for s in [Strategy::Roomiest, Strategy::ConsumeFirst] {
assert_eq!(Strategy::parse(s.as_str()), Some(s));
}
assert_eq!(Strategy::parse("best"), Some(Strategy::Roomiest));
assert_eq!(Strategy::parse("nonsense"), None);
}
}
#[cfg(test)]
mod hysteresis_tests {
use super::*;
#[test]
fn a_marginal_improvement_is_not_worth_the_move() {
assert!(!worth_moving_to(Some(8.0), Some(12.0), HYSTERESIS_MARGIN));
assert!(worth_moving_to(Some(8.0), Some(40.0), HYSTERESIS_MARGIN));
}
#[test]
fn moving_to_something_worse_is_never_worth_it() {
assert!(!worth_moving_to(Some(50.0), Some(20.0), HYSTERESIS_MARGIN));
}
#[test]
fn an_unmeasured_side_is_not_a_reason_to_move() {
assert!(!worth_moving_to(None, Some(90.0), HYSTERESIS_MARGIN));
assert!(!worth_moving_to(Some(1.0), None, HYSTERESIS_MARGIN));
}
}
pub fn measure_after(headroom: Option<f64>) -> std::time::Duration {
let secs = match headroom {
None => 60,
Some(h) if h <= 10.0 => 60,
Some(h) if h <= 25.0 => 120,
Some(h) if h <= 50.0 => 300,
_ => 900,
};
std::time::Duration::from_secs(secs)
}
#[cfg(test)]
mod pacing_tests {
use super::*;
#[test]
fn a_nearly_spent_account_is_watched_closely() {
assert!(measure_after(Some(5.0)) <= std::time::Duration::from_secs(60));
}
#[test]
fn a_fresh_one_is_left_alone_far_longer() {
assert!(measure_after(Some(95.0)) >= std::time::Duration::from_secs(600));
}
#[test]
fn more_room_never_means_a_shorter_wait() {
let mut last = std::time::Duration::ZERO;
for h in [0.0, 10.0, 25.0, 50.0, 75.0, 100.0] {
let d = measure_after(Some(h));
assert!(d >= last, "wait shrank at {h}%: {last:?} -> {d:?}");
last = d;
}
}
#[test]
fn an_unmeasured_account_is_read_soon() {
assert_eq!(measure_after(None), measure_after(Some(0.0)));
}
}