Skip to main content

cortiq_engine/
qwen3te.rs

1//! Qwen3-VL as MiniMax-H3's prompt encoder: token ids → the
2//! unnormalized hidden state after layer 50.
3//!
4//! The conditioning checkpoint is the 32 B model truncated at layer 50,
5//! and the DiT consumes that stream directly — no final norm, no LM
6//! head, and no chat template either: the H3 presentation is raw prompt
7//! text with no special tokens at all.
8//!
9//! Same shape as the Gemma encoder next door and deliberately as plain:
10//! a full-sequence causal forward, no KV cache, no sampling. Qwen3
11//! specifics carried exactly — per-head RMSNorm on q and k BEFORE the
12//! rotation, GQA 64/8, split-half RoPE at θ=5e6, SwiGLU, plain-w
13//! RMSNorm (not Gemma's 1+w) and no embedding scale.
14//!
15//! The rotation is Qwen3-VL's interleaved MRoPE. A text token's three
16//! axis positions are equal, so every frequency slot sees the same
17//! angle and the interleave is invisible; an image span pins the time
18//! axis and lays its merged grid out on the other two, and the tokens
19//! after it resume from the grid's larger side rather than from the
20//! span's length. `encode` is the text-only entry point and
21//! `encode_with_images` the general one.
22//!
23//! Deepstack features go in at LM layers 0, 1, 2 — the FIRST layers.
24//! `deepstack_visual_indexes` (8, 16, 24) names the vision layers the
25//! features are taken FROM, which is a different list and an easy one
26//! to conflate.
27
28use crate::dit::Proj;
29use crate::pool::Pool;
30use crate::qtensor::QTensor;
31use cortiq_core::CmfModel;
32use std::sync::Arc;
33
34struct Layer {
35    input_norm: Vec<f32>,
36    q: Proj,
37    k: Proj,
38    v: Proj,
39    o: Proj,
40    q_norm: Vec<f32>, // [head_dim]
41    k_norm: Vec<f32>,
42    post_attn_norm: Vec<f32>,
43    gate: Proj,
44    up: Proj,
45    down: Proj,
46}
47
48pub struct Qwen3Encoder {
49    embed: QTensor,
50    layers: Vec<Layer>,
51    final_norm: Option<Vec<f32>>,
52    proj: Option<ClipProj>,
53    /// Optional vision-row twin (`te.proj.vis.*`): a projection fitted
54    /// on VISION activations, routed to image-span rows only. The base
55    /// projection was fitted on text and holds R^2 0.65 there while
56    /// actively wrong on vision (-0.57); one affine map cannot serve
57    /// both distributions — measured, 2026-08-15.
58    proj_vis: Option<ClipProj>,
59    pool: Option<Arc<Pool>>,
60    pub hidden: usize,
61    nh: usize,
62    nkv: usize,
63    hd: usize,
64    theta: f32,
65    eps: f64,
66}
67
68fn rms_norm_into(x: &[f32], w: &[f32], eps: f64, dst: &mut [f32]) {
69    let ss = x.iter().map(|&v| (v as f64) * (v as f64)).sum::<f64>() / x.len() as f64;
70    let inv = 1.0 / (ss + eps).sqrt();
71    for ((d, &v), &g) in dst.iter_mut().zip(x).zip(w) {
72        *d = (v as f64 * inv) as f32 * g;
73    }
74}
75
76fn silu(v: f32) -> f32 {
77    v / (1.0 + (-v).exp())
78}
79
80struct SendPtr(*mut f32);
81unsafe impl Send for SendPtr {}
82unsafe impl Sync for SendPtr {}
83impl SendPtr {
84    /// SAFETY: caller guarantees disjoint `[off, off+len)` per worker.
85    #[allow(clippy::mut_from_ref)]
86    unsafe fn row(&self, off: usize, len: usize) -> &mut [f32] {
87        unsafe { std::slice::from_raw_parts_mut(self.0.add(off), len) }
88    }
89}
90
91/// Gauss error function, Abramowitz & Stegun 7.1.26 (|ε| < 1.5e-7).
92/// `nn.GELU()` with no `approximate=` is the erf form, and the residual
93/// this feeds was fitted through exactly that.
94fn erf(x: f64) -> f64 {
95    let s = if x < 0.0 { -1.0 } else { 1.0 };
96    let x = x.abs();
97    let t = 1.0 / (1.0 + 0.327_591_1 * x);
98    let y = 1.0
99        - (((((1.061_405_429 * t - 1.453_152_027) * t) + 1.421_413_741) * t - 0.284_496_736) * t
100            + 0.254_829_592)
101            * t
102            * (-x * x).exp();
103    s * y
104}
105
106fn gelu(v: f32) -> f32 {
107    (0.5 * v as f64 * (1.0 + erf(v as f64 / std::f64::consts::SQRT_2))) as f32
108}
109
110/// The GELU residual of a `-mlp` ClipProj: `d_in → hidden → d_out`,
111/// added to the ridge matrix's output in the STANDARDIZED space.
112struct ProjMlp {
113    w0: Vec<f32>, // [hidden, d_in], torch Linear layout
114    b0: Vec<f32>,
115    w2: Vec<f32>, // [d_out, hidden]
116    b2: Vec<f32>,
117    hidden: usize,
118}
119
120/// ClipProj: the fitted map that lets a SMALL Qwen3-VL stand in for the
121/// 32 B prompt encoder.
122///
123/// ```text
124/// cond = ((h - mean_in) / std_in) @ W [+ mlp(...)] * std_out + mean_out
125/// ```
126///
127/// Token 0 is not projected but OVERWRITTEN with `sink_out`: it is the
128/// attention sink, an outlier the ridge fit cannot represent and would
129/// otherwise smear across the whole conditioning.
130struct ClipProj {
131    w: Vec<f32>, // [d_in, d_out], so the product is a plain row sweep
132    mean_in: Vec<f32>,
133    std_in: Vec<f32>,
134    mean_out: Vec<f32>,
135    std_out: Vec<f32>,
136    sink_out: Vec<f32>,
137    mlp: Option<ProjMlp>,
138    d_in: usize,
139    d_out: usize,
140}
141
142impl ClipProj {
143    /// `[n, d_in]` tapped hidden state → `[n, d_out]` conditioning.
144    fn apply(&self, h: &[f32], n: usize, pool: Option<&Pool>) -> Vec<f32> {
145        self.apply_inner(h, n, pool, true)
146    }
147
148    /// `sink=false` skips the token-0 overwrite — for span-gathered
149    /// rows, whose first row is NOT the sequence's attention sink.
150    fn apply_inner(&self, h: &[f32], n: usize, pool: Option<&Pool>, sink: bool) -> Vec<f32> {
151        let (di, dout) = (self.d_in, self.d_out);
152        let mut out = vec![0f32; n * dout];
153        let ptr = SendPtr(out.as_mut_ptr());
154        let work = |lo: usize, hi: usize| {
155            let mut xn = vec![0f32; di];
156            let mut g = vec![0f32; self.mlp.as_ref().map_or(0, |m| m.hidden)];
157            for p in lo..hi {
158                let hp = &h[p * di..(p + 1) * di];
159                for i in 0..di {
160                    xn[i] = (hp[i] - self.mean_in[i]) / self.std_in[i];
161                }
162                // SAFETY: workers own disjoint token ranges.
163                let o = unsafe { ptr.row(p * dout, dout) };
164                o.fill(0.0);
165                for i in 0..di {
166                    let x = xn[i];
167                    let row = &self.w[i * dout..(i + 1) * dout];
168                    for (d, &wv) in o.iter_mut().zip(row) {
169                        *d += x * wv;
170                    }
171                }
172                if let Some(m) = &self.mlp {
173                    for (oi, gv) in g.iter_mut().enumerate() {
174                        let row = &m.w0[oi * di..(oi + 1) * di];
175                        let mut s = m.b0[oi];
176                        for (&r, &x) in row.iter().zip(&xn) {
177                            s += r * x;
178                        }
179                        *gv = gelu(s);
180                    }
181                    for (j, d) in o.iter_mut().enumerate() {
182                        let row = &m.w2[j * m.hidden..(j + 1) * m.hidden];
183                        let mut s = m.b2[j];
184                        for (&r, &gv) in row.iter().zip(&g) {
185                            s += r * gv;
186                        }
187                        *d += s;
188                    }
189                }
190                for (j, d) in o.iter_mut().enumerate() {
191                    *d = *d * self.std_out[j] + self.mean_out[j];
192                }
193            }
194        };
195        match pool {
196            Some(pl) => pl.run_rows(n, &work),
197            None => work(0, n),
198        }
199        if sink && n > 0 {
200            out[..dout].copy_from_slice(&self.sink_out);
201        }
202        out
203    }
204}
205
206/// One image spliced into the prompt: where its tokens start, how many
207/// there are, and the merged patch grid they came from.
208pub struct ImageSpan {
209    pub start: usize,
210    pub len: usize,
211    pub merged_h: usize,
212    pub merged_w: usize,
213}
214
215impl Qwen3Encoder {
216    pub fn from_cmf(model: &Arc<CmfModel>) -> Result<Self, String> {
217        let cfg: serde_json::Value = serde_json::from_slice(
218            model
219                .tensor_bytes("te.config_json")
220                .map_err(|e| e.to_string())?,
221        )
222        .map_err(|e| format!("te.config_json: {e}"))?;
223        let u = |k: &str, d: usize| cfg[k].as_u64().map(|v| v as usize).unwrap_or(d);
224        // `CMF_TE_TAP` runs FEWER layers than the file carries. The tap
225        // a ClipProj was fitted on is an index, and index conventions
226        // differ by one between frameworks; packing one layer spare and
227        // calibrating against the teacher beats repacking to find out.
228        let nl = match std::env::var("CMF_TE_TAP")
229            .ok()
230            .and_then(|v| v.parse().ok())
231        {
232            Some(t) if t > 0 && t <= u("num_hidden_layers", 0) => t,
233            _ => u("num_hidden_layers", 0),
234        };
235        let f32v = |n: &str| crate::dit::cmf_f32(model, n);
236        let mut layers = Vec::with_capacity(nl);
237        for l in 0..nl {
238            let p = format!("te.layers.{l}");
239            layers.push(Layer {
240                input_norm: f32v(&format!("{p}.input_layernorm.weight"))?,
241                q: Proj::from_model(model, &format!("{p}.self_attn.q_proj.weight"))?,
242                k: Proj::from_model(model, &format!("{p}.self_attn.k_proj.weight"))?,
243                v: Proj::from_model(model, &format!("{p}.self_attn.v_proj.weight"))?,
244                o: Proj::from_model(model, &format!("{p}.self_attn.o_proj.weight"))?,
245                q_norm: f32v(&format!("{p}.self_attn.q_norm.weight"))?,
246                k_norm: f32v(&format!("{p}.self_attn.k_norm.weight"))?,
247                post_attn_norm: f32v(&format!("{p}.post_attention_layernorm.weight"))?,
248                gate: Proj::from_model(model, &format!("{p}.mlp.gate_proj.weight"))?,
249                up: Proj::from_model(model, &format!("{p}.mlp.up_proj.weight"))?,
250                down: Proj::from_model(model, &format!("{p}.mlp.down_proj.weight"))?,
251            });
252        }
253        let hidden = u("hidden_size", 0);
254        let proj = match model.tensor_bytes("te.proj.config_json") {
255            Ok(b) => {
256                let pc: serde_json::Value =
257                    serde_json::from_slice(b).map_err(|e| format!("te.proj.config_json: {e}"))?;
258                let pu = |k: &str| pc[k].as_u64().unwrap_or(0) as usize;
259                let (d_in, d_out) = (pu("d_in"), pu("d_out"));
260                if d_in != hidden {
261                    return Err(format!(
262                        "te.proj expects a {d_in}-wide encoder, the packed one is {hidden}: \
263                         the projection and the encoder come from different models"
264                    ));
265                }
266                let mlp = match pc["mlp"].as_bool() {
267                    Some(true) => Some(ProjMlp {
268                        w0: f32v("te.proj.mlp.0.weight")?,
269                        b0: f32v("te.proj.mlp.0.bias")?,
270                        w2: f32v("te.proj.mlp.2.weight")?,
271                        b2: f32v("te.proj.mlp.2.bias")?,
272                        hidden: pu("mlp_hidden"),
273                    }),
274                    _ => None,
275                };
276                Some(ClipProj {
277                    w: f32v("te.proj.W")?,
278                    mean_in: f32v("te.proj.mean_in")?,
279                    std_in: f32v("te.proj.std_in")?,
280                    mean_out: f32v("te.proj.mean_out")?,
281                    std_out: f32v("te.proj.std_out")?,
282                    sink_out: f32v("te.proj.sink_out")?,
283                    mlp,
284                    d_in,
285                    d_out,
286                })
287            }
288            Err(_) => None,
289        };
290        let proj_vis = match (&proj, model.tensor_bytes("te.proj.vis.W").is_ok()) {
291            (Some(base), true) => {
292                let g = |n: &str| crate::dit::cmf_f32(model, n);
293                Some(ClipProj {
294                    w: g("te.proj.vis.W")?,
295                    mean_in: g("te.proj.vis.mean_in")?,
296                    std_in: g("te.proj.vis.std_in")?,
297                    mean_out: g("te.proj.vis.mean_out")?,
298                    std_out: g("te.proj.vis.std_out")?,
299                    // vision rows never include token 0; the base sink is
300                    // authoritative. Kept for struct uniformity.
301                    sink_out: base.sink_out.clone(),
302                    mlp: None,
303                    d_in: base.d_in,
304                    d_out: base.d_out,
305                })
306            }
307            _ => None,
308        };
309        Ok(Self {
310            embed: QTensor::from_model(model, "te.embed_tokens.weight")?,
311            layers,
312            final_norm: match cfg["final_norm"].as_bool() {
313                Some(false) | None => None,
314                Some(true) => Some(f32v("te.norm.weight")?),
315            },
316            proj,
317            proj_vis,
318            pool: Pool::from_env(),
319            hidden,
320            nh: u("num_attention_heads", 0),
321            nkv: u("num_key_value_heads", 0),
322            hd: u("head_dim", 128),
323            theta: cfg["rope_theta"].as_f64().unwrap_or(5e6) as f32,
324            eps: cfg["rms_norm_eps"].as_f64().unwrap_or(1e-6),
325        })
326    }
327
328    /// Per-head RMSNorm then split-half RoPE over the whole head, in
329    /// place across a `[n, heads·hd]` buffer.
330    fn norm_rope(&self, all: &mut [f32], n: usize, heads: usize, w: &[f32], pos: &[[f32; 3]]) {
331        let hd = self.hd;
332        let half = hd / 2;
333        let pool = self.pool.as_deref();
334        let ptr = SendPtr(all.as_mut_ptr());
335        let work = |lo: usize, hi: usize| {
336            for p in lo..hi {
337                for h in 0..heads {
338                    // SAFETY: workers own disjoint token ranges.
339                    let x = unsafe { ptr.row((p * heads + h) * hd, hd) };
340                    let ss = x.iter().map(|&v| (v as f64) * (v as f64)).sum::<f64>() / hd as f64;
341                    let inv = 1.0 / (ss + self.eps).sqrt();
342                    for (d, &g) in x.iter_mut().zip(w) {
343                        *d = (*d as f64 * inv) as f32 * g;
344                    }
345                    for i in 0..half {
346                        let freq = 1.0 / self.theta.powf(2.0 * i as f32 / hd as f32);
347                        // Qwen3-VL's interleaved MRoPE: the time axis by
348                        // default, with height and width taking every
349                        // third slot below 3·rope_dims. All three axes
350                        // carry the same value on a text token, so this
351                        // is `p` there whatever the slot.
352                        let axis = if i < 60 && i % 3 == 1 {
353                            1
354                        } else if i < 60 && i % 3 == 2 {
355                            2
356                        } else {
357                            0
358                        };
359                        let (s, c) = (pos[p][axis] * freq).sin_cos();
360                        let (a, b) = (x[i], x[i + half]);
361                        x[i] = a * c - b * s;
362                        x[i + half] = a * s + b * c;
363                    }
364                }
365            }
366        };
367        match pool {
368            Some(pl) => pl.run_rows(n, &work),
369            None => work(0, n),
370        }
371    }
372
373    /// Causal full-sequence forward. Returns `[n, hidden]` — the
374    /// residual stream leaving the last layer, normed only if the
375    /// config says the checkpoint has a final norm (H3's does not).
376    pub fn encode(&self, ids: &[u32]) -> Vec<f32> {
377        self.encode_with_images(ids, &[], &[], &[])
378    }
379
380    /// The 3-axis MRoPE position of every token.
381    ///
382    /// Text runs sequentially on all three axes; an image span pins the
383    /// time axis and lays its merged grid out on the other two, and
384    /// everything after it resumes from the grid's larger side rather
385    /// than from the span's length. With no image the three axes are
386    /// equal and this reduces to `0..n` — which is why the text-only
387    /// path can ignore the interleave entirely.
388    fn mrope_positions(&self, n: usize, spans: &[ImageSpan]) -> Vec<[f32; 3]> {
389        let mut pos: Vec<[f32; 3]> = (0..n).map(|i| [i as f32; 3]).collect();
390        let mut offset = 0i64;
391        for sp in spans {
392            let (start, end) = (sp.start, sp.start + sp.len);
393            let len_max = sp.merged_h.max(sp.merged_w) as i64;
394            let base = start as i64 + offset;
395            for i in start..end {
396                let k = i - start;
397                pos[i] = [
398                    base as f32,
399                    (base + (k / sp.merged_w) as i64) as f32,
400                    (base + (k % sp.merged_w) as i64) as f32,
401                ];
402            }
403            let next = len_max + start as i64;
404            for (j, p) in pos.iter_mut().enumerate().skip(end) {
405                let v = (next + offset + (j - end) as i64) as f32;
406                *p = [v; 3];
407            }
408            offset += len_max - sp.len as i64;
409        }
410        pos
411    }
412
413    /// Full forward with images. `embeds` replaces the token embedding
414    /// at `[span.start, span.start + span.len)` with the vision tower's
415    /// merged tokens; `deepstack[k]` is added at the visual positions
416    /// after LM layer k — the first layers, not the vision layers the
417    /// features were taken from.
418    pub fn encode_with_images(
419        &self,
420        ids: &[u32],
421        spans: &[ImageSpan],
422        embeds: &[Vec<f32>],
423        deepstack: &[Vec<f32>],
424    ) -> Vec<f32> {
425        let n = ids.len();
426        let hs = self.hidden;
427        let (nh, nkv, hd) = (self.nh, self.nkv, self.hd);
428        let hpk = nh / nkv;
429        let pool = self.pool.as_deref();
430        let scale = 1.0 / (hd as f32).sqrt();
431
432        let mut h = vec![0f32; n * hs];
433        for (i, &id) in ids.iter().enumerate() {
434            self.embed
435                .row_f32(id as usize, &mut h[i * hs..(i + 1) * hs]);
436        }
437        for (sp, e) in spans.iter().zip(embeds) {
438            h[sp.start * hs..(sp.start + sp.len) * hs].copy_from_slice(&e[..sp.len * hs]);
439        }
440        let pos = self.mrope_positions(n, spans);
441        // Which rows a deepstack feature lands on, in span order.
442        let visual: Vec<usize> = spans
443            .iter()
444            .flat_map(|s| s.start..s.start + s.len)
445            .collect();
446        let mut xn = vec![0f32; n * hs];
447        let mut q_all = vec![0f32; n * nh * hd];
448        let mut k_all = vec![0f32; n * nkv * hd];
449        let mut v_all = vec![0f32; n * nkv * hd];
450        let mut attn = vec![0f32; n * nh * hd];
451        let mut proj = vec![0f32; n * hs];
452
453        for (li, layer) in self.layers.iter().enumerate() {
454            for (o, src) in xn.chunks_exact_mut(hs).zip(h.chunks_exact(hs)) {
455                rms_norm_into(src, &layer.input_norm, self.eps, o);
456            }
457            layer.q.matmat(&xn, n, &mut q_all, pool);
458            layer.k.matmat(&xn, n, &mut k_all, pool);
459            layer.v.matmat(&xn, n, &mut v_all, pool);
460            self.norm_rope(&mut q_all, n, nh, &layer.q_norm, &pos);
461            self.norm_rope(&mut k_all, n, nkv, &layer.k_norm, &pos);
462
463            attn.fill(0.0);
464            let ap = SendPtr(attn.as_mut_ptr());
465            let heads = |lo: usize, hi: usize| {
466                let mut row = vec![0f32; n];
467                for hh in lo..hi {
468                    let kv = hh / hpk;
469                    for p in 0..n {
470                        let qv = &q_all[(p * nh + hh) * hd..(p * nh + hh + 1) * hd];
471                        for (j, r) in row[..=p].iter_mut().enumerate() {
472                            let kvv = &k_all[(j * nkv + kv) * hd..(j * nkv + kv + 1) * hd];
473                            *r = qv.iter().zip(kvv).map(|(&a, &b)| a * b).sum::<f32>() * scale;
474                        }
475                        let mx = row[..=p].iter().cloned().fold(f32::MIN, f32::max);
476                        let mut den = 0f32;
477                        for r in row[..=p].iter_mut() {
478                            *r = (*r - mx).exp();
479                            den += *r;
480                        }
481                        let inv = 1.0 / den;
482                        // SAFETY: workers own disjoint head ranges, and
483                        // one head's slice is disjoint per token.
484                        let out = unsafe { ap.row((p * nh + hh) * hd, hd) };
485                        for (j, &rw) in row[..=p].iter().enumerate() {
486                            let vv = &v_all[(j * nkv + kv) * hd..(j * nkv + kv + 1) * hd];
487                            let wgt = rw * inv;
488                            for (o, &s) in out.iter_mut().zip(vv) {
489                                *o += wgt * s;
490                            }
491                        }
492                    }
493                }
494            };
495            match pool {
496                Some(pl) => pl.run_rows(nh, &heads),
497                None => heads(0, nh),
498            }
499
500            layer.o.matmat(&attn, n, &mut proj, pool);
501            for (d, &v) in h.iter_mut().zip(&proj) {
502                *d += v;
503            }
504
505            for (o, src) in xn.chunks_exact_mut(hs).zip(h.chunks_exact(hs)) {
506                rms_norm_into(src, &layer.post_attn_norm, self.eps, o);
507            }
508            let inter = layer.gate.rows();
509            let mut g = vec![0f32; n * inter];
510            let mut u = vec![0f32; n * inter];
511            layer.gate.matmat(&xn, n, &mut g, pool);
512            layer.up.matmat(&xn, n, &mut u, pool);
513            for (a, &b) in g.iter_mut().zip(&u) {
514                *a = silu(*a) * b;
515            }
516            layer.down.matmat(&g, n, &mut proj, pool);
517            for (d, &v) in h.iter_mut().zip(&proj) {
518                *d += v;
519            }
520            if let Some(f) = deepstack.get(li) {
521                for (k, &row) in visual.iter().enumerate() {
522                    for c in 0..hs {
523                        h[row * hs + c] += f[k * hs + c];
524                    }
525                }
526            }
527        }
528        if let Some(w) = &self.final_norm {
529            let mut out = vec![0f32; n * hs];
530            for (o, src) in out.chunks_exact_mut(hs).zip(h.chunks_exact(hs)) {
531                rms_norm_into(src, w, self.eps, o);
532            }
533            return out;
534        }
535        // A ClipProj file makes this a STAND-IN encoder: the stream
536        // leaving the tap is in the small model's space and the DiT
537        // never sees it raw.
538        // `CMF_CP_DUMP=<path>`: the RAW tapped hidden state, before the
539        // projection — one side of a (student, teacher) pair a refit
540        // regresses on. Same [u64 n][u64 width] + f32 rows framing as
541        // CMF_TE_DUMP, so one reader serves both.
542        if let (Ok(path), Some(_)) = (std::env::var("CMF_CP_DUMP"), &self.proj) {
543            let mut b = Vec::with_capacity(16 + h.len() * 4);
544            b.extend_from_slice(&(n as u64).to_le_bytes());
545            b.extend_from_slice(&(self.hidden as u64).to_le_bytes());
546            for v in &h {
547                b.extend_from_slice(&v.to_le_bytes());
548            }
549            if let Err(e) = std::fs::write(&path, &b) {
550                tracing::warn!("CMF_CP_DUMP {path}: {e}");
551            } else {
552                eprintln!("cp dump: {n} tokens x {} -> {path}", self.hidden);
553            }
554        }
555        match &self.proj {
556            Some(p) => {
557                let mut out = p.apply(&h, n, self.pool.as_deref());
558                // Vision rows go through their own map when the file
559                // ships one: gather span rows, project, scatter back.
560                if let Some(pv) = &self.proj_vis {
561                    if !visual.is_empty() {
562                        let (din, dout) = (pv.d_in, pv.d_out);
563                        let mut hv = Vec::with_capacity(visual.len() * din);
564                        for &r in &visual {
565                            hv.extend_from_slice(&h[r * din..(r + 1) * din]);
566                        }
567                        let ov = pv.apply_inner(&hv, visual.len(), self.pool.as_deref(), false);
568                        for (i, &r) in visual.iter().enumerate() {
569                            out[r * dout..(r + 1) * dout]
570                                .copy_from_slice(&ov[i * dout..(i + 1) * dout]);
571                        }
572                    }
573                }
574                out
575            }
576            None => h,
577        }
578    }
579
580    /// Width of what `encode` returns — the DiT's conditioning width,
581    /// which is the projection's output when one is packed and the
582    /// encoder's own hidden size otherwise.
583    pub fn out_hidden(&self) -> usize {
584        self.proj.as_ref().map_or(self.hidden, |p| p.d_out)
585    }
586}