Skip to main content

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}