1use serde::Serialize;
22
23use crate::domain::residency::SecondarySlotDecision;
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
27pub struct ResidentSlotSnapshot {
28 pub slot: usize,
30 pub model_name: String,
32 pub model_id: u32,
34 pub port: u16,
36 pub inflight: u32,
41 pub is_primary: bool,
44 pub resident_for_secs: u64,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
50pub struct QueuedModelSnapshot {
51 pub model_name: String,
53 pub waiting: usize,
55 pub oldest_wait_ms: u64,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
64pub struct SecondarySlotStatus {
65 pub state: &'static str,
69 pub detail: String,
72}
73
74impl Default for SecondarySlotStatus {
75 fn default() -> Self {
76 Self {
77 state: "available",
78 detail: "No second model has been requested yet.".to_string(),
79 }
80 }
81}
82
83impl SecondarySlotStatus {
84 #[must_use]
86 pub fn resident(model_name: &str) -> Self {
87 Self {
88 state: "resident",
89 detail: format!("{model_name} is co-resident and never waits for a swap."),
90 }
91 }
92
93 #[must_use]
99 pub fn from_decision(decision: SecondarySlotDecision) -> Self {
100 let detail = match decision {
101 SecondarySlotDecision::Grant { .. } => {
102 "A second model fits and is being loaded.".to_string()
103 }
104 SecondarySlotDecision::RefuseTooLarge {
105 footprint_bytes,
106 ceiling_bytes,
107 } => format!(
108 "The requested model needs about {} — too large for the second slot, which is \
109 capped at {}. It will be swapped in instead.",
110 format_bytes(footprint_bytes),
111 format_bytes(ceiling_bytes),
112 ),
113 SecondarySlotDecision::RefuseNoHeadroom {
114 footprint_bytes,
115 free_bytes,
116 } => format!(
117 "Not enough free VRAM to keep a second model loaded: it needs about {}, and only \
118 {} is free.",
119 format_bytes(footprint_bytes),
120 format_bytes(free_bytes),
121 ),
122 SecondarySlotDecision::RefuseUnknownFootprint => {
123 "The requested model's memory footprint could not be estimated from its GGUF \
124 metadata, so it is swapped in rather than co-loaded."
125 .to_string()
126 }
127 SecondarySlotDecision::RefuseUnknownBudget => {
128 "gglib cannot read this machine's free VRAM, so it keeps one model loaded at a \
129 time. Free-VRAM readings are available on NVIDIA and Apple Silicon."
130 .to_string()
131 }
132 };
133
134 let state = if decision.is_grant() {
138 "available"
139 } else {
140 decision.label()
141 };
142
143 Self { state, detail }
144 }
145}
146
147#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
149pub struct AdmissionSnapshot {
150 pub slots: Vec<ResidentSlotSnapshot>,
153 pub queued: Vec<QueuedModelSnapshot>,
156 pub total_queued: u64,
160 pub total_swaps: u64,
162 pub secondary_slot: SecondarySlotStatus,
164}
165
166impl AdmissionSnapshot {
167 #[must_use]
169 pub fn waiting(&self) -> usize {
170 self.queued.iter().map(|q| q.waiting).sum()
171 }
172
173 #[must_use]
175 pub fn inflight(&self) -> u32 {
176 self.slots.iter().map(|s| s.inflight).sum()
177 }
178}
179
180fn format_bytes(bytes: u64) -> String {
183 const MIB: f64 = 1024.0 * 1024.0;
184 const GIB: f64 = MIB * 1024.0;
185 #[allow(clippy::cast_precision_loss)]
186 let value = bytes as f64;
187 if value >= GIB {
188 format!("{:.1} GiB", value / GIB)
189 } else {
190 format!("{:.0} MiB", value / MIB)
191 }
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197
198 const MIB: u64 = 1024 * 1024;
199 const GIB: u64 = 1024 * MIB;
200
201 fn slot(name: &str, inflight: u32, is_primary: bool) -> ResidentSlotSnapshot {
202 ResidentSlotSnapshot {
203 slot: usize::from(!is_primary),
204 model_name: name.to_string(),
205 model_id: 1,
206 port: 8080,
207 inflight,
208 is_primary,
209 resident_for_secs: 10,
210 }
211 }
212
213 #[test]
214 fn an_empty_snapshot_reports_nothing_waiting_and_nothing_running() {
215 let snapshot = AdmissionSnapshot::default();
216 assert_eq!(snapshot.waiting(), 0);
217 assert_eq!(snapshot.inflight(), 0);
218 assert_eq!(snapshot.secondary_slot.state, "available");
219 }
220
221 #[test]
222 fn totals_aggregate_across_slots_and_queues() {
223 let snapshot = AdmissionSnapshot {
224 slots: vec![slot("qwen-coder", 2, true), slot("nomic-embed", 1, false)],
225 queued: vec![QueuedModelSnapshot {
226 model_name: "llama-3".to_string(),
227 waiting: 4,
228 oldest_wait_ms: 900,
229 }],
230 total_queued: 12,
231 total_swaps: 2,
232 secondary_slot: SecondarySlotStatus::resident("nomic-embed"),
233 };
234
235 assert_eq!(snapshot.inflight(), 3);
236 assert_eq!(snapshot.waiting(), 4);
237 }
238
239 #[test]
242 fn each_refusal_carries_a_distinct_label_and_a_populated_detail() {
243 let cases = [
244 SecondarySlotDecision::RefuseTooLarge {
245 footprint_bytes: 10 * GIB,
246 ceiling_bytes: 2 * GIB,
247 },
248 SecondarySlotDecision::RefuseNoHeadroom {
249 footprint_bytes: 307 * MIB,
250 free_bytes: 200 * MIB,
251 },
252 SecondarySlotDecision::RefuseUnknownFootprint,
253 SecondarySlotDecision::RefuseUnknownBudget,
254 ];
255
256 let mut seen = Vec::new();
257 for case in cases {
258 let status = SecondarySlotStatus::from_decision(case);
259 assert!(!status.detail.is_empty(), "{case:?} produced no detail");
260 assert!(!seen.contains(&status.state), "duplicate label {case:?}");
261 seen.push(status.state);
262 }
263 }
264
265 #[test]
268 fn a_headroom_refusal_names_both_figures() {
269 let status = SecondarySlotStatus::from_decision(SecondarySlotDecision::RefuseNoHeadroom {
270 footprint_bytes: 307 * MIB,
271 free_bytes: 200 * MIB,
272 });
273
274 assert!(status.detail.contains("307 MiB"), "{}", status.detail);
275 assert!(status.detail.contains("200 MiB"), "{}", status.detail);
276 }
277
278 #[test]
279 fn resident_status_names_the_model() {
280 let status = SecondarySlotStatus::resident("nomic-embed-text");
281 assert_eq!(status.state, "resident");
282 assert!(status.detail.contains("nomic-embed-text"));
283 }
284
285 #[test]
286 fn snapshot_always_serializes() {
287 let json = serde_json::to_string(&AdmissionSnapshot::default())
288 .expect("AdmissionSnapshot must always serialize");
289 assert!(json.contains("secondary_slot"));
290 assert!(json.contains("total_swaps"));
291 }
292
293 #[test]
296 fn bytes_render_as_gib_above_a_gibibyte_and_mib_below() {
297 assert_eq!(format_bytes(307 * MIB), "307 MiB");
298 assert_eq!(format_bytes(2 * GIB), "2.0 GiB");
299 assert_eq!(format_bytes(GIB + 512 * MIB), "1.5 GiB");
300 }
301}