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//! five-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//! the `backend` [`LaunchDecision`] 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)]
43#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
44pub struct LaunchDecision {
45 /// Short stable key naming the decision: `ctx`, `backend`, `kv`,
46 /// `cache`, `mtp`, `flags`, `dialect`.
47 ///
48 /// Stable because the GUI and the CLI dashboard both key styling off it;
49 /// treat it as part of the wire contract rather than a display string.
50 pub label: String,
51 /// Display-ready value, e.g. `32768` or `q8_0 -> 2.1 GiB, f16 would be 4.2 GiB`.
52 pub value: String,
53 /// Where the value came from, rendered in parentheses by every consumer.
54 ///
55 /// `None` only for decisions whose value already states its own origin —
56 /// never as a shortcut for "didn't bother", since an unexplained value is
57 /// the exact failure mode this type exists to prevent.
58 pub source: Option<String>,
59}
60
61impl LaunchDecision {
62 /// A decision whose provenance is worth stating.
63 #[must_use]
64 pub fn new(
65 label: impl Into<String>,
66 value: impl Into<String>,
67 source: impl Into<String>,
68 ) -> Self {
69 Self {
70 label: label.into(),
71 value: value.into(),
72 source: Some(source.into()),
73 }
74 }
75
76 /// A decision that is self-explanatory — see [`Self::source`] for when
77 /// that is legitimate.
78 #[must_use]
79 pub fn bare(label: impl Into<String>, value: impl Into<String>) -> Self {
80 Self {
81 label: label.into(),
82 value: value.into(),
83 source: None,
84 }
85 }
86}
87
88/// Everything the runtime decided for one llama-server launch.
89///
90/// Built at spawn by the runtime; consumed by the CLI banner, the proxy
91/// status endpoint, and the GUI dashboard. See the [module docs](self) for
92/// why the decisions are an ordered list rather than named fields: the three
93/// consumers all render the same rows in the same order, and a list keeps
94/// adding a decision to a single site instead of four.
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
97pub struct LaunchNarration {
98 /// Model name as the catalog knows it.
99 pub model_name: String,
100 /// Quantization label (`Q4_K_M`), when the catalog recorded one.
101 pub quantization: Option<String>,
102 /// On-disk weight size in bytes, summed across shards. `0` when unknown —
103 /// rendered as absent rather than as "0 GiB".
104 #[cfg_attr(feature = "ts-bindings", ts(type = "number"))]
105 pub weights_bytes: u64,
106 /// The decisions, in the order they should be displayed.
107 pub decisions: Vec<LaunchDecision>,
108}
109
110impl LaunchNarration {
111 /// Start a narration for a model; decisions are appended by the runtime
112 /// as each resolution comes into scope.
113 #[must_use]
114 pub fn new(
115 model_name: impl Into<String>,
116 quantization: Option<String>,
117 weights_bytes: u64,
118 ) -> Self {
119 Self {
120 model_name: model_name.into(),
121 quantization,
122 weights_bytes,
123 decisions: Vec::new(),
124 }
125 }
126
127 /// Append a decision, keeping display order.
128 pub fn push(&mut self, decision: LaunchDecision) {
129 self.decisions.push(decision);
130 }
131
132 /// The identity line: `qwen3-30b-a3b · Q4_K_M · 17.2 GiB`.
133 ///
134 /// Unknown quantization and unknown size are dropped rather than rendered
135 /// as `unknown` or `0 GiB` — a banner that pads itself with non-answers
136 /// reads as broken.
137 #[must_use]
138 pub fn headline(&self) -> String {
139 let mut parts = vec![self.model_name.clone()];
140 if let Some(quant) = &self.quantization {
141 parts.push(quant.clone());
142 }
143 if self.weights_bytes > 0 {
144 parts.push(format_gib(self.weights_bytes));
145 }
146 parts.join(" \u{b7} ")
147 }
148
149 /// Look up a decision by its stable label.
150 #[must_use]
151 pub fn decision(&self, label: &str) -> Option<&LaunchDecision> {
152 self.decisions.iter().find(|d| d.label == label)
153 }
154}
155
156/// Format a byte count as GiB with one decimal, e.g. `17.2 GiB`.
157///
158/// GiB rather than GB, because the division is by 1024^3: it matches how every
159/// other memory figure in the launch path is computed, and a banner whose
160/// numbers disagree with the `--cache-ram` budget beside them is worse than no
161/// banner.
162#[must_use]
163pub fn format_gib(bytes: u64) -> String {
164 #[allow(clippy::cast_precision_loss)]
165 let gib = bytes as f64 / 1_073_741_824.0;
166 format!("{gib:.1} GiB")
167}
168
169/// Format a MiB count as GiB with one decimal, for the RAM cache budget.
170#[must_use]
171pub fn format_mib_as_gib(mib: u64) -> String {
172 #[allow(clippy::cast_precision_loss)]
173 let gib = mib as f64 / 1024.0;
174 format!("{gib:.1} GiB")
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 #[test]
182 fn headline_joins_the_three_identity_parts() {
183 let n = LaunchNarration::new("qwen3-30b-a3b", Some("Q4_K_M".to_string()), 18_476_297_420);
184 assert_eq!(n.headline(), "qwen3-30b-a3b \u{b7} Q4_K_M \u{b7} 17.2 GiB");
185 }
186
187 /// Unknown quant and unknown size drop out rather than rendering as
188 /// filler — see [`LaunchNarration::headline`].
189 #[test]
190 fn headline_drops_unknown_quantization_and_zero_size() {
191 let n = LaunchNarration::new("mystery", None, 0);
192 assert_eq!(n.headline(), "mystery");
193 }
194
195 #[test]
196 fn headline_keeps_size_when_only_quantization_is_unknown() {
197 let n = LaunchNarration::new("mystery", None, 1_073_741_824);
198 assert_eq!(n.headline(), "mystery \u{b7} 1.0 GiB");
199 }
200
201 #[test]
202 fn decisions_keep_insertion_order_and_are_findable_by_label() {
203 let mut n = LaunchNarration::new("m", None, 0);
204 n.push(LaunchDecision::new("ctx", "32768", "flag"));
205 n.push(LaunchDecision::new("kv", "q8_0", "default"));
206 let labels: Vec<&str> = n.decisions.iter().map(|d| d.label.as_str()).collect();
207 assert_eq!(labels, ["ctx", "kv"]);
208 assert_eq!(n.decision("kv").unwrap().value, "q8_0");
209 assert!(n.decision("mtp").is_none());
210 }
211
212 #[test]
213 fn format_mib_as_gib_scales_by_1024() {
214 assert_eq!(format_mib_as_gib(6144), "6.0 GiB");
215 }
216}