Skip to main content

cortiq_engine/
ltxlora.rs

1//! Runtime LoRA for the LTX-2.5 DiT, and the reference-slot embedding the
2//! multi-subject adapters ship alongside it.
3//!
4//! The container's weights are q4tp — four bits on a per-row scale ladder —
5//! so a low-rank update cannot be folded into them without dequantizing the
6//! whole DiT and requantizing it, which would cost the file's size in RAM and
7//! throw away the codec's error budget on an update the size of a rounding
8//! step. The branch is evaluated instead:
9//!
10//! ```text
11//! y = x·Wᵀ + s · (x·Aᵀ)·Bᵀ
12//! ```
13//!
14//! `A` is `[rank, in]` and `B` is `[out, rank]`, which is the layout
15//! `lora_A.weight` / `lora_B.weight` already have. At rank 128 against a
16//! 4096×4096 projection that is 2·4096·128 multiply-adds against the base's
17//! 16.8 M — about 6% — so the branch costs a few per cent of a step and no
18//! memory beyond the adapter itself.
19//!
20//! `SlotEmbed` is the second half of the multi-subject adapters: a Fourier
21//! feature of the slot index through a two-layer MLP, added to the *latent*
22//! channels of a reference image before it is prepended to the sequence. See
23//! [`crate::ltxpipe::Conditioning::with_references`] for where the tokens go.
24
25use crate::ltxdit::{Shared, rows};
26use crate::pool::Pool;
27use std::collections::HashMap;
28use std::path::Path;
29
30/// One projection's low-rank branch, already scaled.
31pub struct LoraBranch {
32    a: Vec<f32>, // [rank, in]
33    b: Vec<f32>, // [out, rank]
34    rank: usize,
35    inn: usize,
36    out: usize,
37    scale: f32,
38    /// Stable for this branch's lifetime — keys its device-resident copy so
39    /// the adapter uploads once per render rather than once per step.
40    id: usize,
41    /// The router's state: how loudly this branch spoke on the first step
42    /// it ran, and whether it still runs. See [`route_threshold`].
43    resonance: std::sync::atomic::AtomicU32,
44    live: std::sync::atomic::AtomicBool,
45}
46
47/// `CMF_LORA_ROUTE=<r>` turns on branch routing: a branch whose first-step
48/// contribution `‖s·ΔY‖ / ‖Y‖` is below `r` is switched off for the rest of
49/// the render, and the projection it sits on goes back to the fused device
50/// path it would have taken without an adapter.
51///
52/// The point is not the branch's own arithmetic — that is a few per cent —
53/// but what carrying one *costs the base*: a branch has to read the panel
54/// its projection produces, so the fused kernels that keep qkv, the
55/// attention output and the FFN's middle on the card have to stand down and
56/// ship those panels home. Turning off the branches that do not matter buys
57/// that fusion back for most of the blocks.
58///
59/// Adapters are not uniform across depth — measured on
60/// `fal/MiniMax-H3-Realism-People-LoRA`, the numbers are in `docs/LORA.md`.
61pub fn route_threshold() -> Option<f32> {
62    use std::sync::atomic::Ordering::Relaxed;
63    // `u32::MAX` is "not read yet"; a stored 0 means routing is off. Held
64    // as an atomic rather than a `OnceLock` so a caller — or a test — can
65    // set it after the first read.
66    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
80/// Set the routing threshold programmatically, overriding the environment.
81pub 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
88/// Whether anything wants a branch MEASURED this render — the probe's report
89/// or the router's threshold. A fused base+branch kernel has to stand down
90/// when this is true, or the measurement never happens.
91pub fn wants_measurement() -> bool {
92    probe_on() || route_threshold().is_some()
93}
94
95/// Whether the per-branch report is wanted (`CMF_LORA_PROBE=1`).
96pub 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    /// The pieces the fused device path needs.
113    #[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    /// Whether the router still lets this branch run. The fused device
125    /// paths ask this, not `is_some`: a branch switched off must give the
126    /// fusion back, or routing saves the arithmetic and keeps the cost.
127    pub fn live(&self) -> bool {
128        self.live.load(std::sync::atomic::Ordering::Relaxed)
129    }
130
131    /// The measured first-step contribution, once it has run.
132    pub fn resonance(&self) -> f32 {
133        f32::from_bits(self.resonance.load(std::sync::atomic::Ordering::Relaxed))
134    }
135
136    /// `dst += scale · (x·Aᵀ)·Bᵀ`, over `n` rows of `x`.
137    ///
138    /// Both halves are N·Kᵀ products, which is the shape `gemm_nt` is built
139    /// for — Accelerate's AMX path on Apple silicon, register-blocked SIMD
140    /// elsewhere, and the wgpu arm when the shape earns it. Written as two
141    /// scalar loops instead, rank 128 over 480 branches cost 40 s a step on
142    /// an M4 against the base GEMMs' 9; the arithmetic is small only if it
143    /// runs on the same machinery as everything else.
144    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        // Held on the host on purpose. These are small f32 GEMMs standing
151        // beside a q4tp GEMM that already owns the device, and the generic
152        // `GemmNt` probe will happily send them there — where they queue
153        // behind the base projection and pay a submit and a readback each.
154        // Measured on an M4, 384 tokens: 39.7 s a step through the probe
155        // against 12.4 pinned to the host, for GEMMs whose own arithmetic is
156        // 1.1 s of that. Accelerate runs these shapes at 250-1300 GFLOP/s.
157        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        // The router's measurement, and the probe's: the branch against
165        // the base it is correcting, both as sums of squares over the whole
166        // panel. Taken BEFORE the add, so `dst` is still the base alone.
167        // One pass over two panels once per render — the branch's own GEMMs
168        // are two orders of magnitude more work.
169        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            // A zero ratio would read as "not measured yet" on the next
183            // step; the smallest positive float says "measured, silent".
184            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
204/// The learned per-reference slot embedding: `slot_id` through Fourier
205/// features and a two-layer SiLU MLP, landing on the latent's channel count.
206///
207/// Reproduces the adapter's own definition exactly — the index is divided by
208/// 16 before the phases are taken, and the raw scaled value is the first
209/// feature, so the input width is `1 + 2·num_frequencies`.
210pub 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    /// The embedding for slot `slot_id` (1-based, as the adapter numbers them).
226    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            // SiLU
245            *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
260/// Every branch an adapter file carries, keyed by the projection it belongs
261/// to — the container's name with the `dit.` prefix dropped, which is what
262/// the adapters use after their own `diffusion_model.`.
263pub 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    /// Read an adapter. `scale` multiplies every branch — the strength dial.
335    ///
336    /// `alpha` is honoured when the file records it: the trained convention is
337    /// `scale = strength · alpha / rank`, and an adapter that ships neither
338    /// `alpha` nor `lora_alpha` is taken at `strength` as-is, which is what
339    /// the diffusers loaders do for a file whose A/B are already scaled.
340    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            // Three conventions in the wild for the same thing: the
347            // ComfyUI single-file adapters write `diffusion_model.`, the
348            // PEFT ones `base_model.model.` (keeping the module path,
349            // `dit.` included, behind it), and a few write the module
350            // path bare. Strip whichever is there and key on the rest.
351            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            // PEFT keeps the module path after its own prefix, `dit.` and
357            // all; the container's names carry the same `dit.` and the
358            // lookup strips it. Normalize to the same side of that prefix
359            // here, or a PEFT adapter binds nothing at all.
360            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; // per-branch scale is uniform; kept on the bank
399        }
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        // The slot embedding is present only on the multi-reference adapters.
406        // Half of it is not a thing we can guess at: refuse a partial one
407        // rather than silently render without the reference conditioning.
408        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    /// The rank the file was trained at, for the log line.
462    pub fn rank(&self) -> usize {
463        self.pairs.values().next().map(|p| p.2).unwrap_or(0)
464    }
465
466    /// Every projection this adapter names. The caller binds what its
467    /// container has and reports the rest: an adapter that trained a
468    /// module we fold away (adaLN, on a curve-form pack) must say so
469    /// rather than render as though it had been applied.
470    pub fn keys(&self) -> Vec<&str> {
471        self.pairs.keys().map(|s| s.as_str()).collect()
472    }
473
474    /// The branch for a projection of a KNOWN shape. A branch whose matrices
475    /// do not match the panel it will be handed belongs to another model —
476    /// `add` writes `n·out` floats through a raw pointer, so a mismatch is a
477    /// buffer overrun, not a bad picture. Refuse it by name instead.
478    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    /// The branch for a container tensor name, if this adapter carries one.
498    /// `name` is the projection without `.weight` — `dit.transformer_blocks.0.attn1.to_q`.
499    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    /// How the adapter wants reference tokens placed. Both are refused rather
516    /// than approximated when they are not what this implementation does.
517    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    /// The branch must equal a plain dense evaluation of `s·(xAᵀ)Bᵀ`.
542    #[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    /// Write a minimal safetensors carrying one A/B pair per name.
579    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    /// Adapter names for the video DiT next door must land on ITS
611    /// projections too: the H3 adapters write `diffusion_model.blocks.N.…`
612    /// and the PEFT ones `base_model.model.dit.blocks.N.…`, and both have
613    /// to key on what `mmh3` asks for — `dit.blocks.N.attn.qkv_proj`.
614    #[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    /// An adapter for another model must be refused by name. The branch
634    /// writes `n·out` floats through a raw pointer, so a shape it was not
635    /// trained for is a buffer overrun, not a bad picture.
636    #[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        // A [2, 3] and B [3, 2]: a branch from 3 inputs to 3 outputs.
642        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    /// The router silences a branch that contributes nothing and leaves a
663    /// loud one alone — measured on the first call, applied from the second.
664    #[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    /// A zero-rank-B adapter must be the identity on the output.
700    #[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    /// A file's names must land on the container's projections: the adapters
719    /// write `diffusion_model.transformer_blocks.N.attn1.to_q.lora_A.weight`
720    /// and the container calls that tensor
721    /// `dit.transformer_blocks.N.attn1.to_q.weight`, so the bank has to strip
722    /// one prefix and the lookup the other.
723    #[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        // rank 2, in 3, out 4 — one pair, plus a slot embedding
730        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        // and the branch computes: x = [1,0,0] picks A's first column
768        let mut out = vec![0f32; 4];
769        br.add(&[1.0, 0.0, 0.0], 1, &mut out, None);
770        // A = [[0,.25,.5],[.75,1,1.25]] so h = [0, .75]
771        // B = [[0,.25],[.5,.75],[1,1.25],[1.5,1.75]] so out = .75 * B[:,1]
772        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    /// A file with only one side of a pair is a broken adapter, and must say
780    /// so rather than render as if the branch were zero.
781    #[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    /// The slot embedding's feature vector is `[v, sin(v·f), cos(v·f)]` with
811    /// `v = slot/16` — check it against a hand-evaluated one-frequency MLP.
812    #[test]
813    fn slot_embedding_matches_definition() {
814        let s = SlotEmbed {
815            freqs: vec![2.0],
816            // hidden = 1, input width 3
817            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}