1use crate::ltxdit::{Shared, rows};
26use crate::pool::Pool;
27use std::collections::HashMap;
28use std::path::Path;
29
30pub struct LoraBranch {
32 a: Vec<f32>, b: Vec<f32>, rank: usize,
35 inn: usize,
36 out: usize,
37 scale: f32,
38 id: usize,
41 resonance: std::sync::atomic::AtomicU32,
44 live: std::sync::atomic::AtomicBool,
45}
46
47pub fn route_threshold() -> Option<f32> {
62 use std::sync::atomic::Ordering::Relaxed;
63 let mut bits = ROUTE.load(Relaxed);
67 if bits == u32::MAX {
68 bits = std::env::var("CMF_LORA_ROUTE")
69 .ok()
70 .and_then(|v| v.parse::<f32>().ok())
71 .filter(|v| *v > 0.0)
72 .map_or(0, f32::to_bits);
73 ROUTE.store(bits, Relaxed);
74 }
75 (bits != 0).then(|| f32::from_bits(bits))
76}
77
78static ROUTE: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(u32::MAX);
79
80pub fn set_route_threshold(t: Option<f32>) {
82 ROUTE.store(
83 t.filter(|v| *v > 0.0).map_or(0, f32::to_bits),
84 std::sync::atomic::Ordering::Relaxed,
85 );
86}
87
88pub fn wants_measurement() -> bool {
92 probe_on() || route_threshold().is_some()
93}
94
95pub fn probe_report_on() -> bool {
97 probe_on()
98}
99
100fn probe_on() -> bool {
101 static P: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
102 *P.get_or_init(|| std::env::var("CMF_LORA_PROBE").is_ok())
103}
104
105static NEXT_ID: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(1);
106
107impl LoraBranch {
108 pub fn rank(&self) -> usize {
109 self.rank
110 }
111
112 #[cfg(target_os = "macos")]
114 pub(crate) fn side(&self) -> crate::gpu_metal::LoraSide<'_> {
115 crate::gpu_metal::LoraSide {
116 a: &self.a,
117 b: &self.b,
118 rank: self.rank,
119 scale: self.scale,
120 id: self.id,
121 }
122 }
123
124 pub fn live(&self) -> bool {
128 self.live.load(std::sync::atomic::Ordering::Relaxed)
129 }
130
131 pub fn resonance(&self) -> f32 {
133 f32::from_bits(self.resonance.load(std::sync::atomic::Ordering::Relaxed))
134 }
135
136 pub fn add(&self, x: &[f32], n: usize, dst: &mut [f32], pool: Option<&Pool>) {
145 debug_assert_eq!(x.len(), n * self.inn);
146 debug_assert_eq!(dst.len(), n * self.out);
147 if n == 0 || !self.live() {
148 return;
149 }
150 let mut h = vec![0f32; n * self.rank];
158 let mut d = vec![0f32; n * self.out];
159 crate::gpu::cpu_scope(|| {
160 crate::fcd_ops::gemm_nt(x, &self.a, &mut h, n, self.inn, self.rank, pool);
161 crate::fcd_ops::gemm_nt(&h, &self.b, &mut d, n, self.rank, self.out, pool);
162 });
163 let scale = self.scale;
164 let measure = (probe_on() || route_threshold().is_some())
170 && self.resonance.load(std::sync::atomic::Ordering::Relaxed) == 0;
171 if measure {
172 let (mut dd, mut bb) = (0f64, 0f64);
173 for (&dv, &bv) in d.iter().zip(dst.iter()) {
174 dd += (scale * dv) as f64 * (scale * dv) as f64;
175 bb += bv as f64 * bv as f64;
176 }
177 let r = if bb > 0.0 {
178 (dd / bb).sqrt() as f32
179 } else {
180 f32::INFINITY
181 };
182 self.resonance.store(
185 r.max(f32::MIN_POSITIVE).to_bits(),
186 std::sync::atomic::Ordering::Relaxed,
187 );
188 if let Some(t) = route_threshold() {
189 if r < t {
190 self.live.store(false, std::sync::atomic::Ordering::Relaxed);
191 }
192 }
193 }
194 let sink = Shared(dst.as_mut_ptr());
195 rows(pool, n, &|s, e| {
196 let row = unsafe { sink.at(s * self.out, (e - s) * self.out) };
197 for (o, v) in row.iter_mut().enumerate() {
198 *v += scale * d[s * self.out + o];
199 }
200 });
201 }
202}
203
204pub struct SlotEmbed {
211 freqs: Vec<f32>,
212 w0: Vec<f32>,
213 b0: Vec<f32>,
214 w2: Vec<f32>,
215 b2: Vec<f32>,
216 hidden: usize,
217 dim: usize,
218}
219
220impl SlotEmbed {
221 pub fn dim(&self) -> usize {
222 self.dim
223 }
224
225 pub fn embed(&self, slot_id: usize) -> Vec<f32> {
227 let scaled = slot_id as f32 / 16.0;
228 let mut feat = Vec::with_capacity(1 + 2 * self.freqs.len());
229 feat.push(scaled);
230 for f in &self.freqs {
231 feat.push((scaled * f).sin());
232 }
233 for f in &self.freqs {
234 feat.push((scaled * f).cos());
235 }
236 let win = feat.len();
237 let mut hid = vec![0f32; self.hidden];
238 for (o, hv) in hid.iter_mut().enumerate() {
239 let row = &self.w0[o * win..(o + 1) * win];
240 let mut acc = self.b0[o];
241 for (fv, wv) in feat.iter().zip(row) {
242 acc += fv * wv;
243 }
244 *hv = acc / (1.0 + (-acc).exp());
246 }
247 let mut outv = vec![0f32; self.dim];
248 for (o, ov) in outv.iter_mut().enumerate() {
249 let row = &self.w2[o * self.hidden..(o + 1) * self.hidden];
250 let mut acc = self.b2[o];
251 for (hv, wv) in hid.iter().zip(row) {
252 acc += hv * wv;
253 }
254 *ov = acc;
255 }
256 outv
257 }
258}
259
260pub struct LoraBank {
264 pairs: HashMap<String, (Vec<f32>, Vec<f32>, usize, usize, usize)>,
265 pub slot: Option<SlotEmbed>,
266 pub meta: HashMap<String, String>,
267 scale: f32,
268}
269
270fn st_read(path: &Path) -> Result<(HashMap<String, (Vec<usize>, Vec<f32>)>, HashMap<String, String>), String> {
271 let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
272 if bytes.len() < 8 {
273 return Err("lora: truncated safetensors header".into());
274 }
275 let hlen = u64::from_le_bytes(bytes[..8].try_into().unwrap()) as usize;
276 let header: serde_json::Value = serde_json::from_slice(
277 bytes.get(8..8 + hlen).ok_or("lora: header past end of file")?,
278 )
279 .map_err(|e| format!("lora header: {e}"))?;
280 let base = 8 + hlen;
281 let obj = header.as_object().ok_or("lora: header not an object")?;
282 let mut meta = HashMap::new();
283 let mut out = HashMap::new();
284 for (name, m) in obj {
285 if name == "__metadata__" {
286 if let Some(o) = m.as_object() {
287 for (k, v) in o {
288 if let Some(s) = v.as_str() {
289 meta.insert(k.clone(), s.to_string());
290 }
291 }
292 }
293 continue;
294 }
295 let dtype = m["dtype"].as_str().ok_or("lora: dtype")?;
296 let shape: Vec<usize> = m["shape"]
297 .as_array()
298 .ok_or("lora: shape")?
299 .iter()
300 .map(|v| v.as_u64().unwrap_or(0) as usize)
301 .collect();
302 let offs = m["data_offsets"].as_array().ok_or("lora: offsets")?;
303 let s = offs[0].as_u64().unwrap_or(0) as usize + base;
304 let e = offs[1].as_u64().unwrap_or(0) as usize + base;
305 let raw = bytes.get(s..e).ok_or("lora: tensor span past end of file")?;
306 let mut data = Vec::with_capacity(shape.iter().product::<usize>().max(1));
307 match dtype {
308 "F32" => {
309 for c in raw.chunks_exact(4) {
310 data.push(f32::from_le_bytes(c.try_into().unwrap()));
311 }
312 }
313 "F16" => {
314 for c in raw.chunks_exact(2) {
315 data.push(cortiq_core::quant::f16_to_f32(u16::from_le_bytes(
316 c.try_into().unwrap(),
317 )));
318 }
319 }
320 "BF16" => {
321 for c in raw.chunks_exact(2) {
322 let b = u16::from_le_bytes(c.try_into().unwrap());
323 data.push(f32::from_bits((b as u32) << 16));
324 }
325 }
326 other => return Err(format!("lora: unsupported dtype {other} on {name}")),
327 }
328 out.insert(name.clone(), (shape, data));
329 }
330 Ok((out, meta))
331}
332
333impl LoraBank {
334 pub fn load(path: &Path, strength: f32) -> Result<LoraBank, String> {
341 let (tensors, meta) = st_read(path)?;
342 let mut a_side: HashMap<String, (Vec<usize>, Vec<f32>)> = HashMap::new();
343 let mut b_side: HashMap<String, (Vec<usize>, Vec<f32>)> = HashMap::new();
344 let mut slot_parts: HashMap<String, (Vec<usize>, Vec<f32>)> = HashMap::new();
345 for (name, val) in tensors {
346 let short = name
352 .strip_prefix("diffusion_model.")
353 .or_else(|| name.strip_prefix("base_model.model."))
354 .or_else(|| name.strip_prefix("transformer."))
355 .unwrap_or(&name);
356 let short = short.strip_prefix("dit.").unwrap_or(short).to_string();
361 if let Some(rest) = short.strip_prefix("reference_slot_embedding.") {
362 slot_parts.insert(rest.to_string(), val);
363 } else if let Some(base) = short.strip_suffix(".lora_A.weight") {
364 a_side.insert(base.to_string(), val);
365 } else if let Some(base) = short.strip_suffix(".lora_B.weight") {
366 b_side.insert(base.to_string(), val);
367 } else if let Some(base) = short.strip_suffix(".lora_down.weight") {
368 a_side.insert(base.to_string(), val);
369 } else if let Some(base) = short.strip_suffix(".lora_up.weight") {
370 b_side.insert(base.to_string(), val);
371 }
372 }
373 let alpha = meta
374 .get("alpha")
375 .or_else(|| meta.get("lora_alpha"))
376 .and_then(|v| v.parse::<f32>().ok());
377
378 let mut pairs = HashMap::new();
379 for (base, (ashape, adata)) in a_side {
380 let Some((bshape, bdata)) = b_side.remove(&base) else {
381 return Err(format!("lora: {base} has an A side and no B side"));
382 };
383 if ashape.len() != 2 || bshape.len() != 2 {
384 return Err(format!("lora: {base} is not a matrix pair"));
385 }
386 let (rank, inn) = (ashape[0], ashape[1]);
387 let (out, rank_b) = (bshape[0], bshape[1]);
388 if rank != rank_b {
389 return Err(format!(
390 "lora: {base} rank mismatch — A is {rank}, B is {rank_b}"
391 ));
392 }
393 let scale = match alpha {
394 Some(al) if rank > 0 => strength * al / rank as f32,
395 _ => strength,
396 };
397 pairs.insert(base, (adata, bdata, rank, inn, out));
398 let _ = scale; }
400 if !b_side.is_empty() {
401 let orphan = b_side.keys().next().cloned().unwrap_or_default();
402 return Err(format!("lora: {orphan} has a B side and no A side"));
403 }
404
405 let slot = if slot_parts.is_empty() {
409 None
410 } else {
411 let need = |k: &str| -> Result<&(Vec<usize>, Vec<f32>), String> {
412 slot_parts
413 .get(k)
414 .ok_or_else(|| format!("lora: reference_slot_embedding.{k} is missing"))
415 };
416 let freqs = need("frequencies")?.1.clone();
417 let (s0, w0) = need("net.0.weight").map(|t| (t.0.clone(), t.1.clone()))?;
418 let b0 = need("net.0.bias")?.1.clone();
419 let (s2, w2) = need("net.2.weight").map(|t| (t.0.clone(), t.1.clone()))?;
420 let b2 = need("net.2.bias")?.1.clone();
421 if s0.len() != 2 || s2.len() != 2 {
422 return Err("lora: slot embedding layers are not matrices".into());
423 }
424 if s0[1] != 1 + 2 * freqs.len() {
425 return Err(format!(
426 "lora: slot embedding takes {} features, {} frequencies imply {}",
427 s0[1],
428 freqs.len(),
429 1 + 2 * freqs.len()
430 ));
431 }
432 Some(SlotEmbed {
433 freqs,
434 w0,
435 b0,
436 w2,
437 b2,
438 hidden: s0[0],
439 dim: s2[0],
440 })
441 };
442
443 let scale = match alpha {
444 Some(al) => {
445 let r = pairs.values().next().map(|p| p.2).unwrap_or(1).max(1);
446 strength * al / r as f32
447 }
448 None => strength,
449 };
450 Ok(LoraBank { pairs, slot, meta, scale })
451 }
452
453 pub fn len(&self) -> usize {
454 self.pairs.len()
455 }
456
457 pub fn is_empty(&self) -> bool {
458 self.pairs.is_empty()
459 }
460
461 pub fn rank(&self) -> usize {
463 self.pairs.values().next().map(|p| p.2).unwrap_or(0)
464 }
465
466 pub fn keys(&self) -> Vec<&str> {
471 self.pairs.keys().map(|s| s.as_str()).collect()
472 }
473
474 pub fn branch_for(
479 &self,
480 name: &str,
481 out: usize,
482 inn: usize,
483 ) -> Result<Option<LoraBranch>, String> {
484 let Some(br) = self.branch(name) else {
485 return Ok(None);
486 };
487 if br.out != out || br.inn != inn {
488 return Err(format!(
489 "lora: {name} is [{}, {}] in the adapter and [{out}, {inn}] in this container \
490 — that adapter was trained for a different model",
491 br.out, br.inn
492 ));
493 }
494 Ok(Some(br))
495 }
496
497 pub fn branch(&self, name: &str) -> Option<LoraBranch> {
500 let key = name.strip_prefix("dit.").unwrap_or(name);
501 let (a, b, rank, inn, out) = self.pairs.get(key)?;
502 Some(LoraBranch {
503 a: a.clone(),
504 b: b.clone(),
505 rank: *rank,
506 inn: *inn,
507 out: *out,
508 scale: self.scale,
509 id: NEXT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
510 resonance: Default::default(),
511 live: std::sync::atomic::AtomicBool::new(true),
512 })
513 }
514
515 pub fn check_reference_convention(&self) -> Result<(), String> {
518 if let Some(order) = self.meta.get("reference_token_order") {
519 if order != "prepend" {
520 return Err(format!(
521 "lora: reference_token_order={order}, this build only prepends"
522 ));
523 }
524 }
525 if let Some(off) = self.meta.get("reference_slot_time_offsets") {
526 if off != "pic1_based_negative_time" {
527 return Err(format!(
528 "lora: reference_slot_time_offsets={off}, this build only places \
529 references at negative latent frames"
530 ));
531 }
532 }
533 Ok(())
534 }
535}
536
537#[cfg(test)]
538mod tests {
539 use super::*;
540
541 #[test]
543 fn branch_matches_dense() {
544 let (n, inn, rank, out) = (3usize, 5usize, 2usize, 4usize);
545 let a: Vec<f32> = (0..rank * inn).map(|i| (i as f32 * 0.37).sin()).collect();
546 let b: Vec<f32> = (0..out * rank).map(|i| (i as f32 * 0.11).cos()).collect();
547 let x: Vec<f32> = (0..n * inn).map(|i| (i as f32 * 0.7).sin()).collect();
548 let br = LoraBranch {
549 a: a.clone(),
550 b: b.clone(),
551 rank,
552 inn,
553 out,
554 scale: 0.5,
555 id: 0,
556 resonance: Default::default(),
557 live: std::sync::atomic::AtomicBool::new(true),
558 };
559 let mut got = vec![1.5f32; n * out];
560 br.add(&x, n, &mut got, None);
561 for t in 0..n {
562 for o in 0..out {
563 let mut acc = 0f32;
564 for r in 0..rank {
565 let h: f32 = (0..inn).map(|i| x[t * inn + i] * a[r * inn + i]).sum();
566 acc += h * b[o * rank + r];
567 }
568 let want = 1.5 + 0.5 * acc;
569 assert!(
570 (got[t * out + o] - want).abs() < 1e-4,
571 "row {t} col {o}: {} vs {want}",
572 got[t * out + o]
573 );
574 }
575 }
576 }
577
578 fn write_pairs(path: &std::path::Path, pairs: &[(&str, usize, usize)]) {
580 use std::io::Write;
581 let mut header = serde_json::Map::new();
582 let mut blob: Vec<u8> = Vec::new();
583 for (base, inn, rank) in pairs {
584 for (suffix, shape) in [
585 ("lora_A.weight", vec![*rank, *inn]),
586 ("lora_B.weight", vec![*inn, *rank]),
587 ] {
588 let count: usize = shape.iter().product();
589 let start = blob.len();
590 for i in 0..count {
591 blob.extend_from_slice(&(i as f32 * 0.25).to_le_bytes());
592 }
593 header.insert(
594 format!("{base}.{suffix}"),
595 serde_json::json!({
596 "dtype": "F32",
597 "shape": shape,
598 "data_offsets": [start, blob.len()],
599 }),
600 );
601 }
602 }
603 let hdr = serde_json::to_vec(&serde_json::Value::Object(header)).unwrap();
604 let mut f = std::fs::File::create(path).unwrap();
605 f.write_all(&(hdr.len() as u64).to_le_bytes()).unwrap();
606 f.write_all(&hdr).unwrap();
607 f.write_all(&blob).unwrap();
608 }
609
610 #[test]
615 fn mmh3_names_bind_to_container_projections() {
616 let dir = std::env::temp_dir().join(format!("mmh3lora{}", std::process::id()));
617 std::fs::create_dir_all(&dir).unwrap();
618 let path = dir.join("h3.safetensors");
619 write_pairs(
620 &path,
621 &[
622 ("diffusion_model.blocks.0.attn.qkv_proj", 3, 2),
623 ("base_model.model.dit.blocks.1.mlp.fc1", 3, 2),
624 ],
625 );
626 let bank = LoraBank::load(&path, 1.0).unwrap();
627 assert!(bank.branch("dit.blocks.0.attn.qkv_proj").is_some());
628 assert!(bank.branch("dit.blocks.1.mlp.fc1").is_some());
629 assert!(bank.branch("dit.blocks.2.mlp.fc1").is_none());
630 let _ = std::fs::remove_dir_all(&dir);
631 }
632
633 #[test]
637 fn a_branch_of_the_wrong_shape_is_refused() {
638 let dir = std::env::temp_dir().join(format!("lorashape{}", std::process::id()));
639 std::fs::create_dir_all(&dir).unwrap();
640 let path = dir.join("wrong.safetensors");
641 write_pairs(&path, &[("diffusion_model.blocks.0.attn.qkv_proj", 3, 2)]);
643 let bank = LoraBank::load(&path, 1.0).unwrap();
644 let key = "dit.blocks.0.attn.qkv_proj";
645 assert!(
646 matches!(bank.branch_for(key, 3, 3), Ok(Some(_))),
647 "its own shape binds"
648 );
649 let err = match bank.branch_for(key, 4096, 3) {
650 Err(e) => e,
651 Ok(_) => panic!("a [4096, 3] projection must not take a [3, 3] branch"),
652 };
653 assert!(err.contains("different model"), "{err}");
654 assert!(matches!(bank.branch_for(key, 3, 4096), Err(_)));
655 assert!(matches!(
656 bank.branch_for("dit.blocks.9.attn.qkv_proj", 3, 3),
657 Ok(None)
658 ));
659 let _ = std::fs::remove_dir_all(&dir);
660 }
661
662 #[test]
665 fn router_silences_a_quiet_branch() {
666 set_route_threshold(Some(0.01));
667 let quiet = LoraBranch {
668 a: vec![1.0; 2],
669 b: vec![1e-6; 2],
670 rank: 1,
671 inn: 2,
672 out: 2,
673 scale: 1.0,
674 id: 0,
675 resonance: Default::default(),
676 live: std::sync::atomic::AtomicBool::new(true),
677 };
678 let mut out = vec![1.0f32; 2];
679 quiet.add(&[1.0, 1.0], 1, &mut out, None);
680 assert!(!quiet.live(), "a 1e-6 branch on a unit base must be routed off");
681 let loud = LoraBranch {
682 a: vec![1.0; 2],
683 b: vec![1.0; 2],
684 rank: 1,
685 inn: 2,
686 out: 2,
687 scale: 1.0,
688 id: 0,
689 resonance: Default::default(),
690 live: std::sync::atomic::AtomicBool::new(true),
691 };
692 let mut out = vec![1.0f32; 2];
693 loud.add(&[1.0, 1.0], 1, &mut out, None);
694 assert!(loud.live(), "a branch the size of the base must stay");
695 assert!(loud.resonance() > 1.0);
696 set_route_threshold(None);
697 }
698
699 #[test]
701 fn zero_b_changes_nothing() {
702 let br = LoraBranch {
703 a: vec![1.0; 4],
704 b: vec![0.0; 6],
705 rank: 2,
706 inn: 2,
707 out: 3,
708 scale: 1.0,
709 id: 0,
710 resonance: Default::default(),
711 live: std::sync::atomic::AtomicBool::new(true),
712 };
713 let mut out = vec![7.0f32; 3];
714 br.add(&[1.0, 2.0], 1, &mut out, None);
715 assert_eq!(out, vec![7.0, 7.0, 7.0]);
716 }
717
718 #[test]
724 fn names_bind_to_container_projections() {
725 use std::io::Write;
726 let dir = std::env::temp_dir().join("cmf_lora_name_test");
727 std::fs::create_dir_all(&dir).unwrap();
728 let path = dir.join("tiny.safetensors");
729 let names = [
731 ("diffusion_model.transformer_blocks.0.attn1.to_q.lora_A.weight", vec![2usize, 3]),
732 ("diffusion_model.transformer_blocks.0.attn1.to_q.lora_B.weight", vec![4, 2]),
733 ];
734 let mut header = serde_json::Map::new();
735 let mut blob: Vec<u8> = Vec::new();
736 for (n, shape) in &names {
737 let count: usize = shape.iter().product();
738 let start = blob.len();
739 for i in 0..count {
740 blob.extend_from_slice(&(i as f32 * 0.25).to_le_bytes());
741 }
742 header.insert(
743 (*n).to_string(),
744 serde_json::json!({
745 "dtype": "F32",
746 "shape": shape,
747 "data_offsets": [start, blob.len()],
748 }),
749 );
750 }
751 let hdr = serde_json::to_vec(&serde_json::Value::Object(header)).unwrap();
752 let mut f = std::fs::File::create(&path).unwrap();
753 f.write_all(&(hdr.len() as u64).to_le_bytes()).unwrap();
754 f.write_all(&hdr).unwrap();
755 f.write_all(&blob).unwrap();
756 drop(f);
757
758 let bank = LoraBank::load(&path, 1.0).expect("load");
759 assert_eq!(bank.len(), 1);
760 assert_eq!(bank.rank(), 2);
761 assert!(bank.slot.is_none());
762 let br = bank
763 .branch("dit.transformer_blocks.0.attn1.to_q")
764 .expect("the container's name must find the adapter's branch");
765 assert_eq!(br.rank(), 2);
766 assert!(bank.branch("dit.transformer_blocks.0.attn1.to_k").is_none());
767 let mut out = vec![0f32; 4];
769 br.add(&[1.0, 0.0, 0.0], 1, &mut out, None);
770 let want = [0.25, 0.75, 1.25, 1.75].map(|v: f32| v * 0.75);
773 for (g, w) in out.iter().zip(&want) {
774 assert!((g - w).abs() < 1e-5, "{g} vs {w}");
775 }
776 let _ = std::fs::remove_dir_all(&dir);
777 }
778
779 #[test]
782 fn orphan_side_is_refused() {
783 use std::io::Write;
784 let dir = std::env::temp_dir().join("cmf_lora_orphan_test");
785 std::fs::create_dir_all(&dir).unwrap();
786 let path = dir.join("orphan.safetensors");
787 let mut header = serde_json::Map::new();
788 let mut blob: Vec<u8> = Vec::new();
789 for i in 0..6 {
790 blob.extend_from_slice(&(i as f32).to_le_bytes());
791 }
792 header.insert(
793 "diffusion_model.transformer_blocks.0.attn1.to_q.lora_A.weight".to_string(),
794 serde_json::json!({"dtype":"F32","shape":[2,3],"data_offsets":[0,24]}),
795 );
796 let hdr = serde_json::to_vec(&serde_json::Value::Object(header)).unwrap();
797 let mut f = std::fs::File::create(&path).unwrap();
798 f.write_all(&(hdr.len() as u64).to_le_bytes()).unwrap();
799 f.write_all(&hdr).unwrap();
800 f.write_all(&blob).unwrap();
801 drop(f);
802 let err = match LoraBank::load(&path, 1.0) {
803 Err(e) => e,
804 Ok(_) => panic!("an adapter with a lone A side must be refused"),
805 };
806 assert!(err.contains("no B side"), "{err}");
807 let _ = std::fs::remove_dir_all(&dir);
808 }
809
810 #[test]
813 fn slot_embedding_matches_definition() {
814 let s = SlotEmbed {
815 freqs: vec![2.0],
816 w0: vec![1.0, 0.5, -0.25],
818 b0: vec![0.1],
819 w2: vec![2.0],
820 b2: vec![-0.3],
821 hidden: 1,
822 dim: 1,
823 };
824 let v = 3.0f32 / 16.0;
825 let feat = [v, (v * 2.0).sin(), (v * 2.0).cos()];
826 let pre = 0.1 + feat[0] * 1.0 + feat[1] * 0.5 + feat[2] * -0.25;
827 let hid = pre / (1.0 + (-pre).exp());
828 let want = -0.3 + hid * 2.0;
829 let got = s.embed(3);
830 assert_eq!(got.len(), 1);
831 assert!((got[0] - want).abs() < 1e-6, "{} vs {want}", got[0]);
832 }
833}