1use std::collections::HashMap;
32use std::path::PathBuf;
33
34use super::context_detector::ContextType;
35
36#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum LoRAError {
40 Io(String),
41 InvalidHeader,
42 InvalidMagic,
43 ChecksumMismatch,
44 DimensionMismatch {
45 expected: (usize, usize),
46 got: (usize, usize),
47 },
48 AdapterNotFound(ContextType),
49 InferenceDimMismatch {
50 input_len: usize,
51 lora_n_in: usize,
52 },
53}
54
55impl std::fmt::Display for LoRAError {
56 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57 match self {
58 LoRAError::Io(e) => write!(f, "LoRA I/O error: {e}"),
59 LoRAError::InvalidHeader => write!(f, "LoRA header too short"),
60 LoRAError::InvalidMagic => write!(f, "LoRA bad magic (expected LORA)"),
61 LoRAError::ChecksumMismatch => write!(f, "LoRA checksum mismatch"),
62 LoRAError::DimensionMismatch { expected, got } => write!(
63 f,
64 "LoRA dimension mismatch: expected {expected:?}, got {got:?}"
65 ),
66 LoRAError::AdapterNotFound(ctx) => write!(f, "LoRA adapter not found for {ctx}"),
67 LoRAError::InferenceDimMismatch {
68 input_len,
69 lora_n_in,
70 } => write!(
71 f,
72 "LoRA inference: input len {input_len} ≠ lora n_in {lora_n_in}"
73 ),
74 }
75 }
76}
77
78impl std::error::Error for LoRAError {}
79
80#[derive(Clone)]
86pub struct LoRATensor {
87 pub data: Box<[f32]>,
88 pub rows: usize,
89 pub cols: usize,
90}
91
92impl LoRATensor {
93 pub fn new(data: Box<[f32]>, rows: usize, cols: usize) -> Self {
94 assert_eq!(data.len(), rows * cols, "LoRATensor data length mismatch");
95 Self { data, rows, cols }
96 }
97
98 #[inline]
101 pub fn matvec_add(&self, x: &[f32], out: &mut [f32]) {
102 debug_assert_eq!(x.len(), self.cols);
103 debug_assert_eq!(out.len(), self.rows);
104 for i in 0..self.rows {
105 let row = &self.data[i * self.cols..(i + 1) * self.cols];
106 let mut acc = 0f32;
107 for (a, b) in row.iter().zip(x.iter()) {
108 acc += a * b;
109 }
110 out[i] += acc;
111 }
112 }
113}
114
115impl std::fmt::Debug for LoRATensor {
116 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117 write!(f, "LoRATensor({}×{})", self.rows, self.cols)
118 }
119}
120
121#[derive(Debug, Clone)]
124pub struct LoRAMetadata {
125 pub name: String,
126 pub version: String,
127 pub adapter_id: u8,
128 pub rank: u32,
129 pub alpha: f32,
130 pub n_in: usize,
131 pub n_out: usize,
132 pub checksum: [u8; 32],
133 pub file_size: usize,
134}
135
136impl LoRAMetadata {
137 #[inline]
139 pub fn scaling(&self) -> f32 {
140 self.alpha / self.rank.max(1) as f32
141 }
142}
143
144#[derive(Debug, Clone)]
148pub struct LoRAAdapter {
149 pub context_type: ContextType,
150 pub meta: LoRAMetadata,
151 pub lora_a: LoRATensor,
153 pub lora_b: LoRATensor,
155}
156
157impl LoRAAdapter {
158 pub fn apply_cpu(&self, input: &[f32], output: &mut [f32]) -> Result<(), LoRAError> {
165 let n_in = self.meta.n_in;
166 let n_out = self.meta.n_out;
167 let rank = self.meta.rank as usize;
168
169 if input.len() != n_in {
170 return Err(LoRAError::InferenceDimMismatch {
171 input_len: input.len(),
172 lora_n_in: n_in,
173 });
174 }
175
176 let mut z = vec![0f32; rank];
178 self.lora_a.matvec_add(input, &mut z);
179
180 let scaling = self.meta.scaling();
182 for i in 0..n_out {
183 let row = &self.lora_b.data[i * rank..(i + 1) * rank];
184 let mut acc = 0f32;
185 for (bval, zval) in row.iter().zip(z.iter()) {
186 acc += bval * zval;
187 }
188 output[i] += acc * scaling;
189 }
190
191 Ok(())
192 }
193
194 pub fn compute_delta(&self, input: &[f32]) -> Result<Vec<f32>, LoRAError> {
197 let mut delta = vec![0f32; self.meta.n_out];
198 let mut output_ref = vec![0f32; self.meta.n_out];
199 self.apply_cpu(input, &mut output_ref)?;
200 delta.copy_from_slice(&output_ref);
201 Ok(delta)
202 }
203}
204
205const HEADER_SIZE: usize = 64;
208const MAGIC: [u8; 4] = *b"LORA";
209
210#[repr(C, packed)]
211struct RawHeader {
212 magic: [u8; 4],
213 version: u32,
214 adapter_id: u8,
215 _pad0: [u8; 3],
216 rank: u32,
217 alpha_bits: u32, n_in: u32,
219 n_out: u32,
220 _pad1: u32,
221 checksum: [u8; 32],
222}
223
224const _: () = assert!(std::mem::size_of::<RawHeader>() == HEADER_SIZE);
225
226fn parse_adapter(data: &[u8], context_type: ContextType) -> Result<LoRAAdapter, LoRAError> {
229 if data.len() < HEADER_SIZE {
230 return Err(LoRAError::InvalidHeader);
231 }
232
233 let hdr: RawHeader = unsafe { std::ptr::read_unaligned(data.as_ptr() as *const RawHeader) };
235
236 if hdr.magic != MAGIC {
237 return Err(LoRAError::InvalidMagic);
238 }
239
240 let rank = u32::from_le(hdr.rank) as usize;
241 let alpha = f32::from_bits(u32::from_le(hdr.alpha_bits));
242 let n_in = u32::from_le(hdr.n_in) as usize;
243 let n_out = u32::from_le(hdr.n_out) as usize;
244
245 let a_elems = rank * n_in;
246 let b_elems = n_out * rank;
247 let payload_bytes = (a_elems + b_elems) * 4;
248
249 if data.len() < HEADER_SIZE + payload_bytes {
250 return Err(LoRAError::DimensionMismatch {
251 expected: (a_elems + b_elems, 4),
252 got: (data.len().saturating_sub(HEADER_SIZE), 4),
253 });
254 }
255
256 let payload = &data[HEADER_SIZE..HEADER_SIZE + payload_bytes];
257
258 let expected = hdr.checksum;
260 let actual = sha256(payload);
261 if actual != expected {
262 return Err(LoRAError::ChecksumMismatch);
263 }
264
265 let a_bytes = &payload[..a_elems * 4];
266 let b_bytes = &payload[a_elems * 4..a_elems * 4 + b_elems * 4];
267
268 let lora_a = LoRATensor::new(f32_slice_from_le_bytes(a_bytes), rank, n_in);
269 let lora_b = LoRATensor::new(f32_slice_from_le_bytes(b_bytes), n_out, rank);
270
271 Ok(LoRAAdapter {
272 context_type,
273 meta: LoRAMetadata {
274 name: format!("{}", context_type),
275 version: format!("{}", u32::from_le(hdr.version)),
276 adapter_id: hdr.adapter_id,
277 rank: rank as u32,
278 alpha,
279 n_in,
280 n_out,
281 checksum: expected,
282 file_size: data.len(),
283 },
284 lora_a,
285 lora_b,
286 })
287}
288
289#[inline]
290fn f32_slice_from_le_bytes(bytes: &[u8]) -> Box<[f32]> {
291 bytes
292 .chunks_exact(4)
293 .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
294 .collect::<Vec<_>>()
295 .into_boxed_slice()
296}
297
298fn sha256(data: &[u8]) -> [u8; 32] {
299 use sha2::{Digest, Sha256};
300 let mut h = Sha256::new();
301 h.update(data);
302 let result = h.finalize();
303 let mut out = [0u8; 32];
304 out.copy_from_slice(&result);
305 out
306}
307
308pub fn encode_adapter(
311 context_type: ContextType,
312 adapter_id: u8,
313 rank: u32,
314 alpha: f32,
315 lora_a: &LoRATensor, lora_b: &LoRATensor, ) -> Vec<u8> {
318 let n_in = lora_a.cols;
319 let n_out = lora_b.rows;
320
321 let a_bytes: Vec<u8> = lora_a.data.iter().flat_map(|&f| f.to_le_bytes()).collect();
322 let b_bytes: Vec<u8> = lora_b.data.iter().flat_map(|&f| f.to_le_bytes()).collect();
323 let mut payload = a_bytes;
324 payload.extend_from_slice(&b_bytes);
325 let checksum = sha256(&payload);
326
327 let _ = context_type; let mut hdr = [0u8; HEADER_SIZE];
329 hdr[0..4].copy_from_slice(&MAGIC);
330 hdr[4..8].copy_from_slice(&1u32.to_le_bytes()); hdr[8] = adapter_id;
332 hdr[12..16].copy_from_slice(&(rank as u32).to_le_bytes());
333 hdr[16..20].copy_from_slice(&alpha.to_bits().to_le_bytes());
334 hdr[20..24].copy_from_slice(&(n_in as u32).to_le_bytes());
335 hdr[24..28].copy_from_slice(&(n_out as u32).to_le_bytes());
336 hdr[32..64].copy_from_slice(&checksum);
337
338 let mut out = hdr.to_vec();
339 out.extend_from_slice(&payload);
340 out
341}
342
343struct LruCache<K, V> {
346 cap: usize,
347 store: HashMap<K, (V, u64)>,
348 clock: u64,
349}
350
351impl<K: Eq + std::hash::Hash + Clone, V: Clone> LruCache<K, V> {
352 fn new(cap: usize) -> Self {
353 Self {
354 cap: cap.max(1),
355 store: HashMap::new(),
356 clock: 0,
357 }
358 }
359
360 fn get(&mut self, key: &K) -> Option<&V> {
361 if let Some(entry) = self.store.get_mut(key) {
362 self.clock += 1;
363 entry.1 = self.clock;
364 Some(unsafe { &*((&entry.0) as *const V) })
366 } else {
367 None
368 }
369 }
370
371 fn put(&mut self, key: K, val: V) {
372 self.clock += 1;
373 if self.store.len() >= self.cap && !self.store.contains_key(&key) {
374 let lru_key = self
376 .store
377 .iter()
378 .min_by_key(|(_, (_, ts))| ts)
379 .map(|(k, _)| k.clone());
380 if let Some(k) = lru_key {
381 self.store.remove(&k);
382 }
383 }
384 self.store.insert(key, (val, self.clock));
385 }
386
387 fn contains(&self, key: &K) -> bool {
388 self.store.contains_key(key)
389 }
390}
391
392pub struct LoRAAdapterManager {
401 adapter_dir: PathBuf,
402 cache: LruCache<ContextType, LoRAAdapter>,
403 active_adapter: Option<LoRAAdapter>,
404 pub detector: super::context_detector::ContextDetector,
405 pub expected_n_in: Option<usize>,
407 pub expected_n_out: Option<usize>,
409 active_adapter_by_hash: std::collections::HashMap<u64, u64>,
412}
413
414impl LoRAAdapterManager {
415 pub fn new(adapter_dir: impl Into<PathBuf>) -> Self {
420 Self {
421 adapter_dir: adapter_dir.into(),
422 cache: LruCache::new(10),
423 active_adapter: None,
424 detector: super::context_detector::ContextDetector::new(),
425 expected_n_in: None,
426 expected_n_out: None,
427 active_adapter_by_hash: std::collections::HashMap::new(),
428 }
429 }
430
431 pub fn default_path() -> PathBuf {
433 let home = std::env::var("HOME")
434 .or_else(|_| std::env::var("USERPROFILE"))
435 .unwrap_or_else(|_| ".".to_string());
436 PathBuf::from(home).join(".qualia").join("lora_adapters")
437 }
438
439 pub fn set_expected_dims(&mut self, n_in: usize, n_out: usize) {
442 self.expected_n_in = Some(n_in);
443 self.expected_n_out = Some(n_out);
444 }
445
446 pub fn detect_context(&self, prompt: &str) -> (ContextType, f32) {
452 self.detector.analyze_text(prompt)
453 }
454
455 pub fn active_adapter_for_hash(&self, content_hash: u64) -> Option<u64> {
458 self.active_adapter_by_hash.get(&content_hash).copied()
459 }
460
461 pub fn set_adapter_for_hash(&mut self, content_hash: u64, adapter_id: u64) {
463 self.active_adapter_by_hash.insert(content_hash, adapter_id);
464 }
465
466 pub fn clear_hash_associations(&mut self) {
468 self.active_adapter_by_hash.clear();
469 }
470
471 pub fn switch_to(&mut self, target: ContextType) -> Result<bool, LoRAError> {
477 if let Some(ref a) = self.active_adapter {
479 if a.context_type == target {
480 return Ok(false);
481 }
482 }
483
484 if self.cache.contains(&target) {
486 let adapter = self.cache.get(&target).unwrap().clone();
487 self.active_adapter = Some(adapter);
488 return Ok(true);
489 }
490
491 let adapter = self.load_from_disk(target)?;
493
494 if let Some(n_in) = self.expected_n_in {
496 if adapter.meta.n_in != n_in {
497 return Err(LoRAError::DimensionMismatch {
498 expected: (n_in, adapter.meta.n_out),
499 got: (adapter.meta.n_in, adapter.meta.n_out),
500 });
501 }
502 }
503
504 self.cache.put(target, adapter.clone());
505 self.active_adapter = Some(adapter);
506 Ok(true)
507 }
508
509 pub fn auto_switch(&mut self, prompt: &str, threshold: f32) -> (ContextType, f32, bool) {
513 let (ctx, conf) = self.detect_context(prompt);
514
515 if conf < threshold {
516 return (ContextType::General, conf, false);
517 }
518
519 let switched = self.switch_to(ctx).unwrap_or(false);
520 (ctx, conf, switched)
521 }
522
523 pub fn apply_active(&self, input: &[f32], output: &mut [f32]) -> Result<(), LoRAError> {
530 match &self.active_adapter {
531 Some(a) => a.apply_cpu(input, output),
532 None => Ok(()),
533 }
534 }
535
536 pub fn active(&self) -> Option<&LoRAAdapter> {
538 self.active_adapter.as_ref()
539 }
540
541 pub fn deactivate(&mut self) {
543 self.active_adapter = None;
544 }
545
546 fn adapter_path(&self, ctx: ContextType) -> PathBuf {
549 self.adapter_dir.join(ctx.adapter_filename())
550 }
551
552 fn load_from_disk(&self, ctx: ContextType) -> Result<LoRAAdapter, LoRAError> {
553 let path = self.adapter_path(ctx);
554
555 #[cfg(not(target_arch = "wasm32"))]
557 {
558 use std::fs::File;
559 let file =
560 File::open(&path).map_err(|e| LoRAError::Io(format!("{}: {e}", path.display())))?;
561 let mmap = unsafe { memmap2::MmapOptions::new().map(&file) }
562 .map_err(|e| LoRAError::Io(format!("mmap {}: {e}", path.display())))?;
563 parse_adapter(&mmap, ctx)
564 }
565
566 #[cfg(target_arch = "wasm32")]
567 {
568 let data = std::fs::read(&path)
569 .map_err(|e| LoRAError::Io(format!("{}: {e}", path.display())))?;
570 parse_adapter(&data, ctx)
571 }
572 }
573
574 pub fn available_adapters(&self) -> Vec<ContextType> {
576 ContextType::all()
577 .iter()
578 .filter(|&&ctx| self.adapter_path(ctx).exists())
579 .copied()
580 .collect()
581 }
582
583 pub fn save_adapter(
585 &self,
586 ctx: ContextType,
587 adapter: &LoRAAdapter,
588 ) -> Result<PathBuf, LoRAError> {
589 let path = self.adapter_path(ctx);
590 if let Some(parent) = path.parent() {
591 std::fs::create_dir_all(parent).map_err(|e| LoRAError::Io(e.to_string()))?;
592 }
593 let bytes = encode_adapter(
594 ctx,
595 adapter.meta.adapter_id,
596 adapter.meta.rank,
597 adapter.meta.alpha,
598 &adapter.lora_a,
599 &adapter.lora_b,
600 );
601 std::fs::write(&path, &bytes).map_err(|e| LoRAError::Io(e.to_string()))?;
602 Ok(path)
603 }
604
605 pub fn build_synthetic(
608 ctx: ContextType,
609 rank: u32,
610 alpha: f32,
611 n_in: usize,
612 n_out: usize,
613 adapter_id: u8,
614 ) -> LoRAAdapter {
615 let scale = (2.0 / n_in as f32).sqrt();
616 let seed_a: Box<[f32]> = (0..rank as usize * n_in)
618 .map(|i| {
619 let x = (i as u64)
621 .wrapping_mul(6364136223846793005)
622 .wrapping_add(1442695040888963407);
623 let frac = (x >> 32) as f32 / u32::MAX as f32; (frac * 2.0 - 1.0) * scale
625 })
626 .collect::<Vec<_>>()
627 .into_boxed_slice();
628
629 let seed_b = vec![0f32; n_out * rank as usize].into_boxed_slice();
630
631 let lora_a = LoRATensor::new(seed_a, rank as usize, n_in);
632 let lora_b = LoRATensor::new(seed_b, n_out, rank as usize);
633
634 LoRAAdapter {
635 context_type: ctx,
636 meta: LoRAMetadata {
637 name: ctx.to_string(),
638 version: "synthetic".to_string(),
639 adapter_id,
640 rank,
641 alpha,
642 n_in,
643 n_out,
644 checksum: [0u8; 32],
645 file_size: 0,
646 },
647 lora_a,
648 lora_b,
649 }
650 }
651}
652
653#[cfg(test)]
656mod tests {
657 use super::*;
658
659 fn make_adapter(rank: u32, n_in: usize, n_out: usize) -> LoRAAdapter {
660 LoRAAdapterManager::build_synthetic(
661 ContextType::Technical,
662 rank,
663 rank as f32,
664 n_in,
665 n_out,
666 5,
667 )
668 }
669
670 #[test]
671 fn test_apply_cpu_shape() {
672 let adapter = make_adapter(4, 16, 32);
673 let input = vec![1.0f32; 16];
674 let mut out = vec![0.0f32; 32];
675 adapter.apply_cpu(&input, &mut out).unwrap();
676 assert!(out.iter().all(|&v| v == 0.0));
678 }
679
680 #[test]
681 fn test_apply_cpu_nonzero() {
682 let rank = 2usize;
684 let n_in = 4;
685 let n_out = 4;
686 let lora_a = LoRATensor::new(vec![1.0; rank * n_in].into_boxed_slice(), rank, n_in);
687 let lora_b = LoRATensor::new(vec![1.0; n_out * rank].into_boxed_slice(), n_out, rank);
688 let adapter = LoRAAdapter {
689 context_type: ContextType::Technical,
690 meta: LoRAMetadata {
691 name: "test".into(),
692 version: "0".into(),
693 adapter_id: 0,
694 rank: rank as u32,
695 alpha: rank as f32,
696 n_in,
697 n_out,
698 checksum: [0; 32],
699 file_size: 0,
700 },
701 lora_a,
702 lora_b,
703 };
704 let input = vec![1.0f32; n_in];
705 let mut out = vec![0.0f32; n_out];
706 adapter.apply_cpu(&input, &mut out).unwrap();
707 for &v in &out {
713 assert!((v - 8.0).abs() < 1e-5, "expected 8.0, got {v}");
714 }
715 }
716
717 #[test]
718 fn test_roundtrip_encode_parse() {
719 let adapter = make_adapter(4, 8, 16);
720 let bytes = encode_adapter(
721 ContextType::Technical,
722 adapter.meta.adapter_id,
723 adapter.meta.rank,
724 adapter.meta.alpha,
725 &adapter.lora_a,
726 &adapter.lora_b,
727 );
728 let parsed = parse_adapter(&bytes, ContextType::Technical).unwrap();
729 assert_eq!(parsed.meta.rank, adapter.meta.rank);
730 assert_eq!(parsed.meta.n_in, adapter.meta.n_in);
731 assert_eq!(parsed.meta.n_out, adapter.meta.n_out);
732 assert_eq!(parsed.lora_a.data.len(), adapter.lora_a.data.len());
733 for (a, b) in parsed.lora_a.data.iter().zip(adapter.lora_a.data.iter()) {
734 assert!(
735 (a - b).abs() < 1e-6,
736 "A matrix roundtrip mismatch: {a} vs {b}"
737 );
738 }
739 }
740
741 #[test]
742 fn test_bad_magic_rejected() {
743 let mut bytes = encode_adapter(
744 ContextType::Medical,
745 0,
746 4,
747 1.0,
748 &LoRATensor::new(vec![0.0f32; 4].into_boxed_slice(), 1, 4),
749 &LoRATensor::new(vec![0.0f32; 4].into_boxed_slice(), 4, 1),
750 );
751 bytes[0] = b'X'; assert!(matches!(
753 parse_adapter(&bytes, ContextType::Medical),
754 Err(LoRAError::InvalidMagic)
755 ));
756 }
757
758 #[test]
759 fn test_checksum_corruption_detected() {
760 let mut bytes = encode_adapter(
769 ContextType::Medical,
770 0,
771 1,
772 1.0,
773 &LoRATensor::new(vec![0.0f32; 4].into_boxed_slice(), 1, 4),
774 &LoRATensor::new(vec![0.0f32; 4].into_boxed_slice(), 4, 1),
775 );
776 *bytes.last_mut().unwrap() ^= 0xFF;
778 assert!(matches!(
779 parse_adapter(&bytes, ContextType::Medical),
780 Err(LoRAError::ChecksumMismatch)
781 ));
782 }
783
784 #[test]
785 fn test_synthetic_output_zeroed_initially() {
786 let adapter =
787 LoRAAdapterManager::build_synthetic(ContextType::Biological, 8, 8.0, 32, 64, 4);
788 let input = vec![1.0f32; 32];
790 let mut out = vec![0.0f32; 64];
791 adapter.apply_cpu(&input, &mut out).unwrap();
792 assert!(out.iter().all(|&v| v == 0.0));
793 }
794
795 #[test]
796 fn test_lru_eviction() {
797 let mut cache: LruCache<u32, u32> = LruCache::new(3);
798 cache.put(1, 10);
799 cache.put(2, 20);
800 cache.put(3, 30);
801 let _ = cache.get(&1); cache.put(4, 40); assert!(!cache.contains(&2));
804 assert!(cache.contains(&1));
805 assert!(cache.contains(&3));
806 assert!(cache.contains(&4));
807 }
808
809 #[test]
810 fn test_manager_auto_switch_below_threshold() {
811 let mgr = LoRAAdapterManager::new("/tmp/nonexistent_lora");
812 let (ctx, conf) = mgr.detector.analyze_text("hello world");
814 assert_eq!(ctx, ContextType::General);
815 assert!(conf < mgr.detector.confidence_threshold);
816 }
817}