Skip to main content

cortiq_engine/
qwen3vis.rs

1//! Qwen3-VL's vision tower — the half of `fl2va` that rides in the
2//! TEXT stream.
3//!
4//! MiniMax-H3 conditions on a keyframe twice: the VAE latent goes to the
5//! DiT as a condition row, and the picture itself goes to the prompt
6//! encoder as `"<Picture 1>: "` followed by a vision block. This is the
7//! second path — 27 ViT blocks at hidden 1152 over 16×16 patches, a
8//! patch merger down to the language model's 5120, and three deepstack
9//! mergers whose output is added into the LM's residual stream at layers
10//! 8, 16 and 24.
11//!
12//! Three conventions here are easy to get wrong and are therefore
13//! spelled out:
14//!
15//! * **The position grid is interpolated, not looked up.** The table is
16//!   48×48 and an image is whatever it is, so each patch reads four
17//!   neighbours bilinearly — and the result is then reordered into
18//!   2×2 merge blocks, because that is the order the merger consumes.
19//! * **The rotation is 2-D.** Every patch carries a (row, column) pair,
20//!   each contributing 18 angles; the 36 are concatenated with
21//!   themselves to 72 and applied split-half over the whole head.
22//! * **Two different GELUs.** The per-block MLP uses the tanh
23//!   approximation; both mergers use the exact one. They are not
24//!   interchangeable at this width.
25//!
26//! Parity: `tools/mk_qwen3vis_toy.py` against ComfyUI's own
27//! `Qwen3VLVisionModel`.
28
29use crate::dit::Proj;
30use crate::pool::Pool;
31use cortiq_core::CmfModel;
32use std::sync::Arc;
33
34/// Side of the learned position grid (2304 entries).
35const GRID_SIDE: usize = 48;
36const EPS: f64 = 1e-6;
37
38struct Block {
39    n1_w: Vec<f32>,
40    n1_b: Vec<f32>,
41    n2_w: Vec<f32>,
42    n2_b: Vec<f32>,
43    qkv: Proj,
44    qkv_b: Vec<f32>,
45    proj: Proj,
46    proj_b: Vec<f32>,
47    fc1: Proj,
48    fc1_b: Vec<f32>,
49    fc2: Proj,
50    fc2_b: Vec<f32>,
51}
52
53/// A merger: LayerNorm, then two linears with an exact GELU between.
54/// The main one norms BEFORE the 2×2 shuffle, the deepstack ones after —
55/// which is the only thing that distinguishes them.
56struct Merger {
57    n_w: Vec<f32>,
58    n_b: Vec<f32>,
59    fc1: Proj,
60    fc1_b: Vec<f32>,
61    fc2: Proj,
62    fc2_b: Vec<f32>,
63    post_shuffle: bool,
64}
65
66pub struct VisionTower {
67    patch: Proj, // [hidden, in·t·p·p] — the conv is a linear per patch
68    patch_b: Vec<f32>,
69    pos: Vec<f32>, // [GRID_SIDE², hidden]
70    blocks: Vec<Block>,
71    merger: Merger,
72    deepstack: Vec<Merger>,
73    deepstack_at: Vec<usize>,
74    pool: Option<Arc<Pool>>,
75    pub hidden: usize,
76    pub out_hidden: usize,
77    heads: usize,
78    head_dim: usize,
79    pub patch_size: usize,
80    pub temporal_patch: usize,
81    pub merge: usize,
82}
83
84fn layer_norm(x: &[f32], w: &[f32], b: &[f32], dst: &mut [f32]) {
85    let n = x.len() as f64;
86    let mean = x.iter().map(|&v| v as f64).sum::<f64>() / n;
87    let var = x.iter().map(|&v| (v as f64 - mean).powi(2)).sum::<f64>() / n;
88    let inv = 1.0 / (var + EPS).sqrt();
89    for (((d, &v), &g), &bb) in dst.iter_mut().zip(x).zip(w).zip(b) {
90        *d = ((v as f64 - mean) * inv) as f32 * g + bb;
91    }
92}
93
94/// `x·Φ(x)` with the error function — what `F.gelu` does by default.
95fn gelu_exact(v: f32) -> f32 {
96    0.5 * v * (1.0 + erf(v as f64 / std::f64::consts::SQRT_2) as f32)
97}
98
99/// The tanh approximation — what the per-block MLP asks for by name.
100fn gelu_tanh(v: f32) -> f32 {
101    const C: f32 = 0.797_884_6; // √(2/π)
102    0.5 * v * (1.0 + (C * (v + 0.044715 * v * v * v)).tanh())
103}
104
105/// `erf` by the Numerical Recipes rational form — 1.2e-7 relative
106/// everywhere, which is comfortably under what an f32 activation can
107/// carry. Written out because the alternatives here are a series that
108/// loses digits to cancellation past |x| = 3 and a continued fraction
109/// that is easy to get subtly wrong; this one is checked against known
110/// values below.
111fn erf(x: f64) -> f64 {
112    let z = x.abs();
113    let t = 1.0 / (1.0 + 0.5 * z);
114    let ans = t
115        * (-z * z - 1.265_512_23
116            + t * (1.000_023_68
117                + t * (0.374_091_96
118                    + t * (0.096_784_18
119                        + t * (-0.186_288_06
120                            + t * (0.278_868_07
121                                + t * (-1.135_203_98
122                                    + t * (1.488_515_87
123                                        + t * (-0.822_152_23 + t * 0.170_872_77)))))))))
124            .exp();
125    // `ans` is erfc(|x|); erfc is 2 − that on the negative side.
126    if x >= 0.0 { 1.0 - ans } else { ans - 1.0 }
127}
128
129struct SendPtr(*mut f32);
130unsafe impl Send for SendPtr {}
131unsafe impl Sync for SendPtr {}
132impl SendPtr {
133    /// SAFETY: caller guarantees disjoint `[off, off+len)` per worker.
134    #[allow(clippy::mut_from_ref)]
135    unsafe fn row(&self, off: usize, len: usize) -> &mut [f32] {
136        unsafe { std::slice::from_raw_parts_mut(self.0.add(off), len) }
137    }
138}
139
140fn softmax_inplace(row: &mut [f32]) {
141    let mx = row.iter().cloned().fold(f32::MIN, f32::max);
142    let mut den = 0f32;
143    for r in row.iter_mut() {
144        *r = (*r - mx).exp();
145        den += *r;
146    }
147    if den > 0.0 {
148        let inv = 1.0 / den;
149        for r in row.iter_mut() {
150            *r *= inv;
151        }
152    }
153}
154
155impl Merger {
156    fn load(model: &Arc<CmfModel>, prefix: &str, post_shuffle: bool) -> Result<Self, String> {
157        let f = |n: &str| crate::dit::cmf_f32(model, n);
158        Ok(Self {
159            n_w: f(&format!("{prefix}.norm.weight"))?,
160            n_b: f(&format!("{prefix}.norm.bias"))?,
161            fc1: Proj::from_model(model, &format!("{prefix}.linear_fc1.weight"))?,
162            fc1_b: f(&format!("{prefix}.linear_fc1.bias"))?,
163            fc2: Proj::from_model(model, &format!("{prefix}.linear_fc2.weight"))?,
164            fc2_b: f(&format!("{prefix}.linear_fc2.bias"))?,
165            post_shuffle,
166        })
167    }
168
169    /// `[n, hidden]` → `[n/merge², out]`. `merge_dim = hidden·merge²`.
170    fn apply(
171        &self,
172        x: &[f32],
173        n: usize,
174        hidden: usize,
175        merge_dim: usize,
176        pool: Option<&Pool>,
177    ) -> Vec<f32> {
178        let groups = n * hidden / merge_dim;
179        let mut buf = vec![0f32; n * hidden];
180        if self.post_shuffle {
181            // norm over the merged 4608, after the shuffle
182            for g in 0..groups {
183                layer_norm(
184                    &x[g * merge_dim..(g + 1) * merge_dim],
185                    &self.n_w,
186                    &self.n_b,
187                    &mut buf[g * merge_dim..(g + 1) * merge_dim],
188                );
189            }
190        } else {
191            // norm over the 1152 of each patch, before the shuffle
192            for p in 0..n {
193                layer_norm(
194                    &x[p * hidden..(p + 1) * hidden],
195                    &self.n_w,
196                    &self.n_b,
197                    &mut buf[p * hidden..(p + 1) * hidden],
198                );
199            }
200        }
201        let mut h = vec![0f32; groups * merge_dim];
202        self.fc1.matmat(&buf, groups, &mut h, pool);
203        for r in h.chunks_exact_mut(merge_dim) {
204            for (v, &b) in r.iter_mut().zip(&self.fc1_b) {
205                *v = gelu_exact(*v + b);
206            }
207        }
208        let out_dim = self.fc2.rows();
209        let mut out = vec![0f32; groups * out_dim];
210        self.fc2.matmat(&h, groups, &mut out, pool);
211        for r in out.chunks_exact_mut(out_dim) {
212            for (v, &b) in r.iter_mut().zip(&self.fc2_b) {
213                *v += b;
214            }
215        }
216        out
217    }
218}
219
220impl VisionTower {
221    pub fn from_cmf(model: &Arc<CmfModel>) -> Result<Self, String> {
222        let cfg: serde_json::Value = serde_json::from_slice(
223            model
224                .tensor_bytes("vis.config_json")
225                .map_err(|e| e.to_string())?,
226        )
227        .map_err(|e| format!("vis.config_json: {e}"))?;
228        let u = |k: &str, d: usize| cfg[k].as_u64().map(|v| v as usize).unwrap_or(d);
229        let n = u("depth", 27);
230        let f = |s: &str| crate::dit::cmf_f32(model, s);
231        let mut blocks = Vec::with_capacity(n);
232        for i in 0..n {
233            let p = format!("vis.blocks.{i}");
234            blocks.push(Block {
235                n1_w: f(&format!("{p}.norm1.weight"))?,
236                n1_b: f(&format!("{p}.norm1.bias"))?,
237                n2_w: f(&format!("{p}.norm2.weight"))?,
238                n2_b: f(&format!("{p}.norm2.bias"))?,
239                qkv: Proj::from_model(model, &format!("{p}.attn.qkv.weight"))?,
240                qkv_b: f(&format!("{p}.attn.qkv.bias"))?,
241                proj: Proj::from_model(model, &format!("{p}.attn.proj.weight"))?,
242                proj_b: f(&format!("{p}.attn.proj.bias"))?,
243                fc1: Proj::from_model(model, &format!("{p}.mlp.linear_fc1.weight"))?,
244                fc1_b: f(&format!("{p}.mlp.linear_fc1.bias"))?,
245                fc2: Proj::from_model(model, &format!("{p}.mlp.linear_fc2.weight"))?,
246                fc2_b: f(&format!("{p}.mlp.linear_fc2.bias"))?,
247            });
248        }
249        let deepstack_at: Vec<usize> = cfg["deepstack_visual_indexes"]
250            .as_array()
251            .map(|a| a.iter().map(|v| v.as_u64().unwrap_or(0) as usize).collect())
252            .unwrap_or_default();
253        let deepstack = (0..deepstack_at.len())
254            .map(|i| Merger::load(model, &format!("vis.deepstack.{i}"), true))
255            .collect::<Result<Vec<_>, _>>()?;
256        let hidden = u("hidden_size", 1152);
257        Ok(Self {
258            patch: Proj::from_model(model, "vis.patch_embed.weight")?,
259            patch_b: f("vis.patch_embed.bias")?,
260            pos: f("vis.pos_embed.weight")?,
261            blocks,
262            merger: Merger::load(model, "vis.merger", false)?,
263            deepstack,
264            deepstack_at,
265            pool: Pool::from_env(),
266            hidden,
267            out_hidden: u("out_hidden_size", 5120),
268            heads: u("num_heads", 16),
269            head_dim: hidden / u("num_heads", 16),
270            patch_size: u("patch_size", 16),
271            temporal_patch: u("temporal_patch_size", 2),
272            merge: u("spatial_merge_size", 2),
273        })
274    }
275
276    /// Bilinear read of the 48×48 grid at `h`×`w`, then reordered into
277    /// 2×2 merge blocks: `[h·w, hidden]` in the order the blocks see.
278    fn positions(&self, h: usize, w: usize) -> Vec<f32> {
279        let hd = self.hidden;
280        let m = self.merge;
281        let lin = |n: usize, i: usize| -> f64 {
282            if n == 1 {
283                0.0
284            } else {
285                i as f64 * (GRID_SIDE - 1) as f64 / (n - 1) as f64
286            }
287        };
288        let mut flat = vec![0f32; h * w * hd];
289        for y in 0..h {
290            let fy = lin(h, y);
291            let y0 = fy as usize;
292            let y1 = (y0 + 1).min(GRID_SIDE - 1);
293            let dy = (fy - y0 as f64) as f32;
294            for x in 0..w {
295                let fx = lin(w, x);
296                let x0 = fx as usize;
297                let x1 = (x0 + 1).min(GRID_SIDE - 1);
298                let dx = (fx - x0 as f64) as f32;
299                let dst = &mut flat[(y * w + x) * hd..(y * w + x + 1) * hd];
300                for (idx, wt) in [
301                    (y0 * GRID_SIDE + x0, (1.0 - dy) * (1.0 - dx)),
302                    (y0 * GRID_SIDE + x1, (1.0 - dy) * dx),
303                    (y1 * GRID_SIDE + x0, dy * (1.0 - dx)),
304                    (y1 * GRID_SIDE + x1, dy * dx),
305                ] {
306                    let src = &self.pos[idx * hd..(idx + 1) * hd];
307                    for (d, &s) in dst.iter_mut().zip(src) {
308                        *d += s * wt;
309                    }
310                }
311            }
312        }
313        // (h/m, m, w/m, m) → (h/m, w/m, m, m)
314        let mut out = vec![0f32; h * w * hd];
315        let mut k = 0usize;
316        for by in 0..h / m {
317            for bx in 0..w / m {
318                for iy in 0..m {
319                    for ix in 0..m {
320                        let s = ((by * m + iy) * w + bx * m + ix) * hd;
321                        out[k * hd..(k + 1) * hd].copy_from_slice(&flat[s..s + hd]);
322                        k += 1;
323                    }
324                }
325            }
326        }
327        out
328    }
329
330    /// 18 angles for the row index and 18 for the column, in the same
331    /// merge-block order: `[n, 36]`.
332    fn rope_angles(&self, h: usize, w: usize) -> Vec<f32> {
333        let half = self.head_dim / 2; // 36
334        let k = half / 2; // 18 frequencies per axis
335        let inv: Vec<f64> = (0..k)
336            .map(|i| 1.0 / 10000f64.powf(2.0 * i as f64 / half as f64))
337            .collect();
338        let m = self.merge;
339        let mut out = Vec::with_capacity(h * w * half);
340        for by in 0..h / m {
341            for bx in 0..w / m {
342                for iy in 0..m {
343                    for ix in 0..m {
344                        let (r, c) = ((by * m + iy) as f64, (bx * m + ix) as f64);
345                        for &f in &inv {
346                            out.push((r * f) as f32);
347                        }
348                        for &f in &inv {
349                            out.push((c * f) as f32);
350                        }
351                    }
352                }
353            }
354        }
355        out
356    }
357
358    fn attention(&self, qkv: &[f32], n: usize, angles: &[f32], out: &mut [f32]) {
359        let (nh, hd) = (self.heads, self.head_dim);
360        let inner = nh * hd;
361        let half = hd / 2;
362        let scale = 1.0 / (hd as f32).sqrt();
363        let pool = self.pool.as_deref();
364        let mut qh = vec![0f32; n * hd];
365        let mut kh = vec![0f32; n * hd];
366        let mut vt = vec![0f32; hd * n];
367        let mut scores = vec![0f32; n * n];
368        let mut oh = vec![0f32; n * hd];
369        for h in 0..nh {
370            for p in 0..n {
371                // qkv is [n, 3, heads, hd] — the head axis is innermost
372                // of the three, not three separate planes.
373                let base = p * 3 * inner;
374                let (qs, ks, vs) = (
375                    &qkv[base + h * hd..base + (h + 1) * hd],
376                    &qkv[base + inner + h * hd..base + inner + (h + 1) * hd],
377                    &qkv[base + 2 * inner + h * hd..base + 2 * inner + (h + 1) * hd],
378                );
379                let ang = &angles[p * half..(p + 1) * half];
380                // split-half over the whole head: angle j serves dims
381                // j and j+half, and the angle list is the 36 duplicated.
382                for j in 0..half {
383                    let (s, c) = ang[j].sin_cos();
384                    qh[p * hd + j] = (qs[j] * c - qs[j + half] * s) * scale;
385                    qh[p * hd + j + half] = (qs[j] * s + qs[j + half] * c) * scale;
386                    kh[p * hd + j] = ks[j] * c - ks[j + half] * s;
387                    kh[p * hd + j + half] = ks[j] * s + ks[j + half] * c;
388                }
389                for (d, &val) in vs.iter().enumerate() {
390                    vt[d * n + p] = val;
391                }
392            }
393            crate::fcd_ops::gemm_nt(&qh, &kh, &mut scores, n, hd, n, pool);
394            let sp = SendPtr(scores.as_mut_ptr());
395            let soft = |lo: usize, hi: usize| {
396                for r in lo..hi {
397                    // SAFETY: workers own disjoint score rows.
398                    softmax_inplace(unsafe { sp.row(r * n, n) });
399                }
400            };
401            match pool {
402                Some(p) => p.run_rows(n, &soft),
403                None => soft(0, n),
404            }
405            crate::fcd_ops::gemm_nt(&scores, &vt, &mut oh, n, n, hd, pool);
406            for p in 0..n {
407                out[p * inner + h * hd..p * inner + (h + 1) * hd]
408                    .copy_from_slice(&oh[p * hd..(p + 1) * hd]);
409            }
410        }
411    }
412
413    /// Flattened patches `[n, in·t·p·p]` on a `h`×`w` patch grid →
414    /// `(merged [n/4, out_hidden], deepstack [k][n/4, out_hidden])`.
415    pub fn forward(&self, patches: &[f32], h: usize, w: usize) -> (Vec<f32>, Vec<Vec<f32>>) {
416        let n = h * w;
417        let hd = self.hidden;
418        let pool = self.pool.as_deref();
419        let mut x = vec![0f32; n * hd];
420        self.patch.matmat(patches, n, &mut x, pool);
421        let pos = self.positions(h, w);
422        for (i, v) in x.iter_mut().enumerate() {
423            *v += self.patch_b[i % hd] + pos[i];
424        }
425        let angles = self.rope_angles(h, w);
426
427        let inner = self.heads * self.head_dim;
428        let inter = self.blocks[0].fc1.rows();
429        let mut xn = vec![0f32; n * hd];
430        let mut qkv = vec![0f32; n * 3 * inner];
431        let mut attn = vec![0f32; n * inner];
432        let mut proj = vec![0f32; n * hd];
433        let mut ff = vec![0f32; n * inter];
434        let merge_dim = hd * self.merge * self.merge;
435        let mut deep = Vec::new();
436        for (li, blk) in self.blocks.iter().enumerate() {
437            for (o, s) in xn.chunks_exact_mut(hd).zip(x.chunks_exact(hd)) {
438                layer_norm(s, &blk.n1_w, &blk.n1_b, o);
439            }
440            blk.qkv.matmat(&xn, n, &mut qkv, pool);
441            for r in qkv.chunks_exact_mut(3 * inner) {
442                for (v, &b) in r.iter_mut().zip(&blk.qkv_b) {
443                    *v += b;
444                }
445            }
446            self.attention(&qkv, n, &angles, &mut attn);
447            blk.proj.matmat(&attn, n, &mut proj, pool);
448            for (p, r) in proj.chunks_exact(hd).enumerate() {
449                for (i, &v) in r.iter().enumerate() {
450                    x[p * hd + i] += v + blk.proj_b[i];
451                }
452            }
453            for (o, s) in xn.chunks_exact_mut(hd).zip(x.chunks_exact(hd)) {
454                layer_norm(s, &blk.n2_w, &blk.n2_b, o);
455            }
456            blk.fc1.matmat(&xn, n, &mut ff, pool);
457            for r in ff.chunks_exact_mut(inter) {
458                for (v, &b) in r.iter_mut().zip(&blk.fc1_b) {
459                    *v = gelu_tanh(*v + b);
460                }
461            }
462            blk.fc2.matmat(&ff, n, &mut proj, pool);
463            for (p, r) in proj.chunks_exact(hd).enumerate() {
464                for (i, &v) in r.iter().enumerate() {
465                    x[p * hd + i] += v + blk.fc2_b[i];
466                }
467            }
468            if let Some(k) = self.deepstack_at.iter().position(|&d| d == li) {
469                deep.push(self.deepstack[k].apply(&x, n, hd, merge_dim, pool));
470            }
471        }
472        (self.merger.apply(&x, n, hd, merge_dim, pool), deep)
473    }
474}
475
476/// An RGB image in [0, 1], `[3, h, w]` → the flattened patches the tower
477/// takes and its `(grid_h, grid_w)`.
478///
479/// The reference resizes to a multiple of `patch·merge`, normalizes to
480/// [-1, 1] — Qwen3-VL uses mean and std 0.5, not CLIP's — and lays each
481/// patch out as `[in, t, ph, pw]` with the two temporal slots holding
482/// the SAME frame for a still image.
483pub fn preprocess(
484    rgb: &[f32],
485    h: usize,
486    w: usize,
487    patch: usize,
488    temporal: usize,
489    merge: usize,
490) -> (Vec<f32>, usize, usize) {
491    let factor = patch * merge;
492    let hb = ((h as f64 / factor as f64).round() as usize).max(1) * factor;
493    let wb = ((w as f64 / factor as f64).round() as usize).max(1) * factor;
494    // Bilinear resize, align_corners=false, as `F.interpolate` does.
495    let mut img = vec![0f32; 3 * hb * wb];
496    for c in 0..3 {
497        for y in 0..hb {
498            let sy = ((y as f64 + 0.5) * h as f64 / hb as f64 - 0.5).max(0.0);
499            let y0 = sy.floor() as usize;
500            let y1 = (y0 + 1).min(h - 1);
501            let fy = (sy - y0 as f64) as f32;
502            for x in 0..wb {
503                let sx = ((x as f64 + 0.5) * w as f64 / wb as f64 - 0.5).max(0.0);
504                let x0 = sx.floor() as usize;
505                let x1 = (x0 + 1).min(w - 1);
506                let fx = (sx - x0 as f64) as f32;
507                let p = |yy: usize, xx: usize| rgb[(c * h + yy) * w + xx];
508                let top = p(y0, x0) * (1.0 - fx) + p(y0, x1) * fx;
509                let bot = p(y1, x0) * (1.0 - fx) + p(y1, x1) * fx;
510                img[(c * hb + y) * wb + x] = (top * (1.0 - fy) + bot * fy - 0.5) / 0.5;
511            }
512        }
513    }
514    let (gh, gw) = (hb / patch, wb / patch);
515    let per = 3 * temporal * patch * patch;
516    let mut out = vec![0f32; gh * gw * per];
517    // The patch ORDER is the 2x2 merge-block one, not row-major: the
518    // reference's `permute(0, 3, 6, 4, 7, ...)` walks block row, block
519    // column, then the two intra-block indices. The position table and
520    // the rotation are built in the same order, and the merger consumes
521    // four consecutive patches as one cell — row-major here would
522    // scramble all three at once.
523    let mut k = 0usize;
524    for bby in 0..gh / merge {
525        for bbx in 0..gw / merge {
526            for iy in 0..merge {
527                for ix in 0..merge {
528                    let (by, bx) = (bby * merge + iy, bbx * merge + ix);
529                    let dst = &mut out[k * per..(k + 1) * per];
530                    k += 1;
531                    let mut j = 0;
532                    for c in 0..3 {
533                        for _t in 0..temporal {
534                            for y in 0..patch {
535                                for x in 0..patch {
536                                    dst[j] = img[(c * hb + by * patch + y) * wb + bx * patch + x];
537                                    j += 1;
538                                }
539                            }
540                        }
541                    }
542                }
543            }
544        }
545    }
546    (out, gh, gw)
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552
553    #[test]
554    fn the_two_gelus_are_not_the_same_function() {
555        // Both agree to ~1e-3 in the middle and diverge in the tails;
556        // the point is that the code picks deliberately.
557        assert!((gelu_exact(0.0)).abs() < 1e-12);
558        assert!((gelu_exact(1.0) - 0.841_344_8).abs() < 2e-7);
559        assert!((gelu_tanh(1.0) - 0.841_192).abs() < 1e-5);
560        assert!((gelu_exact(1.0) - gelu_tanh(1.0)).abs() > 1e-5);
561        assert!((gelu_exact(-3.0) - -0.004_049_5).abs() < 2e-6);
562    }
563
564    #[test]
565    fn erf_is_accurate_across_the_range() {
566        for (x, want) in [
567            (0.0, 0.0),
568            (0.25, 0.276_326_390_168_236_9),
569            (0.5, 0.520_499_877_813_046_5),
570            (1.0, 0.842_700_792_949_714_9),
571            (2.0, 0.995_322_265_018_952_7),
572            (3.5, 0.999_999_256_901_627_7),
573        ] {
574            let got = erf(x);
575            assert!((got - want).abs() < 2e-7, "erf({x}) = {got}, want {want}");
576            assert!((erf(-x) + want).abs() < 2e-7, "erf is odd");
577        }
578    }
579
580    #[test]
581    fn preprocess_rounds_the_canvas_to_the_merge_factor() {
582        let (h, w) = (70usize, 100usize);
583        let rgb = vec![0.5f32; 3 * h * w];
584        let (p, gh, gw) = preprocess(&rgb, h, w, 16, 2, 2);
585        // 70 → 64 (round(70/32)=2 → 64), 100 → 96
586        assert_eq!((gh, gw), (4, 6));
587        assert_eq!(p.len(), gh * gw * 3 * 2 * 16 * 16);
588        // A flat 0.5 image normalizes to exactly zero.
589        assert!(p.iter().all(|&v| v.abs() < 1e-6));
590    }
591}