Skip to main content

frink_models/
lora.rs

1//! Reading a LoRA adapter GGUF: the file format llama.cpp's
2//! `convert_lora_to_gguf.py` writes and `src/llama-adapter.cpp` reads.
3//!
4//! The contract, line by line from `llama_adapter_lora_init_impl`
5//! (`llama-adapter.cpp:169-497`):
6//!
7//! * `general.type` must be `"adapter"` (`:202-205`), `adapter.type`
8//!   must be `"lora"` (`:213-216`), and `general.architecture` must be
9//!   the base model's (`:207-211`, "model arch and LoRA arch mismatch").
10//! * `adapter.lora.alpha` is read as f32, absent meaning `0`, which
11//!   `get_scale` treats as "no alpha scaling" (`llama-adapter.h:53-57`).
12//! * Every tensor is `<base name>.lora_a` or `<base name>.lora_b`,
13//!   bundled into pairs by base name (`:273-292`). A `_norm.weight`
14//!   tensor is SKIPPED ("we don't really care because most adapters
15//!   still work fine without it", `:287-290`); any other suffix is
16//!   refused (`:291-293`). A pair missing one half is refused
17//!   (`:342-344`).
18//! * `adapter.alora.invocation_tokens` marks an *activated* LoRA, whose
19//!   delta is applied only from an invocation sequence onward
20//!   (`:219-238`, `server-context.cpp:1752-1800`). frink does not
21//!   implement that gating, so the key is refused by name here rather
22//!   than the adapter being applied to every position.
23//!
24//! What this module does NOT decide is whether a pair fits the base
25//! model: that needs the base's shapes and lives in
26//! [`crate::lora_attach`], beside the walk over the decoder's
27//! projections. Shapes here are `[rows, cols]` in frink's row-major
28//! sense, i.e. ggml's `ne` reversed.
29
30use std::collections::BTreeMap;
31use std::path::{Path, PathBuf};
32
33use frink_gguf::{GgufFile, TensorSource};
34
35use crate::loader::{load_f32_vec, LoadError};
36
37/// A tensor of the adapter file, widened to f32.
38#[derive(Debug, Clone)]
39pub struct LoraTensor {
40    /// `[rows, cols]`: ggml's `ne` reversed.
41    pub shape: [usize; 2],
42    pub data: Vec<f32>,
43}
44
45impl LoraTensor {
46    pub fn rows(&self) -> usize {
47        self.shape[0]
48    }
49
50    pub fn cols(&self) -> usize {
51        self.shape[1]
52    }
53}
54
55/// One base tensor's `(lora_a, lora_b)` pair.
56#[derive(Debug, Clone)]
57pub struct LoraPair {
58    pub a: LoraTensor,
59    pub b: LoraTensor,
60}
61
62/// A parsed adapter file, not yet matched against a base model.
63#[derive(Debug)]
64pub struct LoraAdapter {
65    pub path: PathBuf,
66    /// `general.architecture`, compared against the base's.
67    pub arch: String,
68    /// `adapter.lora.alpha`, `0.0` when the file carries none.
69    pub alpha: f32,
70    /// `adapter.lora.task_name` / `adapter.lora.prompt_prefix`, the two
71    /// metadata strings `GET /lora-adapters` reports upstream
72    /// (`common.cpp:1274-1277`, `server-task.cpp:1616-1617`); empty
73    /// when absent, as there.
74    pub task_name: String,
75    pub prompt_prefix: String,
76    /// Base tensor name -> its pair, in name order so an attach walks
77    /// deterministically.
78    pub pairs: BTreeMap<String, LoraPair>,
79    /// `_norm.weight` tensors the file carries and llama.cpp ignores.
80    pub skipped_norms: Vec<String>,
81}
82
83/// Why an adapter file, or its match against a base, was refused.
84#[derive(Debug, thiserror::Error)]
85pub enum LoraError {
86    #[error("{0}")]
87    Load(#[from] LoadError),
88    #[error("{path}: expect general.type to be 'adapter', but got: {got:?}")]
89    NotAnAdapter { path: PathBuf, got: String },
90    #[error("{path}: expect adapter.type to be 'lora', but got: {got:?}")]
91    NotLora { path: PathBuf, got: String },
92    #[error(
93        "{path}: model arch and LoRA arch mismatch (adapter declares {adapter:?}, base is \
94         {base:?})"
95    )]
96    ArchMismatch {
97        path: PathBuf,
98        adapter: String,
99        base: String,
100    },
101    #[error("{path}: LoRA tensor '{name}' has unexpected suffix (want .lora_a or .lora_b)")]
102    UnexpectedSuffix { path: PathBuf, name: String },
103    #[error("{path}: LoRA tensor pair for '{name}' is missing one component")]
104    MissingComponent { path: PathBuf, name: String },
105    #[error(
106        "{path}: this is an activated LoRA (`adapter.alora.invocation_tokens`, {n} tokens), \
107         which llama.cpp applies only from the invocation sequence onward \
108         (server-context.cpp:1752-1800); frink applies an adapter to every position and \
109         refuses rather than activate it early"
110    )]
111    Alora { path: PathBuf, n: usize },
112    #[error("{path}: LoRA tensor '{name}' is not 2-D (shape {shape:?})")]
113    NotTwoD {
114        path: PathBuf,
115        name: String,
116        shape: Vec<u64>,
117    },
118    #[error(
119        "{path}: LoRA tensor '{name}' does not exist in base model (hint: maybe wrong base \
120         model?)"
121    )]
122    NotInBase { path: PathBuf, name: String },
123    #[error(
124        "{path}: tensor '{name}' has incorrect shape (hint: maybe wrong base model?): base is \
125         [{rows} x {cols}], lora_a is {a:?}, lora_b is {b:?}"
126    )]
127    Shape {
128        path: PathBuf,
129        name: String,
130        rows: usize,
131        cols: usize,
132        a: [usize; 2],
133        b: [usize; 2],
134    },
135    #[error(
136        "{path}: lora_a tensor for '{name}' is not transposed (hint: adapter from \"finetune\" \
137         example is no longer supported): lora_a is {a:?}, lora_b is {b:?}"
138    )]
139    NotTransposed {
140        path: PathBuf,
141        name: String,
142        a: [usize; 2],
143        b: [usize; 2],
144    },
145    #[error(
146        "{path}: '{name}' adapts a routed-expert tensor; llama.cpp applies that through \
147         `build_lora_mm_id` (llama-graph.cpp:1517-1550) and frink has no per-expert delta, \
148         so the adapter is refused rather than applied to the dense projections only"
149    )]
150    RoutedExperts { path: PathBuf, name: String },
151    #[error(
152        "{path}: '{name}' adapts a tensor frink holds under no projection (norms, biases and \
153         side tables are not `WeightMatrix`), so the delta would be dropped; refused rather \
154         than applied partially"
155    )]
156    NoProjection { path: PathBuf, name: String },
157    #[error(
158        "{path}: 'token_embd.weight' is adapted but the base model ties its output head to the \
159         embedding (no `output.weight`); llama.cpp builds `build_lora_mm(model.output, ..)` \
160         (llama-graph.cpp:1490) over the FLIPPED embedding pair there and aborts with \
161         `ggml.c:3282: GGML_ASSERT(ggml_can_mul_mat(a, b))` (measured), so no engine serves \
162         this combination"
163    )]
164    TiedHead { path: PathBuf },
165    #[error(
166        "{path}: '{name}' targets a store-backed expert layer, whose weights are leased \
167             per use; run with resident experts to attach an adapter"
168    )]
169    StoredExperts { path: PathBuf, name: String },
170}
171
172impl LoraAdapter {
173    /// Parses the file and its tensors. Every check llama.cpp makes on
174    /// the file ALONE is made here; the shape checks need the base and
175    /// are made at attach.
176    pub fn open(path: impl AsRef<Path>) -> Result<Self, LoraError> {
177        let path = path.as_ref().to_path_buf();
178        let file = GgufFile::open(&path).map_err(LoadError::from)?;
179        Self::from_file(path, &file)
180    }
181
182    fn from_file(path: PathBuf, file: &GgufFile) -> Result<Self, LoraError> {
183        let general_type = file.metadata_str("general.type").unwrap_or("");
184        if general_type != "adapter" {
185            return Err(LoraError::NotAnAdapter {
186                path,
187                got: general_type.to_string(),
188            });
189        }
190        let adapter_type = file.metadata_str("adapter.type").unwrap_or("");
191        if adapter_type != "lora" {
192            return Err(LoraError::NotLora {
193                path,
194                got: adapter_type.to_string(),
195            });
196        }
197        if let Some(v) = file.metadata("adapter.alora.invocation_tokens") {
198            let n = match v {
199                frink_gguf::GgufValue::Array(items) => items.len(),
200                _ => 1,
201            };
202            return Err(LoraError::Alora { path, n });
203        }
204        let arch = file
205            .metadata_str("general.architecture")
206            .unwrap_or("")
207            .to_string();
208        let alpha = file.metadata_f32("adapter.lora.alpha").unwrap_or(0.0);
209        let task_name = file
210            .metadata_str("adapter.lora.task_name")
211            .unwrap_or("")
212            .to_string();
213        let prompt_prefix = file
214            .metadata_str("adapter.lora.prompt_prefix")
215            .unwrap_or("")
216            .to_string();
217
218        // Bundle `lora_a` / `lora_b` into pairs by base name, exactly
219        // as `llama-adapter.cpp:273-293` does, including which suffixes
220        // are skipped and which are refused.
221        let mut halves: BTreeMap<String, (Option<LoraTensor>, Option<LoraTensor>)> =
222            BTreeMap::new();
223        let mut skipped_norms = Vec::new();
224        for info in &file.tensors {
225            let name = info.name.as_str();
226            let (base, is_a) = if let Some(base) = name.strip_suffix(".lora_a") {
227                (base, true)
228            } else if let Some(base) = name.strip_suffix(".lora_b") {
229                (base, false)
230            } else if name.ends_with("_norm.weight") {
231                skipped_norms.push(name.to_string());
232                continue;
233            } else {
234                return Err(LoraError::UnexpectedSuffix {
235                    path,
236                    name: name.to_string(),
237                });
238            };
239            if info.shape.len() != 2 {
240                return Err(LoraError::NotTwoD {
241                    path,
242                    name: name.to_string(),
243                    shape: info.shape.clone(),
244                });
245            }
246            let tensor = LoraTensor {
247                // ggml `ne = [n_cols, n_rows]`.
248                shape: [info.shape[1] as usize, info.shape[0] as usize],
249                data: load_f32_vec(file, name)?,
250            };
251            let entry = halves.entry(base.to_string()).or_default();
252            if is_a {
253                entry.0 = Some(tensor);
254            } else {
255                entry.1 = Some(tensor);
256            }
257        }
258        let mut pairs = BTreeMap::new();
259        for (name, (a, b)) in halves {
260            match (a, b) {
261                (Some(a), Some(b)) => {
262                    pairs.insert(name, LoraPair { a, b });
263                }
264                _ => return Err(LoraError::MissingComponent { path, name }),
265            }
266        }
267        Ok(Self {
268            path,
269            arch,
270            alpha,
271            task_name,
272            prompt_prefix,
273            pairs,
274            skipped_norms,
275        })
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use frink_gguf::writer::{GgufWriter, TensorPlan};
283    use frink_gguf::GgmlType;
284
285    fn fixture_dir() -> PathBuf {
286        Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures")
287    }
288
289    /// A hand-written adapter file: `kv` as metadata, each tensor F32
290    /// of the given `ne` (GGUF dimension order), filled with 0.5.
291    fn write_adapter(
292        name: &str,
293        kv: &[(&str, frink_gguf::GgufValue)],
294        tensors: &[(&str, Vec<u64>)],
295    ) -> PathBuf {
296        let dir = std::env::temp_dir().join(format!("frink-lora-{}-{name}", std::process::id()));
297        std::fs::create_dir_all(&dir).unwrap();
298        let path = dir.join("adapter.gguf");
299        let metadata: BTreeMap<String, frink_gguf::GgufValue> =
300            kv.iter().map(|(k, v)| (k.to_string(), v.clone())).collect();
301        let plan: Vec<TensorPlan> = tensors
302            .iter()
303            .map(|(t, ne)| TensorPlan {
304                name: t.to_string(),
305                shape: ne.clone(),
306                dtype: GgmlType::F32,
307                byte_len: ne.iter().product::<u64>() as usize * 4,
308            })
309            .collect();
310        let file = std::fs::File::create(&path).unwrap();
311        let mut w = GgufWriter::create(std::io::BufWriter::new(file), &metadata, plan).unwrap();
312        for (t, ne) in tensors {
313            let n = ne.iter().product::<u64>() as usize;
314            let bytes: Vec<u8> = std::iter::repeat_n(0.5f32.to_le_bytes(), n)
315                .flatten()
316                .collect();
317            w.write_tensor(t, &bytes).unwrap();
318        }
319        w.finish().unwrap();
320        path
321    }
322
323    fn base_kv() -> Vec<(&'static str, frink_gguf::GgufValue)> {
324        use frink_gguf::GgufValue as V;
325        vec![
326            ("general.type", V::String("adapter".into())),
327            ("adapter.type", V::String("lora".into())),
328            ("general.architecture", V::String("llama".into())),
329            ("adapter.lora.alpha", V::F32(8.0)),
330        ]
331    }
332
333    /// The real converter's output parses, with every pair and the
334    /// flipped embedding shape read as `[rows, cols]`.
335    #[test]
336    fn the_converter_s_file_parses_into_pairs() {
337        let a = LoraAdapter::open(fixture_dir().join("lora_a_tiny.gguf")).unwrap();
338        assert_eq!(a.arch, "llama");
339        assert_eq!(a.alpha, 8.0);
340        assert_eq!(a.pairs.len(), 16, "7 per layer x 2 + embedding + head");
341        let q = &a.pairs["blk.0.attn_q.weight"];
342        assert_eq!(q.a.shape, [4, 24], "lora_a is [rank, n_in]");
343        assert_eq!(q.b.shape, [24, 4], "lora_b is [n_out, rank]");
344        let e = &a.pairs["token_embd.weight"];
345        assert_eq!(
346            e.a.shape,
347            [48, 4],
348            "the embedding's lora_a is flipped: [n_vocab, rank]"
349        );
350        assert_eq!(e.b.shape, [24, 4], "[n_embd, rank]");
351        assert!(a.skipped_norms.is_empty());
352    }
353
354    #[test]
355    fn a_plain_model_file_is_not_an_adapter() {
356        let err = LoraAdapter::open(fixture_dir().join("lora_base_tiny.gguf")).unwrap_err();
357        assert!(matches!(err, LoraError::NotAnAdapter { .. }), "{err}");
358        assert!(err.to_string().contains("general.type"), "{err}");
359    }
360
361    #[test]
362    fn a_control_vector_adapter_is_refused_by_type() {
363        let mut kv = base_kv();
364        kv[1] = (
365            "adapter.type",
366            frink_gguf::GgufValue::String("control_vector".into()),
367        );
368        let p = write_adapter("cvec", &kv, &[]);
369        let err = LoraAdapter::open(&p).unwrap_err();
370        assert!(matches!(err, LoraError::NotLora { .. }), "{err}");
371    }
372
373    #[test]
374    fn a_tensor_with_another_suffix_is_refused_and_a_norm_is_skipped() {
375        let p = write_adapter(
376            "suffix",
377            &base_kv(),
378            &[("blk.0.attn_q.weight.lora_c", vec![4, 4])],
379        );
380        let err = LoraAdapter::open(&p).unwrap_err();
381        assert!(matches!(err, LoraError::UnexpectedSuffix { .. }), "{err}");
382
383        let p = write_adapter(
384            "norm",
385            &base_kv(),
386            &[
387                ("blk.0.attn_norm.weight", vec![24]),
388                ("blk.0.attn_q.weight.lora_a", vec![24, 4]),
389                ("blk.0.attn_q.weight.lora_b", vec![4, 24]),
390            ],
391        );
392        let a = LoraAdapter::open(&p).unwrap();
393        assert_eq!(a.skipped_norms, vec!["blk.0.attn_norm.weight".to_string()]);
394        assert_eq!(a.pairs.len(), 1);
395    }
396
397    #[test]
398    fn a_pair_missing_one_half_is_refused() {
399        let p = write_adapter(
400            "half",
401            &base_kv(),
402            &[("blk.0.attn_q.weight.lora_a", vec![24, 4])],
403        );
404        let err = LoraAdapter::open(&p).unwrap_err();
405        assert!(matches!(err, LoraError::MissingComponent { .. }), "{err}");
406        assert!(err.to_string().contains("blk.0.attn_q.weight"), "{err}");
407    }
408
409    #[test]
410    fn an_activated_lora_is_refused_by_name() {
411        let mut kv = base_kv();
412        kv.push((
413            "adapter.alora.invocation_tokens",
414            frink_gguf::GgufValue::Array(vec![
415                frink_gguf::GgufValue::U32(5),
416                frink_gguf::GgufValue::U32(9),
417            ]),
418        ));
419        let p = write_adapter("alora", &kv, &[]);
420        let err = LoraAdapter::open(&p).unwrap_err();
421        assert!(matches!(err, LoraError::Alora { n: 2, .. }), "{err}");
422        assert!(err.to_string().contains("invocation"), "{err}");
423    }
424
425    #[test]
426    fn a_missing_alpha_reads_as_zero() {
427        let kv: Vec<_> = base_kv().into_iter().take(3).collect();
428        let p = write_adapter("noalpha", &kv, &[]);
429        let a = LoraAdapter::open(&p).unwrap();
430        assert_eq!(a.alpha, 0.0);
431    }
432}