use std::collections::HashMap;
use std::path::PathBuf;
use std::time::SystemTime;
use crate::core::hardware::HardwareProfile;
#[derive(Clone, Default)]
pub struct EngineConfig {
pub tokenizer_path: Option<PathBuf>,
pub config_path: Option<PathBuf>,
pub queue_capacity: usize,
pub warmup_synchronously: bool,
pub kv_metrics_sink:
Option<std::sync::Arc<dyn crate::serve::kv_persist::metrics::KvCacheMetricsSink>>,
pub dwq_overlay_path: Option<PathBuf>,
pub engine_mode: crate::serve::api::engine::EngineMode,
}
impl std::fmt::Debug for EngineConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EngineConfig")
.field("tokenizer_path", &self.tokenizer_path)
.field("config_path", &self.config_path)
.field("queue_capacity", &self.queue_capacity)
.field("warmup_synchronously", &self.warmup_synchronously)
.field("kv_metrics_sink_present", &self.kv_metrics_sink.is_some())
.field("dwq_overlay_path", &self.dwq_overlay_path)
.field("engine_mode", &self.engine_mode)
.finish()
}
}
pub const DEFAULT_POOL_CAPACITY: usize = 3;
pub const DEFAULT_MEMORY_BUDGET_FRACTION: f64 = 0.80;
#[derive(Debug, Clone)]
pub struct LoadedHandle {
pub repo_id: String,
pub quant: String,
pub loaded_at: SystemTime,
pub bytes_resident: u64,
}
impl LoadedHandle {
pub fn new(repo_id: impl Into<String>, quant: impl Into<String>, bytes_resident: u64) -> Self {
Self {
repo_id: repo_id.into(),
quant: quant.into(),
loaded_at: SystemTime::now(),
bytes_resident,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PoolError {
OversizedHandle {
repo_id: String,
handle_bytes: u64,
budget_bytes: u64,
},
ZeroCapacity,
}
impl std::fmt::Display for PoolError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::OversizedHandle {
repo_id,
handle_bytes,
budget_bytes,
} => write!(
f,
"model {repo_id} resident bytes {handle_bytes} exceed pool memory budget \
{budget_bytes}; cannot load (consider raising the budget via the \
Phase 4 hot-swap config knob, or pick a smaller quant)"
),
Self::ZeroCapacity => write!(
f,
"multi-model pool is configured with capacity_models = 0; refusing \
every load. Either raise capacity (default 3) or remove the pool \
entirely from the deployment config."
),
}
}
}
impl std::error::Error for PoolError {}
#[derive(Debug)]
pub struct LoadedPool {
capacity_models: usize,
memory_budget_bytes: u64,
entries: HashMap<String, LoadedHandle>,
lru_order: Vec<String>,
total_resident_bytes: u64,
}
impl LoadedPool {
pub fn with_capacity_and_budget(capacity_models: usize, memory_budget_bytes: u64) -> Self {
Self {
capacity_models,
memory_budget_bytes,
entries: HashMap::with_capacity(capacity_models.max(1)),
lru_order: Vec::with_capacity(capacity_models.max(1)),
total_resident_bytes: 0,
}
}
pub fn from_hardware(hw: &HardwareProfile) -> Self {
if let Ok(s) = std::env::var("HF2Q_POOL_BUDGET_BYTES") {
if let Ok(n) = s.parse::<u64>() {
return Self::with_capacity_and_budget(DEFAULT_POOL_CAPACITY, n);
} else {
eprintln!(
"[ADR-017 iter-8] HF2Q_POOL_BUDGET_BYTES={s:?} did not \
parse as u64; falling through to hardware-derived budget"
);
}
}
Self::from_hardware_with(hw, DEFAULT_POOL_CAPACITY, DEFAULT_MEMORY_BUDGET_FRACTION)
}
pub fn from_hardware_with(hw: &HardwareProfile, capacity_models: usize, fraction: f64) -> Self {
let f = fraction.clamp(0.0, 1.0);
let budget = ((hw.total_memory_bytes as f64) * f).floor() as u64;
Self::with_capacity_and_budget(capacity_models, budget)
}
pub fn capacity_models(&self) -> usize {
self.capacity_models
}
pub fn memory_budget_bytes(&self) -> u64 {
self.memory_budget_bytes
}
pub fn total_resident_bytes(&self) -> u64 {
self.total_resident_bytes
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn get(&self, repo_id: &str) -> Option<&LoadedHandle> {
self.entries.get(repo_id)
}
pub fn touch(&mut self, repo_id: &str) -> bool {
if !self.entries.contains_key(repo_id) {
return false;
}
if let Some(pos) = self.lru_order.iter().position(|r| r == repo_id) {
let key = self.lru_order.remove(pos);
self.lru_order.push(key);
}
true
}
pub fn iter(&self) -> impl Iterator<Item = &LoadedHandle> {
self.lru_order
.iter()
.filter_map(move |k| self.entries.get(k))
}
pub fn remove(&mut self, repo_id: &str) -> Option<LoadedHandle> {
let handle = self.entries.remove(repo_id)?;
if let Some(pos) = self.lru_order.iter().position(|r| r == repo_id) {
self.lru_order.remove(pos);
}
self.total_resident_bytes = self
.total_resident_bytes
.saturating_sub(handle.bytes_resident);
Some(handle)
}
pub fn insert(&mut self, handle: LoadedHandle) -> Result<Vec<LoadedHandle>, PoolError> {
if self.capacity_models == 0 {
return Err(PoolError::ZeroCapacity);
}
if handle.bytes_resident > self.memory_budget_bytes {
return Err(PoolError::OversizedHandle {
repo_id: handle.repo_id,
handle_bytes: handle.bytes_resident,
budget_bytes: self.memory_budget_bytes,
});
}
let mut evicted: Vec<LoadedHandle> = Vec::new();
if let Some(existing) = self.entries.get_mut(&handle.repo_id) {
self.total_resident_bytes = self
.total_resident_bytes
.saturating_sub(existing.bytes_resident);
existing.bytes_resident = handle.bytes_resident;
existing.loaded_at = handle.loaded_at;
existing.quant = handle.quant.clone();
self.total_resident_bytes = self
.total_resident_bytes
.saturating_add(handle.bytes_resident);
if let Some(pos) = self.lru_order.iter().position(|r| r == &handle.repo_id) {
let key = self.lru_order.remove(pos);
self.lru_order.push(key);
}
self.evict_until_within_budget(&mut evicted, &handle.repo_id);
return Ok(evicted);
}
if self.entries.len() >= self.capacity_models {
if let Some(victim_key) = self.lru_order.first().cloned() {
if let Some(victim) = self.entries.remove(&victim_key) {
self.total_resident_bytes = self
.total_resident_bytes
.saturating_sub(victim.bytes_resident);
self.lru_order.remove(0);
evicted.push(victim);
}
}
}
while !self.lru_order.is_empty()
&& self
.total_resident_bytes
.saturating_add(handle.bytes_resident)
> self.memory_budget_bytes
{
let victim_key = self.lru_order.remove(0);
if let Some(victim) = self.entries.remove(&victim_key) {
self.total_resident_bytes = self
.total_resident_bytes
.saturating_sub(victim.bytes_resident);
evicted.push(victim);
}
}
self.total_resident_bytes = self
.total_resident_bytes
.saturating_add(handle.bytes_resident);
self.lru_order.push(handle.repo_id.clone());
self.entries.insert(handle.repo_id.clone(), handle);
Ok(evicted)
}
fn evict_until_within_budget(&mut self, evicted: &mut Vec<LoadedHandle>, spare_repo_id: &str) {
while self.total_resident_bytes > self.memory_budget_bytes {
let victim_idx = self.lru_order.iter().position(|k| k != spare_repo_id);
let Some(idx) = victim_idx else {
break;
};
let victim_key = self.lru_order.remove(idx);
if let Some(victim) = self.entries.remove(&victim_key) {
self.total_resident_bytes = self
.total_resident_bytes
.saturating_sub(victim.bytes_resident);
evicted.push(victim);
}
}
}
}
use std::path::Path;
use std::sync::Arc;
use crate::serve::api::engine::Engine;
use crate::serve::quant_select::QuantType;
#[derive(Debug)]
pub struct LoadedEngine<E> {
pub engine: E,
pub repo: String,
pub quant: QuantType,
pub bytes_resident: u64,
pub loaded_at: SystemTime,
}
pub trait ModelLoader<E>: Send + Sync {
fn load(&self, path: &Path, config: &EngineConfig) -> anyhow::Result<E>;
}
#[derive(Debug, Clone, Default)]
pub struct DefaultModelLoader;
impl ModelLoader<Engine> for DefaultModelLoader {
fn load(&self, path: &Path, config: &EngineConfig) -> anyhow::Result<Engine> {
crate::serve::load_engine(path, config)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SpillErrorKind {
CodecErr,
IoErr,
ParityFail,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RestoreErrorKind {
CodecErr,
IoErr,
ParityFail,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SpillOutcome {
Skipped,
EnqueuedBlocks(u32),
Error(SpillErrorKind),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RestoreOutcome {
Skipped,
RestoredBlocks(u32),
Error(RestoreErrorKind),
}
pub trait KvSpiller<E>: Send + Sync {
fn pre_evict(&self, handle: &LoadedHandle, engine: &Arc<LoadedEngine<E>>) -> SpillOutcome;
fn post_admit(
&self,
repo: &str,
quant: QuantType,
engine: &Arc<LoadedEngine<E>>,
) -> RestoreOutcome;
fn drop_family(&self, _repo: &str, _quant: QuantType) {}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct NoopKvSpiller;
impl<E> KvSpiller<E> for NoopKvSpiller {
fn pre_evict(&self, _handle: &LoadedHandle, _engine: &Arc<LoadedEngine<E>>) -> SpillOutcome {
SpillOutcome::Skipped
}
fn post_admit(
&self,
_repo: &str,
_quant: QuantType,
_engine: &Arc<LoadedEngine<E>>,
) -> RestoreOutcome {
RestoreOutcome::Skipped
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PoolStats {
pub loaded_count: usize,
pub capacity_models: usize,
pub total_resident_bytes: u64,
pub memory_budget_bytes: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LoadedSummary {
pub pool_key: String,
pub quant: String,
pub bytes_resident: u64,
}
#[derive(Debug)]
pub enum HotSwapError {
PoolRefused(PoolError),
LoaderFailed(anyhow::Error),
FileSize {
path: PathBuf,
source: std::io::Error,
},
}
impl std::fmt::Display for HotSwapError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::PoolRefused(e) => write!(f, "hot-swap pool refused entry: {e}"),
Self::LoaderFailed(e) => write!(f, "hot-swap loader failed: {e}"),
Self::FileSize { path, source } => write!(
f,
"hot-swap failed to read GGUF file size for {}: {source}",
path.display()
),
}
}
}
impl std::error::Error for HotSwapError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::PoolRefused(e) => Some(e),
Self::LoaderFailed(e) => Some(e.as_ref()),
Self::FileSize { source, .. } => Some(source),
}
}
}
fn pool_key(repo: &str, quant: QuantType) -> String {
format!("{repo}@{}", quant.as_str())
}
fn unpack_pool_key(pool_key_str: &str, quant_str: &str) -> Option<(String, QuantType)> {
let suffix_len = quant_str.len().checked_add(1)?;
if pool_key_str.len() < suffix_len {
return None;
}
let split = pool_key_str.len() - suffix_len;
let (repo, sep_and_quant) = pool_key_str.split_at(split);
if !sep_and_quant.starts_with('@') || &sep_and_quant[1..] != quant_str {
return None;
}
QuantType::from_canonical_str(quant_str)
.ok()
.map(|qt| (repo.to_string(), qt))
}
pub struct HotSwapManager<E> {
pool: LoadedPool,
loader: Arc<dyn ModelLoader<E>>,
spiller: Arc<dyn KvSpiller<E>>,
kv_counters: Option<Arc<crate::serve::api::state::KvSpillCounters>>,
engines: HashMap<String, Arc<LoadedEngine<E>>>,
}
impl<E> HotSwapManager<E> {
pub fn new(pool: LoadedPool, loader: Arc<dyn ModelLoader<E>>) -> Self
where
E: 'static,
{
Self::new_with_spiller(pool, loader, Arc::new(NoopKvSpiller))
}
pub fn new_with_spiller(
pool: LoadedPool,
loader: Arc<dyn ModelLoader<E>>,
spiller: Arc<dyn KvSpiller<E>>,
) -> Self {
Self {
pool,
loader,
spiller,
kv_counters: None,
engines: HashMap::new(),
}
}
pub fn set_kv_counters(&mut self, counters: Arc<crate::serve::api::state::KvSpillCounters>) {
self.kv_counters = Some(counters);
}
#[cfg(test)]
pub fn admit_for_test(
&mut self,
repo: &str,
quant: QuantType,
bytes_resident: u64,
engine: E,
) -> Result<Arc<LoadedEngine<E>>, PoolError>
where
E: 'static,
{
let k = pool_key(repo, quant);
let loaded_engine = Arc::new(LoadedEngine {
engine,
repo: repo.to_string(),
quant,
bytes_resident,
loaded_at: SystemTime::now(),
});
let handle = LoadedHandle {
repo_id: k.clone(),
quant: quant.as_str().to_string(),
loaded_at: loaded_engine.loaded_at,
bytes_resident,
};
let evicted = self.pool.insert(handle)?;
for victim in evicted {
self.engines.remove(&victim.repo_id);
}
self.engines.insert(k, Arc::clone(&loaded_engine));
Ok(loaded_engine)
}
pub fn pool_stats(&self) -> PoolStats {
PoolStats {
loaded_count: self.pool.len(),
capacity_models: self.pool.capacity_models(),
total_resident_bytes: self.pool.total_resident_bytes(),
memory_budget_bytes: self.pool.memory_budget_bytes(),
}
}
pub fn try_get(&self, repo: &str, quant: QuantType) -> Option<Arc<LoadedEngine<E>>> {
let k = pool_key(repo, quant);
self.engines.get(&k).cloned()
}
pub fn snapshot_engines(&self) -> Vec<Arc<LoadedEngine<E>>> {
self.pool
.iter()
.filter_map(|h| self.engines.get(&h.repo_id).cloned())
.collect()
}
pub fn iter_loaded(&self) -> impl Iterator<Item = LoadedSummary> + '_ {
self.pool.iter().map(|h| LoadedSummary {
pool_key: h.repo_id.clone(),
quant: h.quant.clone(),
bytes_resident: h.bytes_resident,
})
}
pub fn evict(&mut self, repo: &str, quant: QuantType) -> u64 {
let k = pool_key(repo, quant);
let removed = self.pool.remove(&k);
if let (Some(handle), Some(arc)) = (removed.as_ref(), self.engines.get(&k).cloned()) {
let outcome = self.spiller.pre_evict(handle, &arc);
if let Some(counters) = self.kv_counters.as_ref() {
counters.record_spill(repo, quant, outcome);
}
}
self.engines.remove(&k);
self.spiller.drop_family(repo, quant);
removed.map(|h| h.bytes_resident).unwrap_or(0)
}
pub fn load_or_get(
&mut self,
repo: &str,
quant: QuantType,
gguf_path: &Path,
config: &EngineConfig,
) -> Result<Arc<LoadedEngine<E>>, HotSwapError> {
let k = pool_key(repo, quant);
if let Some(existing) = self.engines.get(&k).cloned() {
self.pool.touch(&k);
return Ok(existing);
}
let bytes_resident = std::fs::metadata(gguf_path)
.map_err(|source| HotSwapError::FileSize {
path: gguf_path.to_path_buf(),
source,
})?
.len();
let engine = self
.loader
.load(gguf_path, config)
.map_err(HotSwapError::LoaderFailed)?;
let loaded_engine = Arc::new(LoadedEngine {
engine,
repo: repo.to_string(),
quant,
bytes_resident,
loaded_at: SystemTime::now(),
});
let handle = LoadedHandle {
repo_id: k.clone(),
quant: quant.as_str().to_string(),
loaded_at: loaded_engine.loaded_at,
bytes_resident,
};
let evicted = match self.pool.insert(handle) {
Ok(evicted) => evicted,
Err(e) => {
drop(loaded_engine);
return Err(HotSwapError::PoolRefused(e));
}
};
for victim in evicted {
if let Some(arc) = self.engines.get(&victim.repo_id).cloned() {
let outcome = self.spiller.pre_evict(&victim, &arc);
if let Some(counters) = self.kv_counters.as_ref() {
if let Some((victim_repo, victim_quant)) =
unpack_pool_key(&victim.repo_id, &victim.quant)
{
counters.record_spill(&victim_repo, victim_quant, outcome);
}
}
}
self.engines.remove(&victim.repo_id);
if let Some((victim_repo, victim_quant)) =
unpack_pool_key(&victim.repo_id, &victim.quant)
{
self.spiller.drop_family(&victim_repo, victim_quant);
}
}
let outcome = self.spiller.post_admit(repo, quant, &loaded_engine);
if let Some(counters) = self.kv_counters.as_ref() {
counters.record_restore(repo, quant, outcome);
}
self.engines.insert(k, Arc::clone(&loaded_engine));
Ok(loaded_engine)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn h(repo: &str, bytes: u64) -> LoadedHandle {
LoadedHandle::new(repo, "Q4_K_M", bytes)
}
#[test]
fn empty_pool_is_empty() {
let p = LoadedPool::with_capacity_and_budget(3, 1_000);
assert!(p.is_empty());
assert_eq!(p.len(), 0);
assert_eq!(p.total_resident_bytes(), 0);
assert_eq!(p.capacity_models(), 3);
assert_eq!(p.memory_budget_bytes(), 1_000);
assert!(p.iter().next().is_none());
}
fn synthetic_hw(total_memory_bytes: u64) -> HardwareProfile {
HardwareProfile {
chip_model: "Synthetic Test Chip".into(),
total_memory_bytes,
available_memory_bytes: total_memory_bytes,
performance_cores: 8,
efficiency_cores: 4,
total_cores: 12,
memory_bandwidth_gbs: 400.0,
}
}
#[test]
fn from_hardware_applies_eighty_percent_default() {
let hw = synthetic_hw(128 * 1024 * 1024 * 1024); let p = LoadedPool::from_hardware(&hw);
assert_eq!(p.capacity_models(), DEFAULT_POOL_CAPACITY);
let expected = ((128.0_f64 * 1024.0 * 1024.0 * 1024.0) * 0.80).floor() as u64;
assert_eq!(p.memory_budget_bytes(), expected);
}
#[test]
fn from_hardware_with_clamps_fraction() {
let hw = synthetic_hw(1_000_000);
let p_high = LoadedPool::from_hardware_with(&hw, 3, 1.5);
assert_eq!(p_high.memory_budget_bytes(), 1_000_000);
let p_low = LoadedPool::from_hardware_with(&hw, 3, -0.1);
assert_eq!(p_low.memory_budget_bytes(), 0);
}
#[test]
fn capacity_one_evicts_on_second_insert() {
let mut p = LoadedPool::with_capacity_and_budget(1, 1_000_000);
let evicted_a = p.insert(h("a/1", 100)).unwrap();
assert!(evicted_a.is_empty());
let evicted_b = p.insert(h("b/2", 200)).unwrap();
assert_eq!(evicted_b.len(), 1);
assert_eq!(evicted_b[0].repo_id, "a/1");
assert_eq!(p.len(), 1);
assert!(p.get("a/1").is_none());
assert!(p.get("b/2").is_some());
assert_eq!(p.total_resident_bytes(), 200);
}
#[test]
fn capacity_three_evicts_lru_first() {
let mut p = LoadedPool::with_capacity_and_budget(3, 1_000_000_000);
let _ = p.insert(h("a/1", 100)).unwrap(); let _ = p.insert(h("b/2", 200)).unwrap();
let _ = p.insert(h("c/3", 300)).unwrap(); assert_eq!(p.len(), 3);
let evicted = p.insert(h("d/4", 400)).unwrap();
assert_eq!(evicted.len(), 1);
assert_eq!(evicted[0].repo_id, "a/1");
let order: Vec<&str> = p.iter().map(|h| h.repo_id.as_str()).collect();
assert_eq!(order, vec!["b/2", "c/3", "d/4"]);
assert_eq!(p.total_resident_bytes(), 200 + 300 + 400);
}
#[test]
fn budget_evicts_without_capacity_overflow() {
let mut p = LoadedPool::with_capacity_and_budget(5, 1_000);
let _ = p.insert(h("a/1", 300)).unwrap(); let _ = p.insert(h("b/2", 300)).unwrap(); let _ = p.insert(h("c/3", 300)).unwrap(); assert_eq!(p.total_resident_bytes(), 900);
let evicted = p.insert(h("d/4", 200)).unwrap();
assert_eq!(evicted.len(), 1);
assert_eq!(evicted[0].repo_id, "a/1");
assert_eq!(p.len(), 3);
assert_eq!(p.total_resident_bytes(), 800);
}
#[test]
fn budget_eviction_chains_until_fit() {
let mut p = LoadedPool::with_capacity_and_budget(5, 1_000);
let _ = p.insert(h("a/1", 300)).unwrap();
let _ = p.insert(h("b/2", 300)).unwrap();
let _ = p.insert(h("c/3", 300)).unwrap();
let evicted = p.insert(h("big/1", 950)).unwrap();
assert_eq!(evicted.len(), 3);
assert_eq!(
evicted
.iter()
.map(|h| h.repo_id.as_str())
.collect::<Vec<_>>(),
vec!["a/1", "b/2", "c/3"]
);
assert_eq!(p.len(), 1);
assert_eq!(p.total_resident_bytes(), 950);
}
#[test]
fn capacity_and_budget_evict_in_one_insert() {
let mut p = LoadedPool::with_capacity_and_budget(2, 800);
let _ = p.insert(h("a/1", 400)).unwrap();
let _ = p.insert(h("b/2", 400)).unwrap(); let evicted = p.insert(h("c/3", 500)).unwrap();
assert_eq!(evicted.len(), 2);
let names: Vec<&str> = evicted.iter().map(|h| h.repo_id.as_str()).collect();
assert_eq!(names, vec!["a/1", "b/2"]);
assert_eq!(p.len(), 1);
assert_eq!(p.total_resident_bytes(), 500);
}
#[test]
fn touch_promotes_to_mru() {
let mut p = LoadedPool::with_capacity_and_budget(3, 1_000_000);
let _ = p.insert(h("a/1", 100)).unwrap(); let _ = p.insert(h("b/2", 200)).unwrap();
let _ = p.insert(h("c/3", 300)).unwrap(); assert!(p.touch("a/1"));
let order: Vec<&str> = p.iter().map(|h| h.repo_id.as_str()).collect();
assert_eq!(order, vec!["b/2", "c/3", "a/1"]);
let evicted = p.insert(h("d/4", 400)).unwrap();
assert_eq!(evicted.len(), 1);
assert_eq!(evicted[0].repo_id, "b/2");
}
#[test]
fn get_does_not_touch() {
let mut p = LoadedPool::with_capacity_and_budget(3, 1_000_000);
let _ = p.insert(h("a/1", 100)).unwrap(); let _ = p.insert(h("b/2", 200)).unwrap();
let _ = p.insert(h("c/3", 300)).unwrap();
let _ = p.get("a/1").unwrap();
let evicted = p.insert(h("d/4", 400)).unwrap();
assert_eq!(evicted.len(), 1);
assert_eq!(evicted[0].repo_id, "a/1");
}
#[test]
fn touch_unknown_is_noop() {
let mut p = LoadedPool::with_capacity_and_budget(3, 1_000);
assert!(!p.touch("nope/0"));
}
#[test]
fn remove_returns_handle_and_updates_bytes() {
let mut p = LoadedPool::with_capacity_and_budget(3, 1_000);
let _ = p.insert(h("a/1", 100)).unwrap();
let _ = p.insert(h("b/2", 200)).unwrap();
assert_eq!(p.total_resident_bytes(), 300);
let removed = p.remove("a/1").unwrap();
assert_eq!(removed.repo_id, "a/1");
assert_eq!(removed.bytes_resident, 100);
assert_eq!(p.total_resident_bytes(), 200);
assert_eq!(p.len(), 1);
assert!(p.remove("a/1").is_none());
}
#[test]
fn reinsert_same_repo_updates_bytes_and_promotes() {
let mut p = LoadedPool::with_capacity_and_budget(3, 10_000);
let _ = p.insert(h("a/1", 100)).unwrap(); let _ = p.insert(h("b/2", 200)).unwrap();
let _ = p.insert(h("c/3", 300)).unwrap(); let evicted = p.insert(h("a/1", 1500)).unwrap();
assert!(evicted.is_empty(), "re-insert must not self-evict");
let order: Vec<&str> = p.iter().map(|h| h.repo_id.as_str()).collect();
assert_eq!(order, vec!["b/2", "c/3", "a/1"]);
assert_eq!(p.total_resident_bytes(), 1500 + 200 + 300);
assert_eq!(p.get("a/1").unwrap().bytes_resident, 1500);
}
#[test]
fn reinsert_with_budget_overflow_evicts_others_not_self() {
let mut p = LoadedPool::with_capacity_and_budget(5, 1_000);
let _ = p.insert(h("a/1", 300)).unwrap();
let _ = p.insert(h("b/2", 300)).unwrap();
let _ = p.insert(h("c/3", 300)).unwrap();
let evicted = p.insert(h("b/2", 800)).unwrap();
let evicted_names: Vec<&str> = evicted.iter().map(|h| h.repo_id.as_str()).collect();
assert_eq!(evicted_names, vec!["a/1", "c/3"]);
assert!(p.get("b/2").is_some(), "self never evicted on re-insert");
assert_eq!(p.len(), 1);
assert_eq!(p.total_resident_bytes(), 800);
}
#[test]
fn zero_capacity_refuses_every_insert() {
let mut p = LoadedPool::with_capacity_and_budget(0, 1_000);
let err = p.insert(h("a/1", 100)).unwrap_err();
assert_eq!(err, PoolError::ZeroCapacity);
let msg = format!("{err}");
assert!(msg.contains("capacity_models = 0"), "msg = {msg}");
}
#[test]
fn oversized_handle_refused() {
let mut p = LoadedPool::with_capacity_and_budget(3, 1_000);
let err = p.insert(h("big/1", 1_001)).unwrap_err();
match err {
PoolError::OversizedHandle {
repo_id,
handle_bytes,
budget_bytes,
} => {
assert_eq!(repo_id, "big/1");
assert_eq!(handle_bytes, 1_001);
assert_eq!(budget_bytes, 1_000);
}
other => panic!("unexpected error: {other:?}"),
}
assert!(p.is_empty());
}
#[test]
fn zero_budget_refuses_any_nonzero_handle() {
let mut p = LoadedPool::with_capacity_and_budget(3, 0);
let err = p.insert(h("a/1", 1)).unwrap_err();
assert!(matches!(err, PoolError::OversizedHandle { .. }));
let evicted = p.insert(h("zerobyte/0", 0)).unwrap();
assert!(evicted.is_empty());
assert_eq!(p.len(), 1);
}
#[test]
fn iter_yields_lru_to_mru() {
let mut p = LoadedPool::with_capacity_and_budget(3, 10_000);
let _ = p.insert(h("a/1", 100)).unwrap();
let _ = p.insert(h("b/2", 200)).unwrap();
let _ = p.insert(h("c/3", 300)).unwrap();
let order: Vec<&str> = p.iter().map(|h| h.repo_id.as_str()).collect();
assert_eq!(order, vec!["a/1", "b/2", "c/3"]);
}
#[test]
fn oversized_error_message_names_budget_and_repo() {
let err = PoolError::OversizedHandle {
repo_id: "huge/model".into(),
handle_bytes: 9_000,
budget_bytes: 1_000,
};
let msg = format!("{err}");
assert!(msg.contains("huge/model"));
assert!(msg.contains("9000"));
assert!(msg.contains("1000"));
assert!(msg.contains("Phase 4"));
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct MockEngine {
load_serial: u64,
}
struct MockLoader {
calls: std::sync::atomic::AtomicU64,
fail_on_call: Option<u64>, }
impl MockLoader {
fn new() -> Self {
Self {
calls: std::sync::atomic::AtomicU64::new(0),
fail_on_call: None,
}
}
fn fail_on(call_num: u64) -> Self {
Self {
calls: std::sync::atomic::AtomicU64::new(0),
fail_on_call: Some(call_num),
}
}
fn call_count(&self) -> u64 {
self.calls.load(std::sync::atomic::Ordering::SeqCst)
}
}
impl ModelLoader<MockEngine> for MockLoader {
fn load(&self, _path: &Path, _config: &EngineConfig) -> anyhow::Result<MockEngine> {
let n = self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1;
if self.fail_on_call == Some(n) {
anyhow::bail!("MockLoader synthetic failure on call {n}");
}
Ok(MockEngine { load_serial: n })
}
}
fn synthetic_gguf(size: usize) -> tempfile::NamedTempFile {
use std::io::Write;
let mut f = tempfile::NamedTempFile::new().expect("temp file");
let chunk = vec![0u8; 4096.min(size)];
let mut remaining = size;
while remaining > 0 {
let n = remaining.min(chunk.len());
f.write_all(&chunk[..n]).expect("write");
remaining -= n;
}
f.flush().expect("flush");
f
}
fn empty_config() -> EngineConfig {
EngineConfig::default()
}
#[test]
fn hotswap_loads_on_first_request() {
let loader = Arc::new(MockLoader::new());
let pool = LoadedPool::with_capacity_and_budget(3, 100_000);
let mut mgr = HotSwapManager::<MockEngine>::new(pool, loader.clone());
let f = synthetic_gguf(1_000);
let cfg = empty_config();
let engine = mgr
.load_or_get("acme/m1", QuantType::Q4_K_M, f.path(), &cfg)
.expect("first load");
assert_eq!(engine.repo, "acme/m1");
assert_eq!(engine.quant, QuantType::Q4_K_M);
assert_eq!(engine.bytes_resident, 1_000);
assert_eq!(engine.engine.load_serial, 1);
assert_eq!(loader.call_count(), 1);
let stats = mgr.pool_stats();
assert_eq!(stats.loaded_count, 1);
assert_eq!(stats.total_resident_bytes, 1_000);
}
#[test]
fn hotswap_reuses_pooled_engine() {
let loader = Arc::new(MockLoader::new());
let pool = LoadedPool::with_capacity_and_budget(3, 100_000);
let mut mgr = HotSwapManager::<MockEngine>::new(pool, loader.clone());
let f = synthetic_gguf(500);
let cfg = empty_config();
let e1 = mgr
.load_or_get("acme/m1", QuantType::Q4_K_M, f.path(), &cfg)
.expect("first");
let e2 = mgr
.load_or_get("acme/m1", QuantType::Q4_K_M, f.path(), &cfg)
.expect("second");
assert!(Arc::ptr_eq(&e1, &e2), "second call must return same Arc");
assert_eq!(loader.call_count(), 1);
}
#[test]
fn hotswap_evicts_lru_on_pressure() {
let loader = Arc::new(MockLoader::new());
let pool = LoadedPool::with_capacity_and_budget(2, 1_000_000);
let mut mgr = HotSwapManager::<MockEngine>::new(pool, loader);
let f = synthetic_gguf(1_000);
let cfg = empty_config();
let _e1 = mgr
.load_or_get("a/1", QuantType::Q4_K_M, f.path(), &cfg)
.expect("a/1");
let _e2 = mgr
.load_or_get("b/2", QuantType::Q4_K_M, f.path(), &cfg)
.expect("b/2");
let _e3 = mgr
.load_or_get("c/3", QuantType::Q4_K_M, f.path(), &cfg)
.expect("c/3");
assert!(mgr.try_get("a/1", QuantType::Q4_K_M).is_none());
assert!(mgr.try_get("b/2", QuantType::Q4_K_M).is_some());
assert!(mgr.try_get("c/3", QuantType::Q4_K_M).is_some());
let stats = mgr.pool_stats();
assert_eq!(stats.loaded_count, 2);
}
#[test]
fn hotswap_evicts_lru_on_byte_pressure() {
let loader = Arc::new(MockLoader::new());
let pool = LoadedPool::with_capacity_and_budget(5, 2_500);
let mut mgr = HotSwapManager::<MockEngine>::new(pool, loader);
let f1 = synthetic_gguf(1_000);
let f2 = synthetic_gguf(1_000);
let f3 = synthetic_gguf(1_000);
let cfg = empty_config();
let _ = mgr
.load_or_get("a/1", QuantType::Q4_K_M, f1.path(), &cfg)
.expect("a");
let _ = mgr
.load_or_get("b/2", QuantType::Q4_K_M, f2.path(), &cfg)
.expect("b");
let _ = mgr
.load_or_get("c/3", QuantType::Q4_K_M, f3.path(), &cfg)
.expect("c");
assert!(mgr.try_get("a/1", QuantType::Q4_K_M).is_none());
let stats = mgr.pool_stats();
assert_eq!(stats.loaded_count, 2);
assert_eq!(stats.total_resident_bytes, 2_000);
}
#[test]
fn hotswap_errors_when_no_evictable_fits() {
let loader = Arc::new(MockLoader::new());
let pool = LoadedPool::with_capacity_and_budget(3, 500);
let mut mgr = HotSwapManager::<MockEngine>::new(pool, loader.clone());
let f = synthetic_gguf(1_500);
let cfg = empty_config();
let err = mgr
.load_or_get("big/1", QuantType::Q4_K_M, f.path(), &cfg)
.expect_err("should refuse oversized");
match err {
HotSwapError::PoolRefused(PoolError::OversizedHandle {
repo_id,
handle_bytes,
budget_bytes,
}) => {
assert_eq!(repo_id, "big/1@Q4_K_M");
assert_eq!(handle_bytes, 1_500);
assert_eq!(budget_bytes, 500);
}
other => panic!("unexpected error: {other:?}"),
}
assert_eq!(loader.call_count(), 1);
assert_eq!(mgr.pool_stats().loaded_count, 0);
assert!(mgr.try_get("big/1", QuantType::Q4_K_M).is_none());
}
#[test]
fn hotswap_evict_explicit_removes() {
let loader = Arc::new(MockLoader::new());
let pool = LoadedPool::with_capacity_and_budget(3, 100_000);
let mut mgr = HotSwapManager::<MockEngine>::new(pool, loader);
let f = synthetic_gguf(700);
let cfg = empty_config();
let _ = mgr
.load_or_get("acme/m1", QuantType::Q4_K_M, f.path(), &cfg)
.expect("load");
assert_eq!(mgr.pool_stats().loaded_count, 1);
let bytes_freed = mgr.evict("acme/m1", QuantType::Q4_K_M);
assert_eq!(bytes_freed, 700);
assert!(mgr.try_get("acme/m1", QuantType::Q4_K_M).is_none());
assert_eq!(mgr.pool_stats().loaded_count, 0);
assert_eq!(mgr.pool_stats().total_resident_bytes, 0);
assert_eq!(mgr.evict("acme/m1", QuantType::Q4_K_M), 0);
}
#[test]
fn hotswap_try_get_returns_none_when_absent() {
let loader = Arc::new(MockLoader::new());
let pool = LoadedPool::with_capacity_and_budget(3, 1_000);
let mgr = HotSwapManager::<MockEngine>::new(pool, loader);
assert!(mgr.try_get("nope/0", QuantType::Q4_K_M).is_none());
}
#[test]
fn hotswap_try_get_returns_arc_when_present() {
let loader = Arc::new(MockLoader::new());
let pool = LoadedPool::with_capacity_and_budget(3, 100_000);
let mut mgr = HotSwapManager::<MockEngine>::new(pool, loader);
let f = synthetic_gguf(500);
let cfg = empty_config();
let loaded = mgr
.load_or_get("acme/m1", QuantType::Q4_K_M, f.path(), &cfg)
.expect("load");
let viewed = mgr.try_get("acme/m1", QuantType::Q4_K_M).expect("present");
assert!(Arc::ptr_eq(&loaded, &viewed));
}
#[test]
fn hotswap_try_get_does_not_touch_lru() {
let loader = Arc::new(MockLoader::new());
let pool = LoadedPool::with_capacity_and_budget(2, 1_000_000);
let mut mgr = HotSwapManager::<MockEngine>::new(pool, loader);
let f = synthetic_gguf(1_000);
let cfg = empty_config();
let _ = mgr
.load_or_get("a/1", QuantType::Q4_K_M, f.path(), &cfg)
.expect("a");
let _ = mgr
.load_or_get("b/2", QuantType::Q4_K_M, f.path(), &cfg)
.expect("b");
let _peek = mgr.try_get("a/1", QuantType::Q4_K_M).unwrap();
let _ = mgr
.load_or_get("c/3", QuantType::Q4_K_M, f.path(), &cfg)
.expect("c");
assert!(
mgr.try_get("a/1", QuantType::Q4_K_M).is_none(),
"try_get must NOT promote — a/1 should evict as LRU"
);
}
#[test]
fn hotswap_pool_stats_reflects_loads_and_evictions() {
let loader = Arc::new(MockLoader::new());
let pool = LoadedPool::with_capacity_and_budget(2, 5_000);
let mut mgr = HotSwapManager::<MockEngine>::new(pool, loader);
let f1 = synthetic_gguf(800);
let f2 = synthetic_gguf(800);
let cfg = empty_config();
let s0 = mgr.pool_stats();
assert_eq!(s0.loaded_count, 0);
assert_eq!(s0.total_resident_bytes, 0);
assert_eq!(s0.capacity_models, 2);
assert_eq!(s0.memory_budget_bytes, 5_000);
let _ = mgr.load_or_get("a/1", QuantType::Q4_K_M, f1.path(), &cfg);
let s1 = mgr.pool_stats();
assert_eq!(s1.loaded_count, 1);
assert_eq!(s1.total_resident_bytes, 800);
let _ = mgr.load_or_get("b/2", QuantType::Q4_K_M, f2.path(), &cfg);
let s2 = mgr.pool_stats();
assert_eq!(s2.loaded_count, 2);
assert_eq!(s2.total_resident_bytes, 1_600);
mgr.evict("a/1", QuantType::Q4_K_M);
let s3 = mgr.pool_stats();
assert_eq!(s3.loaded_count, 1);
assert_eq!(s3.total_resident_bytes, 800);
}
#[test]
fn hotswap_loader_error_propagates() {
let loader = Arc::new(MockLoader::fail_on(1));
let pool = LoadedPool::with_capacity_and_budget(3, 100_000);
let mut mgr = HotSwapManager::<MockEngine>::new(pool, loader.clone());
let f = synthetic_gguf(500);
let cfg = empty_config();
let err = mgr
.load_or_get("acme/m1", QuantType::Q4_K_M, f.path(), &cfg)
.expect_err("loader failure must propagate");
match err {
HotSwapError::LoaderFailed(e) => {
let msg = format!("{e}");
assert!(
msg.contains("synthetic failure"),
"expected synthetic failure msg, got: {msg}"
);
}
other => panic!("unexpected error: {other:?}"),
}
assert_eq!(loader.call_count(), 1);
assert_eq!(mgr.pool_stats().loaded_count, 0);
assert!(mgr.try_get("acme/m1", QuantType::Q4_K_M).is_none());
}
#[test]
fn hotswap_file_size_error_when_gguf_missing() {
let loader = Arc::new(MockLoader::new());
let pool = LoadedPool::with_capacity_and_budget(3, 100_000);
let mut mgr = HotSwapManager::<MockEngine>::new(pool, loader.clone());
let cfg = empty_config();
let err = mgr
.load_or_get(
"acme/m1",
QuantType::Q4_K_M,
Path::new("/nonexistent/path/to/no.gguf"),
&cfg,
)
.expect_err("missing GGUF must error");
match err {
HotSwapError::FileSize { path, .. } => {
assert!(path.to_string_lossy().contains("/nonexistent/"));
}
other => panic!("unexpected error: {other:?}"),
}
assert_eq!(loader.call_count(), 0);
assert_eq!(mgr.pool_stats().loaded_count, 0);
}
#[test]
fn hotswap_two_quants_of_same_repo_coexist() {
let loader = Arc::new(MockLoader::new());
let pool = LoadedPool::with_capacity_and_budget(3, 100_000);
let mut mgr = HotSwapManager::<MockEngine>::new(pool, loader.clone());
let f = synthetic_gguf(500);
let cfg = empty_config();
let _ = mgr
.load_or_get("acme/m1", QuantType::Q4_K_M, f.path(), &cfg)
.expect("Q4_K_M");
let _ = mgr
.load_or_get("acme/m1", QuantType::Q8_0, f.path(), &cfg)
.expect("Q8_0");
assert!(mgr.try_get("acme/m1", QuantType::Q4_K_M).is_some());
assert!(mgr.try_get("acme/m1", QuantType::Q8_0).is_some());
assert_eq!(mgr.pool_stats().loaded_count, 2);
assert_eq!(loader.call_count(), 2);
}
#[test]
fn hotswap_in_flight_arc_survives_eviction() {
let loader = Arc::new(MockLoader::new());
let pool = LoadedPool::with_capacity_and_budget(1, 100_000);
let mut mgr = HotSwapManager::<MockEngine>::new(pool, loader);
let f = synthetic_gguf(500);
let cfg = empty_config();
let inflight = mgr
.load_or_get("a/1", QuantType::Q4_K_M, f.path(), &cfg)
.expect("a/1");
assert_eq!(Arc::strong_count(&inflight), 2);
let _ = mgr
.load_or_get("b/2", QuantType::Q4_K_M, f.path(), &cfg)
.expect("b/2");
assert_eq!(
Arc::strong_count(&inflight),
1,
"manager must have released its Arc on eviction"
);
assert_eq!(inflight.repo, "a/1");
assert_eq!(inflight.engine.load_serial, 1);
drop(inflight);
}
#[test]
fn pool_holds_three_models_within_budget() {
let loader = Arc::new(MockLoader::new());
let pool = LoadedPool::with_capacity_and_budget(DEFAULT_POOL_CAPACITY, 1_000_000);
let mut mgr = HotSwapManager::<MockEngine>::new(pool, loader.clone());
let cfg = empty_config();
let f1 = synthetic_gguf(1_024);
let f2 = synthetic_gguf(1_024);
let f3 = synthetic_gguf(1_024);
let _e1 = mgr
.load_or_get("acme/m1", QuantType::Q4_K_M, f1.path(), &cfg)
.expect("admit m1");
let _e2 = mgr
.load_or_get("acme/m2", QuantType::Q4_K_M, f2.path(), &cfg)
.expect("admit m2");
let _e3 = mgr
.load_or_get("acme/m3", QuantType::Q4_K_M, f3.path(), &cfg)
.expect("admit m3");
let stats = mgr.pool_stats();
assert_eq!(
stats.loaded_count, 3,
"expected 3 distinct repos pooled; stats={stats:?}"
);
assert_eq!(stats.capacity_models, DEFAULT_POOL_CAPACITY);
assert_eq!(stats.total_resident_bytes, 3 * 1_024);
assert!(
stats.total_resident_bytes < stats.memory_budget_bytes,
"AC 5467 invariant violated: total_resident_bytes={} >= memory_budget_bytes={}",
stats.total_resident_bytes,
stats.memory_budget_bytes,
);
assert_eq!(loader.call_count(), 3);
assert!(mgr.try_get("acme/m1", QuantType::Q4_K_M).is_some());
assert!(mgr.try_get("acme/m2", QuantType::Q4_K_M).is_some());
assert!(mgr.try_get("acme/m3", QuantType::Q4_K_M).is_some());
}
#[test]
fn pool_from_hardware_yields_eighty_percent_budget_for_three_models() {
const M5_MAX_UNIFIED_BYTES: u64 = 128 * 1024 * 1024 * 1024;
const GEMMA_SIZED_BYTES: u64 = 16 * 1024 * 1024 * 1024;
let hw = synthetic_hw(M5_MAX_UNIFIED_BYTES);
let pool = LoadedPool::from_hardware(&hw);
let expected_budget = ((M5_MAX_UNIFIED_BYTES as f64) * 0.80).floor() as u64;
assert_eq!(pool.memory_budget_bytes(), expected_budget);
assert_eq!(pool.capacity_models(), DEFAULT_POOL_CAPACITY);
assert!(
3 * GEMMA_SIZED_BYTES < pool.memory_budget_bytes(),
"M5 Max budget {} cannot fit 3× 16 GiB Gemma; AC 5467 sizing assumption broken",
pool.memory_budget_bytes()
);
}
#[test]
fn hotswap_chains_multiple_evictions_in_one_load() {
let loader = Arc::new(MockLoader::new());
let pool = LoadedPool::with_capacity_and_budget(5, 1_500);
let mut mgr = HotSwapManager::<MockEngine>::new(pool, loader.clone());
let f_small = synthetic_gguf(500);
let f_big = synthetic_gguf(1_000);
let cfg = empty_config();
let _ = mgr
.load_or_get("a/1", QuantType::Q4_K_M, f_small.path(), &cfg)
.expect("a");
let _ = mgr
.load_or_get("b/2", QuantType::Q4_K_M, f_small.path(), &cfg)
.expect("b");
let _ = mgr
.load_or_get("c/3", QuantType::Q4_K_M, f_small.path(), &cfg)
.expect("c");
assert_eq!(mgr.pool_stats().loaded_count, 3);
assert_eq!(mgr.pool_stats().total_resident_bytes, 1_500);
let _ = mgr
.load_or_get("d/4", QuantType::Q4_K_M, f_big.path(), &cfg)
.expect("d");
assert!(
mgr.try_get("a/1", QuantType::Q4_K_M).is_none(),
"A (LRU) must be evicted"
);
assert!(
mgr.try_get("b/2", QuantType::Q4_K_M).is_none(),
"B (second-LRU) must also be evicted — single eviction insufficient"
);
assert!(
mgr.try_get("c/3", QuantType::Q4_K_M).is_some(),
"C (MRU before D) must survive — eviction stops as soon as budget fits"
);
assert!(
mgr.try_get("d/4", QuantType::Q4_K_M).is_some(),
"D (newly admitted) must be resident"
);
let stats = mgr.pool_stats();
assert_eq!(stats.loaded_count, 2);
assert_eq!(stats.total_resident_bytes, 1_500);
assert!(
stats.total_resident_bytes <= stats.memory_budget_bytes,
"post-eviction budget invariant must hold: total={} budget={}",
stats.total_resident_bytes,
stats.memory_budget_bytes
);
}
#[test]
fn hotswap_oversized_handle_preserves_existing_entries() {
let loader = Arc::new(MockLoader::new());
let pool = LoadedPool::with_capacity_and_budget(3, 1_000);
let mut mgr = HotSwapManager::<MockEngine>::new(pool, loader.clone());
let f_small = synthetic_gguf(300);
let f_oversized = synthetic_gguf(1_500); let cfg = empty_config();
let arc_a = mgr
.load_or_get("a/1", QuantType::Q4_K_M, f_small.path(), &cfg)
.expect("a admit");
let arc_b = mgr
.load_or_get("b/2", QuantType::Q4_K_M, f_small.path(), &cfg)
.expect("b admit");
assert_eq!(mgr.pool_stats().loaded_count, 2);
let bytes_before = mgr.pool_stats().total_resident_bytes;
let err = mgr
.load_or_get("huge/1", QuantType::Q4_K_M, f_oversized.path(), &cfg)
.expect_err("oversized must refuse");
match err {
HotSwapError::PoolRefused(PoolError::OversizedHandle {
repo_id,
handle_bytes,
budget_bytes,
}) => {
assert_eq!(repo_id, "huge/1@Q4_K_M");
assert_eq!(handle_bytes, 1_500);
assert_eq!(budget_bytes, 1_000);
}
other => panic!("expected OversizedHandle, got: {other:?}"),
}
assert_eq!(
mgr.pool_stats().loaded_count,
2,
"oversized refusal must not change loaded_count"
);
assert_eq!(
mgr.pool_stats().total_resident_bytes,
bytes_before,
"oversized refusal must not change total_resident_bytes"
);
let arc_a_after = mgr
.try_get("a/1", QuantType::Q4_K_M)
.expect("A must remain resident");
let arc_b_after = mgr
.try_get("b/2", QuantType::Q4_K_M)
.expect("B must remain resident");
assert!(
Arc::ptr_eq(&arc_a, &arc_a_after),
"A's Arc identity must be preserved across refused oversized load"
);
assert!(
Arc::ptr_eq(&arc_b, &arc_b_after),
"B's Arc identity must be preserved across refused oversized load"
);
assert_eq!(loader.call_count(), 3);
}
#[test]
fn hotswap_load_or_get_promotes_on_cache_hit() {
let loader = Arc::new(MockLoader::new());
let pool = LoadedPool::with_capacity_and_budget(2, 1_000_000);
let mut mgr = HotSwapManager::<MockEngine>::new(pool, loader.clone());
let f = synthetic_gguf(1_000);
let cfg = empty_config();
let _ = mgr
.load_or_get("a/1", QuantType::Q4_K_M, f.path(), &cfg)
.expect("a admit");
let _ = mgr
.load_or_get("b/2", QuantType::Q4_K_M, f.path(), &cfg)
.expect("b admit");
let calls_before = loader.call_count();
let _ = mgr
.load_or_get("a/1", QuantType::Q4_K_M, f.path(), &cfg)
.expect("a cache-hit");
assert_eq!(
loader.call_count(),
calls_before,
"cache-hit must not invoke the loader"
);
let _ = mgr
.load_or_get("c/3", QuantType::Q4_K_M, f.path(), &cfg)
.expect("c admit");
assert!(
mgr.try_get("a/1", QuantType::Q4_K_M).is_some(),
"A must SURVIVE — cache-hit promoted it to MRU"
);
assert!(
mgr.try_get("b/2", QuantType::Q4_K_M).is_none(),
"B must EVICT — it became LRU after A's promotion"
);
assert!(
mgr.try_get("c/3", QuantType::Q4_K_M).is_some(),
"C must be resident (newly admitted)"
);
}
#[test]
fn hotswap_concurrent_load_or_get_serializes_under_mutex() {
use std::sync::Mutex;
const CAPACITY: usize = 3;
const N_THREADS: usize = 8;
let loader = Arc::new(MockLoader::new());
let pool = LoadedPool::with_capacity_and_budget(CAPACITY, 1_000_000);
let mgr = Arc::new(Mutex::new(HotSwapManager::<MockEngine>::new(
pool,
loader.clone(),
)));
let fixtures: Vec<tempfile::NamedTempFile> =
(0..N_THREADS).map(|_| synthetic_gguf(500)).collect();
let paths: Vec<PathBuf> = fixtures.iter().map(|f| f.path().to_path_buf()).collect();
let cfg = empty_config();
let cfg = Arc::new(cfg);
let mut handles = Vec::with_capacity(N_THREADS);
for i in 0..N_THREADS {
let mgr = Arc::clone(&mgr);
let path = paths[i].clone();
let cfg = Arc::clone(&cfg);
let repo = format!("repo/{i}");
handles.push(std::thread::spawn(move || {
let mut guard = mgr.lock().expect("lock manager");
let _ = guard
.load_or_get(&repo, QuantType::Q4_K_M, &path, &cfg)
.expect("load_or_get");
let stats = guard.pool_stats();
assert!(
stats.total_resident_bytes <= stats.memory_budget_bytes,
"budget invariant violated under contention: \
total={} budget={} thread={}",
stats.total_resident_bytes,
stats.memory_budget_bytes,
i,
);
assert!(
stats.loaded_count <= stats.capacity_models,
"capacity invariant violated under contention: \
loaded={} capacity={} thread={}",
stats.loaded_count,
stats.capacity_models,
i,
);
}));
}
for h in handles {
h.join().expect("thread join");
}
assert_eq!(
loader.call_count(),
N_THREADS as u64,
"expected exactly {N_THREADS} loader calls under Mutex serialization; \
got {} (TOCTOU: duplicate load OR missed load)",
loader.call_count()
);
let final_mgr = mgr.lock().expect("lock manager final");
let stats = final_mgr.pool_stats();
assert_eq!(
stats.loaded_count, CAPACITY,
"capacity must hold post-join: loaded={} capacity={}",
stats.loaded_count, CAPACITY
);
assert!(
stats.total_resident_bytes <= stats.memory_budget_bytes,
"budget invariant violated post-join: total={} budget={}",
stats.total_resident_bytes,
stats.memory_budget_bytes
);
let survivors: usize = (0..N_THREADS)
.filter(|i| {
final_mgr
.try_get(&format!("repo/{i}"), QuantType::Q4_K_M)
.is_some()
})
.count();
assert_eq!(
survivors, CAPACITY,
"exactly CAPACITY={CAPACITY} survivors expected post-join; got {survivors}"
);
}
struct MockSpiller {
pre_evict_calls: std::sync::atomic::AtomicU64,
post_admit_calls: std::sync::atomic::AtomicU64,
pre_evict_strong_counts: std::sync::Mutex<Vec<(u64, usize)>>,
post_admit_strong_counts: std::sync::Mutex<Vec<(u64, usize)>>,
pre_evict_outcome: std::sync::Mutex<Option<SpillOutcome>>,
post_admit_outcome: std::sync::Mutex<Option<RestoreOutcome>>,
}
impl MockSpiller {
fn new() -> Self {
Self {
pre_evict_calls: std::sync::atomic::AtomicU64::new(0),
post_admit_calls: std::sync::atomic::AtomicU64::new(0),
pre_evict_strong_counts: std::sync::Mutex::new(Vec::new()),
post_admit_strong_counts: std::sync::Mutex::new(Vec::new()),
pre_evict_outcome: std::sync::Mutex::new(None),
post_admit_outcome: std::sync::Mutex::new(None),
}
}
fn pre_evict_count(&self) -> u64 {
self.pre_evict_calls
.load(std::sync::atomic::Ordering::SeqCst)
}
fn post_admit_count(&self) -> u64 {
self.post_admit_calls
.load(std::sync::atomic::Ordering::SeqCst)
}
fn set_pre_evict_outcome(&self, outcome: SpillOutcome) {
*self.pre_evict_outcome.lock().expect("lock") = Some(outcome);
}
fn set_post_admit_outcome(&self, outcome: RestoreOutcome) {
*self.post_admit_outcome.lock().expect("lock") = Some(outcome);
}
fn pre_evict_strong_counts_snapshot(&self) -> Vec<(u64, usize)> {
self.pre_evict_strong_counts.lock().expect("lock").clone()
}
}
impl ModelLoader<MockEngine> for MockSpiller {
fn load(&self, _path: &Path, _config: &EngineConfig) -> anyhow::Result<MockEngine> {
unreachable!("MockSpiller is not a loader")
}
}
impl KvSpiller<MockEngine> for MockSpiller {
fn pre_evict(
&self,
_handle: &LoadedHandle,
engine: &Arc<LoadedEngine<MockEngine>>,
) -> SpillOutcome {
let n = self
.pre_evict_calls
.fetch_add(1, std::sync::atomic::Ordering::SeqCst)
+ 1;
let sc = Arc::strong_count(engine);
self.pre_evict_strong_counts
.lock()
.expect("lock")
.push((n, sc));
self.pre_evict_outcome
.lock()
.expect("lock")
.unwrap_or(SpillOutcome::Skipped)
}
fn post_admit(
&self,
_repo: &str,
_quant: QuantType,
engine: &Arc<LoadedEngine<MockEngine>>,
) -> RestoreOutcome {
let n = self
.post_admit_calls
.fetch_add(1, std::sync::atomic::Ordering::SeqCst)
+ 1;
let sc = Arc::strong_count(engine);
self.post_admit_strong_counts
.lock()
.expect("lock")
.push((n, sc));
self.post_admit_outcome
.lock()
.expect("lock")
.unwrap_or(RestoreOutcome::Skipped)
}
}
fn mgr_with_spiller(
capacity_models: usize,
memory_budget_bytes: u64,
) -> (
HotSwapManager<MockEngine>,
Arc<MockLoader>,
Arc<MockSpiller>,
) {
let loader = Arc::new(MockLoader::new());
let spiller = Arc::new(MockSpiller::new());
let pool = LoadedPool::with_capacity_and_budget(capacity_models, memory_budget_bytes);
let mgr =
HotSwapManager::<MockEngine>::new_with_spiller(pool, loader.clone(), spiller.clone());
(mgr, loader, spiller)
}
#[test]
fn hotswap_pre_evict_fires_on_lru_eviction() {
let (mut mgr, _loader, spiller) = mgr_with_spiller(2, 1_000_000);
let f = synthetic_gguf(1_000);
let cfg = empty_config();
let _ = mgr
.load_or_get("a/1", QuantType::Q4_K_M, f.path(), &cfg)
.expect("a");
let _ = mgr
.load_or_get("b/2", QuantType::Q4_K_M, f.path(), &cfg)
.expect("b");
assert_eq!(
spiller.pre_evict_count(),
0,
"no eviction at capacity boundary; pre_evict must NOT fire"
);
let _ = mgr
.load_or_get("c/3", QuantType::Q4_K_M, f.path(), &cfg)
.expect("c");
assert_eq!(
spiller.pre_evict_count(),
1,
"exactly one pre_evict call expected on single LRU eviction"
);
assert!(mgr.try_get("a/1", QuantType::Q4_K_M).is_none());
}
#[test]
fn hotswap_pre_evict_fires_on_explicit_evict() {
let (mut mgr, _loader, spiller) = mgr_with_spiller(3, 100_000);
let f = synthetic_gguf(500);
let cfg = empty_config();
let _ = mgr
.load_or_get("acme/m1", QuantType::Q4_K_M, f.path(), &cfg)
.expect("admit");
assert_eq!(spiller.pre_evict_count(), 0);
assert_eq!(spiller.post_admit_count(), 1);
let bytes_freed = mgr.evict("acme/m1", QuantType::Q4_K_M);
assert_eq!(bytes_freed, 500);
assert_eq!(
spiller.pre_evict_count(),
1,
"explicit evict() must fire pre_evict exactly once"
);
let bytes_freed_2 = mgr.evict("acme/m1", QuantType::Q4_K_M);
assert_eq!(bytes_freed_2, 0);
assert_eq!(
spiller.pre_evict_count(),
1,
"idempotent evict() must NOT double-fire pre_evict"
);
}
#[test]
fn hotswap_post_admit_fires_on_cold_load() {
let (mut mgr, loader, spiller) = mgr_with_spiller(3, 100_000);
let f = synthetic_gguf(500);
let cfg = empty_config();
assert_eq!(loader.call_count(), 0);
assert_eq!(spiller.post_admit_count(), 0);
let _ = mgr
.load_or_get("acme/m1", QuantType::Q4_K_M, f.path(), &cfg)
.expect("admit");
assert_eq!(
loader.call_count(),
1,
"loader must have run before post_admit — sequencing"
);
assert_eq!(
spiller.post_admit_count(),
1,
"post_admit must fire exactly once on cold load"
);
}
#[test]
fn hotswap_post_admit_does_not_fire_on_cache_hit() {
let (mut mgr, loader, spiller) = mgr_with_spiller(3, 100_000);
let f = synthetic_gguf(500);
let cfg = empty_config();
let _ = mgr
.load_or_get("acme/m1", QuantType::Q4_K_M, f.path(), &cfg)
.expect("first");
assert_eq!(spiller.post_admit_count(), 1);
assert_eq!(loader.call_count(), 1);
let _ = mgr
.load_or_get("acme/m1", QuantType::Q4_K_M, f.path(), &cfg)
.expect("cache-hit");
assert_eq!(
loader.call_count(),
1,
"cache-hit must not invoke the loader"
);
assert_eq!(
spiller.post_admit_count(),
1,
"cache-hit must NOT fire post_admit a second time"
);
}
#[test]
fn hotswap_pre_evict_fires_before_engine_drop() {
let (mut mgr, _loader, spiller) = mgr_with_spiller(1, 100_000);
let f = synthetic_gguf(500);
let cfg = empty_config();
let inflight = mgr
.load_or_get("a/1", QuantType::Q4_K_M, f.path(), &cfg)
.expect("a");
assert_eq!(Arc::strong_count(&inflight), 2);
let _ = mgr
.load_or_get("b/2", QuantType::Q4_K_M, f.path(), &cfg)
.expect("b");
let snapshots = spiller.pre_evict_strong_counts_snapshot();
assert_eq!(snapshots.len(), 1, "exactly one pre_evict call");
let (call_n, observed_count) = snapshots[0];
assert_eq!(call_n, 1);
assert!(
observed_count >= 2,
"pre_evict must observe a live Arc (strong_count >= 2); observed = {observed_count}"
);
assert_eq!(
Arc::strong_count(&inflight),
1,
"manager must have released its Arc on eviction; \
pre_evict trigger must NOT extend the Arc lifetime"
);
assert_eq!(inflight.repo, "a/1");
assert_eq!(inflight.engine.load_serial, 1);
assert!(mgr.try_get("a/1", QuantType::Q4_K_M).is_none());
assert!(mgr.try_get("b/2", QuantType::Q4_K_M).is_some());
}
#[test]
fn hotswap_chained_evictions_fire_pre_evict_per_evictee() {
let (mut mgr, _loader, spiller) = mgr_with_spiller(3, 1_500);
let f_small = synthetic_gguf(500);
let f_big = synthetic_gguf(1_000);
let cfg = empty_config();
let _ = mgr
.load_or_get("a/1", QuantType::Q4_K_M, f_small.path(), &cfg)
.expect("a");
let _ = mgr
.load_or_get("b/2", QuantType::Q4_K_M, f_small.path(), &cfg)
.expect("b");
let _ = mgr
.load_or_get("c/3", QuantType::Q4_K_M, f_small.path(), &cfg)
.expect("c");
assert_eq!(spiller.pre_evict_count(), 0);
assert_eq!(spiller.post_admit_count(), 3);
let _ = mgr
.load_or_get("d/4", QuantType::Q4_K_M, f_big.path(), &cfg)
.expect("d");
assert_eq!(
spiller.pre_evict_count(),
2,
"chained eviction must fire pre_evict once PER evictee"
);
assert_eq!(
spiller.post_admit_count(),
4,
"post_admit fires once for the new admission (d/4)"
);
}
#[test]
fn hotswap_pre_evict_skipped_outcome_does_not_block_eviction() {
let (mut mgr, _loader, spiller) = mgr_with_spiller(2, 1_000_000);
let f = synthetic_gguf(1_000);
let cfg = empty_config();
spiller.set_pre_evict_outcome(SpillOutcome::Skipped);
let _ = mgr
.load_or_get("a/1", QuantType::Q4_K_M, f.path(), &cfg)
.expect("a");
let _ = mgr
.load_or_get("b/2", QuantType::Q4_K_M, f.path(), &cfg)
.expect("b");
let _ = mgr
.load_or_get("c/3", QuantType::Q4_K_M, f.path(), &cfg)
.expect("c");
assert!(mgr.try_get("a/1", QuantType::Q4_K_M).is_none());
assert!(mgr.try_get("b/2", QuantType::Q4_K_M).is_some());
assert!(mgr.try_get("c/3", QuantType::Q4_K_M).is_some());
let stats = mgr.pool_stats();
assert_eq!(
stats.loaded_count, 2,
"eviction must complete normally regardless of pre_evict outcome"
);
assert_eq!(spiller.pre_evict_count(), 1);
}
#[test]
fn hotswap_post_admit_error_does_not_block_admission() {
let (mut mgr, loader, spiller) = mgr_with_spiller(3, 100_000);
let f = synthetic_gguf(500);
let cfg = empty_config();
spiller.set_post_admit_outcome(RestoreOutcome::Error(RestoreErrorKind::ParityFail));
let arc = mgr
.load_or_get("acme/m1", QuantType::Q4_K_M, f.path(), &cfg)
.expect("admission must succeed even when post_admit errors");
assert_eq!(arc.repo, "acme/m1");
assert_eq!(arc.engine.load_serial, 1);
assert_eq!(loader.call_count(), 1);
assert_eq!(spiller.post_admit_count(), 1);
let stats = mgr.pool_stats();
assert_eq!(
stats.loaded_count, 1,
"engine must be admitted to the pool despite post_admit error"
);
assert!(
mgr.try_get("acme/m1", QuantType::Q4_K_M).is_some(),
"post-error admission must publish the engine to the engines map"
);
}
#[test]
fn hotswap_concurrent_load_with_spiller_serializes_under_mutex() {
use std::sync::Mutex;
const CAPACITY: usize = 3;
const N_THREADS: usize = 8;
let loader = Arc::new(MockLoader::new());
let spiller = Arc::new(MockSpiller::new());
let pool = LoadedPool::with_capacity_and_budget(CAPACITY, 1_000_000);
let mgr = Arc::new(Mutex::new(HotSwapManager::<MockEngine>::new_with_spiller(
pool,
loader.clone(),
spiller.clone(),
)));
let fixtures: Vec<tempfile::NamedTempFile> =
(0..N_THREADS).map(|_| synthetic_gguf(500)).collect();
let paths: Vec<PathBuf> = fixtures.iter().map(|f| f.path().to_path_buf()).collect();
let cfg = Arc::new(empty_config());
let mut handles = Vec::with_capacity(N_THREADS);
for i in 0..N_THREADS {
let mgr = Arc::clone(&mgr);
let path = paths[i].clone();
let cfg = Arc::clone(&cfg);
let repo = format!("repo/{i}");
handles.push(std::thread::spawn(move || {
let mut guard = mgr.lock().expect("lock manager");
let _ = guard
.load_or_get(&repo, QuantType::Q4_K_M, &path, &cfg)
.expect("load_or_get");
}));
}
for h in handles {
h.join().expect("thread join");
}
assert_eq!(
loader.call_count(),
N_THREADS as u64,
"expected exactly {N_THREADS} loader calls under Mutex serialization"
);
assert_eq!(
spiller.post_admit_count(),
N_THREADS as u64,
"post_admit must fire exactly once per cold load under contention"
);
let expected_evictions = (N_THREADS - CAPACITY) as u64;
assert_eq!(
spiller.pre_evict_count(),
expected_evictions,
"pre_evict must fire exactly {expected_evictions} times \
(N - CAPACITY = {N_THREADS} - {CAPACITY}) under contention; \
observed = {}",
spiller.pre_evict_count()
);
let pre_evict_snapshots = spiller.pre_evict_strong_counts_snapshot();
let mut seen = std::collections::HashSet::new();
for (idx, _) in &pre_evict_snapshots {
assert!(
seen.insert(*idx),
"pre_evict call index {idx} repeated — double-fire under contention"
);
}
let final_mgr = mgr.lock().expect("lock manager final");
let stats = final_mgr.pool_stats();
assert_eq!(stats.loaded_count, CAPACITY);
assert!(stats.total_resident_bytes <= stats.memory_budget_bytes);
}
}