1use serde::Serialize;
22
23use crate::domain::residency::SecondarySlotDecision;
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
27#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
28pub struct ResidentSlotSnapshot {
29 pub slot: usize,
31 pub model_name: String,
33 pub model_id: u32,
35 pub port: u16,
37 pub inflight: u32,
42 pub is_primary: bool,
45 #[cfg_attr(feature = "ts-bindings", ts(type = "number"))]
47 pub resident_for_secs: u64,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
52#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
53pub struct QueuedModelSnapshot {
54 pub model_name: String,
56 pub waiting: usize,
58 #[cfg_attr(feature = "ts-bindings", ts(type = "number"))]
63 pub oldest_wait_ms: u64,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
68#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
69pub struct SecondarySlotStatus {
70 #[cfg_attr(
85 feature = "ts-bindings",
86 ts(type = "\"resident\" | \"available\" | \"too_large\" \
87 | \"no_headroom\" | \"unknown_footprint\" | \"unknown_budget\"")
88 )]
89 pub state: &'static str,
90 pub detail: String,
93}
94
95impl Default for SecondarySlotStatus {
96 fn default() -> Self {
97 Self {
98 state: "available",
99 detail: "No second model has been requested yet.".to_string(),
100 }
101 }
102}
103
104impl SecondarySlotStatus {
105 #[must_use]
107 pub fn resident(model_name: &str) -> Self {
108 Self {
109 state: "resident",
110 detail: format!("{model_name} is co-resident and never waits for a swap."),
111 }
112 }
113
114 #[must_use]
120 pub fn from_decision(decision: SecondarySlotDecision) -> Self {
121 let detail = match decision {
122 SecondarySlotDecision::Grant { .. } => {
123 "A second model fits and is being loaded.".to_string()
124 }
125 SecondarySlotDecision::RefuseTooLarge {
126 footprint_bytes,
127 ceiling_bytes,
128 } => format!(
129 "The requested model needs about {} — too large for the second slot, which is \
130 capped at {}. It will be swapped in instead.",
131 format_bytes(footprint_bytes),
132 format_bytes(ceiling_bytes),
133 ),
134 SecondarySlotDecision::RefuseNoHeadroom {
135 footprint_bytes,
136 free_bytes,
137 } => format!(
138 "Not enough free VRAM to keep a second model loaded: it needs about {}, and only \
139 {} is free.",
140 format_bytes(footprint_bytes),
141 format_bytes(free_bytes),
142 ),
143 SecondarySlotDecision::RefuseUnknownFootprint => {
144 "The requested model's memory footprint could not be estimated from its GGUF \
145 metadata, so it is swapped in rather than co-loaded."
146 .to_string()
147 }
148 SecondarySlotDecision::RefuseUnknownBudget => {
149 "gglib cannot read this machine's free VRAM, so it keeps one model loaded at a \
150 time. Free-VRAM readings are available on NVIDIA and Apple Silicon."
151 .to_string()
152 }
153 };
154
155 let state = if decision.is_grant() {
159 "available"
160 } else {
161 decision.label()
162 };
163
164 Self { state, detail }
165 }
166}
167
168#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
170#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
171pub struct AdmissionSnapshot {
172 pub slots: Vec<ResidentSlotSnapshot>,
175 pub queued: Vec<QueuedModelSnapshot>,
178 #[cfg_attr(feature = "ts-bindings", ts(type = "number"))]
182 pub total_queued: u64,
183 #[cfg_attr(feature = "ts-bindings", ts(type = "number"))]
185 pub total_swaps: u64,
186 pub secondary_slot: SecondarySlotStatus,
188}
189
190impl AdmissionSnapshot {
191 #[must_use]
193 pub fn waiting(&self) -> usize {
194 self.queued.iter().map(|q| q.waiting).sum()
195 }
196
197 #[must_use]
199 pub fn inflight(&self) -> u32 {
200 self.slots.iter().map(|s| s.inflight).sum()
201 }
202}
203
204fn format_bytes(bytes: u64) -> String {
207 const MIB: f64 = 1024.0 * 1024.0;
208 const GIB: f64 = MIB * 1024.0;
209 #[allow(clippy::cast_precision_loss)]
210 let value = bytes as f64;
211 if value >= GIB {
212 format!("{:.1} GiB", value / GIB)
213 } else {
214 format!("{:.0} MiB", value / MIB)
215 }
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221
222 const MIB: u64 = 1024 * 1024;
223 const GIB: u64 = 1024 * MIB;
224
225 fn slot(name: &str, inflight: u32, is_primary: bool) -> ResidentSlotSnapshot {
226 ResidentSlotSnapshot {
227 slot: usize::from(!is_primary),
228 model_name: name.to_string(),
229 model_id: 1,
230 port: 8080,
231 inflight,
232 is_primary,
233 resident_for_secs: 10,
234 }
235 }
236
237 #[test]
238 fn an_empty_snapshot_reports_nothing_waiting_and_nothing_running() {
239 let snapshot = AdmissionSnapshot::default();
240 assert_eq!(snapshot.waiting(), 0);
241 assert_eq!(snapshot.inflight(), 0);
242 assert_eq!(snapshot.secondary_slot.state, "available");
243 }
244
245 #[test]
246 fn totals_aggregate_across_slots_and_queues() {
247 let snapshot = AdmissionSnapshot {
248 slots: vec![slot("qwen-coder", 2, true), slot("nomic-embed", 1, false)],
249 queued: vec![QueuedModelSnapshot {
250 model_name: "llama-3".to_string(),
251 waiting: 4,
252 oldest_wait_ms: 900,
253 }],
254 total_queued: 12,
255 total_swaps: 2,
256 secondary_slot: SecondarySlotStatus::resident("nomic-embed"),
257 };
258
259 assert_eq!(snapshot.inflight(), 3);
260 assert_eq!(snapshot.waiting(), 4);
261 }
262
263 #[test]
266 fn each_refusal_carries_a_distinct_label_and_a_populated_detail() {
267 let cases = [
268 SecondarySlotDecision::RefuseTooLarge {
269 footprint_bytes: 10 * GIB,
270 ceiling_bytes: 2 * GIB,
271 },
272 SecondarySlotDecision::RefuseNoHeadroom {
273 footprint_bytes: 307 * MIB,
274 free_bytes: 200 * MIB,
275 },
276 SecondarySlotDecision::RefuseUnknownFootprint,
277 SecondarySlotDecision::RefuseUnknownBudget,
278 ];
279
280 let mut seen = Vec::new();
281 for case in cases {
282 let status = SecondarySlotStatus::from_decision(case);
283 assert!(!status.detail.is_empty(), "{case:?} produced no detail");
284 assert!(!seen.contains(&status.state), "duplicate label {case:?}");
285 seen.push(status.state);
286 }
287 }
288
289 #[test]
292 fn a_headroom_refusal_names_both_figures() {
293 let status = SecondarySlotStatus::from_decision(SecondarySlotDecision::RefuseNoHeadroom {
294 footprint_bytes: 307 * MIB,
295 free_bytes: 200 * MIB,
296 });
297
298 assert!(status.detail.contains("307 MiB"), "{}", status.detail);
299 assert!(status.detail.contains("200 MiB"), "{}", status.detail);
300 }
301
302 #[test]
303 fn resident_status_names_the_model() {
304 let status = SecondarySlotStatus::resident("nomic-embed-text");
305 assert_eq!(status.state, "resident");
306 assert!(status.detail.contains("nomic-embed-text"));
307 }
308
309 #[test]
310 fn snapshot_always_serializes() {
311 let json = serde_json::to_string(&AdmissionSnapshot::default())
312 .expect("AdmissionSnapshot must always serialize");
313 assert!(json.contains("secondary_slot"));
314 assert!(json.contains("total_swaps"));
315 }
316
317 #[test]
320 fn bytes_render_as_gib_above_a_gibibyte_and_mib_below() {
321 assert_eq!(format_bytes(307 * MIB), "307 MiB");
322 assert_eq!(format_bytes(2 * GIB), "2.0 GiB");
323 assert_eq!(format_bytes(GIB + 512 * MIB), "1.5 GiB");
324 }
325}