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