Skip to main content

qualia_core_db/lora/
adapter_manager.rs

1//! LoRA adapter loading, caching, and application.
2//!
3//! # File format (`*.lora`)
4//!
5//! ```text
6//! Offset  Size  Field
7//! 0       4     magic `LORA`
8//! 4       4     format version (u32 le) — currently 1
9//! 8       1     adapter_id (0–255)
10//! 9       3     _pad
11//! 12      4     rank (u32 le)
12//! 16      4     alpha (f32 le)
13//! 20      4     n_in — lora_a cols, lora_b rows (u32 le)
14//! 24      4     n_out — lora_b rows, lora_a rows (u32 le)
15//! 28      4     _pad
16//! 32      32    sha256 checksum of payload (lora_a ++ lora_b, raw f32 le)
17//! 64      …     lora_a data: rank × n_in f32 le, row-major
18//!               lora_b data: n_out × rank f32 le, row-major
19//! ```
20//!
21//! Both matrices are stored as raw `f32` little-endian.
22//! `scaling = alpha / rank`; the caller multiplies by this before adding the delta.
23//!
24//! # Invariants
25//!
26//! - `lora_a.rows == rank`, `lora_a.cols == n_in` (down-projection)
27//! - `lora_b.rows == n_out`, `lora_b.cols == rank` (up-projection)
28//! - `lora_a` is initialised with Kaiming uniform; `lora_b` is zeroed (standard LoRA init)
29//! - The checksum covers `lora_a_data ++ lora_b_data` only, not the header
30
31use std::collections::HashMap;
32use std::path::PathBuf;
33
34use super::context_detector::ContextType;
35
36// ─── Errors ──────────────────────────────────────────────────────────────────
37
38#[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// ─── LoRATensor ───────────────────────────────────────────────────────────────
81
82/// Dense f32 matrix stored in row-major order.
83///
84/// Using `Box<[f32]>` (not `Vec`) signals fixed-size after construction.
85#[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    /// Multiply `self` (rows × cols) by `x` (cols,) → output `(rows,)`.
99    /// Accumulates into `out` (must already be the right length and may be pre-filled).
100    #[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// ─── LoRAMetadata ─────────────────────────────────────────────────────────────
122
123#[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    /// `scaling = alpha / rank` — the factor applied when adding the LoRA delta.
138    #[inline]
139    pub fn scaling(&self) -> f32 {
140        self.alpha / self.rank.max(1) as f32
141    }
142}
143
144// ─── LoRAAdapter ─────────────────────────────────────────────────────────────
145
146/// A loaded LoRA adapter ready for CPU or GPU application.
147#[derive(Debug, Clone)]
148pub struct LoRAAdapter {
149    pub context_type: ContextType,
150    pub meta: LoRAMetadata,
151    /// Down-projection: `[rank × n_in]`
152    pub lora_a: LoRATensor,
153    /// Up-projection: `[n_out × rank]`
154    pub lora_b: LoRATensor,
155}
156
157impl LoRAAdapter {
158    /// Apply the LoRA delta **additively** to `output`.
159    ///
160    /// Implements `output += lora_b @ (lora_a @ input) * scaling`.
161    ///
162    /// - `input`  must have length `meta.n_in`
163    /// - `output` must have length `meta.n_out` (values are preserved and incremented)
164    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        // Phase 1 — down-projection: z[rank] = A @ input
177        let mut z = vec![0f32; rank];
178        self.lora_a.matvec_add(input, &mut z);
179
180        // Phase 2 — up-projection + scaling: output += B @ z * scaling
181        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    /// Compute the LoRA delta vector without applying it.
195    /// Returns `delta` of length `meta.n_out`.
196    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
205// ─── Binary header (64 bytes) ────────────────────────────────────────────────
206
207const 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, // f32 as raw bits (avoids packed f32 UB)
218    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
226// ─── Parser ──────────────────────────────────────────────────────────────────
227
228fn parse_adapter(data: &[u8], context_type: ContextType) -> Result<LoRAAdapter, LoRAError> {
229    if data.len() < HEADER_SIZE {
230        return Err(LoRAError::InvalidHeader);
231    }
232
233    // Safety: data is at least HEADER_SIZE bytes; RawHeader is repr(C, packed).
234    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    // Verify SHA-256 checksum over payload
259    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
308/// Build a minimal valid `.lora` file from raw A and B matrices.
309/// Useful for generating test adapters and offline tooling.
310pub fn encode_adapter(
311    context_type: ContextType,
312    adapter_id: u8,
313    rank: u32,
314    alpha: f32,
315    lora_a: &LoRATensor, // [rank × n_in]
316    lora_b: &LoRATensor, // [n_out × rank]
317) -> 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; // used for naming, not encoded in header
328    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()); // version
331    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
343// ─── LruCache (no external crate) ────────────────────────────────────────────
344
345struct 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            // Safety: we just confirmed the key exists; return a reference.
365            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            // Evict LRU entry
375            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
392// ─── LoRAAdapterManager ───────────────────────────────────────────────────────
393
394/// Manages LoRA adapter loading, caching, and active-adapter switching.
395///
396/// The manager maintains:
397/// - A filesystem directory for `.lora` files
398/// - An LRU cache of up to 10 loaded adapters (≈150 MB at 15 MB each)
399/// - The currently active adapter (fast path for repeated calls)
400pub struct LoRAAdapterManager {
401    adapter_dir: PathBuf,
402    cache: LruCache<ContextType, LoRAAdapter>,
403    active_adapter: Option<LoRAAdapter>,
404    pub detector: super::context_detector::ContextDetector,
405    /// Expected embedding dimension — used for dim-check at apply time.
406    pub expected_n_in: Option<usize>,
407    /// Expected hidden/output dimension — used for dim-check at apply time.
408    pub expected_n_out: Option<usize>,
409    /// Side-table: NQuin content-hash → active adapter ID.
410    /// Replaces the retired metadata bit-packing (§4.1 migration).
411    active_adapter_by_hash: std::collections::HashMap<u64, u64>,
412}
413
414impl LoRAAdapterManager {
415    /// Create a manager pointing at `adapter_dir`.
416    ///
417    /// The directory does not need to exist at construction time; adapters
418    /// are loaded lazily on first `switch_to`.
419    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    /// Standard location: `~/.qualia/lora_adapters/`
432    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    /// Hint the expected input/output dimensions so that mis-shaped adapters
440    /// are rejected early rather than causing panic in `apply_cpu`.
441    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    // ── Context detection ────────────────────────────────────────────────────
447
448    /// Detect `ContextType` from prompt text.
449    /// LoRA context is no longer encoded in NQuin metadata bits (§4.1 migration);
450    /// use `active_adapter_for_hash()` to look up the adapter for a specific quin.
451    pub fn detect_context(&self, prompt: &str) -> (ContextType, f32) {
452        self.detector.analyze_text(prompt)
453    }
454
455    /// Look up the active adapter ID for a given NQuin content-hash.
456    /// Returns `None` if no adapter has been associated with that quin.
457    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    /// Associate an adapter ID with a NQuin content-hash in the side-table.
462    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    /// Remove all side-table associations (e.g. at end of an inference batch).
467    pub fn clear_hash_associations(&mut self) {
468        self.active_adapter_by_hash.clear();
469    }
470
471    // ── Adapter switching ────────────────────────────────────────────────────
472
473    /// Ensure the adapter for `target` is loaded and set as active.
474    ///
475    /// Returns `Ok(true)` if a switch occurred, `Ok(false)` if already active.
476    pub fn switch_to(&mut self, target: ContextType) -> Result<bool, LoRAError> {
477        // Already active — nothing to do
478        if let Some(ref a) = self.active_adapter {
479            if a.context_type == target {
480                return Ok(false);
481            }
482        }
483
484        // Promote from cache
485        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        // Load from disk
492        let adapter = self.load_from_disk(target)?;
493
494        // Validate dimensions if hints are available
495        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    /// Detect context from `prompt` and switch adapter if confidence exceeds `threshold`.
510    ///
511    /// Returns `(context_type, confidence, switched)`.
512    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    // ── Inference integration ────────────────────────────────────────────────
524
525    /// Apply the active adapter's delta to `output` (if any adapter is loaded).
526    ///
527    /// `input` is the current hidden-state vector before the LoRA correction.
528    /// `output` is incremented in-place: `output += B @ (A @ input) * scaling`.
529    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    /// Return a reference to the active adapter, if any.
537    pub fn active(&self) -> Option<&LoRAAdapter> {
538        self.active_adapter.as_ref()
539    }
540
541    /// Clear the active adapter (revert to base model behaviour).
542    pub fn deactivate(&mut self) {
543        self.active_adapter = None;
544    }
545
546    // ── Disk I/O ─────────────────────────────────────────────────────────────
547
548    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        // memmap2 for zero-copy loading
556        #[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    /// Check which adapters exist on disk (for UI / resource catalog).
575    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    /// Persist an adapter to disk in the `.lora` binary format.
584    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    /// Build a synthetic adapter populated with Kaiming-uniform A / zero B weights.
606    /// Used for fine-tuning initialisation and test harness.
607    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        // Kaiming uniform: U(-scale, scale)
617        let seed_a: Box<[f32]> = (0..rank as usize * n_in)
618            .map(|i| {
619                // Deterministic pseudo-random without rand dep: LCG
620                let x = (i as u64)
621                    .wrapping_mul(6364136223846793005)
622                    .wrapping_add(1442695040888963407);
623                let frac = (x >> 32) as f32 / u32::MAX as f32; // [0, 1)
624                (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// ─── Tests ───────────────────────────────────────────────────────────────────
654
655#[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        // lora_b is zeroed → delta = 0; output stays all-zero
677        assert!(out.iter().all(|&v| v == 0.0));
678    }
679
680    #[test]
681    fn test_apply_cpu_nonzero() {
682        // Build adapter with non-zero lora_b so we can check the delta path
683        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        // With all-one A and all-one B and input=[1,1,1,1]:
708        // z[k] = sum_j(A[k,j]*x[j]) = n_in = 4  for each k
709        // delta[i] = sum_k(B[i,k]*z[k])*scaling = rank * n_in * scaling
710        // scaling = alpha/rank = 1
711        // delta[i] = 2 * 4 * 1 = 8
712        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'; // corrupt magic
752        assert!(matches!(
753            parse_adapter(&bytes, ContextType::Medical),
754            Err(LoRAError::InvalidMagic)
755        ));
756    }
757
758    #[test]
759    fn test_checksum_corruption_detected() {
760        // Use rank=1 so tensor dimensions are consistent with the header:
761        //   lora_a: [rank × n_in] = [1 × 4] = 4 elements
762        //   lora_b: [n_out × rank] = [4 × 1] = 4 elements
763        // The earlier test_bad_magic_rejected used rank=4 with the same small tensors
764        // (1×4 and 4×1), which makes the header declare 16 elements per matrix but only
765        // 4 are present — parse_adapter rejects that with DimensionMismatch before it
766        // ever reaches the checksum.  rank=1 makes header and payload agree so the
767        // checksum is actually verified, and the byte flip produces ChecksumMismatch.
768        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        // Flip a payload byte to corrupt the data without touching the stored checksum
777        *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        // lora_b is all-zero → delta is always zero regardless of input
789        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); // touch 1 so 2 is now LRU
802        cache.put(4, 40); // evicts 2 (LRU)
803        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        // analyze_text returns (ContextType, f32); "hello world" has no domain keywords
813        let (ctx, conf) = mgr.detector.analyze_text("hello world");
814        assert_eq!(ctx, ContextType::General);
815        assert!(conf < mgr.detector.confidence_threshold);
816    }
817}