gglib_core/domain/launch_narration.rs
1//! What the runtime decided for one llama-server launch, and why.
2//!
3//! ## Why this type exists
4//!
5//! gglib makes a series of non-obvious choices every time it launches a
6//! model: it sizes the host-RAM prompt cache against free memory, quantizes
7//! the KV cache, enables speculative decoding from the model's own metadata,
8//! picks a tool-call dialect parser, and resolves the context size through a
9//! four-level fallback chain. Every one of those values already existed at
10//! launch — but only in a `debug!` line or a local variable, so the user's
11//! evidence that any of it happened was a 1,000-line README.
12//!
13//! [`LaunchNarration`] is the record of those decisions, captured once at
14//! spawn where each resolver's `*Source` enum is still in scope. It carries
15//! **provenance, not just values**: "32768" is a number, "32768 (model
16//! `server_defaults`)" is an explanation, and the second is the one that makes
17//! the runtime's behaviour auditable rather than magical.
18//!
19//! ## One record, three surfaces
20//!
21//! The same narration is rendered by the CLI banner at startup, served on
22//! `GET /v1/proxy/status`, and displayed in the GUI dashboard. It lives in
23//! `gglib-core` so all three can name the type without any of them depending
24//! on the runtime that produces it.
25//!
26//! ## Presentation, not computation
27//!
28//! Nothing here re-derives a value. Every field is assigned from a resolution
29//! the launch already performed; a decision gglib does not actually make has
30//! no business appearing in this struct. Notably absent is the GPU layer
31//! split: gglib never emits `-ngl`, so how many layers get offloaded is
32//! llama.cpp's decision and is not gglib's to report. See
33//! [`Self::backend`] for what *is* known.
34
35use serde::{Deserialize, Serialize};
36
37/// One resolved launch decision, paired with the reason it was chosen.
38///
39/// [`Self::source`] is the whole point of the type — a value without its
40/// provenance tells a user what happened but never why, which is precisely
41/// the gap this record closes.
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct LaunchDecision {
44 /// Short stable key naming the decision: `ctx`, `backend`, `kv`,
45 /// `cache`, `mtp`, `flags`, `dialect`.
46 ///
47 /// Stable because the GUI and the CLI dashboard both key styling off it;
48 /// treat it as part of the wire contract rather than a display string.
49 pub label: String,
50 /// Display-ready value, e.g. `32768` or `q8_0 -> 2.1 GB, f16 would be 4.2 GB`.
51 pub value: String,
52 /// Where the value came from, rendered in parentheses by every consumer.
53 ///
54 /// `None` only for decisions whose value already states its own origin —
55 /// never as a shortcut for "didn't bother", since an unexplained value is
56 /// the exact failure mode this type exists to prevent.
57 pub source: Option<String>,
58}
59
60impl LaunchDecision {
61 /// A decision whose provenance is worth stating.
62 #[must_use]
63 pub fn new(
64 label: impl Into<String>,
65 value: impl Into<String>,
66 source: impl Into<String>,
67 ) -> Self {
68 Self {
69 label: label.into(),
70 value: value.into(),
71 source: Some(source.into()),
72 }
73 }
74
75 /// A decision that is self-explanatory — see [`Self::source`] for when
76 /// that is legitimate.
77 #[must_use]
78 pub fn bare(label: impl Into<String>, value: impl Into<String>) -> Self {
79 Self {
80 label: label.into(),
81 value: value.into(),
82 source: None,
83 }
84 }
85
86 /// Render as `value (source)`, or just `value` when unsourced.
87 #[must_use]
88 pub fn render_value(&self) -> String {
89 self.source.as_ref().map_or_else(
90 || self.value.clone(),
91 |source| format!("{} ({})", self.value, source),
92 )
93 }
94}
95
96/// Everything the runtime decided for one llama-server launch.
97///
98/// Built at spawn by the runtime; consumed by the CLI banner, the proxy
99/// status endpoint, and the GUI dashboard. See the [module docs](self) for
100/// why the decisions are an ordered list rather than named fields: the three
101/// consumers all render the same rows in the same order, and a list keeps
102/// adding a decision to a single site instead of four.
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104pub struct LaunchNarration {
105 /// Model name as the catalog knows it.
106 pub model_name: String,
107 /// Quantization label (`Q4_K_M`), when the catalog recorded one.
108 pub quantization: Option<String>,
109 /// On-disk weight size in bytes, summed across shards. `0` when unknown —
110 /// rendered as absent rather than as "0 GB".
111 pub weights_bytes: u64,
112 /// The decisions, in the order they should be displayed.
113 pub decisions: Vec<LaunchDecision>,
114}
115
116impl LaunchNarration {
117 /// Start a narration for a model; decisions are appended by the runtime
118 /// as each resolution comes into scope.
119 #[must_use]
120 pub fn new(
121 model_name: impl Into<String>,
122 quantization: Option<String>,
123 weights_bytes: u64,
124 ) -> Self {
125 Self {
126 model_name: model_name.into(),
127 quantization,
128 weights_bytes,
129 decisions: Vec::new(),
130 }
131 }
132
133 /// Append a decision, keeping display order.
134 pub fn push(&mut self, decision: LaunchDecision) {
135 self.decisions.push(decision);
136 }
137
138 /// The identity line: `qwen3-30b-a3b · Q4_K_M · 17.2 GB`.
139 ///
140 /// Unknown quantization and unknown size are dropped rather than rendered
141 /// as `unknown` or `0 GB` — a banner that pads itself with non-answers
142 /// reads as broken.
143 #[must_use]
144 pub fn headline(&self) -> String {
145 let mut parts = vec![self.model_name.clone()];
146 if let Some(quant) = &self.quantization {
147 parts.push(quant.clone());
148 }
149 if self.weights_bytes > 0 {
150 parts.push(format_gib(self.weights_bytes));
151 }
152 parts.join(" \u{b7} ")
153 }
154
155 /// Look up a decision by its stable label.
156 #[must_use]
157 pub fn decision(&self, label: &str) -> Option<&LaunchDecision> {
158 self.decisions.iter().find(|d| d.label == label)
159 }
160}
161
162/// Format a byte count as GiB with one decimal, e.g. `17.2 GB`.
163///
164/// Deliberately GiB-not-GB: it matches how every other memory figure in the
165/// launch path is computed, and a banner whose numbers disagree with the
166/// `--cache-ram` budget beside them is worse than no banner.
167#[must_use]
168pub fn format_gib(bytes: u64) -> String {
169 #[allow(clippy::cast_precision_loss)]
170 let gib = bytes as f64 / 1_073_741_824.0;
171 format!("{gib:.1} GB")
172}
173
174/// Format a MiB count as GiB with one decimal, for the RAM cache budget.
175#[must_use]
176pub fn format_mib_as_gib(mib: u64) -> String {
177 #[allow(clippy::cast_precision_loss)]
178 let gib = mib as f64 / 1024.0;
179 format!("{gib:.1} GB")
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185
186 #[test]
187 fn render_value_appends_provenance_in_parentheses() {
188 let d = LaunchDecision::new("ctx", "32768", "model server_defaults");
189 assert_eq!(d.render_value(), "32768 (model server_defaults)");
190 }
191
192 #[test]
193 fn render_value_omits_parentheses_when_unsourced() {
194 assert_eq!(LaunchDecision::bare("ctx", "32768").render_value(), "32768");
195 }
196
197 #[test]
198 fn headline_joins_the_three_identity_parts() {
199 let n = LaunchNarration::new("qwen3-30b-a3b", Some("Q4_K_M".to_string()), 18_476_297_420);
200 assert_eq!(n.headline(), "qwen3-30b-a3b \u{b7} Q4_K_M \u{b7} 17.2 GB");
201 }
202
203 /// Unknown quant and unknown size drop out rather than rendering as
204 /// filler — see [`LaunchNarration::headline`].
205 #[test]
206 fn headline_drops_unknown_quantization_and_zero_size() {
207 let n = LaunchNarration::new("mystery", None, 0);
208 assert_eq!(n.headline(), "mystery");
209 }
210
211 #[test]
212 fn headline_keeps_size_when_only_quantization_is_unknown() {
213 let n = LaunchNarration::new("mystery", None, 1_073_741_824);
214 assert_eq!(n.headline(), "mystery \u{b7} 1.0 GB");
215 }
216
217 #[test]
218 fn decisions_keep_insertion_order_and_are_findable_by_label() {
219 let mut n = LaunchNarration::new("m", None, 0);
220 n.push(LaunchDecision::new("ctx", "32768", "flag"));
221 n.push(LaunchDecision::new("kv", "q8_0", "default"));
222 let labels: Vec<&str> = n.decisions.iter().map(|d| d.label.as_str()).collect();
223 assert_eq!(labels, ["ctx", "kv"]);
224 assert_eq!(n.decision("kv").unwrap().value, "q8_0");
225 assert!(n.decision("mtp").is_none());
226 }
227
228 #[test]
229 fn format_mib_as_gib_scales_by_1024() {
230 assert_eq!(format_mib_as_gib(6144), "6.0 GB");
231 }
232}