gglib_core/domain/kv_memory.rs
1//! Detection of partial-KV-memory architectures from GGUF metadata.
2//!
3//! Some model architectures do not retain the full token history in their KV
4//! memory: sliding-window attention (SWA) layers keep only a recent window,
5//! hybrid-attention models interleave SWA layers with full-attention layers,
6//! and recurrent (SSM/Mamba-family) models keep only a compressed state.
7//!
8//! This matters for llama-server's disk slot persistence
9//! (`/slots?action=save|restore`): the save path serializes only the sequence
10//! KV state and token list — **not** the server's context checkpoints — and
11//! the restore path clears the slot's checkpoint list. On a full-attention
12//! model that's fine (the KV state alone is sufficient to resume). On a
13//! partial-memory model, resuming from position `n_past` requires history the
14//! SWA/recurrent layers no longer hold, which llama-server bridges with
15//! context checkpoints; with the checkpoint list empty after a disk restore,
16//! it falls back to `n_past = 0` and reprocesses the *entire* prompt. A disk
17//! "restore" on such a model therefore costs a full re-prefill — worse than
18//! useless, since the in-RAM prompt cache (`--cache-ram`), which *does* carry
19//! checkpoints, would have resumed cheaply had the slot not been pre-filled
20//! by the restore.
21//!
22//! Inputs come from the raw GGUF key/value map that `gglib-gguf` copies
23//! verbatim into [`crate::domain::Model::metadata`] (see
24//! [`crate::domain::estimate_kv_elems_per_token`] for the same pattern).
25//!
26//! Detection is deliberately *sensitive*: a false positive merely forgoes the
27//! disk-cache layer (the in-RAM cache still works), while a false negative
28//! silently costs minutes of TTFT per restore. Some older GGUFs carry a
29//! `sliding_window` key the runtime ignores; treating them as partial is the
30//! safe direction.
31
32use std::collections::HashMap;
33use std::hash::BuildHasher;
34
35/// Look up an architecture-prefixed GGUF key (`{arch}.{suffix}`), falling back
36/// to the bare suffix for the occasional file that omits the prefix.
37fn lookup_raw<'m, S: BuildHasher>(
38 metadata: &'m HashMap<String, String, S>,
39 arch: &str,
40 suffix: &str,
41) -> Option<&'m str> {
42 metadata
43 .get(&format!("{arch}.{suffix}"))
44 .or_else(|| metadata.get(suffix))
45 .map(|v| v.trim())
46}
47
48/// Numeric variant of [`lookup_raw`].
49fn lookup_u64<S: BuildHasher>(
50 metadata: &HashMap<String, String, S>,
51 arch: &str,
52 suffix: &str,
53) -> Option<u64> {
54 lookup_raw(metadata, arch, suffix).and_then(|v| v.parse::<u64>().ok())
55}
56
57/// Whether the model's KV memory retains only part of the token history
58/// (sliding-window, hybrid, or recurrent attention).
59///
60/// Checks, in order:
61///
62/// * `{arch}.full_attention_interval` > 1 — hybrid interleaved attention
63/// (e.g. `qwen35.full_attention_interval = 4`: every 4th layer is full
64/// attention, the rest sliding-window).
65/// * `{arch}.attention.sliding_window` > 0 — sliding-window attention
66/// (Gemma 2/3, Cohere 2, GPT-OSS, …).
67/// * `{arch}.ssm.conv_kernel` present — recurrent / hybrid-recurrent state
68/// (Mamba, Jamba, Granite-H, Falcon-H, …), which is inherently partial.
69///
70/// # Arguments
71///
72/// * `metadata` — raw GGUF key/value map (see [`crate::domain::Model::metadata`]).
73/// * `architecture` — the model's architecture, used as the key prefix. When
74/// `None`, falls back to the `general.architecture` metadata key.
75///
76/// # Returns
77///
78/// `false` when the metadata carries none of the marker keys — including when
79/// the architecture can't be determined at all, since an unprefixed lookup
80/// still runs and full-attention is the common case.
81#[must_use]
82pub fn kv_memory_is_partial<S: BuildHasher>(
83 metadata: &HashMap<String, String, S>,
84 architecture: Option<&str>,
85) -> bool {
86 let arch = architecture
87 .map(str::to_owned)
88 .or_else(|| metadata.get("general.architecture").cloned())
89 .unwrap_or_default();
90 let arch = arch.trim().to_ascii_lowercase();
91
92 if lookup_u64(metadata, &arch, "full_attention_interval").is_some_and(|v| v > 1) {
93 return true;
94 }
95 if lookup_u64(metadata, &arch, "attention.sliding_window").is_some_and(|v| v > 0) {
96 return true;
97 }
98 if lookup_raw(metadata, &arch, "ssm.conv_kernel").is_some() {
99 return true;
100 }
101
102 false
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108
109 /// Qwen3.6-shaped metadata: hybrid attention, every 4th layer full.
110 fn qwen36_metadata() -> HashMap<String, String> {
111 HashMap::from([
112 ("general.architecture".to_string(), "qwen35".to_string()),
113 (
114 "qwen35.full_attention_interval".to_string(),
115 "4".to_string(),
116 ),
117 ("qwen35.attention.head_count".to_string(), "24".to_string()),
118 ])
119 }
120
121 #[test]
122 fn detects_hybrid_full_attention_interval() {
123 assert!(kv_memory_is_partial(&qwen36_metadata(), Some("qwen35")));
124 }
125
126 #[test]
127 fn architecture_falls_back_to_general_architecture_key() {
128 assert!(kv_memory_is_partial(&qwen36_metadata(), None));
129 }
130
131 #[test]
132 fn architecture_lookup_is_case_insensitive() {
133 assert!(kv_memory_is_partial(&qwen36_metadata(), Some("QWEN35")));
134 }
135
136 /// Interval of 1 means every layer is full attention — not partial.
137 #[test]
138 fn interval_of_one_is_full_attention() {
139 let mut md = qwen36_metadata();
140 md.insert(
141 "qwen35.full_attention_interval".to_string(),
142 "1".to_string(),
143 );
144 assert!(!kv_memory_is_partial(&md, Some("qwen35")));
145 }
146
147 #[test]
148 fn detects_sliding_window_attention() {
149 let md = HashMap::from([
150 ("general.architecture".to_string(), "gemma3".to_string()),
151 (
152 "gemma3.attention.sliding_window".to_string(),
153 "1024".to_string(),
154 ),
155 ]);
156 assert!(kv_memory_is_partial(&md, Some("gemma3")));
157 }
158
159 /// A zero-size window means SWA is effectively disabled.
160 #[test]
161 fn zero_sliding_window_is_full_attention() {
162 let md = HashMap::from([(
163 "gemma3.attention.sliding_window".to_string(),
164 "0".to_string(),
165 )]);
166 assert!(!kv_memory_is_partial(&md, Some("gemma3")));
167 }
168
169 #[test]
170 fn detects_recurrent_ssm_state() {
171 let md = HashMap::from([
172 ("general.architecture".to_string(), "mamba".to_string()),
173 ("mamba.ssm.conv_kernel".to_string(), "4".to_string()),
174 ]);
175 assert!(kv_memory_is_partial(&md, Some("mamba")));
176 }
177
178 /// Plain full-attention transformer metadata (the Qwen3 fixture shape
179 /// from `kv_estimate`) must not trip the detector.
180 #[test]
181 fn full_attention_model_is_not_partial() {
182 let md = HashMap::from([
183 ("general.architecture".to_string(), "qwen3".to_string()),
184 ("qwen3.block_count".to_string(), "64".to_string()),
185 ("qwen3.attention.head_count".to_string(), "40".to_string()),
186 ("qwen3.attention.head_count_kv".to_string(), "8".to_string()),
187 ]);
188 assert!(!kv_memory_is_partial(&md, Some("qwen3")));
189 }
190
191 #[test]
192 fn empty_metadata_is_not_partial() {
193 assert!(!kv_memory_is_partial(&HashMap::new(), Some("llama")));
194 assert!(!kv_memory_is_partial(&HashMap::new(), None));
195 }
196
197 #[test]
198 fn unprefixed_keys_are_accepted_as_a_fallback() {
199 let md = HashMap::from([("attention.sliding_window".to_string(), "512".to_string())]);
200 assert!(kv_memory_is_partial(&md, Some("gemma2")));
201 }
202
203 #[test]
204 fn non_numeric_marker_values_are_ignored() {
205 let md = HashMap::from([(
206 "qwen35.full_attention_interval".to_string(),
207 "four".to_string(),
208 )]);
209 assert!(!kv_memory_is_partial(&md, Some("qwen35")));
210 }
211}