1use super::domain::{read_f32_le, read_u16_le, read_u32_le};
4use super::*;
5
6#[derive(Clone)]
25pub struct LoraAdapter {
26 pub rank: usize,
28 pub in_dim: usize,
30 pub out_dim: usize,
32 pub a: Vec<f32>,
34 pub b: Vec<f32>,
36 pub alpha: f32,
38}
39
40impl LoraAdapter {
41 pub fn load(path: &std::path::Path) -> Result<Vec<Self>, String> {
49 const LORA_MAGIC: &[u8; 4] = b"LORA";
50 const LORA_VERSION: u32 = 1;
51
52 let file_data =
53 std::fs::read(path).map_err(|e| format!("Failed to read lora file: {e}"))?;
54
55 if file_data.len() < 44 {
56 return Err("File too small for lora header".into());
57 }
58
59 if &file_data[0..4] != LORA_MAGIC {
60 return Err("Invalid lora magic bytes".into());
61 }
62
63 let version = u32::from_le_bytes(
64 file_data[4..8]
65 .try_into()
66 .map_err(|e: std::array::TryFromSliceError| format!("Version parse: {e}"))?,
67 );
68 if version != LORA_VERSION {
69 return Err(format!("Unsupported lora version: {version}"));
70 }
71
72 let stored_checksum = &file_data[8..40];
73 let payload = &file_data[40..];
74
75 let computed = blake3::hash(payload);
76 if computed.as_bytes() != stored_checksum {
77 return Err("LoRA file checksum mismatch".into());
78 }
79
80 let mut offset = 0usize;
81 let n_adapters = read_u32_le(payload, &mut offset)? as usize;
82 let rank = read_u32_le(payload, &mut offset)? as usize;
83 let alpha = read_f32_le(payload, &mut offset)?;
84
85 if n_adapters == 0 {
86 return Err("No adapters in lora file".into());
87 }
88
89 let mut adapters = Vec::with_capacity(n_adapters);
90 for i in 0..n_adapters {
91 let in_dim = read_u32_le(payload, &mut offset)? as usize;
92 let out_dim = read_u32_le(payload, &mut offset)? as usize;
93
94 let a_count = rank * in_dim;
95 let b_count = out_dim * rank;
96 let a_bytes = a_count * std::mem::size_of::<f32>();
97 let b_bytes = b_count * std::mem::size_of::<f32>();
98
99 if offset + a_bytes + b_bytes > payload.len() {
100 return Err(format!("Truncated adapter {i} data"));
101 }
102
103 let a: Vec<f32> = {
104 #[cfg(target_endian = "little")]
105 {
106 let mut v = Vec::with_capacity(a_count);
107 unsafe {
108 std::ptr::copy_nonoverlapping(
109 payload[offset..].as_ptr(),
110 v.as_mut_ptr() as *mut u8,
111 a_bytes,
112 );
113 v.set_len(a_count);
114 }
115 v
116 }
117 #[cfg(not(target_endian = "little"))]
118 {
119 payload[offset..offset + a_bytes]
120 .chunks_exact(4)
121 .map(|c| f32::from_le_bytes(c.try_into().expect("chunk is 4 bytes")))
122 .collect()
123 }
124 };
125 offset += a_bytes;
126
127 let b: Vec<f32> = {
128 #[cfg(target_endian = "little")]
129 {
130 let mut v = Vec::with_capacity(b_count);
131 unsafe {
132 std::ptr::copy_nonoverlapping(
133 payload[offset..].as_ptr(),
134 v.as_mut_ptr() as *mut u8,
135 b_bytes,
136 );
137 v.set_len(b_count);
138 }
139 v
140 }
141 #[cfg(not(target_endian = "little"))]
142 {
143 payload[offset..offset + b_bytes]
144 .chunks_exact(4)
145 .map(|c| f32::from_le_bytes(c.try_into().expect("chunk is 4 bytes")))
146 .collect()
147 }
148 };
149 offset += b_bytes;
150
151 adapters.push(Self {
152 rank,
153 in_dim,
154 out_dim,
155 alpha,
156 a,
157 b,
158 });
159 }
160
161 if offset != payload.len() {
162 return Err(format!(
163 "LoRA payload trailing data: consumed {offset}, payload {}",
164 payload.len()
165 ));
166 }
167
168 Ok(adapters)
169 }
170
171 pub fn load_first(path: &std::path::Path) -> Result<Self, String> {
181 let adapters = Self::load(path)?;
182 adapters
183 .into_iter()
184 .next()
185 .ok_or_else(|| "LoRA file declared zero-length adapter list".into())
186 }
187
188 pub fn save(
203 adapters: &[&Self],
204 rank: usize,
205 alpha: f32,
206 path: &std::path::Path,
207 ) -> Result<(), String> {
208 const LORA_MAGIC: &[u8; 4] = b"LORA";
209 const LORA_VERSION: u32 = 1;
210
211 if adapters.is_empty() {
212 return Err("Cannot save zero adapters".into());
213 }
214
215 let mut payload = Vec::with_capacity(12 + adapters.len() * (8 + 16));
216
217 payload.extend_from_slice(&(adapters.len() as u32).to_le_bytes());
219 payload.extend_from_slice(&(rank as u32).to_le_bytes());
220 payload.extend_from_slice(&alpha.to_le_bytes());
221
222 for adapter in adapters {
224 if adapter.rank != rank {
225 return Err(format!(
226 "Adapter rank mismatch: header={rank}, adapter={}",
227 adapter.rank
228 ));
229 }
230 payload.extend_from_slice(&(adapter.in_dim as u32).to_le_bytes());
231 payload.extend_from_slice(&(adapter.out_dim as u32).to_le_bytes());
232
233 let a_count = rank * adapter.in_dim;
234 let b_count = adapter.out_dim * rank;
235 if adapter.a.len() != a_count {
236 return Err(format!(
237 "A matrix size mismatch: expected {a_count}, got {}",
238 adapter.a.len()
239 ));
240 }
241 if adapter.b.len() != b_count {
242 return Err(format!(
243 "B matrix size mismatch: expected {b_count}, got {}",
244 adapter.b.len()
245 ));
246 }
247 #[cfg(target_endian = "little")]
253 {
254 let a_bytes = unsafe {
255 std::slice::from_raw_parts(
256 adapter.a.as_ptr() as *const u8,
257 adapter.a.len() * std::mem::size_of::<f32>(),
258 )
259 };
260 payload.extend_from_slice(a_bytes);
261 let b_bytes = unsafe {
262 std::slice::from_raw_parts(
263 adapter.b.as_ptr() as *const u8,
264 adapter.b.len() * std::mem::size_of::<f32>(),
265 )
266 };
267 payload.extend_from_slice(b_bytes);
268 }
269 #[cfg(not(target_endian = "little"))]
270 {
271 for val in &adapter.a {
272 payload.extend_from_slice(&val.to_le_bytes());
273 }
274 for val in &adapter.b {
275 payload.extend_from_slice(&val.to_le_bytes());
276 }
277 }
278 }
279
280 let checksum = blake3::hash(&payload);
282
283 let mut file_data = Vec::with_capacity(4 + 4 + 32 + payload.len());
285 file_data.extend_from_slice(LORA_MAGIC);
286 file_data.extend_from_slice(&LORA_VERSION.to_le_bytes());
287 file_data.extend_from_slice(checksum.as_bytes());
288 file_data.extend_from_slice(&payload);
289
290 std::fs::write(path, &file_data).map_err(|e| format!("Failed to write lora file: {e}"))
291 }
292
293 pub fn load_from_bin(path: &std::path::Path) -> Result<Vec<Self>, String> {
313 const LORA_MAGIC: &[u8; 4] = b"LORA";
314 const LORA_VERSION: u8 = 1;
315
316 let file_data =
317 std::fs::read(path).map_err(|e| format!("Failed to read lora bin file: {e}"))?;
318
319 if file_data.len() < 43 {
321 return Err("File too small for lora bin header".into());
322 }
323
324 let data_len = file_data.len() - 32;
326 let stored_checksum = &file_data[data_len..];
327 let computed = blake3::hash(&file_data[..data_len]);
328 if computed.as_bytes() != stored_checksum {
329 return Err("LoRA bin file checksum mismatch".into());
330 }
331
332 let mut offset = 0usize;
333
334 if &file_data[offset..offset + 4] != LORA_MAGIC {
336 return Err("Invalid lora bin magic bytes".into());
337 }
338 offset += 4;
339
340 let version = file_data[offset];
342 if version != LORA_VERSION {
343 return Err(format!("Unsupported lora bin version: {version}"));
344 }
345 offset += 1;
346
347 let rank = read_u16_le(&file_data, &mut offset)? as usize;
349
350 let n_layers = read_u16_le(&file_data, &mut offset)? as usize;
352
353 let n_targets = read_u16_le(&file_data, &mut offset)? as usize;
355
356 if n_layers == 0 || n_targets == 0 {
357 return Err("No layers or targets in lora bin file".into());
358 }
359
360 let mut target_ids = Vec::with_capacity(n_targets);
362 for _ in 0..n_targets {
363 let tid = read_u16_le(&file_data, &mut offset)?;
364 match tid {
365 0..=6 => target_ids.push(tid),
366 _ => return Err(format!("Invalid target ID: {tid}")),
367 }
368 }
369
370 let alpha = (rank * 2) as f32;
372 let mut adapters = Vec::with_capacity(n_layers * n_targets);
373
374 for _layer in 0..n_layers {
375 for &_target_id in &target_ids {
376 let a_rows = read_u16_le(&file_data, &mut offset)? as usize;
378 let a_cols = read_u16_le(&file_data, &mut offset)? as usize;
379 let a_count = a_rows * a_cols;
380 let a_bytes = a_count * std::mem::size_of::<f32>();
381
382 if offset + a_bytes > data_len {
383 return Err("Truncated A matrix data".into());
384 }
385
386 let a: Vec<f32> = {
387 #[cfg(target_endian = "little")]
388 {
389 let mut v = Vec::with_capacity(a_count);
390 unsafe {
391 std::ptr::copy_nonoverlapping(
392 file_data[offset..].as_ptr(),
393 v.as_mut_ptr() as *mut u8,
394 a_bytes,
395 );
396 v.set_len(a_count);
397 }
398 v
399 }
400 #[cfg(not(target_endian = "little"))]
401 {
402 file_data[offset..offset + a_bytes]
403 .chunks_exact(4)
404 .map(|c| f32::from_le_bytes(c.try_into().expect("chunk is 4 bytes")))
405 .collect()
406 }
407 };
408 offset += a_bytes;
409
410 let b_rows = read_u16_le(&file_data, &mut offset)? as usize;
412 let b_cols = read_u16_le(&file_data, &mut offset)? as usize;
413 let b_count = b_rows * b_cols;
414 let b_bytes = b_count * std::mem::size_of::<f32>();
415
416 if offset + b_bytes > data_len {
417 return Err("Truncated B matrix data".into());
418 }
419
420 let b: Vec<f32> = {
421 #[cfg(target_endian = "little")]
422 {
423 let mut v = Vec::with_capacity(b_count);
424 unsafe {
425 std::ptr::copy_nonoverlapping(
426 file_data[offset..].as_ptr(),
427 v.as_mut_ptr() as *mut u8,
428 b_bytes,
429 );
430 v.set_len(b_count);
431 }
432 v
433 }
434 #[cfg(not(target_endian = "little"))]
435 {
436 file_data[offset..offset + b_bytes]
437 .chunks_exact(4)
438 .map(|c| f32::from_le_bytes(c.try_into().expect("chunk is 4 bytes")))
439 .collect()
440 }
441 };
442 offset += b_bytes;
443
444 let in_dim = a_cols;
445 let out_dim = b_rows;
446
447 adapters.push(Self {
448 rank,
449 in_dim,
450 out_dim,
451 alpha,
452 a,
453 b,
454 });
455 }
456 }
457
458 if offset != data_len {
459 return Err(format!(
460 "Unexpected trailing data: read {offset}, expected {data_len}"
461 ));
462 }
463
464 if adapters.is_empty() {
465 return Err("No adapters loaded from lora bin file".into());
466 }
467
468 Ok(adapters)
469 }
470}
471
472#[inline(always)]
478pub fn lora_apply(output: &mut [f32], lora: &LoraAdapter, input: &[f32], lora_buf: &mut [f32]) {
479 let scale = lora.alpha / lora.rank as f32;
480
481 matmul(lora_buf, &lora.a, input, lora.rank, lora.in_dim);
483
484 for r in 0..lora.out_dim {
486 let row_off = r * lora.rank;
487 let sum =
488 crate::simd::simd_dot_f32(&lora.b[row_off..row_off + lora.rank], lora_buf, lora.rank);
489 unsafe {
490 *output.get_unchecked_mut(r) += scale * sum;
491 }
492 }
493}
494
495pub struct LoraPair {
499 pub reader: Option<LoraAdapter>,
501 pub writer: Option<LoraAdapter>,
503}
504
505impl LoraPair {
506 pub fn none() -> Self {
508 Self {
509 reader: None,
510 writer: None,
511 }
512 }
513}
514
515#[cfg(test)]
516mod tests {
517 use super::*;
518
519 fn make_test_adapters() -> Vec<LoraAdapter> {
520 vec![
523 LoraAdapter {
524 rank: 4,
525 in_dim: 32,
526 out_dim: 32,
527 a: (0..4 * 32).map(|i| i as f32 * 0.01).collect(),
528 b: (0..32 * 4).map(|i| i as f32 * -0.01).collect(),
529 alpha: 8.0,
530 },
531 LoraAdapter {
532 rank: 4,
533 in_dim: 32,
534 out_dim: 8, a: (0..4 * 32).map(|i| i as f32).collect(),
536 b: (0..8 * 4).map(|i| i as f32 * 2.0).collect(),
537 alpha: 8.0,
538 },
539 LoraAdapter {
540 rank: 4,
541 in_dim: 32,
542 out_dim: 8,
543 a: (0..4 * 32).map(|i| i as f32 * 0.5).collect(),
544 b: (0..8 * 4).map(|i| i as f32 * -2.0).collect(),
545 alpha: 8.0,
546 },
547 LoraAdapter {
548 rank: 4,
549 in_dim: 32,
550 out_dim: 32,
551 a: (0..4 * 32).map(|i| i as f32 * 0.1).collect(),
552 b: (0..32 * 4).map(|i| i as f32 * 0.3).collect(),
553 alpha: 8.0,
554 },
555 LoraAdapter {
556 rank: 4,
557 in_dim: 32,
558 out_dim: 32, a: (0..4 * 32).map(|i| i as f32 * 1.5).collect(),
560 b: (0..32 * 4).map(|i| i as f32 * -1.5).collect(),
561 alpha: 8.0,
562 },
563 LoraAdapter {
564 rank: 4,
565 in_dim: 32,
566 out_dim: 32, a: (0..4 * 32).map(|i| i as f32 * 2.5).collect(),
568 b: (0..32 * 4).map(|i| i as f32 * -2.5).collect(),
569 alpha: 8.0,
570 },
571 ]
572 }
573
574 #[test]
575 fn save_load_roundtrip_preserves_all_adapters() {
576 let tmp = std::env::temp_dir().join("katgpt_lora_roundtrip_test.bin");
577 let original = make_test_adapters();
578 let refs: Vec<&LoraAdapter> = original.iter().collect();
579
580 LoraAdapter::save(&refs, 4, 8.0, &tmp).expect("save should succeed");
581 let loaded = LoraAdapter::load(&tmp).expect("load should succeed");
582
583 assert_eq!(loaded.len(), original.len(), "adapter count must match");
584 for (i, (orig, load)) in original.iter().zip(loaded.iter()).enumerate() {
585 assert_eq!(load.rank, orig.rank, "adapter {i} rank");
586 assert_eq!(load.in_dim, orig.in_dim, "adapter {i} in_dim");
587 assert_eq!(load.out_dim, orig.out_dim, "adapter {i} out_dim");
588 assert_eq!(load.alpha, orig.alpha, "adapter {i} alpha");
589 assert_eq!(load.a.len(), orig.a.len(), "adapter {i} A length");
590 assert_eq!(load.b.len(), orig.b.len(), "adapter {i} B length");
591 for (j, (a, b)) in orig.a.iter().zip(load.a.iter()).enumerate() {
592 assert_eq!(a.to_bits(), b.to_bits(), "adapter {i} A[{j}] bit-identical");
593 }
594 for (j, (a, b)) in orig.b.iter().zip(load.b.iter()).enumerate() {
595 assert_eq!(a.to_bits(), b.to_bits(), "adapter {i} B[{j}] bit-identical");
596 }
597 }
598
599 let _ = std::fs::remove_file(&tmp);
600 }
601
602 #[test]
603 fn save_rejects_empty_adapter_list() {
604 let tmp = std::env::temp_dir().join("katgpt_lora_empty_test.bin");
605 let empty: Vec<&LoraAdapter> = vec![];
606 let result = LoraAdapter::save(&empty, 4, 8.0, &tmp);
607 assert!(result.is_err());
608 let _ = std::fs::remove_file(&tmp);
609 }
610
611 #[test]
612 fn save_rejects_rank_mismatch() {
613 let tmp = std::env::temp_dir().join("katgpt_lora_rankmismatch_test.bin");
614 let adapters = make_test_adapters();
615 let refs: Vec<&LoraAdapter> = adapters.iter().collect();
616 let result = LoraAdapter::save(&refs, 8, 8.0, &tmp);
618 assert!(result.is_err());
619 let _ = std::fs::remove_file(&tmp);
620 }
621}