1use std::collections::BTreeMap;
31use std::path::{Path, PathBuf};
32
33use frink_gguf::{GgufFile, TensorSource};
34
35use crate::loader::{load_f32_vec, LoadError};
36
37#[derive(Debug, Clone)]
39pub struct LoraTensor {
40 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#[derive(Debug, Clone)]
57pub struct LoraPair {
58 pub a: LoraTensor,
59 pub b: LoraTensor,
60}
61
62#[derive(Debug)]
64pub struct LoraAdapter {
65 pub path: PathBuf,
66 pub arch: String,
68 pub alpha: f32,
70 pub task_name: String,
75 pub prompt_prefix: String,
76 pub pairs: BTreeMap<String, LoraPair>,
79 pub skipped_norms: Vec<String>,
81}
82
83#[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 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 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 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 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 #[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}