use serde::Serialize;
use crate::portfolio::{Assessment, Portfolio, PortfolioAction, Verdict};
use crate::update_prefs::UpdatePolicy;
pub fn kept_models(
nudge: &crate::nudge::NudgeState,
now: u64,
) -> std::collections::BTreeSet<String> {
nudge
.concierge_dismissals
.iter()
.filter(|d| d.reason.is_permanent() || now.saturating_sub(d.timestamp) < 30 * 24 * 60 * 60)
.filter_map(|d| d.key.strip_prefix("retire:").map(str::to_owned))
.collect()
}
pub fn keep_key(model_id: &str) -> String {
format!("retire:{model_id}")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct DiskSpace {
pub free_bytes: u64,
pub total_bytes: u64,
}
impl DiskSpace {
fn below(&self, percent: u64, floor_bytes: u64) -> bool {
let by_share =
self.free_bytes.saturating_mul(100) < self.total_bytes.saturating_mul(percent);
by_share || self.free_bytes < floor_bytes
}
}
pub const PRESSURE_LOW_PERCENT: u64 = 15;
pub const PRESSURE_LOW_BYTES: u64 = 20 * 1_000_000_000;
pub const PRESSURE_HIGH_PERCENT: u64 = 25;
pub const PRESSURE_HIGH_BYTES: u64 = 40 * 1_000_000_000;
pub fn disk_pressure(disk: Option<DiskSpace>, was_pressured: bool) -> bool {
let Some(disk) = disk else {
return false;
};
if was_pressured {
disk.below(PRESSURE_HIGH_PERCENT, PRESSURE_HIGH_BYTES)
} else {
disk.below(PRESSURE_LOW_PERCENT, PRESSURE_LOW_BYTES)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MaintenanceContext {
pub policy: UpdatePolicy,
pub pressured: bool,
pub keep: std::collections::BTreeSet<String>,
pub churned: std::collections::BTreeSet<String>,
pub superseded_unpressured_secs: u64,
}
impl MaintenanceContext {
pub const DEFAULT_SUPERSEDED_UNPRESSURED_SECS: u64 = 30 * 24 * 60 * 60;
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Decision {
pub model_id: String,
pub verdict: Verdict,
pub freed_bytes: u64,
pub digest: String,
pub reason: String,
}
pub fn auto_grace_until(since: u64, now: u64, window: u64) -> Option<u64> {
let until = since.saturating_add(window);
(now < until).then_some(until)
}
pub const AUTO_LOCAL_FOCUSED_MIN_RAM_MB: u64 = 32 * 1024;
pub const AUTO_LOCAL_FOCUSED_MIN_REFUSALS: u64 = 3;
pub const AUTO_LOCAL_FOCUSED_MIN_DAYS: u64 = 2;
pub const AUTO_REVERT_LIVE_REFUSALS: u64 = 3;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct PolicySizing {
pub from: crate::resource_policy::ResourceProfile,
pub to: crate::resource_policy::ResourceProfile,
pub reason: String,
pub applied: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PolicyMove {
Raise(String),
Revert(String),
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PolicyEvidence {
pub ceiling_refusals: u64,
pub ceiling_refusal_days: u64,
pub live_refusals_since_raise: u64,
pub admitted_failures_since_raise: u64,
pub admitted_successes_since_raise: u64,
}
pub fn decide_resource_policy(
current: &crate::resource_policy::ResourcePolicy,
user_chose: bool,
concierge: Option<&crate::resource_policy::ConciergeChoice>,
total_ram_mb: u64,
evidence: &PolicyEvidence,
) -> Option<PolicyMove> {
use crate::resource_policy::ResourceProfile;
if user_chose {
return None;
}
let raised_by_concierge = concierge
.is_some_and(|c| !c.reverted && c.policy.profile == ResourceProfile::LocalFocused)
&& current.profile == ResourceProfile::LocalFocused;
if raised_by_concierge {
if evidence.admitted_failures_since_raise >= AUTO_REVERT_LIVE_REFUSALS
&& evidence.admitted_successes_since_raise == 0
{
return Some(PolicyMove::Revert(format!(
"the models only the raise admits failed {} times and never succeeded since",
evidence.admitted_failures_since_raise
)));
}
return (evidence.live_refusals_since_raise >= AUTO_REVERT_LIVE_REFUSALS).then(|| {
PolicyMove::Revert(format!(
"{} loads of models the Everyday policy admits were refused for live memory \
after the raise (the larger allocation may not be the cause; going back is the \
safe direction)",
evidence.live_refusals_since_raise
))
});
}
if current.profile != ResourceProfile::Everyday
|| concierge.is_some_and(|c| c.reverted)
|| total_ram_mb < AUTO_LOCAL_FOCUSED_MIN_RAM_MB
|| evidence.ceiling_refusals < AUTO_LOCAL_FOCUSED_MIN_REFUSALS
|| evidence.ceiling_refusal_days < AUTO_LOCAL_FOCUSED_MIN_DAYS
{
return None;
}
Some(PolicyMove::Raise(format!(
"{} loads of local models too big for the Everyday policy, that Local-focused admits, \
were refused on {} days, on a machine with {} GB",
evidence.ceiling_refusals,
evidence.ceiling_refusal_days,
total_ram_mb / 1024
)))
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Held {
pub model_id: String,
pub why: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Retired {
pub model_id: String,
pub also_retired: Vec<String>,
pub freed_bytes: u64,
pub restore: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Upgraded {
pub from: String,
pub to: String,
pub lanes: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct RetiredOrphan {
pub repo: String,
pub freed_bytes: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct MaintenanceReport {
pub policy: UpdatePolicy,
pub dry_run: bool,
pub disk: Option<DiskSpace>,
pub pressured: bool,
pub plan: MaintenancePlan,
#[serde(skip_serializing_if = "Option::is_none")]
pub retired: Option<Retired>,
#[serde(skip_serializing_if = "Option::is_none")]
pub orphan_retired: Option<RetiredOrphan>,
pub partials_discarded: Vec<std::path::PathBuf>,
#[serde(skip)]
pub portfolio: Option<crate::portfolio::Portfolio>,
#[serde(skip_serializing_if = "Option::is_none")]
pub upgraded: Option<Upgraded>,
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_grace_until: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resource_policy: Option<PolicySizing>,
pub errors: Vec<String>,
}
impl MaintenanceReport {
pub fn acted(&self) -> bool {
self.retired.is_some()
|| self.upgraded.is_some()
|| self.orphan_retired.is_some()
|| !self.partials_discarded.is_empty()
|| self.resource_policy.as_ref().is_some_and(|p| p.applied)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct MaintenancePlan {
#[serde(skip_serializing_if = "Option::is_none")]
pub act: Option<Decision>,
pub suggest: Vec<Decision>,
pub held: Vec<Held>,
}
fn days(secs: u64) -> u64 {
secs / (24 * 60 * 60)
}
pub fn decide(portfolio: &Portfolio, ctx: &MaintenanceContext) -> MaintenancePlan {
let mut plan = MaintenancePlan::default();
if ctx.policy == UpdatePolicy::Off {
return plan;
}
let now = portfolio.policy.now;
let rank = |a: &Assessment| match a.verdict {
Verdict::NeverRunsHere | Verdict::CannotLoad => 0,
Verdict::Superseded => 1,
_ => 2,
};
let mut candidates: Vec<&Assessment> = portfolio
.models
.iter()
.filter(|a| a.action == PortfolioAction::Retire && a.retire.is_some())
.collect();
candidates.sort_by(|a, b| {
rank(a).cmp(&rank(b)).then_with(|| {
let freed = |x: &Assessment| x.retire.as_ref().map_or(0, |r| r.freed_bytes);
freed(b).cmp(&freed(a))
})
});
for assessment in candidates {
let id = &assessment.model_id;
let does_nothing = matches!(
assessment.verdict,
Verdict::NeverRunsHere | Verdict::CannotLoad
);
if ctx.keep.contains(id) {
plan.held.push(Held {
model_id: id.clone(),
why: "you asked to keep it".into(),
});
continue;
}
if !does_nothing && ctx.churned.contains(id) {
plan.held.push(Held {
model_id: id.clone(),
why: "it was retired before and fetched back, so it is in use".into(),
});
continue;
}
let silent = portfolio
.policy
.tracking_since
.map(|since| now.saturating_sub(assessment.last_used.unwrap_or(0).max(since)));
let allowed = match assessment.verdict {
Verdict::NeverRunsHere | Verdict::CannotLoad => true,
Verdict::Superseded => {
ctx.pressured || silent.is_some_and(|s| s >= ctx.superseded_unpressured_secs)
}
Verdict::Idle => ctx.pressured,
_ => false,
};
if !allowed {
plan.held.push(Held {
model_id: id.clone(),
why: match assessment.verdict {
Verdict::Superseded => format!(
"replaced, but space is not short and it was used within {} days",
days(ctx.superseded_unpressured_secs)
),
_ => "unused, but space is not short".into(),
},
});
continue;
}
let retire = assessment.retire.as_ref().expect("filtered above");
let busy = !retire.refusals.is_empty();
let decision = Decision {
model_id: id.clone(),
verdict: assessment.verdict,
freed_bytes: retire.freed_bytes,
digest: retire.digest.clone(),
reason: assessment.evidence.join("; "),
};
if ctx.policy == UpdatePolicy::Auto && plan.act.is_none() && !busy {
plan.act = Some(decision);
} else {
plan.suggest.push(decision);
}
}
plan
}
#[cfg(test)]
mod tests {
use super::*;
use crate::portfolio::{PortfolioPolicy, RetireSummary};
const DAY: u64 = 24 * 60 * 60;
const NOW: u64 = 1_000 * DAY;
const GB: u64 = 1_000_000_000;
fn assessment(id: &str, verdict: Verdict, freed: u64, last_used: Option<u64>) -> Assessment {
Assessment {
model_id: id.into(),
name: id.into(),
verdict,
action: PortfolioAction::Retire,
evidence: vec![format!("{verdict:?}")],
kept: false,
last_used,
retire: Some(RetireSummary {
freed_bytes: freed,
digest: format!("digest-{id}"),
refusals: Vec::new(),
also_retires: Vec::new(),
}),
}
}
fn portfolio(models: Vec<Assessment>) -> Portfolio {
Portfolio {
policy: PortfolioPolicy::new(NOW, Some(0)),
models,
reclaimable_bytes: 0,
orphans: Vec::new(),
recent_actions: Vec::new(),
revoked_pins: Vec::new(),
revoked_copies: Vec::new(),
}
}
fn ctx(policy: UpdatePolicy, pressured: bool) -> MaintenanceContext {
MaintenanceContext {
policy,
pressured,
keep: Default::default(),
churned: Default::default(),
superseded_unpressured_secs: MaintenanceContext::DEFAULT_SUPERSEDED_UNPRESSURED_SECS,
}
}
#[test]
fn off_does_nothing_and_notify_never_acts() {
let p = portfolio(vec![assessment("a", Verdict::NeverRunsHere, GB, None)]);
assert_eq!(
decide(&p, &ctx(UpdatePolicy::Off, true)),
MaintenancePlan::default()
);
let notify = decide(&p, &ctx(UpdatePolicy::Notify, true));
assert!(notify.act.is_none());
assert_eq!(notify.suggest.len(), 1);
}
#[test]
fn useless_models_go_first_and_idle_waits_for_pressure() {
let p = portfolio(vec![
assessment("idle", Verdict::Idle, 9 * GB, Some(NOW - 40 * DAY)),
assessment("gguf", Verdict::NeverRunsHere, GB, None),
]);
let calm = decide(&p, &ctx(UpdatePolicy::Auto, false));
assert_eq!(calm.act.as_ref().map(|d| d.model_id.as_str()), Some("gguf"));
assert!(calm.suggest.is_empty());
assert!(calm.held.iter().any(|h| h.model_id == "idle"));
let pressed = decide(&p, &ctx(UpdatePolicy::Auto, true));
assert_eq!(
pressed.act.as_ref().map(|d| d.model_id.as_str()),
Some("gguf")
);
assert_eq!(pressed.suggest.len(), 1, "one per pass; the rest queue");
}
#[test]
fn superseded_goes_after_a_month_or_under_pressure() {
let recent = assessment("old", Verdict::Superseded, GB, Some(NOW - 10 * DAY));
let p = portfolio(vec![recent.clone()]);
assert!(decide(&p, &ctx(UpdatePolicy::Auto, false)).act.is_none());
assert!(decide(&p, &ctx(UpdatePolicy::Auto, true)).act.is_some());
let stale = assessment("old", Verdict::Superseded, GB, Some(NOW - 31 * DAY));
assert!(
decide(&portfolio(vec![stale]), &ctx(UpdatePolicy::Auto, false))
.act
.is_some()
);
}
#[test]
fn superseded_silence_counts_only_observed_time() {
let mut p = portfolio(vec![assessment(
"old",
Verdict::Superseded,
GB,
Some(NOW - 40 * DAY),
)]);
p.policy.tracking_since = Some(NOW - 8 * DAY);
assert!(decide(&p, &ctx(UpdatePolicy::Auto, false)).act.is_none());
p.policy.tracking_since = Some(NOW - 35 * DAY);
assert!(decide(&p, &ctx(UpdatePolicy::Auto, false)).act.is_some());
let mut never = portfolio(vec![assessment("old", Verdict::Superseded, GB, None)]);
never.policy.tracking_since = Some(NOW - 10 * DAY);
assert!(decide(&never, &ctx(UpdatePolicy::Auto, false))
.act
.is_none());
}
#[test]
fn a_busy_candidate_does_not_stall_the_queue() {
let mut busy = assessment("busy", Verdict::NeverRunsHere, 9 * GB, None);
busy.retire.as_mut().unwrap().refusals = vec!["in_use".into()];
let p = portfolio(vec![
busy,
assessment("next", Verdict::CannotLoad, GB, None),
]);
let plan = decide(&p, &ctx(UpdatePolicy::Auto, false));
assert_eq!(plan.act.as_ref().map(|d| d.model_id.as_str()), Some("next"));
assert!(plan.suggest.iter().any(|d| d.model_id == "busy"));
}
#[test]
fn keep_and_churn_hold_a_model_back() {
let p = portfolio(vec![
assessment("kept", Verdict::Idle, GB, None),
assessment("loop", Verdict::Idle, GB, None),
assessment("dead", Verdict::CannotLoad, GB, None),
]);
let mut c = ctx(UpdatePolicy::Auto, true);
c.keep.insert("kept".into());
c.churned.insert("loop".into());
c.churned.insert("dead".into());
let plan = decide(&p, &c);
assert_eq!(plan.act.as_ref().map(|d| d.model_id.as_str()), Some("dead"));
assert!(plan.suggest.is_empty(), "{plan:?}");
assert_eq!(plan.held.len(), 2);
}
#[test]
fn the_memory_policy_rises_only_when_unchosen_roomy_and_refused() {
use crate::resource_policy::{ConciergeChoice, ResourcePolicy};
let everyday = ResourcePolicy::everyday();
let ram = 64 * 1024;
let seen = PolicyEvidence {
ceiling_refusals: 3,
ceiling_refusal_days: 2,
..Default::default()
};
let raise = |chose, ram, ev: &PolicyEvidence| {
decide_resource_policy(&everyday, chose, None, ram, ev)
};
assert!(matches!(
raise(false, ram, &seen),
Some(PolicyMove::Raise(_))
));
assert!(raise(true, ram, &seen).is_none(), "user chose");
assert!(raise(false, 16 * 1024, &seen).is_none(), "small machine");
let one_day = PolicyEvidence {
ceiling_refusal_days: 1,
..seen.clone()
};
assert!(
raise(false, ram, &one_day).is_none(),
"one occasion retried"
);
let reverted = ConciergeChoice {
policy: everyday.clone(),
at: 1,
reverted: true,
};
assert!(
decide_resource_policy(&everyday, false, Some(&reverted), ram, &seen).is_none(),
"never raises again after undoing a raise"
);
let local = ResourcePolicy::local_focused();
let raised = ConciergeChoice {
policy: local.clone(),
at: 1,
reverted: false,
};
let pressed = PolicyEvidence {
live_refusals_since_raise: 3,
..Default::default()
};
assert!(matches!(
decide_resource_policy(&local, false, Some(&raised), ram, &pressed),
Some(PolicyMove::Revert(_))
));
assert!(decide_resource_policy(&local, true, None, ram, &pressed).is_none());
let failing = PolicyEvidence {
admitted_failures_since_raise: 3,
..Default::default()
};
assert!(matches!(
decide_resource_policy(&local, false, Some(&raised), ram, &failing),
Some(PolicyMove::Revert(_))
));
let mixed = PolicyEvidence {
admitted_failures_since_raise: 3,
admitted_successes_since_raise: 1,
..Default::default()
};
assert!(decide_resource_policy(&local, false, Some(&raised), ram, &mixed).is_none());
assert!(decide_resource_policy(&local, false, Some(&raised), ram, &seen).is_none());
}
#[test]
fn the_unchosen_default_waits_a_week_before_acting() {
assert_eq!(auto_grace_until(NOW, NOW, 7 * DAY), Some(NOW + 7 * DAY));
assert_eq!(
auto_grace_until(NOW - 6 * DAY, NOW, 7 * DAY),
Some(NOW + DAY)
);
assert_eq!(auto_grace_until(NOW - 7 * DAY, NOW, 7 * DAY), None);
}
#[test]
fn pressure_has_hysteresis() {
let disk = |free_gb: u64| {
Some(DiskSpace {
free_bytes: free_gb * GB,
total_bytes: 1_000 * GB,
})
};
assert!(!disk_pressure(disk(200), false));
assert!(disk_pressure(disk(100), false), "under 15%");
assert!(disk_pressure(disk(200), true), "still under 25%");
assert!(!disk_pressure(disk(300), true), "back over the high mark");
assert!(!disk_pressure(None, true), "unknown space deletes nothing");
let small = Some(DiskSpace {
free_bytes: 19 * GB,
total_bytes: 100 * GB,
});
assert!(disk_pressure(small, false));
}
}