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
270/// Read a `.safetensors` into `{name: (shape, f32 data)}`. Shared with the
271/// latent upscaler next door, which loads its weights the same way.
272pub(crate) fn read_safetensors(
273    path: &Path,
274) -> Result<HashMap<String, (Vec<usize>, Vec<f32>)>, String> {
275    st_read(path).map(|(t, _)| t)
276}
277
278fn st_read(
279    path: &Path,
280) -> Result<
281    (
282        HashMap<String, (Vec<usize>, Vec<f32>)>,
283        HashMap<String, String>,
284    ),
285    String,
286> {
287    let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
288    if bytes.len() < 8 {
289        return Err("lora: truncated safetensors header".into());
290    }
291    let hlen = u64::from_le_bytes(bytes[..8].try_into().unwrap()) as usize;
292    let header: serde_json::Value = serde_json::from_slice(
293        bytes
294            .get(8..8 + hlen)
295            .ok_or("lora: header past end of file")?,
296    )
297    .map_err(|e| format!("lora header: {e}"))?;
298    let base = 8 + hlen;
299    let obj = header.as_object().ok_or("lora: header not an object")?;
300    let mut meta = HashMap::new();
301    let mut out = HashMap::new();
302    for (name, m) in obj {
303        if name == "__metadata__" {
304            if let Some(o) = m.as_object() {
305                for (k, v) in o {
306                    if let Some(s) = v.as_str() {
307                        meta.insert(k.clone(), s.to_string());
308                    }
309                }
310            }
311            continue;
312        }
313        let dtype = m["dtype"].as_str().ok_or("lora: dtype")?;
314        let shape: Vec<usize> = m["shape"]
315            .as_array()
316            .ok_or("lora: shape")?
317            .iter()
318            .map(|v| v.as_u64().unwrap_or(0) as usize)
319            .collect();
320        let offs = m["data_offsets"].as_array().ok_or("lora: offsets")?;
321        let s = offs[0].as_u64().unwrap_or(0) as usize + base;
322        let e = offs[1].as_u64().unwrap_or(0) as usize + base;
323        let raw = bytes
324            .get(s..e)
325            .ok_or("lora: tensor span past end of file")?;
326        let mut data = Vec::with_capacity(shape.iter().product::<usize>().max(1));
327        match dtype {
328            "F32" => {
329                for c in raw.chunks_exact(4) {
330                    data.push(f32::from_le_bytes(c.try_into().unwrap()));
331                }
332            }
333            "F16" => {
334                for c in raw.chunks_exact(2) {
335                    data.push(cortiq_core::quant::f16_to_f32(u16::from_le_bytes(
336                        c.try_into().unwrap(),
337                    )));
338                }
339            }
340            "BF16" => {
341                for c in raw.chunks_exact(2) {
342                    let b = u16::from_le_bytes(c.try_into().unwrap());
343                    data.push(f32::from_bits((b as u32) << 16));
344                }
345            }
346            other => return Err(format!("lora: unsupported dtype {other} on {name}")),
347        }
348        out.insert(name.clone(), (shape, data));
349    }
350    Ok((out, meta))
351}
352
353impl LoraBank {
354    /// Read an adapter. `scale` multiplies every branch — the strength dial.
355    ///
356    /// `alpha` is honoured when the file records it: the trained convention is
357    /// `scale = strength · alpha / rank`, and an adapter that ships neither
358    /// `alpha` nor `lora_alpha` is taken at `strength` as-is, which is what
359    /// the diffusers loaders do for a file whose A/B are already scaled.
360    pub fn load(path: &Path, strength: f32) -> Result<LoraBank, String> {
361        let (tensors, meta) = st_read(path)?;
362        let mut a_side: HashMap<String, (Vec<usize>, Vec<f32>)> = HashMap::new();
363        let mut b_side: HashMap<String, (Vec<usize>, Vec<f32>)> = HashMap::new();
364        let mut slot_parts: HashMap<String, (Vec<usize>, Vec<f32>)> = HashMap::new();
365        for (name, val) in tensors {
366            // Three conventions in the wild for the same thing: the
367            // ComfyUI single-file adapters write `diffusion_model.`, the
368            // PEFT ones `base_model.model.` (keeping the module path,
369            // `dit.` included, behind it), and a few write the module
370            // path bare. Strip whichever is there and key on the rest.
371            let short = name
372                .strip_prefix("diffusion_model.")
373                .or_else(|| name.strip_prefix("base_model.model."))
374                .or_else(|| name.strip_prefix("transformer."))
375                .unwrap_or(&name);
376            // PEFT keeps the module path after its own prefix, `dit.` and
377            // all; the container's names carry the same `dit.` and the
378            // lookup strips it. Normalize to the same side of that prefix
379            // here, or a PEFT adapter binds nothing at all.
380            let short = short.strip_prefix("dit.").unwrap_or(short).to_string();
381            if let Some(rest) = short.strip_prefix("reference_slot_embedding.") {
382                slot_parts.insert(rest.to_string(), val);
383            } else if let Some(base) = short.strip_suffix(".lora_A.weight") {
384                a_side.insert(base.to_string(), val);
385            } else if let Some(base) = short.strip_suffix(".lora_B.weight") {
386                b_side.insert(base.to_string(), val);
387            } else if let Some(base) = short.strip_suffix(".lora_down.weight") {
388                a_side.insert(base.to_string(), val);
389            } else if let Some(base) = short.strip_suffix(".lora_up.weight") {
390                b_side.insert(base.to_string(), val);
391            }
392        }
393        let alpha = meta
394            .get("alpha")
395            .or_else(|| meta.get("lora_alpha"))
396            .and_then(|v| v.parse::<f32>().ok());
397
398        let mut pairs = HashMap::new();
399        for (base, (ashape, adata)) in a_side {
400            let Some((bshape, bdata)) = b_side.remove(&base) else {
401                return Err(format!("lora: {base} has an A side and no B side"));
402            };
403            if ashape.len() != 2 || bshape.len() != 2 {
404                return Err(format!("lora: {base} is not a matrix pair"));
405            }
406            let (rank, inn) = (ashape[0], ashape[1]);
407            let (out, rank_b) = (bshape[0], bshape[1]);
408            if rank != rank_b {
409                return Err(format!(
410                    "lora: {base} rank mismatch — A is {rank}, B is {rank_b}"
411                ));
412            }
413            let scale = match alpha {
414                Some(al) if rank > 0 => strength * al / rank as f32,
415                _ => strength,
416            };
417            pairs.insert(base, (adata, bdata, rank, inn, out));
418            let _ = scale; // per-branch scale is uniform; kept on the bank
419        }
420        if !b_side.is_empty() {
421            let orphan = b_side.keys().next().cloned().unwrap_or_default();
422            return Err(format!("lora: {orphan} has a B side and no A side"));
423        }
424
425        // The slot embedding is present only on the multi-reference adapters.
426        // Half of it is not a thing we can guess at: refuse a partial one
427        // rather than silently render without the reference conditioning.
428        let slot = if slot_parts.is_empty() {
429            None
430        } else {
431            let need = |k: &str| -> Result<&(Vec<usize>, Vec<f32>), String> {
432                slot_parts
433                    .get(k)
434                    .ok_or_else(|| format!("lora: reference_slot_embedding.{k} is missing"))
435            };
436            let freqs = need("frequencies")?.1.clone();
437            let (s0, w0) = need("net.0.weight").map(|t| (t.0.clone(), t.1.clone()))?;
438            let b0 = need("net.0.bias")?.1.clone();
439            let (s2, w2) = need("net.2.weight").map(|t| (t.0.clone(), t.1.clone()))?;
440            let b2 = need("net.2.bias")?.1.clone();
441            if s0.len() != 2 || s2.len() != 2 {
442                return Err("lora: slot embedding layers are not matrices".into());
443            }
444            if s0[1] != 1 + 2 * freqs.len() {
445                return Err(format!(
446                    "lora: slot embedding takes {} features, {} frequencies imply {}",
447                    s0[1],
448                    freqs.len(),
449                    1 + 2 * freqs.len()
450                ));
451            }
452            Some(SlotEmbed {
453                freqs,
454                w0,
455                b0,
456                w2,
457                b2,
458                hidden: s0[0],
459                dim: s2[0],
460            })
461        };
462
463        let scale = match alpha {
464            Some(al) => {
465                let r = pairs.values().next().map(|p| p.2).unwrap_or(1).max(1);
466                strength * al / r as f32
467            }
468            None => strength,
469        };
470        Ok(LoraBank {
471            pairs,
472            slot,
473            meta,
474            scale,
475        })
476    }
477
478    pub fn len(&self) -> usize {
479        self.pairs.len()
480    }
481
482    pub fn is_empty(&self) -> bool {
483        self.pairs.is_empty()
484    }
485
486    /// The rank the file was trained at, for the log line.
487    pub fn rank(&self) -> usize {
488        self.pairs.values().next().map(|p| p.2).unwrap_or(0)
489    }
490
491    /// Every projection this adapter names. The caller binds what its
492    /// container has and reports the rest: an adapter that trained a
493    /// module we fold away (adaLN, on a curve-form pack) must say so
494    /// rather than render as though it had been applied.
495    pub fn keys(&self) -> Vec<&str> {
496        self.pairs.keys().map(|s| s.as_str()).collect()
497    }
498
499    /// The branch for a projection of a KNOWN shape. A branch whose matrices
500    /// do not match the panel it will be handed belongs to another model —
501    /// `add` writes `n·out` floats through a raw pointer, so a mismatch is a
502    /// buffer overrun, not a bad picture. Refuse it by name instead.
503    pub fn branch_for(
504        &self,
505        name: &str,
506        out: usize,
507        inn: usize,
508    ) -> Result<Option<LoraBranch>, String> {
509        let Some(br) = self.branch(name) else {
510            return Ok(None);
511        };
512        if br.out != out || br.inn != inn {
513            return Err(format!(
514                "lora: {name} is [{}, {}] in the adapter and [{out}, {inn}] in this container \
515                 — that adapter was trained for a different model",
516                br.out, br.inn
517            ));
518        }
519        Ok(Some(br))
520    }
521
522    /// The branch for a container tensor name, if this adapter carries one.
523    /// `name` is the projection without `.weight` — `dit.transformer_blocks.0.attn1.to_q`.
524    pub fn branch(&self, name: &str) -> Option<LoraBranch> {
525        let key = name.strip_prefix("dit.").unwrap_or(name);
526        let (a, b, rank, inn, out) = self.pairs.get(key)?;
527        Some(LoraBranch {
528            a: a.clone(),
529            b: b.clone(),
530            rank: *rank,
531            inn: *inn,
532            out: *out,
533            scale: self.scale,
534            id: NEXT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
535            resonance: Default::default(),
536            live: std::sync::atomic::AtomicBool::new(true),
537        })
538    }
539
540    /// How the adapter wants reference tokens placed. Both are refused rather
541    /// than approximated when they are not what this implementation does.
542    pub fn check_reference_convention(&self) -> Result<(), String> {
543        if let Some(order) = self.meta.get("reference_token_order") {
544            if order != "prepend" {
545                return Err(format!(
546                    "lora: reference_token_order={order}, this build only prepends"
547                ));
548            }
549        }
550        if let Some(off) = self.meta.get("reference_slot_time_offsets") {
551            if off != "pic1_based_negative_time" {
552                return Err(format!(
553                    "lora: reference_slot_time_offsets={off}, this build only places \
554                     references at negative latent frames"
555                ));
556            }
557        }
558        Ok(())
559    }
560}
561
562#[cfg(test)]
563mod tests {
564    use super::*;
565
566    /// The branch must equal a plain dense evaluation of `s·(xAᵀ)Bᵀ`.
567    #[test]
568    fn branch_matches_dense() {
569        let (n, inn, rank, out) = (3usize, 5usize, 2usize, 4usize);
570        let a: Vec<f32> = (0..rank * inn).map(|i| (i as f32 * 0.37).sin()).collect();
571        let b: Vec<f32> = (0..out * rank).map(|i| (i as f32 * 0.11).cos()).collect();
572        let x: Vec<f32> = (0..n * inn).map(|i| (i as f32 * 0.7).sin()).collect();
573        let br = LoraBranch {
574            a: a.clone(),
575            b: b.clone(),
576            rank,
577            inn,
578            out,
579            scale: 0.5,
580            id: 0,
581            resonance: Default::default(),
582            live: std::sync::atomic::AtomicBool::new(true),
583        };
584        let mut got = vec![1.5f32; n * out];
585        br.add(&x, n, &mut got, None);
586        for t in 0..n {
587            for o in 0..out {
588                let mut acc = 0f32;
589                for r in 0..rank {
590                    let h: f32 = (0..inn).map(|i| x[t * inn + i] * a[r * inn + i]).sum();
591                    acc += h * b[o * rank + r];
592                }
593                let want = 1.5 + 0.5 * acc;
594                assert!(
595                    (got[t * out + o] - want).abs() < 1e-4,
596                    "row {t} col {o}: {} vs {want}",
597                    got[t * out + o]
598                );
599            }
600        }
601    }
602
603    /// Write a minimal safetensors carrying one A/B pair per name.
604    fn write_pairs(path: &std::path::Path, pairs: &[(&str, usize, usize)]) {
605        use std::io::Write;
606        let mut header = serde_json::Map::new();
607        let mut blob: Vec<u8> = Vec::new();
608        for (base, inn, rank) in pairs {
609            for (suffix, shape) in [
610                ("lora_A.weight", vec![*rank, *inn]),
611                ("lora_B.weight", vec![*inn, *rank]),
612            ] {
613                let count: usize = shape.iter().product();
614                let start = blob.len();
615                for i in 0..count {
616                    blob.extend_from_slice(&(i as f32 * 0.25).to_le_bytes());
617                }
618                header.insert(
619                    format!("{base}.{suffix}"),
620                    serde_json::json!({
621                        "dtype": "F32",
622                        "shape": shape,
623                        "data_offsets": [start, blob.len()],
624                    }),
625                );
626            }
627        }
628        let hdr = serde_json::to_vec(&serde_json::Value::Object(header)).unwrap();
629        let mut f = std::fs::File::create(path).unwrap();
630        f.write_all(&(hdr.len() as u64).to_le_bytes()).unwrap();
631        f.write_all(&hdr).unwrap();
632        f.write_all(&blob).unwrap();
633    }
634
635    /// Adapter names for the video DiT next door must land on ITS
636    /// projections too: the H3 adapters write `diffusion_model.blocks.N.…`
637    /// and the PEFT ones `base_model.model.dit.blocks.N.…`, and both have
638    /// to key on what `mmh3` asks for — `dit.blocks.N.attn.qkv_proj`.
639    #[test]
640    fn mmh3_names_bind_to_container_projections() {
641        let dir = std::env::temp_dir().join(format!("mmh3lora{}", std::process::id()));
642        std::fs::create_dir_all(&dir).unwrap();
643        let path = dir.join("h3.safetensors");
644        write_pairs(
645            &path,
646            &[
647                ("diffusion_model.blocks.0.attn.qkv_proj", 3, 2),
648                ("base_model.model.dit.blocks.1.mlp.fc1", 3, 2),
649            ],
650        );
651        let bank = LoraBank::load(&path, 1.0).unwrap();
652        assert!(bank.branch("dit.blocks.0.attn.qkv_proj").is_some());
653        assert!(bank.branch("dit.blocks.1.mlp.fc1").is_some());
654        assert!(bank.branch("dit.blocks.2.mlp.fc1").is_none());
655        let _ = std::fs::remove_dir_all(&dir);
656    }
657
658    /// An adapter for another model must be refused by name. The branch
659    /// writes `n·out` floats through a raw pointer, so a shape it was not
660    /// trained for is a buffer overrun, not a bad picture.
661    #[test]
662    fn a_branch_of_the_wrong_shape_is_refused() {
663        let dir = std::env::temp_dir().join(format!("lorashape{}", std::process::id()));
664        std::fs::create_dir_all(&dir).unwrap();
665        let path = dir.join("wrong.safetensors");
666        // A [2, 3] and B [3, 2]: a branch from 3 inputs to 3 outputs.
667        write_pairs(&path, &[("diffusion_model.blocks.0.attn.qkv_proj", 3, 2)]);
668        let bank = LoraBank::load(&path, 1.0).unwrap();
669        let key = "dit.blocks.0.attn.qkv_proj";
670        assert!(
671            matches!(bank.branch_for(key, 3, 3), Ok(Some(_))),
672            "its own shape binds"
673        );
674        let err = match bank.branch_for(key, 4096, 3) {
675            Err(e) => e,
676            Ok(_) => panic!("a [4096, 3] projection must not take a [3, 3] branch"),
677        };
678        assert!(err.contains("different model"), "{err}");
679        assert!(matches!(bank.branch_for(key, 3, 4096), Err(_)));
680        assert!(matches!(
681            bank.branch_for("dit.blocks.9.attn.qkv_proj", 3, 3),
682            Ok(None)
683        ));
684        let _ = std::fs::remove_dir_all(&dir);
685    }
686
687    /// The router silences a branch that contributes nothing and leaves a
688    /// loud one alone — measured on the first call, applied from the second.
689    #[test]
690    fn router_silences_a_quiet_branch() {
691        set_route_threshold(Some(0.01));
692        let quiet = LoraBranch {
693            a: vec![1.0; 2],
694            b: vec![1e-6; 2],
695            rank: 1,
696            inn: 2,
697            out: 2,
698            scale: 1.0,
699            id: 0,
700            resonance: Default::default(),
701            live: std::sync::atomic::AtomicBool::new(true),
702        };
703        let mut out = vec![1.0f32; 2];
704        quiet.add(&[1.0, 1.0], 1, &mut out, None);
705        assert!(
706            !quiet.live(),
707            "a 1e-6 branch on a unit base must be routed off"
708        );
709        let loud = LoraBranch {
710            a: vec![1.0; 2],
711            b: vec![1.0; 2],
712            rank: 1,
713            inn: 2,
714            out: 2,
715            scale: 1.0,
716            id: 0,
717            resonance: Default::default(),
718            live: std::sync::atomic::AtomicBool::new(true),
719        };
720        let mut out = vec![1.0f32; 2];
721        loud.add(&[1.0, 1.0], 1, &mut out, None);
722        assert!(loud.live(), "a branch the size of the base must stay");
723        assert!(loud.resonance() > 1.0);
724        set_route_threshold(None);
725    }
726
727    /// A zero-rank-B adapter must be the identity on the output.
728    #[test]
729    fn zero_b_changes_nothing() {
730        let br = LoraBranch {
731            a: vec![1.0; 4],
732            b: vec![0.0; 6],
733            rank: 2,
734            inn: 2,
735            out: 3,
736            scale: 1.0,
737            id: 0,
738            resonance: Default::default(),
739            live: std::sync::atomic::AtomicBool::new(true),
740        };
741        let mut out = vec![7.0f32; 3];
742        br.add(&[1.0, 2.0], 1, &mut out, None);
743        assert_eq!(out, vec![7.0, 7.0, 7.0]);
744    }
745
746    /// A file's names must land on the container's projections: the adapters
747    /// write `diffusion_model.transformer_blocks.N.attn1.to_q.lora_A.weight`
748    /// and the container calls that tensor
749    /// `dit.transformer_blocks.N.attn1.to_q.weight`, so the bank has to strip
750    /// one prefix and the lookup the other.
751    #[test]
752    fn names_bind_to_container_projections() {
753        use std::io::Write;
754        let dir = std::env::temp_dir().join("cmf_lora_name_test");
755        std::fs::create_dir_all(&dir).unwrap();
756        let path = dir.join("tiny.safetensors");
757        // rank 2, in 3, out 4 — one pair, plus a slot embedding
758        let names = [
759            (
760                "diffusion_model.transformer_blocks.0.attn1.to_q.lora_A.weight",
761                vec![2usize, 3],
762            ),
763            (
764                "diffusion_model.transformer_blocks.0.attn1.to_q.lora_B.weight",
765                vec![4, 2],
766            ),
767        ];
768        let mut header = serde_json::Map::new();
769        let mut blob: Vec<u8> = Vec::new();
770        for (n, shape) in &names {
771            let count: usize = shape.iter().product();
772            let start = blob.len();
773            for i in 0..count {
774                blob.extend_from_slice(&(i as f32 * 0.25).to_le_bytes());
775            }
776            header.insert(
777                (*n).to_string(),
778                serde_json::json!({
779                    "dtype": "F32",
780                    "shape": shape,
781                    "data_offsets": [start, blob.len()],
782                }),
783            );
784        }
785        let hdr = serde_json::to_vec(&serde_json::Value::Object(header)).unwrap();
786        let mut f = std::fs::File::create(&path).unwrap();
787        f.write_all(&(hdr.len() as u64).to_le_bytes()).unwrap();
788        f.write_all(&hdr).unwrap();
789        f.write_all(&blob).unwrap();
790        drop(f);
791
792        let bank = LoraBank::load(&path, 1.0).expect("load");
793        assert_eq!(bank.len(), 1);
794        assert_eq!(bank.rank(), 2);
795        assert!(bank.slot.is_none());
796        let br = bank
797            .branch("dit.transformer_blocks.0.attn1.to_q")
798            .expect("the container's name must find the adapter's branch");
799        assert_eq!(br.rank(), 2);
800        assert!(bank.branch("dit.transformer_blocks.0.attn1.to_k").is_none());
801        // and the branch computes: x = [1,0,0] picks A's first column
802        let mut out = vec![0f32; 4];
803        br.add(&[1.0, 0.0, 0.0], 1, &mut out, None);
804        // A = [[0,.25,.5],[.75,1,1.25]] so h = [0, .75]
805        // B = [[0,.25],[.5,.75],[1,1.25],[1.5,1.75]] so out = .75 * B[:,1]
806        let want = [0.25, 0.75, 1.25, 1.75].map(|v: f32| v * 0.75);
807        for (g, w) in out.iter().zip(&want) {
808            assert!((g - w).abs() < 1e-5, "{g} vs {w}");
809        }
810        let _ = std::fs::remove_dir_all(&dir);
811    }
812
813    /// A file with only one side of a pair is a broken adapter, and must say
814    /// so rather than render as if the branch were zero.
815    #[test]
816    fn orphan_side_is_refused() {
817        use std::io::Write;
818        let dir = std::env::temp_dir().join("cmf_lora_orphan_test");
819        std::fs::create_dir_all(&dir).unwrap();
820        let path = dir.join("orphan.safetensors");
821        let mut header = serde_json::Map::new();
822        let mut blob: Vec<u8> = Vec::new();
823        for i in 0..6 {
824            blob.extend_from_slice(&(i as f32).to_le_bytes());
825        }
826        header.insert(
827            "diffusion_model.transformer_blocks.0.attn1.to_q.lora_A.weight".to_string(),
828            serde_json::json!({"dtype":"F32","shape":[2,3],"data_offsets":[0,24]}),
829        );
830        let hdr = serde_json::to_vec(&serde_json::Value::Object(header)).unwrap();
831        let mut f = std::fs::File::create(&path).unwrap();
832        f.write_all(&(hdr.len() as u64).to_le_bytes()).unwrap();
833        f.write_all(&hdr).unwrap();
834        f.write_all(&blob).unwrap();
835        drop(f);
836        let err = match LoraBank::load(&path, 1.0) {
837            Err(e) => e,
838            Ok(_) => panic!("an adapter with a lone A side must be refused"),
839        };
840        assert!(err.contains("no B side"), "{err}");
841        let _ = std::fs::remove_dir_all(&dir);
842    }
843
844    /// The slot embedding's feature vector is `[v, sin(v·f), cos(v·f)]` with
845    /// `v = slot/16` — check it against a hand-evaluated one-frequency MLP.
846    #[test]
847    fn slot_embedding_matches_definition() {
848        let s = SlotEmbed {
849            freqs: vec![2.0],
850            // hidden = 1, input width 3
851            w0: vec![1.0, 0.5, -0.25],
852            b0: vec![0.1],
853            w2: vec![2.0],
854            b2: vec![-0.3],
855            hidden: 1,
856            dim: 1,
857        };
858        let v = 3.0f32 / 16.0;
859        let feat = [v, (v * 2.0).sin(), (v * 2.0).cos()];
860        let pre = 0.1 + feat[0] * 1.0 + feat[1] * 0.5 + feat[2] * -0.25;
861        let hid = pre / (1.0 + (-pre).exp());
862        let want = -0.3 + hid * 2.0;
863        let got = s.embed(3);
864        assert_eq!(got.len(), 1);
865        assert!((got[0] - want).abs() < 1e-6, "{} vs {want}", got[0]);
866    }
867}