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}
39
40impl LoraBranch {
41 pub fn rank(&self) -> usize {
42 self.rank
43 }
44
45 pub fn add(&self, x: &[f32], n: usize, dst: &mut [f32], pool: Option<&Pool>) {
54 debug_assert_eq!(x.len(), n * self.inn);
55 debug_assert_eq!(dst.len(), n * self.out);
56 if n == 0 {
57 return;
58 }
59 let mut h = vec![0f32; n * self.rank];
67 let mut d = vec![0f32; n * self.out];
68 crate::gpu::cpu_scope(|| {
69 crate::fcd_ops::gemm_nt(x, &self.a, &mut h, n, self.inn, self.rank, pool);
70 crate::fcd_ops::gemm_nt(&h, &self.b, &mut d, n, self.rank, self.out, pool);
71 });
72 let scale = self.scale;
73 let sink = Shared(dst.as_mut_ptr());
74 rows(pool, n, &|s, e| {
75 let row = unsafe { sink.at(s * self.out, (e - s) * self.out) };
76 for (o, v) in row.iter_mut().enumerate() {
77 *v += scale * d[s * self.out + o];
78 }
79 });
80 }
81}
82
83pub struct SlotEmbed {
90 freqs: Vec<f32>,
91 w0: Vec<f32>,
92 b0: Vec<f32>,
93 w2: Vec<f32>,
94 b2: Vec<f32>,
95 hidden: usize,
96 dim: usize,
97}
98
99impl SlotEmbed {
100 pub fn dim(&self) -> usize {
101 self.dim
102 }
103
104 pub fn embed(&self, slot_id: usize) -> Vec<f32> {
106 let scaled = slot_id as f32 / 16.0;
107 let mut feat = Vec::with_capacity(1 + 2 * self.freqs.len());
108 feat.push(scaled);
109 for f in &self.freqs {
110 feat.push((scaled * f).sin());
111 }
112 for f in &self.freqs {
113 feat.push((scaled * f).cos());
114 }
115 let win = feat.len();
116 let mut hid = vec![0f32; self.hidden];
117 for (o, hv) in hid.iter_mut().enumerate() {
118 let row = &self.w0[o * win..(o + 1) * win];
119 let mut acc = self.b0[o];
120 for (fv, wv) in feat.iter().zip(row) {
121 acc += fv * wv;
122 }
123 *hv = acc / (1.0 + (-acc).exp());
125 }
126 let mut outv = vec![0f32; self.dim];
127 for (o, ov) in outv.iter_mut().enumerate() {
128 let row = &self.w2[o * self.hidden..(o + 1) * self.hidden];
129 let mut acc = self.b2[o];
130 for (hv, wv) in hid.iter().zip(row) {
131 acc += hv * wv;
132 }
133 *ov = acc;
134 }
135 outv
136 }
137}
138
139pub struct LoraBank {
143 pairs: HashMap<String, (Vec<f32>, Vec<f32>, usize, usize, usize)>,
144 pub slot: Option<SlotEmbed>,
145 pub meta: HashMap<String, String>,
146 scale: f32,
147}
148
149fn st_read(path: &Path) -> Result<(HashMap<String, (Vec<usize>, Vec<f32>)>, HashMap<String, String>), String> {
150 let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
151 if bytes.len() < 8 {
152 return Err("lora: truncated safetensors header".into());
153 }
154 let hlen = u64::from_le_bytes(bytes[..8].try_into().unwrap()) as usize;
155 let header: serde_json::Value = serde_json::from_slice(
156 bytes.get(8..8 + hlen).ok_or("lora: header past end of file")?,
157 )
158 .map_err(|e| format!("lora header: {e}"))?;
159 let base = 8 + hlen;
160 let obj = header.as_object().ok_or("lora: header not an object")?;
161 let mut meta = HashMap::new();
162 let mut out = HashMap::new();
163 for (name, m) in obj {
164 if name == "__metadata__" {
165 if let Some(o) = m.as_object() {
166 for (k, v) in o {
167 if let Some(s) = v.as_str() {
168 meta.insert(k.clone(), s.to_string());
169 }
170 }
171 }
172 continue;
173 }
174 let dtype = m["dtype"].as_str().ok_or("lora: dtype")?;
175 let shape: Vec<usize> = m["shape"]
176 .as_array()
177 .ok_or("lora: shape")?
178 .iter()
179 .map(|v| v.as_u64().unwrap_or(0) as usize)
180 .collect();
181 let offs = m["data_offsets"].as_array().ok_or("lora: offsets")?;
182 let s = offs[0].as_u64().unwrap_or(0) as usize + base;
183 let e = offs[1].as_u64().unwrap_or(0) as usize + base;
184 let raw = bytes.get(s..e).ok_or("lora: tensor span past end of file")?;
185 let mut data = Vec::with_capacity(shape.iter().product::<usize>().max(1));
186 match dtype {
187 "F32" => {
188 for c in raw.chunks_exact(4) {
189 data.push(f32::from_le_bytes(c.try_into().unwrap()));
190 }
191 }
192 "F16" => {
193 for c in raw.chunks_exact(2) {
194 data.push(cortiq_core::quant::f16_to_f32(u16::from_le_bytes(
195 c.try_into().unwrap(),
196 )));
197 }
198 }
199 "BF16" => {
200 for c in raw.chunks_exact(2) {
201 let b = u16::from_le_bytes(c.try_into().unwrap());
202 data.push(f32::from_bits((b as u32) << 16));
203 }
204 }
205 other => return Err(format!("lora: unsupported dtype {other} on {name}")),
206 }
207 out.insert(name.clone(), (shape, data));
208 }
209 Ok((out, meta))
210}
211
212impl LoraBank {
213 pub fn load(path: &Path, strength: f32) -> Result<LoraBank, String> {
220 let (tensors, meta) = st_read(path)?;
221 let mut a_side: HashMap<String, (Vec<usize>, Vec<f32>)> = HashMap::new();
222 let mut b_side: HashMap<String, (Vec<usize>, Vec<f32>)> = HashMap::new();
223 let mut slot_parts: HashMap<String, (Vec<usize>, Vec<f32>)> = HashMap::new();
224 for (name, val) in tensors {
225 let short = name
226 .strip_prefix("diffusion_model.")
227 .unwrap_or(&name)
228 .to_string();
229 if let Some(rest) = short.strip_prefix("reference_slot_embedding.") {
230 slot_parts.insert(rest.to_string(), val);
231 } else if let Some(base) = short.strip_suffix(".lora_A.weight") {
232 a_side.insert(base.to_string(), val);
233 } else if let Some(base) = short.strip_suffix(".lora_B.weight") {
234 b_side.insert(base.to_string(), val);
235 } else if let Some(base) = short.strip_suffix(".lora_down.weight") {
236 a_side.insert(base.to_string(), val);
237 } else if let Some(base) = short.strip_suffix(".lora_up.weight") {
238 b_side.insert(base.to_string(), val);
239 }
240 }
241 let alpha = meta
242 .get("alpha")
243 .or_else(|| meta.get("lora_alpha"))
244 .and_then(|v| v.parse::<f32>().ok());
245
246 let mut pairs = HashMap::new();
247 for (base, (ashape, adata)) in a_side {
248 let Some((bshape, bdata)) = b_side.remove(&base) else {
249 return Err(format!("lora: {base} has an A side and no B side"));
250 };
251 if ashape.len() != 2 || bshape.len() != 2 {
252 return Err(format!("lora: {base} is not a matrix pair"));
253 }
254 let (rank, inn) = (ashape[0], ashape[1]);
255 let (out, rank_b) = (bshape[0], bshape[1]);
256 if rank != rank_b {
257 return Err(format!(
258 "lora: {base} rank mismatch — A is {rank}, B is {rank_b}"
259 ));
260 }
261 let scale = match alpha {
262 Some(al) if rank > 0 => strength * al / rank as f32,
263 _ => strength,
264 };
265 pairs.insert(base, (adata, bdata, rank, inn, out));
266 let _ = scale; }
268 if !b_side.is_empty() {
269 let orphan = b_side.keys().next().cloned().unwrap_or_default();
270 return Err(format!("lora: {orphan} has a B side and no A side"));
271 }
272
273 let slot = if slot_parts.is_empty() {
277 None
278 } else {
279 let need = |k: &str| -> Result<&(Vec<usize>, Vec<f32>), String> {
280 slot_parts
281 .get(k)
282 .ok_or_else(|| format!("lora: reference_slot_embedding.{k} is missing"))
283 };
284 let freqs = need("frequencies")?.1.clone();
285 let (s0, w0) = need("net.0.weight").map(|t| (t.0.clone(), t.1.clone()))?;
286 let b0 = need("net.0.bias")?.1.clone();
287 let (s2, w2) = need("net.2.weight").map(|t| (t.0.clone(), t.1.clone()))?;
288 let b2 = need("net.2.bias")?.1.clone();
289 if s0.len() != 2 || s2.len() != 2 {
290 return Err("lora: slot embedding layers are not matrices".into());
291 }
292 if s0[1] != 1 + 2 * freqs.len() {
293 return Err(format!(
294 "lora: slot embedding takes {} features, {} frequencies imply {}",
295 s0[1],
296 freqs.len(),
297 1 + 2 * freqs.len()
298 ));
299 }
300 Some(SlotEmbed {
301 freqs,
302 w0,
303 b0,
304 w2,
305 b2,
306 hidden: s0[0],
307 dim: s2[0],
308 })
309 };
310
311 let scale = match alpha {
312 Some(al) => {
313 let r = pairs.values().next().map(|p| p.2).unwrap_or(1).max(1);
314 strength * al / r as f32
315 }
316 None => strength,
317 };
318 Ok(LoraBank { pairs, slot, meta, scale })
319 }
320
321 pub fn len(&self) -> usize {
322 self.pairs.len()
323 }
324
325 pub fn is_empty(&self) -> bool {
326 self.pairs.is_empty()
327 }
328
329 pub fn rank(&self) -> usize {
331 self.pairs.values().next().map(|p| p.2).unwrap_or(0)
332 }
333
334 pub fn branch(&self, name: &str) -> Option<LoraBranch> {
337 let key = name.strip_prefix("dit.").unwrap_or(name);
338 let (a, b, rank, inn, out) = self.pairs.get(key)?;
339 Some(LoraBranch {
340 a: a.clone(),
341 b: b.clone(),
342 rank: *rank,
343 inn: *inn,
344 out: *out,
345 scale: self.scale,
346 })
347 }
348
349 pub fn check_reference_convention(&self) -> Result<(), String> {
352 if let Some(order) = self.meta.get("reference_token_order") {
353 if order != "prepend" {
354 return Err(format!(
355 "lora: reference_token_order={order}, this build only prepends"
356 ));
357 }
358 }
359 if let Some(off) = self.meta.get("reference_slot_time_offsets") {
360 if off != "pic1_based_negative_time" {
361 return Err(format!(
362 "lora: reference_slot_time_offsets={off}, this build only places \
363 references at negative latent frames"
364 ));
365 }
366 }
367 Ok(())
368 }
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374
375 #[test]
377 fn branch_matches_dense() {
378 let (n, inn, rank, out) = (3usize, 5usize, 2usize, 4usize);
379 let a: Vec<f32> = (0..rank * inn).map(|i| (i as f32 * 0.37).sin()).collect();
380 let b: Vec<f32> = (0..out * rank).map(|i| (i as f32 * 0.11).cos()).collect();
381 let x: Vec<f32> = (0..n * inn).map(|i| (i as f32 * 0.7).sin()).collect();
382 let br = LoraBranch { a: a.clone(), b: b.clone(), rank, inn, out, scale: 0.5 };
383 let mut got = vec![1.5f32; n * out];
384 br.add(&x, n, &mut got, None);
385 for t in 0..n {
386 for o in 0..out {
387 let mut acc = 0f32;
388 for r in 0..rank {
389 let h: f32 = (0..inn).map(|i| x[t * inn + i] * a[r * inn + i]).sum();
390 acc += h * b[o * rank + r];
391 }
392 let want = 1.5 + 0.5 * acc;
393 assert!(
394 (got[t * out + o] - want).abs() < 1e-4,
395 "row {t} col {o}: {} vs {want}",
396 got[t * out + o]
397 );
398 }
399 }
400 }
401
402 #[test]
404 fn zero_b_changes_nothing() {
405 let br = LoraBranch {
406 a: vec![1.0; 4],
407 b: vec![0.0; 6],
408 rank: 2,
409 inn: 2,
410 out: 3,
411 scale: 1.0,
412 };
413 let mut out = vec![7.0f32; 3];
414 br.add(&[1.0, 2.0], 1, &mut out, None);
415 assert_eq!(out, vec![7.0, 7.0, 7.0]);
416 }
417
418 #[test]
424 fn names_bind_to_container_projections() {
425 use std::io::Write;
426 let dir = std::env::temp_dir().join("cmf_lora_name_test");
427 std::fs::create_dir_all(&dir).unwrap();
428 let path = dir.join("tiny.safetensors");
429 let names = [
431 ("diffusion_model.transformer_blocks.0.attn1.to_q.lora_A.weight", vec![2usize, 3]),
432 ("diffusion_model.transformer_blocks.0.attn1.to_q.lora_B.weight", vec![4, 2]),
433 ];
434 let mut header = serde_json::Map::new();
435 let mut blob: Vec<u8> = Vec::new();
436 for (n, shape) in &names {
437 let count: usize = shape.iter().product();
438 let start = blob.len();
439 for i in 0..count {
440 blob.extend_from_slice(&(i as f32 * 0.25).to_le_bytes());
441 }
442 header.insert(
443 (*n).to_string(),
444 serde_json::json!({
445 "dtype": "F32",
446 "shape": shape,
447 "data_offsets": [start, blob.len()],
448 }),
449 );
450 }
451 let hdr = serde_json::to_vec(&serde_json::Value::Object(header)).unwrap();
452 let mut f = std::fs::File::create(&path).unwrap();
453 f.write_all(&(hdr.len() as u64).to_le_bytes()).unwrap();
454 f.write_all(&hdr).unwrap();
455 f.write_all(&blob).unwrap();
456 drop(f);
457
458 let bank = LoraBank::load(&path, 1.0).expect("load");
459 assert_eq!(bank.len(), 1);
460 assert_eq!(bank.rank(), 2);
461 assert!(bank.slot.is_none());
462 let br = bank
463 .branch("dit.transformer_blocks.0.attn1.to_q")
464 .expect("the container's name must find the adapter's branch");
465 assert_eq!(br.rank(), 2);
466 assert!(bank.branch("dit.transformer_blocks.0.attn1.to_k").is_none());
467 let mut out = vec![0f32; 4];
469 br.add(&[1.0, 0.0, 0.0], 1, &mut out, None);
470 let want = [0.25, 0.75, 1.25, 1.75].map(|v: f32| v * 0.75);
473 for (g, w) in out.iter().zip(&want) {
474 assert!((g - w).abs() < 1e-5, "{g} vs {w}");
475 }
476 let _ = std::fs::remove_dir_all(&dir);
477 }
478
479 #[test]
482 fn orphan_side_is_refused() {
483 use std::io::Write;
484 let dir = std::env::temp_dir().join("cmf_lora_orphan_test");
485 std::fs::create_dir_all(&dir).unwrap();
486 let path = dir.join("orphan.safetensors");
487 let mut header = serde_json::Map::new();
488 let mut blob: Vec<u8> = Vec::new();
489 for i in 0..6 {
490 blob.extend_from_slice(&(i as f32).to_le_bytes());
491 }
492 header.insert(
493 "diffusion_model.transformer_blocks.0.attn1.to_q.lora_A.weight".to_string(),
494 serde_json::json!({"dtype":"F32","shape":[2,3],"data_offsets":[0,24]}),
495 );
496 let hdr = serde_json::to_vec(&serde_json::Value::Object(header)).unwrap();
497 let mut f = std::fs::File::create(&path).unwrap();
498 f.write_all(&(hdr.len() as u64).to_le_bytes()).unwrap();
499 f.write_all(&hdr).unwrap();
500 f.write_all(&blob).unwrap();
501 drop(f);
502 let err = match LoraBank::load(&path, 1.0) {
503 Err(e) => e,
504 Ok(_) => panic!("an adapter with a lone A side must be refused"),
505 };
506 assert!(err.contains("no B side"), "{err}");
507 let _ = std::fs::remove_dir_all(&dir);
508 }
509
510 #[test]
513 fn slot_embedding_matches_definition() {
514 let s = SlotEmbed {
515 freqs: vec![2.0],
516 w0: vec![1.0, 0.5, -0.25],
518 b0: vec![0.1],
519 w2: vec![2.0],
520 b2: vec![-0.3],
521 hidden: 1,
522 dim: 1,
523 };
524 let v = 3.0f32 / 16.0;
525 let feat = [v, (v * 2.0).sin(), (v * 2.0).cos()];
526 let pre = 0.1 + feat[0] * 1.0 + feat[1] * 0.5 + feat[2] * -0.25;
527 let hid = pre / (1.0 + (-pre).exp());
528 let want = -0.3 + hid * 2.0;
529 let got = s.embed(3);
530 assert_eq!(got.len(), 1);
531 assert!((got[0] - want).abs() < 1e-6, "{} vs {want}", got[0]);
532 }
533}