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//! 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}