use crate::api::Owner;
use crate::context::Context;
use crate::provider::ProviderId;
use crate::service::Permit;
use crate::state::{Account, Key, State};
use crate::status::Row;
use crate::usage::{self, Snapshot, Window, same_reset};
use crate::{atomic, budget, home, in_use};
use serde::{Deserialize, Serialize};
use std::cmp::Ordering;
use std::collections::BTreeMap;
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Threshold(u8);
impl Threshold {
pub const LOWEST: u8 = 50;
pub const HIGHEST: u8 = 99;
pub const DEFAULT: Threshold = Threshold(95);
pub fn new(percent: u8) -> Option<Threshold> {
(Threshold::LOWEST..=Threshold::HIGHEST)
.contains(&percent)
.then_some(Threshold(percent))
}
pub fn clamped(percent: i64) -> Threshold {
let lowest = i64::from(Threshold::LOWEST);
let highest = i64::from(Threshold::HIGHEST);
Threshold(u8::try_from(percent.clamp(lowest, highest)).unwrap_or(Threshold::DEFAULT.0))
}
pub const fn percent(self) -> u8 {
self.0
}
pub(crate) fn reached(self, percent: f64) -> bool {
usage::reached(percent, self.0)
}
fn reached_by(self, window: &Window, now: i64) -> bool {
self.reached(window.used(now))
}
}
impl Default for Threshold {
fn default() -> Threshold {
Threshold::DEFAULT
}
}
pub const SETTLING_SECONDS: i64 = 300;
pub const RETRY_SECONDS: i64 = 60;
pub const RETRY_MOST_SECONDS: i64 = 900;
pub const ATTEMPTS: u32 = 3;
pub const DECIDE_EVERY_SECONDS: i64 = 30;
pub fn retry_after(waits: u32) -> i64 {
(RETRY_SECONDS << waits.saturating_sub(1).min(4)).min(RETRY_MOST_SECONDS)
}
#[derive(Debug, Clone, PartialEq)]
pub struct Plan {
pub from: Key,
pub from_id: String,
pub to: Key,
pub limit: Window,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct Hold {
pub(crate) from: Key,
pub(crate) from_id: String,
pub(crate) limit: Window,
pub(crate) why: Skip,
}
impl Hold {
pub(crate) fn skipped(self, state: &State) -> Auto {
Auto::Skipped {
from: state.typed(&self.from),
limit: self.limit,
why: self.why,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum Decision {
Below {
nearest: Option<Window>,
as_of: Option<i64>,
},
NoReading,
Waiting {
limit: Window,
until: i64,
},
Hold(Hold),
Switch(Plan),
}
#[derive(Debug)]
pub enum Auto {
Watching {
account: String,
nearest: Option<Window>,
as_of: Option<i64>,
held_until: Option<i64>,
},
Waiting {
from: String,
limit: Window,
until: i64,
},
Switched {
from: String,
to: String,
limit: Window,
adoption: crate::provider::Adoption,
},
Skipped {
from: String,
limit: Window,
why: Skip,
},
NotWatching { why: Blind },
}
impl Auto {
pub fn told_apart(&self) -> Option<String> {
match self {
Auto::Watching { .. } | Auto::Switched { .. } => None,
Auto::Waiting {
from,
limit: _,
until,
} => Some(format!("waiting/{from}/{until}")),
Auto::Skipped { from, limit, why } => Some(why.told_apart(from, limit)),
Auto::NotWatching { why } => Some(why.told_apart()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Skip {
NoRoom { unread: Vec<String> },
AlreadyLeft,
GaveUp,
Settling { until: i64 },
Overridden(Vec<String>),
}
impl Skip {
pub fn code(&self) -> &'static str {
match self {
Skip::NoRoom { .. } => "no_room",
Skip::AlreadyLeft => "already_switched",
Skip::GaveUp => "attempts_spent",
Skip::Settling { .. } => "settling",
Skip::Overridden(_) => "auth_overridden",
}
}
pub fn told_apart(&self, from: &str, limit: &Window) -> String {
format!(
"skipped/{from}/{}/{}/{}/{}",
limit.kind,
limit.scope.as_deref().unwrap_or_default(),
limit.resets_at.unwrap_or_default(),
self.code()
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Blind {
SwitchInterrupted,
CustomOauth,
NothingSignedIn,
NotEnrolled { email: String },
Unidentified { detail: String, until: i64 },
NoReading { account: String },
}
impl Blind {
pub fn code(&self) -> &'static str {
match self {
Blind::SwitchInterrupted => "switch_interrupted",
Blind::CustomOauth => "custom_oauth_endpoint",
Blind::NothingSignedIn => "nothing_signed_in",
Blind::NotEnrolled { .. } => "not_enrolled",
Blind::Unidentified { .. } => "not_identified",
Blind::NoReading { .. } => "no_reading",
}
}
pub fn told_apart(&self) -> String {
let named = match self {
Blind::SwitchInterrupted | Blind::CustomOauth | Blind::NothingSignedIn => None,
Blind::NotEnrolled { email } => Some(email),
Blind::Unidentified { detail, until: _ } => Some(detail),
Blind::NoReading { account } => Some(account),
};
match named {
Some(named) => format!("not-watching/{}/{named}", self.code()),
None => format!("not-watching/{}", self.code()),
}
}
}
#[derive(Debug)]
pub enum Look {
Stands(Box<Auto>),
Act,
}
fn fuller(a: &Window, b: &Window, now: i64) -> Ordering {
b.used(now)
.total_cmp(&a.used(now))
.then_with(|| sooner(a.resets_at, b.resets_at))
}
fn sooner(a: Option<i64>, b: Option<i64>) -> Ordering {
match (a, b) {
(Some(a), Some(b)) => a.cmp(&b),
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
(None, None) => Ordering::Equal,
}
}
fn answered(row: &Row) -> Option<&Snapshot> {
row.usage.as_ref().filter(|reading| reading.answered())
}
pub(crate) fn decide(
state: &State,
current: &Key,
rows: &[Row],
ledger: &Ledger,
threshold: Threshold,
now: i64,
) -> Decision {
let Some((row, reading)) = rows
.iter()
.find(|row| row.key().as_ref() == Some(current))
.and_then(|row| Some((row, answered(row)?)))
else {
return Decision::NoReading;
};
let mut reached: Vec<&Window> = reading
.windows
.iter()
.filter(|window| threshold.reached_by(window, now))
.collect();
reached.sort_by(|a, b| fuller(a, b, now));
let Some(&furthest) = reached.first() else {
return Decision::Below {
nearest: reading
.windows
.iter()
.filter(|window| window.resets_at.is_none_or(|at| at > now))
.min_by(|a, b| fuller(a, b, now))
.cloned(),
as_of: reading.observed_at,
};
};
let tried = |limit: &Window| ledger.tried(&row.id, limit);
let as_kept =
|limit: &Window| tried(limit).map_or_else(|| limit.clone(), |tried| tried.window(limit));
let hold = |limit: &Window, why| {
Decision::Hold(Hold {
from: current.clone(),
from_id: row.id.clone(),
limit: as_kept(limit),
why,
})
};
let open = reached.iter().copied().find(|&limit| {
tried(limit).is_none_or(|tried| !tried.switched && tried.attempts < ATTEMPTS)
});
let Some(limit) = open else {
let left = tried(furthest).is_some_and(|tried| tried.switched);
return hold(
furthest,
if left {
Skip::AlreadyLeft
} else {
Skip::GaveUp
},
);
};
let put_in_use = state
.get(current)
.and_then(|account| account.last_used_at)
.filter(|at| (0..SETTLING_SECONDS).contains(&(now - at)));
if let Some(at) = put_in_use {
return hold(
limit,
Skip::Settling {
until: at + SETTLING_SECONDS,
},
);
}
let waiting = reached
.iter()
.filter_map(|&window| tried(window).filter(|tried| !tried.switched))
.map(|tried| tried.at + retry_after(tried.waits))
.max()
.filter(|&until| now < until);
if let Some(until) = waiting {
return Decision::Waiting {
limit: as_kept(limit),
until,
};
}
let passed_over: Vec<&str> = reading
.windows
.iter()
.filter_map(tried)
.flat_map(|tried| tried.passed_over.iter().map(String::as_str))
.collect();
let goes = || {
rows.iter().filter(|row| {
row.provider == ProviderId::Claude
&& row.key().is_some_and(|key| key != *current)
&& row.switchable(now)
&& !passed_over.contains(&row.id.as_str())
})
};
let best = usage::roomiest(goes().filter_map(|row| {
let theirs = answered(row)?;
let room = usage::room(
&theirs.windows,
theirs.lists_every_limit,
&reading.windows,
limit,
threshold.percent(),
now,
)?;
Some((row, room))
}));
match best.and_then(|(row, _)| row.key()) {
Some(to) => Decision::Switch(Plan {
from: current.clone(),
from_id: row.id.clone(),
to,
limit: as_kept(limit),
}),
None => hold(
limit,
Skip::NoRoom {
unread: goes()
.filter(|row| answered(row).is_none())
.filter_map(Row::key)
.map(|key| state.typed(&key))
.collect(),
},
),
}
}
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub(crate) struct Ledger {
limits: BTreeMap<String, Tried>,
#[serde(skip_serializing_if = "Option::is_none")]
asked: Option<Asked>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct Asked {
at: i64,
failures: u32,
detail: String,
}
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
struct Tried {
resets_at: i64,
attempts: u32,
#[serde(default)]
waits: u32,
at: i64,
#[serde(default)]
switched: bool,
#[serde(default)]
passed_over: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
said: BTreeMap<String, i64>,
}
impl Tried {
fn last(&self) -> i64 {
self.said.values().copied().fold(self.at, i64::max)
}
fn kept(&self, now: i64) -> bool {
usage::may_still_run(self.resets_at, now)
|| (self.resets_at == 0 && now - self.last() < 7 * 86_400)
}
fn window(&self, limit: &Window) -> Window {
Window {
resets_at: limit.resets_at.map(|_| self.resets_at),
..limit.clone()
}
}
}
fn path(ctx: &Context) -> PathBuf {
home::dir(ctx).join("autoswitch.json")
}
fn entry(id: &str, limit: &Window) -> String {
[
id,
usage::limit_name(&limit.kind),
limit.scope.as_deref().unwrap_or_default(),
]
.join("/")
}
impl Ledger {
pub(crate) fn load(ctx: &Context) -> Ledger {
let mut ledger: Ledger = std::fs::read_to_string(path(ctx))
.ok()
.and_then(|raw| serde_json::from_str(&raw).ok())
.unwrap_or_default();
let now = ctx.now();
ledger.limits.retain(|_, tried| tried.kept(now));
ledger
}
pub(crate) fn save(&mut self, ctx: &Context, permit: Permit) -> crate::error::Result<()> {
let now = ctx.now();
self.limits.retain(|_, tried| tried.kept(now));
let body = serde_json::to_vec(self).map_err(|e| crate::error::Error::StateWriteFailed {
path: path(ctx),
source: std::io::Error::other(e),
})?;
atomic::write(permit, &path(ctx), &body, atomic::Perms::Secret).map_err(|source| {
crate::error::Error::StateWriteFailed {
path: path(ctx),
source,
}
})
}
fn tried(&self, id: &str, limit: &Window) -> Option<&Tried> {
self.limits
.get(&entry(id, limit))
.filter(|tried| same_reset(tried.resets_at, limit.resets_at.unwrap_or(0)))
}
fn trying(&mut self, id: &str, limit: &Window) -> &mut Tried {
let resets_at = limit.resets_at.unwrap_or(0);
let tried = self.limits.entry(entry(id, limit)).or_default();
if !same_reset(tried.resets_at, resets_at) {
*tried = Tried {
resets_at,
..Tried::default()
};
}
tried
}
pub(crate) fn attempt(&mut self, plan: &Plan, now: i64) {
let tried = self.trying(&plan.from_id, &plan.limit);
tried.attempts += 1;
tried.at = now;
}
pub(crate) fn waited(&mut self, plan: &Plan) {
let tried = self.trying(&plan.from_id, &plan.limit);
tried.attempts = tried.attempts.saturating_sub(1);
tried.waits = tried.waits.saturating_add(1);
}
pub(crate) fn switched(&mut self, plan: &Plan) {
self.trying(&plan.from_id, &plan.limit).switched = true;
}
pub(crate) fn pass_over(&mut self, plan: &Plan, to_id: &str) {
let tried = self.trying(&plan.from_id, &plan.limit);
tried.waits = 0;
if !tried.passed_over.iter().any(|id| id == to_id) {
tried.passed_over.push(to_id.to_owned());
}
}
pub(crate) fn said(&self, hold: &Hold) -> bool {
self.tried(&hold.from_id, &hold.limit)
.is_some_and(|tried| tried.said.contains_key(hold.why.code()))
}
pub(crate) fn say(&mut self, hold: &Hold, now: i64) -> bool {
let said = &mut self.trying(&hold.from_id, &hold.limit).said;
let code = hold.why.code();
if said.contains_key(code) {
return false;
}
said.insert(code.to_owned(), now);
true
}
fn not_told_in_a_row(&self, known_at: Option<i64>) -> Option<&Asked> {
self.asked
.as_ref()
.filter(|asked| !known_at.is_some_and(|known_at| known_at > asked.at))
}
pub(crate) fn unidentified(&self, known_at: Option<i64>, now: i64) -> Option<Blind> {
let asked = self.not_told_in_a_row(known_at)?;
let until = asked.at + retry_after(asked.failures);
(now < until).then(|| Blind::Unidentified {
detail: asked.detail.clone(),
until,
})
}
pub(crate) fn not_told(&mut self, detail: String, known_at: Option<i64>, now: i64) -> Blind {
let failures = self
.not_told_in_a_row(known_at)
.map_or(0, |asked| asked.failures)
+ 1;
self.asked = Some(Asked {
at: now,
failures,
detail: detail.clone(),
});
Blind::Unidentified {
detail,
until: now + retry_after(failures),
}
}
pub(crate) fn told(&mut self) -> bool {
self.asked.take().is_some()
}
}
pub(crate) fn look(ctx: &Context, state: &State, threshold: Threshold) -> Look {
let unfinished = crate::switch::unfinished(ctx);
if let Some(why) = blind(ctx, unfinished) {
return Look::Stands(Box::new(Auto::NotWatching { why }));
}
if unfinished.is_some() {
return Look::Act;
}
let now = ctx.now();
let ledger = Ledger::load(ctx);
let known = in_use::known(ctx, state, ProviderId::Claude);
let known_at = known.last.as_ref().map(|last| last.known_at);
if let Some(why) = ledger.unidentified(known_at, now) {
return Look::Stands(Box::new(Auto::NotWatching { why }));
}
if known.doubt.is_some() {
return Look::Act;
}
let account = match watched(state, known.owner()) {
Ok(account) => account,
Err(why) => return Look::Stands(Box::new(Auto::NotWatching { why })),
};
match judge(ctx, state, account, &ledger, threshold, now) {
Judged::Switch(_) => Look::Act,
Judged::Hold(hold) if ledger.said(&hold) => Look::Stands(Box::new(hold.skipped(state))),
Judged::Hold(_) => Look::Act,
Judged::Stands(stands) => Look::Stands(Box::new(stands)),
}
}
fn blind(ctx: &Context, unfinished: Option<crate::switch::Unfinished>) -> Option<Blind> {
if unfinished == Some(crate::switch::Unfinished::Interrupted) {
Some(Blind::SwitchInterrupted)
} else if crate::settings::custom_oauth(ctx) {
Some(Blind::CustomOauth)
} else {
None
}
}
pub(crate) fn watched<'a>(state: &'a State, owner: Option<&Owner>) -> Result<&'a Account, Blind> {
let owner = owner.ok_or(Blind::NothingSignedIn)?;
state
.account_of(ProviderId::Claude, owner)
.ok_or_else(|| Blind::NotEnrolled {
email: owner.email.clone(),
})
}
#[derive(Debug)]
pub(crate) enum Judged {
Switch(Plan),
Hold(Hold),
Stands(Auto),
}
pub(crate) fn judge(
ctx: &Context,
state: &State,
account: &Account,
ledger: &Ledger,
threshold: Threshold,
now: i64,
) -> Judged {
let rows = crate::status::gather_offline(ctx, state).rows;
let key = account.key();
let typed = state.typed(&key);
let plan = match decide(state, &key, &rows, ledger, threshold, now) {
Decision::Below { nearest, as_of } => {
return Judged::Stands(Auto::Watching {
account: typed,
nearest,
as_of,
held_until: budget::held_until(ctx, &account.id),
});
}
Decision::NoReading => {
return Judged::Stands(Auto::NotWatching {
why: Blind::NoReading { account: typed },
});
}
Decision::Waiting { limit, until } => {
return Judged::Stands(Auto::Waiting {
from: typed,
limit,
until,
});
}
Decision::Hold(hold) => return Judged::Hold(hold),
Decision::Switch(plan) => plan,
};
let overridden = crate::provider::of(ProviderId::Claude).overridden_by(ctx);
if overridden.is_empty() {
return Judged::Switch(plan);
}
Judged::Hold(Hold {
from: plan.from,
from_id: plan.from_id,
limit: plan.limit,
why: Skip::Overridden(overridden),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::state::Park;
use crate::switch::harness::{NOW, account, window};
use crate::usage::Source;
fn scoped(kind: &str, model: &str, percent: f64) -> Window {
Window {
scope: Some(model.into()),
..window(kind, percent)
}
}
fn row(label: &str, signed_in: bool, windows: Vec<Window>) -> Row {
Row {
provider: ProviderId::Claude,
label: Some(label.into()),
email: format!("{label}@example.com"),
id: label.into(),
account_uuid: label.into(),
organization_uuid: Some("org".into()),
signed_in,
parked: (!signed_in).then(|| Park {
service: format!("pitboard-park-{label}-1"),
parked_at: NOW,
refresh_fingerprint: label.into(),
access_expires_at: Some(NOW + 3600),
refresh_expires_at: Some(NOW + 30 * 86_400),
}),
usage: Some(Snapshot {
windows,
observed_at: Some(NOW),
answered_at: Some(NOW),
lists_every_limit: true,
source: Source::Remembered,
}),
stale: None,
}
}
fn state_of(rows: &[Row]) -> State {
let mut state = State::default();
for row in rows {
state.accounts.push(account(
row.label.as_deref().unwrap(),
&row.account_uuid,
row.parked.clone(),
));
}
state
}
fn decided_with(
state: &State,
rows: &[Row],
ledger: &Ledger,
threshold: Threshold,
now: i64,
) -> Decision {
let current = rows
.iter()
.find(|row| row.signed_in)
.and_then(Row::key)
.expect("an account in use");
decide(state, ¤t, rows, ledger, threshold, now)
}
fn decided(rows: &[Row]) -> Decision {
decided_with(
&state_of(rows),
rows,
&Ledger::default(),
Threshold::DEFAULT,
NOW,
)
}
fn put_in_use(state: &mut State, at: i64) {
let work = state.accounts.iter_mut().find(|a| a.label == "work");
work.expect("enrolled").last_used_at = Some(at);
}
fn held(limit: &Window, why: Skip) -> Decision {
Decision::Hold(Hold {
from: Key::new(ProviderId::Claude, "work"),
from_id: "work".into(),
limit: limit.clone(),
why,
})
}
fn no_room(decision: &Decision) -> bool {
matches!(
decision,
Decision::Hold(Hold {
why: Skip::NoRoom { .. },
..
})
)
}
fn to(decision: &Decision) -> Option<&str> {
match decision {
Decision::Switch(plan) => Some(plan.to.label.as_str()),
_ => None,
}
}
fn work_at(percent: f64) -> Row {
row(
"work",
true,
vec![window("session", percent), window("weekly_all", 20.0)],
)
}
fn spare() -> Row {
row(
"spare",
false,
vec![window("session", 10.0), window("weekly_all", 30.0)],
)
}
fn other() -> Row {
row(
"other",
false,
vec![window("session", 30.0), window("weekly_all", 30.0)],
)
}
fn work_at_both() -> Row {
row(
"work",
true,
vec![window("session", 96.0), window("weekly_all", 98.0)],
)
}
#[test]
fn a_limit_below_the_share_is_left_alone_and_one_at_it_switches() {
assert_eq!(
decided(&[work_at(94.4), spare()]),
Decision::Below {
nearest: Some(window("session", 94.4)),
as_of: Some(NOW)
}
);
let decision = decided(&[work_at(95.0), spare()]);
assert_eq!(to(&decision), Some("spare"));
let Decision::Switch(plan) = decision else {
unreachable!()
};
assert_eq!(
(plan.from.label.as_str(), plan.from_id.as_str()),
("work", "work")
);
assert_eq!(plan.limit.kind, "session");
}
#[test]
fn a_limit_shown_at_the_share_is_at_the_share() {
assert_eq!(to(&decided(&[work_at(94.5), spare()])), Some("spare"));
}
#[test]
fn every_limit_the_account_reports_can_reach_the_share() {
let weekly = row(
"work",
true,
vec![window("session", 10.0), window("weekly_all", 97.0)],
);
let opus = row(
"work",
true,
vec![
window("session", 10.0),
window("weekly_all", 10.0),
scoped("weekly_scoped", "Opus", 96.0),
],
);
let named_as_a_session_names_them = row(
"work",
true,
vec![window("five_hour", 99.0), window("seven_day", 10.0)],
);
for (case, work) in [
("weekly", weekly),
("opus", opus),
("five_hour", named_as_a_session_names_them),
] {
let spare = row(
"spare",
false,
vec![
window("session", 0.0),
window("weekly_all", 0.0),
scoped("weekly_scoped", "Opus", 0.0),
],
);
assert_eq!(to(&decided(&[work, spare])), Some("spare"), "{case}");
}
}
#[test]
fn the_limit_furthest_past_the_share_is_the_one_acted_on() {
let Decision::Switch(plan) = decided(&[work_at_both(), spare()]) else {
panic!("a switch")
};
assert_eq!(plan.limit.kind, "weekly_all");
}
#[test]
fn a_limit_whose_reset_has_passed_counts_as_reset() {
let mut work = work_at(100.0);
work.usage.as_mut().unwrap().windows[0].resets_at = Some(NOW);
assert_eq!(
decided(&[work, spare()]),
Decision::Below {
nearest: Some(window("weekly_all", 20.0)),
as_of: Some(NOW)
}
);
}
#[test]
fn an_account_in_use_with_no_reading_is_never_switched_from() {
let mut work = work_at(99.0);
work.usage = None;
assert_eq!(decided(&[work, spare()]), Decision::NoReading);
}
#[test]
fn codex_is_never_switched() {
let codex = |mut row: Row| {
row.provider = ProviderId::Codex;
row
};
let home = row("home", true, vec![window("session", 10.0)]);
let rows = [codex(work_at(99.0)), codex(spare()), home];
let decision = decide(
&state_of(&rows),
&Key::new(ProviderId::Claude, "home"),
&rows,
&Ledger::default(),
Threshold::DEFAULT,
NOW,
);
assert!(matches!(decision, Decision::Below { .. }), "{decision:?}");
assert_eq!(
decided(&[work_at(99.0), codex(spare())]),
held(&window("session", 99.0), Skip::NoRoom { unread: vec![] })
);
}
#[test]
fn an_account_with_any_limit_at_the_share_is_no_place_to_go() {
let full_weekly = || {
row(
"spare",
false,
vec![window("session", 0.0), window("weekly_all", 95.0)],
)
};
assert!(no_room(&decided(&[work_at(96.0), full_weekly()])));
let roomy = row(
"roomy",
false,
vec![window("session", 60.0), window("weekly_all", 10.0)],
);
assert_eq!(
to(&decided(&[work_at(96.0), full_weekly(), roomy])),
Some("roomy")
);
}
#[test]
fn an_account_with_a_limit_shown_at_the_share_is_no_place_to_go() {
let spare_at = |weekly: f64| {
row(
"spare",
false,
vec![window("session", 0.0), window("weekly_all", weekly)],
)
};
assert!(no_room(&decided(&[work_at(96.0), spare_at(94.5)])));
assert_eq!(
to(&decided(&[work_at(96.0), spare_at(94.4)])),
Some("spare")
);
}
#[test]
fn an_account_that_cannot_be_switched_to_is_passed_over() {
let mut expired = spare();
expired.parked.as_mut().unwrap().refresh_expires_at = Some(NOW);
assert!(no_room(&decided(&[work_at(96.0), expired])));
}
#[test]
fn an_account_on_a_plan_without_that_limit_is_a_place_to_go() {
let work = row(
"work",
true,
vec![window("session", 20.0), window("weekly_all", 96.0)],
);
let seat = row(
"seat",
false,
vec![
window("session", 10.0),
scoped("weekly_scoped", "Fable", 0.0),
],
);
assert_eq!(to(&decided(&[work, seat])), Some("seat"));
}
#[test]
fn a_limit_the_other_account_does_not_report_is_one_it_does_not_have() {
let no_weekly = row("spare", false, vec![window("session", 0.0)]);
assert_eq!(to(&decided(&[work_at(96.0), no_weekly])), Some("spare"));
let mut unread = spare();
unread.usage = None;
assert!(no_room(&decided(&[work_at(96.0), unread])));
}
#[test]
fn a_limit_left_out_of_a_reading_that_may_leave_limits_out_is_not_known_whichever_it_is() {
let older = |windows| {
let mut older = row("spare", false, windows);
older.usage.as_mut().unwrap().lists_every_limit = false;
older
};
assert!(
no_room(&decided(&[
work_at(96.0),
older(vec![window("seven_day", 10.0)])
])),
"the limit at the share"
);
assert!(
no_room(&decided(&[
work_at(96.0),
older(vec![window("five_hour", 30.0)])
])),
"another limit"
);
let mut weekly_unread = row("spare", false, Vec::new());
weekly_unread.usage = Some(usage::from_usage_object(
&serde_json::json!({"limits": [
{"kind": "session", "percent": 10.0, "scope": null},
{"kind": "weekly_all", "percent": null, "scope": null},
]}),
NOW,
));
assert!(
no_room(&decided(&[work_at(96.0), weekly_unread])),
"a limit whose row was not read"
);
let both = || older(vec![window("five_hour", 30.0), window("seven_day", 10.0)]);
assert_eq!(to(&decided(&[work_at(96.0), both()])), Some("spare"));
let with_opus = |session: f64, opus: f64| {
row(
"work",
true,
vec![
window("session", session),
window("weekly_all", 20.0),
scoped("weekly_scoped", "Opus", opus),
],
)
};
assert_eq!(
to(&decided(&[with_opus(96.0, 40.0), both()])),
Some("spare")
);
assert!(
no_room(&decided(&[with_opus(10.0, 97.0), both()])),
"a model's limit at the share"
);
}
#[test]
fn a_limit_for_a_model_the_other_plan_does_not_have_is_not_counted_against_it() {
let work = row(
"work",
true,
vec![
window("session", 97.0),
window("weekly_all", 20.0),
scoped("weekly_scoped", "Opus", 40.0),
],
);
assert_eq!(to(&decided(&[work, spare()])), Some("spare"));
let opus_ran_out = row(
"work",
true,
vec![
window("session", 10.0),
window("weekly_all", 20.0),
scoped("weekly_scoped", "Opus", 97.0),
],
);
assert_eq!(to(&decided(&[opus_ran_out, spare()])), Some("spare"));
let session_only = row("spare", false, vec![window("session", 10.0)]);
let work = row(
"work",
true,
vec![
window("session", 97.0),
scoped("weekly_scoped", "Opus", 40.0),
],
);
assert_eq!(to(&decided(&[work, session_only])), Some("spare"));
}
#[test]
fn the_account_with_most_room_in_that_limit_is_chosen_then_the_least_full() {
let less = || {
row(
"less",
false,
vec![window("session", 50.0), window("weekly_all", 10.0)],
)
};
let more = row(
"more",
false,
vec![window("session", 20.0), window("weekly_all", 80.0)],
);
assert_eq!(to(&decided(&[work_at(96.0), less(), more])), Some("more"));
let tie = row(
"tie",
false,
vec![window("session", 50.0), window("weekly_all", 5.0)],
);
assert_eq!(to(&decided(&[work_at(96.0), less(), tie])), Some("tie"));
let same = row(
"same",
false,
vec![window("session", 50.0), window("weekly_all", 10.0)],
);
assert_eq!(
to(&decided(&[work_at(96.0), less(), same])),
Some("less"),
"the first of two alike"
);
}
#[test]
fn an_account_just_put_in_use_is_left_to_settle() {
let rows = [work_at(96.0), spare()];
let mut state = state_of(&rows);
put_in_use(&mut state, NOW - SETTLING_SECONDS + 1);
let ledger = Ledger::default();
assert_eq!(
decided_with(&state, &rows, &ledger, Threshold::DEFAULT, NOW),
held(&window("session", 96.0), Skip::Settling { until: NOW + 1 })
);
put_in_use(&mut state, NOW - SETTLING_SECONDS);
assert_eq!(
to(&decided_with(
&state,
&rows,
&ledger,
Threshold::DEFAULT,
NOW
)),
Some("spare")
);
}
fn plan(rows: &[Row]) -> Plan {
match decided(rows) {
Decision::Switch(plan) => plan,
other => panic!("a switch, not {other:?}"),
}
}
#[test]
fn a_limit_switched_away_from_is_switched_away_from_again_in_its_next_window() {
let rows = [work_at(96.0), spare()];
let state = state_of(&rows);
let mut ledger = Ledger::default();
let plan = plan(&rows);
ledger.attempt(&plan, NOW - 3600);
ledger.switched(&plan);
let mut next_window = work_at(96.0);
next_window.usage.as_mut().unwrap().windows[0].resets_at = Some(NOW + 3 * 3600);
let rows = [next_window, spare()];
assert_eq!(
to(&decided_with(
&state,
&rows,
&ledger,
Threshold::DEFAULT,
NOW
)),
Some("spare")
);
}
#[test]
fn a_failed_attempt_is_tried_again_after_a_minute() {
let rows = [work_at(96.0), spare()];
let state = state_of(&rows);
let mut ledger = Ledger::default();
let plan = plan(&rows);
ledger.attempt(&plan, NOW);
let at = |now| decided_with(&state, &rows, &ledger, Threshold::DEFAULT, now);
let waiting = Decision::Waiting {
limit: plan.limit.clone(),
until: NOW + RETRY_SECONDS,
};
assert_eq!(at(NOW + RETRY_SECONDS - 1), waiting);
assert_eq!(to(&at(NOW + RETRY_SECONDS)), Some("spare"));
assert_eq!(at(NOW - 1), waiting, "a clock gone backwards waits");
}
#[test]
fn a_failure_trying_again_may_mend_waits_longer_each_time_and_is_not_counted() {
let rows = [work_at(96.0), spare()];
let state = state_of(&rows);
let mut ledger = Ledger::default();
let plan = plan(&rows);
let mut at = NOW;
for wait in [60, 120, 240, 480, 900, 900] {
ledger.attempt(&plan, at);
ledger.waited(&plan);
let decided = |now| decided_with(&state, &rows, &ledger, Threshold::DEFAULT, now);
assert_eq!(
decided(at + wait - 1),
Decision::Waiting {
limit: plan.limit.clone(),
until: at + wait
},
"within {wait} s"
);
assert_eq!(to(&decided(at + wait)), Some("spare"), "after {wait} s");
at += wait;
}
}
#[test]
fn an_account_a_switch_was_refused_over_is_passed_over_for_the_next() {
let rows = [work_at(96.0), spare(), other()];
let state = state_of(&rows);
let mut ledger = Ledger::default();
let plan = plan(&rows);
assert_eq!(plan.to.label, "spare");
ledger.attempt(&plan, NOW);
ledger.pass_over(&plan, "spare");
assert_eq!(
to(&decided_with(
&state,
&rows,
&ledger,
Threshold::DEFAULT,
NOW + RETRY_SECONDS
)),
Some("other")
);
}
#[test]
fn an_account_refused_over_one_limit_is_passed_over_for_every_limit() {
let rows = [work_at_both(), spare(), other()];
let state = state_of(&rows);
let weekly = plan(&rows);
assert_eq!(
(weekly.limit.kind.as_str(), weekly.to.label.as_str()),
("weekly_all", "spare")
);
let mut ledger = Ledger::default();
ledger.attempt(&weekly, NOW);
ledger.pass_over(&weekly, "spare");
let after = NOW + RETRY_SECONDS;
let next = |ledger: &Ledger| match decided_with(
&state,
&rows,
ledger,
Threshold::DEFAULT,
after,
) {
Decision::Switch(plan) => plan,
other => panic!("a switch, not {other:?}"),
};
let again = next(&ledger);
assert_eq!(
(again.limit.kind.as_str(), again.to.label.as_str()),
("weekly_all", "other")
);
ledger.attempt(&again, after);
ledger.switched(&again);
let session = next(&ledger);
assert_eq!(
(session.limit.kind.as_str(), session.to.label.as_str()),
("session", "other"),
"the account put back in use, its five-hour limit is not switched to spare either"
);
}
#[test]
fn a_failed_attempt_at_one_limit_holds_back_another() {
let rows = [work_at_both(), spare()];
let state = state_of(&rows);
let weekly = plan(&rows);
let session = Plan {
limit: window("session", 96.0),
..weekly.clone()
};
let mut ledger = Ledger::default();
for _ in 0..ATTEMPTS {
ledger.attempt(&weekly, NOW);
}
let waiting = Decision::Waiting {
limit: session.limit.clone(),
until: NOW + RETRY_SECONDS,
};
let at =
|ledger: &Ledger, now| decided_with(&state, &rows, ledger, Threshold::DEFAULT, now);
assert_eq!(at(&ledger, NOW + RETRY_SECONDS - 1), waiting);
let decision = at(&ledger, NOW + RETRY_SECONDS);
assert!(
matches!(&decision, Decision::Switch(plan) if plan.limit.kind == "session"),
"{decision:?}"
);
ledger.attempt(&session, NOW - RETRY_SECONDS / 2);
assert_eq!(
at(&ledger, NOW + RETRY_SECONDS - 1),
waiting,
"the five-hour limit's own wait over first"
);
}
#[test]
fn a_wait_is_told_of_the_window_its_attempt_was_kept_under() {
let rows = [work_at(96.0), spare()];
let state = state_of(&rows);
let plan = plan(&rows);
let mut ledger = Ledger::default();
ledger.attempt(&plan, NOW);
let mut later = work_at(96.0);
later.usage.as_mut().unwrap().windows[0].resets_at = Some(NOW + 3599);
assert_eq!(
decided_with(
&state,
&[later, spare()],
&ledger,
Threshold::DEFAULT,
NOW + 1
),
Decision::Waiting {
limit: plan.limit.clone(),
until: NOW + RETRY_SECONDS
}
);
}
#[test]
fn a_limit_already_left_does_not_hide_another_at_the_share() {
let rows = [work_at_both(), spare()];
let state = state_of(&rows);
let weekly = plan(&rows);
assert_eq!(weekly.limit.kind, "weekly_all");
let mut ledger = Ledger::default();
ledger.attempt(&weekly, NOW - 3600);
ledger.switched(&weekly);
let decision = decided_with(&state, &rows, &ledger, Threshold::DEFAULT, NOW);
assert!(
matches!(&decision, Decision::Switch(plan) if plan.limit.kind == "session"),
"{decision:?}"
);
}
#[test]
fn a_limit_put_back_at_the_share_is_said() {
let rows = [work_at(96.0), spare()];
let mut state = state_of(&rows);
let plan = plan(&rows);
let mut ledger = Ledger::default();
ledger.attempt(&plan, NOW - 3600);
ledger.switched(&plan);
put_in_use(&mut state, NOW - 10);
assert_eq!(
decided_with(&state, &rows, &ledger, Threshold::DEFAULT, NOW),
held(&plan.limit, Skip::AlreadyLeft)
);
}
#[test]
fn three_failed_attempts_are_said() {
let rows = [work_at(96.0), spare()];
let state = state_of(&rows);
let plan = plan(&rows);
let at =
|ledger: &Ledger, now| decided_with(&state, &rows, ledger, Threshold::DEFAULT, now);
let mut ledger = Ledger::default();
for _ in 0..ATTEMPTS {
ledger.attempt(&plan, NOW);
}
assert_eq!(
at(&ledger, NOW + RETRY_SECONDS / 2),
held(&plan.limit, Skip::GaveUp)
);
let mut ledger = Ledger::default();
for _ in 0..4 {
ledger.attempt(&plan, NOW - 3600);
ledger.waited(&plan);
}
for _ in 0..ATTEMPTS {
ledger.attempt(&plan, NOW);
}
assert_eq!(
at(&ledger, NOW + 300),
held(&plan.limit, Skip::GaveUp),
"after failures that waiting may mend"
);
}
#[test]
fn an_account_put_in_use_at_the_share_is_said_to_be_settling() {
let settling = |limit: Window| {
held(
&limit,
Skip::Settling {
until: NOW - 10 + SETTLING_SECONDS,
},
)
};
let rows = [work_at(96.0), spare()];
let mut state = state_of(&rows);
put_in_use(&mut state, NOW - 10);
assert_eq!(
decided_with(&state, &rows, &Ledger::default(), Threshold::DEFAULT, NOW),
settling(window("session", 96.0))
);
let rows = [work_at_both(), spare()];
let weekly = plan(&rows);
let mut ledger = Ledger::default();
ledger.attempt(&weekly, NOW - 3600);
ledger.switched(&weekly);
assert_eq!(
decided_with(&state, &rows, &ledger, Threshold::DEFAULT, NOW),
settling(window("session", 96.0)),
"the weekly limit already left"
);
}
#[test]
fn a_reading_anthropic_did_not_give_is_not_acted_on() {
let unanswered = |mut row: Row| {
row.usage.as_mut().expect("a reading").answered_at = None;
row
};
assert_eq!(
decided(&[unanswered(work_at(99.0)), spare()]),
Decision::NoReading
);
assert_eq!(
decided(&[work_at(99.0), unanswered(spare())]),
held(
&window("session", 99.0),
Skip::NoRoom {
unread: vec!["spare".into()]
}
)
);
}
#[test]
fn no_room_names_the_accounts_not_asked_yet() {
let mut unread = row("unread", false, Vec::new());
unread.usage = None;
let full = row(
"full",
false,
vec![window("session", 96.0), window("weekly_all", 10.0)],
);
assert_eq!(
decided(&[work_at(97.0), full, unread]),
held(
&window("session", 97.0),
Skip::NoRoom {
unread: vec!["unread".into()]
}
)
);
}
#[test]
fn a_reason_not_to_switch_is_told_apart_by_its_limit_its_reset_and_its_code() {
let limit = |kind: &str, scope: Option<&str>, resets_at: i64| Window {
kind: kind.into(),
scope: scope.map(str::to_owned),
percent: 97.0,
resets_at: Some(resets_at),
is_active: true,
severity: None,
length_seconds: None,
};
let session = limit("session", None, 9_000);
assert_eq!(
Skip::GaveUp.told_apart("work", &session),
"skipped/work/session//9000/attempts_spent"
);
let keys = [
Skip::GaveUp.told_apart("work", &session),
Skip::GaveUp.told_apart("work", &limit("session", None, 27_000)),
Skip::GaveUp.told_apart("work", &limit("weekly_scoped", Some("Opus"), 9_000)),
Skip::AlreadyLeft.told_apart("work", &session),
Skip::GaveUp.told_apart("home", &session),
];
let told_apart: std::collections::BTreeSet<&String> = keys.iter().collect();
assert_eq!(told_apart.len(), keys.len(), "{keys:?}");
}
#[test]
fn a_reason_nothing_can_be_judged_is_told_apart_by_what_it_names() {
let unidentified = |until| Blind::Unidentified {
detail: "could not reach Anthropic".into(),
until,
};
assert_eq!(
unidentified(60).told_apart(),
"not-watching/not_identified/could not reach Anthropic"
);
assert_eq!(
unidentified(60).told_apart(),
unidentified(180).told_apart()
);
assert_eq!(
Blind::SwitchInterrupted.told_apart(),
"not-watching/switch_interrupted"
);
let not_enrolled = |email: &str| Blind::NotEnrolled {
email: email.into(),
};
assert_ne!(
not_enrolled("me@example.com").told_apart(),
not_enrolled("you@example.com").told_apart()
);
}
#[test]
fn a_reason_is_said_once_for_each_window_of_a_limit() {
let Decision::Hold(mut hold) = held(&window("session", 96.0), Skip::GaveUp) else {
unreachable!()
};
let mut ledger = Ledger::default();
assert!(!ledger.said(&hold));
assert!(ledger.say(&hold, NOW));
assert!(ledger.said(&hold));
assert!(!ledger.say(&hold, NOW + 60), "said already");
let no_room = Hold {
why: Skip::NoRoom { unread: Vec::new() },
..hold.clone()
};
assert!(!ledger.said(&no_room), "another reason");
hold.limit.resets_at = Some(NOW + 5 * 3600);
assert!(!ledger.said(&hold), "the next window");
assert!(ledger.say(&hold, NOW + 3600));
}
#[test]
fn a_reset_a_second_off_is_the_same_window_and_a_minute_off_is_not() {
let rows = [work_at(96.0), spare()];
let mut ledger = Ledger::default();
let mut plan = plan(&rows);
let resets = plan.limit.resets_at.unwrap();
plan.limit.resets_at = Some(resets + 59);
ledger.attempt(&plan, NOW - 3600);
ledger.switched(&plan);
let state = state_of(&rows);
assert_eq!(
decided_with(&state, &rows, &ledger, Threshold::DEFAULT, NOW),
held(&plan.limit, Skip::AlreadyLeft)
);
plan.limit.resets_at = Some(resets + 61);
let mut ledger = Ledger::default();
ledger.attempt(&plan, NOW - 3600);
ledger.switched(&plan);
assert_eq!(
to(&decided_with(
&state,
&rows,
&ledger,
Threshold::DEFAULT,
NOW
)),
Some("spare")
);
}
#[test]
#[cfg_attr(
windows,
ignore = "W16: Pitboard writing, replacing and removing files on Windows"
)]
fn a_window_is_kept_while_a_reading_of_it_may_still_run() {
let rows = [work_at(96.0), spare()];
let state = state_of(&rows);
let plan = plan(&rows);
let kept = plan.limit.resets_at.expect("a reset");
let mut m = crate::switch::harness::machine("autoswitch-kept-past-reset");
let clock = std::sync::Arc::new(crate::time::FixedClock::at(NOW));
m.ctx = m.ctx.clone().with_clock(clock.clone());
let mut ledger = Ledger::default();
ledger.attempt(&plan, NOW);
ledger.switched(&plan);
ledger.save(&m.ctx, Permit::for_a_test()).expect("saved");
let mut a_second_later = work_at(96.0);
a_second_later.usage.as_mut().unwrap().windows[0].resets_at = Some(kept + 1);
let rows = [a_second_later, spare()];
clock.advance(kept - NOW);
assert_eq!(
decided_with(
&state,
&rows,
&Ledger::load(&m.ctx),
Threshold::DEFAULT,
kept
),
held(&plan.limit, Skip::AlreadyLeft)
);
clock.advance(59);
assert_eq!(
Ledger::load(&m.ctx),
ledger,
"a reading of it may still run"
);
clock.advance(1);
assert_eq!(Ledger::load(&m.ctx), Ledger::default(), "none can");
}
#[test]
fn a_share_is_between_fifty_and_ninety_nine() {
assert_eq!(Threshold::new(49), None);
assert_eq!(Threshold::new(100), None);
assert_eq!(Threshold::new(50).map(Threshold::percent), Some(50));
assert_eq!(Threshold::clamped(120).percent(), 99);
assert_eq!(Threshold::clamped(-3).percent(), 50);
assert_eq!(Threshold::default().percent(), 95);
let lower = Threshold::new(80).unwrap();
let rows = [work_at(85.0), spare()];
assert!(matches!(decided(&rows), Decision::Below { .. }));
assert_eq!(
to(&decided_with(
&state_of(&rows),
&rows,
&Ledger::default(),
lower,
NOW
)),
Some("spare")
);
}
}