Skip to main content

cortiq_engine/
dsv41_vision.rs

1//! DeepSeek-V4.1 vision tower, image preprocessing, and image-span layout.
2//!
3//! This is a direct CPU port of the pinned model's `inference/vision.py` and
4//! `inference/image_processor.py`.  The model's vision weights stay in the
5//! CMF mapping through [`QTensor`]; only the activations are materialised.
6//! The public preparation types are deliberately independent of the text
7//! pipeline so an OpenAI server, CLI, or another caller can build the same
8//! image spans before handing them to the runtime owner.
9
10use crate::pool::Pool;
11use crate::qtensor::QTensor;
12use crate::tokenizer::Tokenizer;
13use base64::Engine as _;
14use cortiq_core::CmfModel;
15use image::imageops::{self, FilterType};
16use image::{Rgb, RgbImage};
17use serde_json::Value;
18use std::fmt;
19use std::sync::Arc;
20use std::sync::atomic::{AtomicUsize, Ordering};
21
22/// Number of vision attention calls that completed through the selected GPU
23/// backend in this process.  The component probe reports this so a Vulkan
24/// timing cannot be mistaken for a CPU fallback.
25static GPU_ATTENTION_DISPATCHES: AtomicUsize = AtomicUsize::new(0);
26static GPU_DENSE_GEMM_DISPATCHES: AtomicUsize = AtomicUsize::new(0);
27
28pub fn gpu_attention_dispatches() -> usize {
29    GPU_ATTENTION_DISPATCHES.load(Ordering::Relaxed)
30}
31
32pub fn gpu_dense_gemm_dispatches() -> usize {
33    GPU_DENSE_GEMM_DISPATCHES.load(Ordering::Relaxed)
34}
35
36/// Text positions carry this type; non-negative values identify image-span
37/// positions.  The values match the reference processor exactly.
38pub const TEXT: i8 = -1;
39pub const IMAGE_START: i8 = 0;
40pub const IMAGE: i8 = 1;
41pub const IMAGE_NEW_LINE: i8 = 2;
42pub const IMAGE_END: i8 = 3;
43
44/// Minimal source configuration needed by the V4.1 vision tower and image
45/// processor.  `from_source` accepts the complete checkpoint config and
46/// reads its nested `vision_config`/`text_config` objects.
47#[derive(Clone, Debug, PartialEq)]
48pub struct VisionConfig {
49    pub vision_n_layers: usize,
50    pub vision_dim: usize,
51    pub vision_n_heads: usize,
52    pub vision_inter_dim: usize,
53    pub vision_patch_size: usize,
54    pub vision_rope_theta: f32,
55    pub vision_downsample_ratio: usize,
56    pub vision_max_n_token: usize,
57    pub vision_min_pixels: usize,
58    pub vision_max_wh_ratio: Option<f64>,
59    pub text_dim: usize,
60    pub image_token_id: u32,
61}
62
63impl Default for VisionConfig {
64    fn default() -> Self {
65        Self {
66            vision_n_layers: 0,
67            vision_dim: 1024,
68            vision_n_heads: 16,
69            vision_inter_dim: 2816,
70            vision_patch_size: 14,
71            vision_rope_theta: 10_000.0,
72            vision_downsample_ratio: 3,
73            vision_max_n_token: 1024,
74            vision_min_pixels: 544 * 544,
75            vision_max_wh_ratio: None,
76            text_dim: 5120,
77            image_token_id: 129_264,
78        }
79    }
80}
81
82impl VisionConfig {
83    /// Read a complete DeepSeek-V4.1 config, or a vision-only object.
84    pub fn from_source(source: &Value) -> Result<Self, String> {
85        let mut out = Self::default();
86        let vision = source.get("vision_config").unwrap_or(source);
87        let empty = Value::Null;
88        let text = source.get("text_config").unwrap_or(&empty);
89        let usize_field = |object: &Value, key: &str, old: usize| {
90            object
91                .get(key)
92                .and_then(Value::as_u64)
93                .map(|v| v as usize)
94                .unwrap_or(old)
95        };
96        let f32_field = |object: &Value, key: &str, old: f32| {
97            object
98                .get(key)
99                .and_then(Value::as_f64)
100                .map(|v| v as f32)
101                .unwrap_or(old)
102        };
103        out.vision_n_layers = usize_field(vision, "num_hidden_layers", out.vision_n_layers);
104        out.vision_dim = usize_field(vision, "hidden_size", out.vision_dim);
105        out.vision_n_heads = usize_field(vision, "num_attention_heads", out.vision_n_heads);
106        out.vision_inter_dim = usize_field(vision, "intermediate_size", out.vision_inter_dim);
107        out.vision_patch_size = usize_field(vision, "patch_size", out.vision_patch_size);
108        out.vision_rope_theta = f32_field(vision, "rope_theta", out.vision_rope_theta);
109        out.vision_downsample_ratio =
110            usize_field(vision, "downsample_ratio", out.vision_downsample_ratio);
111        out.vision_max_n_token = usize_field(vision, "max_image_tokens", out.vision_max_n_token);
112        out.vision_min_pixels = usize_field(vision, "min_pixels", out.vision_min_pixels);
113        out.vision_max_wh_ratio = vision.get("max_wh_ratio").and_then(Value::as_f64);
114        out.text_dim = usize_field(text, "hidden_size", out.text_dim);
115        out.image_token_id = source
116            .get("image_token_id")
117            .and_then(Value::as_u64)
118            .or_else(|| vision.get("image_token_id").and_then(Value::as_u64))
119            .map(|v| v as u32)
120            .unwrap_or(out.image_token_id);
121        out.validate()?;
122        Ok(out)
123    }
124
125    pub fn validate(&self) -> Result<(), String> {
126        if self.vision_n_layers == 0 {
127            return Ok(());
128        }
129        if self.vision_dim == 0
130            || self.vision_n_heads == 0
131            || self.vision_dim % self.vision_n_heads != 0
132            || self.vision_dim / self.vision_n_heads % 2 != 0
133        {
134            return Err(format!(
135                "invalid vision attention geometry dim={} heads={}",
136                self.vision_dim, self.vision_n_heads
137            ));
138        }
139        if self.vision_patch_size == 0 || self.vision_downsample_ratio == 0 {
140            return Err("vision patch_size and downsample_ratio must be non-zero".to_string());
141        }
142        if self.vision_max_n_token < 4 {
143            return Err("vision max_image_tokens must be at least 4".to_string());
144        }
145        if self.text_dim == 0 {
146            return Err("text hidden_size must be non-zero for the aligner".to_string());
147        }
148        Ok(())
149    }
150
151    pub fn vision_enabled(&self) -> bool {
152        self.vision_n_layers > 0
153    }
154
155    pub fn head_dim(&self) -> usize {
156        self.vision_dim / self.vision_n_heads
157    }
158
159    pub fn rope_dim(&self) -> usize {
160        self.head_dim() / 2
161    }
162}
163
164/// An image record after prompt encoding.  Patches use the same contiguous
165/// `[n_vit_h*n_vit_w, 3, patch, patch]` order as PyTorch's reference.
166#[derive(Clone, Debug)]
167pub struct ImageInput {
168    pub start: usize,
169    pub patches: Vec<f32>,
170    pub n_vit_h: usize,
171    pub n_vit_w: usize,
172    pub n_llm_h: usize,
173    pub n_llm_w: usize,
174    pub types: Vec<i8>,
175}
176
177impl ImageInput {
178    pub fn image_positions(&self) -> usize {
179        self.types.iter().filter(|&&kind| kind == IMAGE).count()
180    }
181
182    pub fn span_len(&self) -> usize {
183        self.types.len()
184    }
185}
186
187/// Token IDs plus per-position image type metadata consumed by the text
188/// runtime.  `images` is empty for text-only prompts.
189#[derive(Clone, Debug)]
190pub struct PreparedVlInputs {
191    pub token_ids: Vec<u32>,
192    pub token_types: Vec<i8>,
193    pub images: Vec<ImageInput>,
194}
195
196/// `num_image_tokens` from the pinned processor.
197pub fn num_image_tokens(n_llm_h: usize, n_llm_w: usize) -> usize {
198    n_llm_h.saturating_mul(n_llm_w + 1).saturating_add(2)
199}
200
201/// Number of LLM rows/columns after the aligner's `r×r` downsample.
202pub fn llm_grid(
203    best_height: usize,
204    best_width: usize,
205    patch_size: usize,
206    downsample_ratio: usize,
207) -> (usize, usize) {
208    (
209        (best_height / patch_size).div_ceil(downsample_ratio),
210        (best_width / patch_size).div_ceil(downsample_ratio),
211    )
212}
213
214/// Largest aspect-preserving pixel size whose token grid fits the cap.
215pub fn solve_resize_ratio(
216    height: usize,
217    width: usize,
218    patch_size: usize,
219    downsample_ratio: usize,
220    max_n_token: usize,
221) -> (usize, usize) {
222    solve_resize_ratio_f64(
223        height.max(1) as f64,
224        width.max(1) as f64,
225        patch_size,
226        downsample_ratio,
227        max_n_token,
228    )
229}
230
231fn solve_resize_ratio_f64(
232    height_f: f64,
233    width_f: f64,
234    patch_size: usize,
235    downsample_ratio: usize,
236    max_n_token: usize,
237) -> (usize, usize) {
238    let aspect = height_f / width_f;
239    let max_w_float = (((max_n_token.saturating_sub(2)) as f64 / aspect) + 0.25).sqrt() - 0.5;
240    let max_h_float = max_w_float * aspect;
241    let cell = patch_size.saturating_mul(downsample_ratio).max(1);
242    if max_w_float < 1.0 {
243        return (max_n_token.saturating_sub(2) / 2 * cell, cell);
244    }
245    if max_h_float < 1.0 {
246        return (cell, max_n_token.saturating_sub(3) * cell);
247    }
248    let beta = (max_w_float.floor() * cell as f64 / width_f)
249        .min(max_h_float.floor() * cell as f64 / height_f);
250    let best_h =
251        ((height_f * beta / patch_size.max(1) as f64).floor() as usize).saturating_mul(patch_size);
252    let best_w =
253        ((width_f * beta / patch_size.max(1) as f64).floor() as usize).saturating_mul(patch_size);
254    (best_h.max(patch_size), best_w.max(patch_size))
255}
256
257/// Apply the official token-cap correction to a planned pixel grid.
258pub fn safe_resize(
259    height: usize,
260    width: usize,
261    mut best_height: usize,
262    mut best_width: usize,
263    patch_size: usize,
264    downsample_ratio: usize,
265    max_n_token: usize,
266) -> Result<(usize, usize, usize, usize), String> {
267    let (mut n_llm_h, mut n_llm_w) =
268        llm_grid(best_height, best_width, patch_size, downsample_ratio);
269    if num_image_tokens(n_llm_h, n_llm_w) > max_n_token {
270        let (h, w) = solve_resize_ratio(height, width, patch_size, downsample_ratio, max_n_token);
271        best_height = h;
272        best_width = w;
273        (n_llm_h, n_llm_w) = llm_grid(best_height, best_width, patch_size, downsample_ratio);
274        if num_image_tokens(n_llm_h, n_llm_w) > max_n_token {
275            return Err(format!(
276                "image grid {}x{} costs {} tokens, cap {}",
277                n_llm_h,
278                n_llm_w,
279                num_image_tokens(n_llm_h, n_llm_w),
280                max_n_token
281            ));
282        }
283    }
284    Ok((n_llm_h, n_llm_w, best_height, best_width))
285}
286
287/// Compute the pixel/token plan before decoding the image payload.
288pub fn plan_image_grid(
289    width: usize,
290    height: usize,
291    config: &VisionConfig,
292) -> Result<(usize, usize, usize, usize), String> {
293    config.validate()?;
294    if width == 0 || height == 0 {
295        return Err("image dimensions must be non-zero".to_string());
296    }
297    let mut width_f = width as f64;
298    let mut height_f = height as f64;
299    if let Some(max_ratio) = config.vision_max_wh_ratio.filter(|v| *v > 0.0) {
300        if width_f > height_f * max_ratio {
301            width_f = height_f * max_ratio;
302        }
303    }
304    if width_f * height_f < config.vision_min_pixels as f64 {
305        let scale = (config.vision_min_pixels as f64 / (width_f * height_f)).sqrt();
306        width_f = (width_f * scale) as usize as f64;
307        height_f = (height_f * scale) as usize as f64;
308    }
309    let p = config.vision_patch_size;
310    let mut best_width = (width_f.ceil() as usize)
311        .max(1)
312        .div_ceil(p)
313        .saturating_mul(p);
314    let mut best_height = (height_f.ceil() as usize)
315        .max(1)
316        .div_ceil(p)
317        .saturating_mul(p);
318    let (mut n_h, mut n_w) = llm_grid(best_height, best_width, p, config.vision_downsample_ratio);
319    if num_image_tokens(n_h, n_w) > config.vision_max_n_token {
320        let (h, w) = solve_resize_ratio_f64(
321            height_f,
322            width_f,
323            p,
324            config.vision_downsample_ratio,
325            config.vision_max_n_token,
326        );
327        best_height = h;
328        best_width = w;
329        (n_h, n_w) = llm_grid(best_height, best_width, p, config.vision_downsample_ratio);
330        if num_image_tokens(n_h, n_w) > config.vision_max_n_token {
331            return Err(format!(
332                "image grid {}x{} costs {} tokens, cap {}",
333                n_h,
334                n_w,
335                num_image_tokens(n_h, n_w),
336                config.vision_max_n_token
337            ));
338        }
339    }
340    Ok((n_h, n_w, best_height, best_width))
341}
342
343/// The exact span type order: start, each row's image tokens plus newline,
344/// end.
345pub fn image_token_types(n_llm_h: usize, n_llm_w: usize) -> Vec<i8> {
346    let mut types = Vec::with_capacity(num_image_tokens(n_llm_h, n_llm_w));
347    types.push(IMAGE_START);
348    for _ in 0..n_llm_h {
349        types.extend(std::iter::repeat_n(IMAGE, n_llm_w));
350        types.push(IMAGE_NEW_LINE);
351    }
352    types.push(IMAGE_END);
353    types
354}
355
356/// Load bytes from raw/base64 data, Anthropic source records, data URLs,
357/// HTTP(S), or local paths.  This follows the official loader's precedence.
358pub fn load_image_bytes(record: &Value) -> Result<Vec<u8>, String> {
359    let map = record
360        .as_object()
361        .ok_or_else(|| "image record must be an object".to_string())?;
362    if let Some(data) = map.get("data") {
363        if let Some(s) = data.as_str() {
364            return base64::engine::general_purpose::STANDARD
365                .decode(s)
366                .map_err(|e| format!("invalid base64 image data: {e}"));
367        }
368    }
369    if let Some(source) = map.get("source").and_then(Value::as_object) {
370        if let Some(data) = source.get("data").and_then(Value::as_str) {
371            return base64::engine::general_purpose::STANDARD
372                .decode(data)
373                .map_err(|e| format!("invalid base64 Anthropic image data: {e}"));
374        }
375        if let Some(url) = source.get("url").and_then(Value::as_str) {
376            return load_image_bytes(&serde_json::json!({"url": url}));
377        }
378    }
379    let url = map.get("url").and_then(Value::as_str).ok_or_else(|| {
380        format!(
381            "image record has no data/source/url (keys: {:?})",
382            map.keys()
383        )
384    })?;
385    if let Some((header, payload)) = url.split_once(',').filter(|(h, _)| h.starts_with("data:")) {
386        if !header.contains(";base64") {
387            return Err(format!("unsupported data URL encoding: {header}"));
388        }
389        return base64::engine::general_purpose::STANDARD
390            .decode(payload)
391            .map_err(|e| format!("invalid data URL image: {e}"));
392    }
393    if url.starts_with("http://") || url.starts_with("https://") {
394        let response = ureq::get(url)
395            .timeout(std::time::Duration::from_secs(30))
396            .call()
397            .map_err(|e| format!("image download failed: {e}"))?;
398        let mut reader = response.into_reader();
399        let mut bytes = Vec::new();
400        std::io::Read::read_to_end(&mut reader, &mut bytes)
401            .map_err(|e| format!("image download read failed: {e}"))?;
402        return Ok(bytes);
403    }
404    std::fs::read(url).map_err(|e| format!("image path '{url}' could not be read: {e}"))
405}
406
407fn resize_fit(image: &RgbImage, width: u32, height: u32) -> RgbImage {
408    let scale = (width as f64 / image.width() as f64).min(height as f64 / image.height() as f64);
409    let resized_width = (image.width() as f64 * scale).round().max(1.0) as u32;
410    let resized_height = (image.height() as f64 * scale).round().max(1.0) as u32;
411    imageops::resize(image, resized_width, resized_height, FilterType::CatmullRom)
412}
413
414fn pad_to(image: &RgbImage, width: u32, height: u32) -> RgbImage {
415    let resized = resize_fit(image, width, height);
416    let mut output = RgbImage::from_pixel(width, height, Rgb([127, 127, 127]));
417    let left = (width.saturating_sub(resized.width())) / 2;
418    let top = (height.saturating_sub(resized.height())) / 2;
419    imageops::overlay(&mut output, &resized, i64::from(left), i64::from(top));
420    output
421}
422
423/// PyTorch converts the normalized image tensor to bfloat16 before the ViT
424/// sees it.  The rest of the CPU engine works in f32, so round-trip each
425/// patch value through BF16 at this boundary instead of silently retaining
426/// extra image precision.
427#[inline]
428fn bf16_roundtrip(value: f32) -> f32 {
429    let bits = value.to_bits();
430    let round = 0x7fff + ((bits >> 16) & 1);
431    f32::from_bits((bits.wrapping_add(round) & 0xffff_0000))
432}
433
434/// Decode, resize/pad, normalize, and patchify one image record.
435pub fn load_image(
436    record: &Value,
437    config: &VisionConfig,
438) -> Result<(Vec<f32>, usize, usize, usize, usize), String> {
439    config.validate()?;
440    if !config.vision_enabled() {
441        return Err("image input requires a model with vision_n_layers > 0".to_string());
442    }
443    let bytes = load_image_bytes(record)?;
444    let decoded = image::load_from_memory(&bytes)
445        .map_err(|e| format!("image decode failed: {e}"))?
446        .to_rgb8();
447    let (width, height) = decoded.dimensions();
448    let (n_llm_h, n_llm_w, best_height, best_width) =
449        plan_image_grid(width as usize, height as usize, config)?;
450    let p = config.vision_patch_size as u32;
451    let target_width = best_width as u32;
452    let target_height = best_height as u32;
453    let transformed = if config
454        .vision_max_wh_ratio
455        .is_some_and(|ratio| width as f64 >= ratio * height as f64)
456    {
457        imageops::resize(
458            &decoded,
459            target_width,
460            target_height,
461            FilterType::CatmullRom,
462        )
463    } else {
464        pad_to(&decoded, target_width, target_height)
465    };
466    let n_vit_h = best_height / config.vision_patch_size;
467    let n_vit_w = best_width / config.vision_patch_size;
468    let patch_values = config
469        .vision_patch_size
470        .saturating_mul(config.vision_patch_size)
471        .saturating_mul(3);
472    let mut patches =
473        Vec::with_capacity(n_vit_h.saturating_mul(n_vit_w).saturating_mul(patch_values));
474    // Equivalent to PyTorch:
475    // x.reshape(3,n_h,p,n_w,p).permute(1,3,0,2,4).reshape(n_h*n_w,3,p,p)
476    for patch_y in 0..n_vit_h {
477        for patch_x in 0..n_vit_w {
478            for channel in 0..3 {
479                for dy in 0..config.vision_patch_size {
480                    for dx in 0..config.vision_patch_size {
481                        let pixel = transformed.get_pixel(
482                            (patch_x * config.vision_patch_size + dx) as u32,
483                            (patch_y * config.vision_patch_size + dy) as u32,
484                        );
485                        let value = (pixel[channel] as f32 / 255.0 - 0.5) / 0.5;
486                        patches.push(bf16_roundtrip(value));
487                    }
488                }
489            }
490        }
491    }
492    debug_assert_eq!(patches.len(), n_vit_h * n_vit_w * patch_values);
493    Ok((patches, n_vit_h, n_vit_w, n_llm_h, n_llm_w))
494}
495
496/// Tokenize a prompt and replace each image placeholder with its official
497/// span.  The tokenizer must expose `<|deepseek_image|>` as one added token;
498/// a mismatched known id is rejected rather than silently feeding text tokens.
499pub fn prepare_vl_inputs(
500    prompt: &str,
501    images: &[Value],
502    tokenizer: &Tokenizer,
503    config: &VisionConfig,
504) -> Result<PreparedVlInputs, String> {
505    config.validate()?;
506    let image_token_id = config.image_token_id;
507    if let Some(placeholder_id) = tokenizer.token_to_id(crate::dsv41_encoding::IMAGE_PLACEHOLDER) {
508        if placeholder_id != image_token_id {
509            return Err(format!(
510                "tokenizer image placeholder id {} != config image_token_id {}",
511                placeholder_id, image_token_id
512            ));
513        }
514    }
515    let prompt_tokens = tokenizer.encode(prompt);
516    let placeholders = prompt_tokens
517        .iter()
518        .filter(|&&token| token == image_token_id)
519        .count();
520    if placeholders != images.len() {
521        return Err(format!(
522            "found {placeholders} image tokens but received {} images",
523            images.len()
524        ));
525    }
526    if placeholders > 0 && !config.vision_enabled() {
527        return Err("prompt contains images but the model has no vision tower".to_string());
528    }
529    let mut token_ids = Vec::with_capacity(prompt_tokens.len());
530    let mut token_types = Vec::with_capacity(prompt_tokens.len());
531    let mut image_inputs = Vec::with_capacity(images.len());
532    let mut image_index = 0;
533    for token in prompt_tokens {
534        if token != image_token_id {
535            token_ids.push(token);
536            token_types.push(TEXT);
537            continue;
538        }
539        let (patches, n_vit_h, n_vit_w, n_llm_h, n_llm_w) =
540            load_image(&images[image_index], config)?;
541        let types = image_token_types(n_llm_h, n_llm_w);
542        image_inputs.push(ImageInput {
543            start: token_ids.len(),
544            patches,
545            n_vit_h,
546            n_vit_w,
547            n_llm_h,
548            n_llm_w,
549            types: types.clone(),
550        });
551        token_ids.extend(std::iter::repeat_n(image_token_id, types.len()));
552        token_types.extend(types);
553        image_index += 1;
554    }
555    Ok(PreparedVlInputs {
556        token_ids,
557        token_types,
558        images: image_inputs,
559    })
560}
561
562pub struct VisionLinear {
563    pub weight: QTensor,
564    pub bias: Option<Vec<f32>>,
565}
566
567impl fmt::Debug for VisionLinear {
568    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
569        f.debug_struct("VisionLinear")
570            .field("rows", &self.weight.rows())
571            .field("cols", &self.weight.cols())
572            .field("has_bias", &self.bias.is_some())
573            .finish()
574    }
575}
576
577impl VisionLinear {
578    fn new(weight: QTensor, bias: Option<Vec<f32>>) -> Result<Self, String> {
579        if let Some(values) = &bias {
580            if values.len() != weight.rows() {
581                return Err(format!(
582                    "linear bias length {} != output rows {}",
583                    values.len(),
584                    weight.rows()
585                ));
586            }
587        }
588        Ok(Self { weight, bias })
589    }
590
591    fn apply_many(&self, input: &[f32], batch: usize, output: &mut [f32], pool: Option<&Pool>) {
592        assert_eq!(input.len(), batch * self.weight.cols());
593        assert!(output.len() >= batch * self.weight.rows());
594
595        // F16 vision matrices are owned as f32 by QTensor after the one-time
596        // CMF decode.  Reuse the existing dense f32 NT GEMM on an explicitly
597        // enabled discrete backend for the large image batch; small calls
598        // and every backend refusal retain the portable QTensor path.
599        let rows = self.weight.rows();
600        let cols = self.weight.cols();
601        if batch >= 8
602            && batch.saturating_mul(rows).saturating_mul(cols) >= (1 << 22)
603            && crate::gpu::enabled_here()
604            && let Some(weight) = self.weight.as_f32()
605            && crate::gpu::gemm_nt_f32(input, weight, output, batch, cols, rows)
606        {
607            GPU_DENSE_GEMM_DISPATCHES.fetch_add(1, Ordering::Relaxed);
608            if let Some(bias) = &self.bias {
609                for row in output[..batch * rows].chunks_exact_mut(rows) {
610                    for (value, &b) in row.iter_mut().zip(bias) {
611                        *value += b;
612                    }
613                }
614            }
615            return;
616        }
617        self.weight.matmat(input, batch, output, pool);
618        if let Some(bias) = &self.bias {
619            for row in output[..batch * self.weight.rows()].chunks_exact_mut(self.weight.rows()) {
620                for (value, &b) in row.iter_mut().zip(bias) {
621                    *value += b;
622                }
623            }
624        }
625    }
626}
627
628#[derive(Debug)]
629pub struct VisionAttention {
630    pub wqkv: VisionLinear,
631    pub wo: VisionLinear,
632}
633
634#[derive(Debug)]
635pub struct VisionMlp {
636    pub w1: VisionLinear,
637    pub w2: VisionLinear,
638}
639
640#[derive(Debug)]
641pub struct VisionBlock {
642    pub norm1: Vec<f32>,
643    pub attn: VisionAttention,
644    pub norm2: Vec<f32>,
645    pub mlp: VisionMlp,
646}
647
648/// Loaded V4.1 vision tower plus projector/marker embeddings.
649#[derive(Debug)]
650pub struct VisionModel {
651    pub config: VisionConfig,
652    pub patch_embed: VisionLinear,
653    pub blocks: Vec<VisionBlock>,
654    pub norm: Vec<f32>,
655    pub aligner_w1: VisionLinear,
656    pub aligner_w2: VisionLinear,
657    pub image_start: Vec<f32>,
658    pub image_end: Vec<f32>,
659    pub image_newline: Vec<f32>,
660}
661
662fn load_tensor(model: &Arc<CmfModel>, name: &str) -> Result<QTensor, String> {
663    QTensor::from_model(model, name)
664}
665
666fn load_vector(model: &Arc<CmfModel>, name: &str, expected: usize) -> Result<Vec<f32>, String> {
667    let entry = model
668        .tensor(name)
669        .ok_or_else(|| format!("tensor '{name}' not found"))?;
670    if entry.shape.iter().product::<usize>() != expected {
671        return Err(format!(
672            "tensor '{name}' has shape {:?}, expected {} elements",
673            entry.shape, expected
674        ));
675    }
676    let mut data = vec![0.0f32; expected];
677    cortiq_core::quant::dequant_tensor(entry, model.entry_bytes(entry), &mut data)?;
678    Ok(data)
679}
680
681fn load_optional_vector(
682    model: &Arc<CmfModel>,
683    name: &str,
684    expected: usize,
685) -> Result<Option<Vec<f32>>, String> {
686    model
687        .tensor(name)
688        .map(|_| load_vector(model, name, expected))
689        .transpose()
690}
691
692fn required_norm(model: &Arc<CmfModel>, name: &str, dim: usize) -> Result<Vec<f32>, String> {
693    load_vector(model, name, dim)
694}
695
696/// The pinned aligner uses `F.pad(x, (0, -n_w % r, 0, -n_h % r))`.
697/// Python's modulo is non-negative, so the apparently negative arguments add
698/// zero padding up to the next multiple rather than cropping a partial cell.
699fn padded_vit_grid(n_vit_h: usize, n_vit_w: usize, ratio: usize) -> (usize, usize) {
700    (
701        n_vit_h.div_ceil(ratio) * ratio,
702        n_vit_w.div_ceil(ratio) * ratio,
703    )
704}
705
706/// Materialize the channel-major windows consumed by the reference
707/// `F.unfold`.  The source first pads the `[dim, n_vit_h, n_vit_w]` feature
708/// map on the bottom/right, then unfolds non-overlapping `ratio×ratio`
709/// windows.  Keeping this operation separate makes its boundary behavior
710/// directly testable without loading the 1 GB vision component.
711fn unfold_padded(
712    x: &[f32],
713    n_vit_h: usize,
714    n_vit_w: usize,
715    vision_dim: usize,
716    ratio: usize,
717) -> (Vec<f32>, usize, usize) {
718    let (h, w) = padded_vit_grid(n_vit_h, n_vit_w, ratio);
719    let rows = (h / ratio) * (w / ratio);
720    let in_dim = vision_dim * ratio * ratio;
721    debug_assert_eq!(x.len(), n_vit_h * n_vit_w * vision_dim);
722    let mut input = vec![0.0f32; rows * in_dim];
723    for block_y in 0..h / ratio {
724        for block_x in 0..w / ratio {
725            let row = block_y * (w / ratio) + block_x;
726            let mut at = 0;
727            // F.unfold after CHW permutation is channel-major, then the
728            // ratio×ratio values in row-major order.
729            for channel in 0..vision_dim {
730                for dy in 0..ratio {
731                    for dx in 0..ratio {
732                        let patch_y = block_y * ratio + dy;
733                        let patch_x = block_x * ratio + dx;
734                        input[row * in_dim + at] = if patch_y < n_vit_h && patch_x < n_vit_w {
735                            let patch = patch_y * n_vit_w + patch_x;
736                            x[patch * vision_dim + channel]
737                        } else {
738                            0.0
739                        };
740                        at += 1;
741                    }
742                }
743            }
744        }
745    }
746    (input, h / ratio, w / ratio)
747}
748
749impl VisionModel {
750    /// Load canonical V4.1 names from a CMF model.  The marker vectors are
751    /// required whenever the tower is enabled because they replace the
752    /// image-start/end/newline token embeddings in the text hidden state.
753    pub fn from_model(model: &Arc<CmfModel>, config: VisionConfig) -> Result<Self, String> {
754        config.validate()?;
755        if !config.vision_enabled() {
756            return Err("cannot load a disabled vision tower".to_string());
757        }
758        let patch_in = 3 * config.vision_patch_size * config.vision_patch_size;
759        let patch_embed = VisionLinear::new(
760            load_tensor(model, "vision.patch_embed.proj.weight")?,
761            load_optional_vector(model, "vision.patch_embed.proj.bias", config.vision_dim)?,
762        )?;
763        if patch_embed.weight.rows() != config.vision_dim || patch_embed.weight.cols() != patch_in {
764            return Err(format!(
765                "patch embedding shape {}x{}, expected {}x{}",
766                patch_embed.weight.rows(),
767                patch_embed.weight.cols(),
768                config.vision_dim,
769                patch_in
770            ));
771        }
772        let mut blocks = Vec::with_capacity(config.vision_n_layers);
773        for layer in 0..config.vision_n_layers {
774            let prefix = format!("vision.blocks.{layer}");
775            let norm1 = required_norm(model, &format!("{prefix}.norm1.weight"), config.vision_dim)?;
776            let wqkv = VisionLinear::new(
777                load_tensor(model, &format!("{prefix}.attn.wqkv.weight"))?,
778                load_optional_vector(
779                    model,
780                    &format!("{prefix}.attn.wqkv.bias"),
781                    3 * config.vision_dim,
782                )?,
783            )?;
784            let wo = VisionLinear::new(
785                load_tensor(model, &format!("{prefix}.attn.wo.weight"))?,
786                load_optional_vector(model, &format!("{prefix}.attn.wo.bias"), config.vision_dim)?,
787            )?;
788            let norm2 = required_norm(model, &format!("{prefix}.norm2.weight"), config.vision_dim)?;
789            let w1 = VisionLinear::new(
790                load_tensor(model, &format!("{prefix}.mlp.w1.weight"))?,
791                load_optional_vector(
792                    model,
793                    &format!("{prefix}.mlp.w1.bias"),
794                    2 * config.vision_inter_dim,
795                )?,
796            )?;
797            let w2 = VisionLinear::new(
798                load_tensor(model, &format!("{prefix}.mlp.w2.weight"))?,
799                load_optional_vector(model, &format!("{prefix}.mlp.w2.bias"), config.vision_dim)?,
800            )?;
801            if wqkv.weight.rows() != 3 * config.vision_dim
802                || wqkv.weight.cols() != config.vision_dim
803                || wo.weight.rows() != config.vision_dim
804                || wo.weight.cols() != config.vision_dim
805                || w1.weight.rows() != 2 * config.vision_inter_dim
806                || w1.weight.cols() != config.vision_dim
807                || w2.weight.rows() != config.vision_dim
808                || w2.weight.cols() != config.vision_inter_dim
809            {
810                return Err(format!(
811                    "vision block {layer} has a non-reference linear shape"
812                ));
813            }
814            blocks.push(VisionBlock {
815                norm1,
816                attn: VisionAttention { wqkv, wo },
817                norm2,
818                mlp: VisionMlp { w1, w2 },
819            });
820        }
821        let norm = required_norm(model, "vision.norm.weight", config.vision_dim)?;
822        let aligner_in = config.vision_dim * config.vision_downsample_ratio.pow(2);
823        let aligner_w1 = VisionLinear::new(
824            load_tensor(model, "aligner.w1.weight")?,
825            load_optional_vector(model, "aligner.w1.bias", config.text_dim)?,
826        )?;
827        let aligner_w2 = VisionLinear::new(
828            load_tensor(model, "aligner.w2.weight")?,
829            load_optional_vector(model, "aligner.w2.bias", config.text_dim)?,
830        )?;
831        if aligner_w1.weight.rows() != config.text_dim
832            || aligner_w1.weight.cols() != aligner_in
833            || aligner_w2.weight.rows() != config.text_dim
834            || aligner_w2.weight.cols() != config.text_dim
835        {
836            return Err("aligner linear shapes do not match vision config".to_string());
837        }
838        let image_start = load_vector(model, "image_start", config.text_dim)?;
839        let image_end = load_vector(model, "image_end", config.text_dim)?;
840        let image_newline = load_vector(model, "image_newline", config.text_dim)?;
841        Ok(Self {
842            config,
843            patch_embed,
844            blocks,
845            norm,
846            aligner_w1,
847            aligner_w2,
848            image_start,
849            image_end,
850            image_newline,
851        })
852    }
853
854    /// Compute a vision image embedding with the official ViT and aligner.
855    /// The returned rows correspond only to `IMAGE` positions, in reading
856    /// order; marker vectors remain separate for the text runtime to insert.
857    pub fn encode_image(
858        &self,
859        image: &ImageInput,
860        pool: Option<&Pool>,
861    ) -> Result<Vec<f32>, String> {
862        if image.n_vit_h == 0 || image.n_vit_w == 0 {
863            return Err("image patch grid must be non-empty".to_string());
864        }
865        let patch_dim = 3 * self.config.vision_patch_size * self.config.vision_patch_size;
866        let n = image.n_vit_h * image.n_vit_w;
867        if image.patches.len() != n * patch_dim {
868            return Err(format!(
869                "patch payload has {} values, expected {}",
870                image.patches.len(),
871                n * patch_dim
872            ));
873        }
874        let mut x = vec![0.0f32; n * self.config.vision_dim];
875        self.patch_embed.apply_many(&image.patches, n, &mut x, pool);
876        let (cos, sin) = get_vision_cos_sin(
877            image.n_vit_h,
878            image.n_vit_w,
879            self.config.rope_dim(),
880            self.config.vision_rope_theta,
881        );
882        for block in &self.blocks {
883            let mut normed = vec![0.0f32; x.len()];
884            for (src, dst) in x
885                .chunks_exact(self.config.vision_dim)
886                .zip(normed.chunks_exact_mut(self.config.vision_dim))
887            {
888                rms_norm(src, &block.norm1, 1e-6, dst);
889            }
890            let attention = attention_forward(
891                &normed,
892                &block.attn,
893                &cos,
894                &sin,
895                self.config.vision_dim,
896                self.config.vision_n_heads,
897                pool,
898            );
899            for (dst, update) in x
900                .chunks_exact_mut(self.config.vision_dim)
901                .zip(attention.chunks_exact(self.config.vision_dim))
902            {
903                for (v, &u) in dst.iter_mut().zip(update) {
904                    *v += u;
905                }
906            }
907            let mut normed = vec![0.0f32; x.len()];
908            for (src, dst) in x
909                .chunks_exact(self.config.vision_dim)
910                .zip(normed.chunks_exact_mut(self.config.vision_dim))
911            {
912                rms_norm(src, &block.norm2, 1e-6, dst);
913            }
914            let inter = block.mlp.w1.weight.rows() / 2;
915            let mut hidden = vec![0.0f32; n * 2 * inter];
916            block.mlp.w1.apply_many(&normed, n, &mut hidden, pool);
917            let mut activated = vec![0.0f32; n * inter];
918            for (source, output) in hidden
919                .chunks_exact(2 * inter)
920                .zip(activated.chunks_exact_mut(inter))
921            {
922                for i in 0..inter {
923                    let gate = source[i];
924                    output[i] = gate / (1.0 + (-gate).exp()) * source[inter + i];
925                }
926            }
927            let mut mlp_out = vec![0.0f32; x.len()];
928            block.mlp.w2.apply_many(&activated, n, &mut mlp_out, pool);
929            for (dst, update) in x
930                .chunks_exact_mut(self.config.vision_dim)
931                .zip(mlp_out.chunks_exact(self.config.vision_dim))
932            {
933                for (v, &u) in dst.iter_mut().zip(update) {
934                    *v += u;
935                }
936            }
937        }
938        for src in x.chunks_exact_mut(self.config.vision_dim).take(n) {
939            let copy = src.to_vec();
940            rms_norm(&copy, &self.norm, 1e-6, src);
941        }
942        self.align(image, &x, pool)
943    }
944
945    /// Insert image marker/projector rows into a text hidden-state span.
946    /// `span` must have the exact `ImageInput::types` length and be laid out
947    /// with the text hidden size in its last dimension.
948    pub fn fill_image_span(
949        &self,
950        image: &ImageInput,
951        span: &mut [f32],
952        pool: Option<&Pool>,
953    ) -> Result<(), String> {
954        let dim = self.config.text_dim;
955        if span.len() != image.types.len() * dim {
956            return Err(format!(
957                "image span has {} values, expected {}",
958                span.len(),
959                image.types.len() * dim
960            ));
961        }
962        let embeds = self.encode_image(image, pool)?;
963        let mut image_row = 0;
964        for (kind, row) in image.types.iter().zip(span.chunks_exact_mut(dim)) {
965            match *kind {
966                IMAGE_START => row.copy_from_slice(&self.image_start),
967                IMAGE_END => row.copy_from_slice(&self.image_end),
968                IMAGE_NEW_LINE => row.copy_from_slice(&self.image_newline),
969                IMAGE => {
970                    let source = embeds
971                        .get(image_row * dim..(image_row + 1) * dim)
972                        .ok_or_else(|| "aligner/image token count mismatch".to_string())?;
973                    row.copy_from_slice(source);
974                    image_row += 1;
975                }
976                TEXT => return Err("TEXT type cannot occur inside an image span".to_string()),
977                other => return Err(format!("unknown image token type {other}")),
978            }
979        }
980        if image_row != embeds.len() / dim {
981            return Err("aligner produced a different number of image rows".to_string());
982        }
983        Ok(())
984    }
985
986    /// Return one complete text-hidden image span (markers plus projected
987    /// image rows) for callers that do not already own a preallocated hidden
988    /// buffer.  The runtime's batched prefill can copy these rows directly
989    /// over the corresponding placeholder positions.
990    pub fn image_span(&self, image: &ImageInput, pool: Option<&Pool>) -> Result<Vec<f32>, String> {
991        let mut span = vec![0.0f32; image.types.len() * self.config.text_dim];
992        self.fill_image_span(image, &mut span, pool)?;
993        Ok(span)
994    }
995
996    fn align(
997        &self,
998        image: &ImageInput,
999        x: &[f32],
1000        pool: Option<&Pool>,
1001    ) -> Result<Vec<f32>, String> {
1002        let r = self.config.vision_downsample_ratio;
1003        let (input, out_h, out_w) =
1004            unfold_padded(x, image.n_vit_h, image.n_vit_w, self.config.vision_dim, r);
1005        let rows = out_h * out_w;
1006        let mut out = vec![0.0f32; rows * self.config.text_dim];
1007        let mut hidden = vec![0.0f32; rows * self.config.text_dim];
1008        self.aligner_w1.apply_many(&input, rows, &mut hidden, pool);
1009        for value in &mut hidden {
1010            *value = gelu_exact(*value);
1011        }
1012        self.aligner_w2.apply_many(&hidden, rows, &mut out, pool);
1013        if out.len() != image.image_positions() * self.config.text_dim {
1014            return Err(format!(
1015                "aligner produced {} rows but span requests {} image positions",
1016                rows,
1017                image.image_positions()
1018            ));
1019        }
1020        Ok(out)
1021    }
1022}
1023
1024/// Official 2-D RoPE grid.  Returned vectors are `[tokens, rope_dim]`, where
1025/// `rope_dim` is half a head and is broadcast over each q/k half.
1026pub fn get_vision_cos_sin(n_h: usize, n_w: usize, dim: usize, theta: f32) -> (Vec<f32>, Vec<f32>) {
1027    let mut inv_freq = Vec::with_capacity(dim / 2);
1028    for i in (0..dim).step_by(2) {
1029        inv_freq.push(1.0 / theta.powf(i as f32 / dim.max(1) as f32));
1030    }
1031    let mut cos = Vec::with_capacity(n_h * n_w * dim);
1032    let mut sin = Vec::with_capacity(n_h * n_w * dim);
1033    for h in 0..n_h {
1034        for w in 0..n_w {
1035            for &position in &[h as f32, w as f32] {
1036                for &frequency in &inv_freq {
1037                    let angle = position * frequency;
1038                    cos.push(angle.cos());
1039                    sin.push(angle.sin());
1040                }
1041            }
1042        }
1043    }
1044    debug_assert_eq!(cos.len(), n_h * n_w * dim);
1045    debug_assert_eq!(sin.len(), cos.len());
1046    (cos, sin)
1047}
1048
1049pub fn apply_rotary(x: &mut [f32], cos: &[f32], sin: &[f32]) {
1050    assert_eq!(x.len(), cos.len() * 2);
1051    let half = x.len() / 2;
1052    let left = x[..half].to_vec();
1053    let right = x[half..].to_vec();
1054    for i in 0..half {
1055        x[i] = left[i] * cos[i] - right[i] * sin[i];
1056        x[half + i] = right[i] * cos[i] + left[i] * sin[i];
1057    }
1058}
1059
1060pub fn rms_norm(input: &[f32], weight: &[f32], eps: f32, output: &mut [f32]) {
1061    assert_eq!(input.len(), weight.len());
1062    assert!(output.len() >= input.len());
1063    let mean = input.iter().map(|v| v * v).sum::<f32>() / input.len().max(1) as f32;
1064    let scale = (mean + eps).sqrt().recip();
1065    for ((dst, &value), &factor) in output.iter_mut().zip(input).zip(weight) {
1066        *dst = value * scale * factor;
1067    }
1068}
1069
1070fn gelu_exact(value: f32) -> f32 {
1071    // Abramowitz-Stegun erf approximation (maximum error ~1.5e-7), matching
1072    // torch.nn.functional.gelu(..., approximate="none") closely in f32.
1073    let sign = if value < 0.0 { -1.0 } else { 1.0 };
1074    let x = value.abs() / std::f32::consts::SQRT_2;
1075    let t = 1.0 / (1.0 + 0.3275911 * x);
1076    let polynomial = (((((1.061_405_4 * t - 1.453_152_1) * t) + 1.421_413_8) * t - 0.284_496_72)
1077        * t
1078        + 0.254_829_6)
1079        * t;
1080    let erf = sign * (1.0 - polynomial * (-x * x).exp());
1081    0.5 * value * (1.0 + erf)
1082}
1083
1084fn attention_forward(
1085    input: &[f32],
1086    attention: &VisionAttention,
1087    cos: &[f32],
1088    sin: &[f32],
1089    dim: usize,
1090    heads: usize,
1091    pool: Option<&Pool>,
1092) -> Vec<f32> {
1093    let n = input.len() / dim;
1094    let head_dim = dim / heads;
1095    let rope_dim = head_dim / 2;
1096    // The reference applies qkv and output projections token by token.  The
1097    // mathematical result is the same when the existing QTensor GEMM path
1098    // handles the complete image batch, while the weight stream is read once
1099    // per projection (and Q4TP can use its GPU matmat kernel).
1100    let mut qkv = vec![0.0f32; n * 3 * dim];
1101    attention.wqkv.apply_many(input, n, &mut qkv, pool);
1102    let mut q = vec![0.0f32; n * dim];
1103    let mut k = vec![0.0f32; n * dim];
1104    let mut v = vec![0.0f32; n * dim];
1105    for (token, source) in qkv.chunks_exact(3 * dim).enumerate() {
1106        q[token * dim..(token + 1) * dim].copy_from_slice(&source[..dim]);
1107        k[token * dim..(token + 1) * dim].copy_from_slice(&source[dim..2 * dim]);
1108        v[token * dim..(token + 1) * dim].copy_from_slice(&source[2 * dim..]);
1109        let cos_row = &cos[token * rope_dim..(token + 1) * rope_dim];
1110        let sin_row = &sin[token * rope_dim..(token + 1) * rope_dim];
1111        for head in 0..heads {
1112            let offset = token * dim + head * head_dim;
1113            apply_rotary(&mut q[offset..offset + head_dim], cos_row, sin_row);
1114            apply_rotary(&mut k[offset..offset + head_dim], cos_row, sin_row);
1115        }
1116    }
1117    let scale = (head_dim as f32).sqrt().recip();
1118
1119    // The CPU reference below is intentionally retained as the portable
1120    // fallback, but its O(heads * n² * head_dim) loop is not viable for the
1121    // 3072-token V4.1 image grid.  The existing backend attention kernel
1122    // consumes head-major panels and returns the original token-major
1123    // layout, so transpose only when the caller has explicitly enabled a
1124    // live backend.  A refusal falls through to the exact same CPU path.
1125    if n >= 128 && crate::gpu::enabled_here() {
1126        let panel_len = n * heads * head_dim;
1127        let mut qh = vec![0.0f32; panel_len];
1128        let mut kh = vec![0.0f32; panel_len];
1129        let mut vh = vec![0.0f32; panel_len];
1130        for token in 0..n {
1131            for head in 0..heads {
1132                let src = token * dim + head * head_dim;
1133                let dst = head * n * head_dim + token * head_dim;
1134                qh[dst..dst + head_dim].copy_from_slice(&q[src..src + head_dim]);
1135                kh[dst..dst + head_dim].copy_from_slice(&k[src..src + head_dim]);
1136                vh[dst..dst + head_dim].copy_from_slice(&v[src..src + head_dim]);
1137            }
1138        }
1139        let mut context = vec![0.0f32; panel_len];
1140        if crate::gpu::dit_attention(
1141            &qh,
1142            &kh,
1143            &vh,
1144            heads,
1145            heads,
1146            n,
1147            head_dim,
1148            scale,
1149            &mut context,
1150        ) {
1151            GPU_ATTENTION_DISPATCHES.fetch_add(1, Ordering::Relaxed);
1152            let mut output = vec![0.0f32; n * dim];
1153            attention.wo.apply_many(&context, n, &mut output, pool);
1154            return output;
1155        }
1156    }
1157
1158    let mut context = vec![0.0f32; n * dim];
1159    let mut scores = vec![0.0f32; n];
1160    for head in 0..heads {
1161        for query in 0..n {
1162            let qrow = &q[query * dim + head * head_dim..query * dim + (head + 1) * head_dim];
1163            let mut max_score = f32::NEG_INFINITY;
1164            for key in 0..n {
1165                let krow = &k[key * dim + head * head_dim..key * dim + (head + 1) * head_dim];
1166                let score = qrow.iter().zip(krow).map(|(a, b)| a * b).sum::<f32>() * scale;
1167                scores[key] = score;
1168                max_score = max_score.max(score);
1169            }
1170            let mut denominator = 0.0f32;
1171            for score in &mut scores {
1172                *score = (*score - max_score).exp();
1173                denominator += *score;
1174            }
1175            let inv = denominator.recip();
1176            let out =
1177                &mut context[query * dim + head * head_dim..query * dim + (head + 1) * head_dim];
1178            for key in 0..n {
1179                let probability = scores[key] * inv;
1180                let vrow = &v[key * dim + head * head_dim..key * dim + (head + 1) * head_dim];
1181                for (dst, &value) in out.iter_mut().zip(vrow) {
1182                    *dst += probability * value;
1183                }
1184            }
1185        }
1186    }
1187    let mut output = vec![0.0f32; n * dim];
1188    attention.wo.apply_many(&context, n, &mut output, pool);
1189    output
1190}
1191
1192#[cfg(test)]
1193mod tests {
1194    use super::*;
1195
1196    #[test]
1197    fn image_grid_and_types_match_reference() {
1198        let config = VisionConfig {
1199            vision_n_layers: 1,
1200            ..VisionConfig::default()
1201        };
1202        let (h, w, best_h, best_w) = plan_image_grid(544, 544, &config).unwrap();
1203        assert_eq!((h, w), (13, 13));
1204        assert_eq!((best_h, best_w), (546, 546));
1205        assert_eq!(num_image_tokens(h, w), 184);
1206        let types = image_token_types(h, w);
1207        assert_eq!(types.first(), Some(&IMAGE_START));
1208        assert_eq!(types.last(), Some(&IMAGE_END));
1209        assert_eq!(types.iter().filter(|&&v| v == IMAGE_NEW_LINE).count(), h);
1210        assert_eq!(types.iter().filter(|&&v| v == IMAGE).count(), h * w);
1211    }
1212
1213    #[test]
1214    fn rope_grid_has_reference_first_rows() {
1215        let (cos, sin) = get_vision_cos_sin(2, 2, 4, 10_000.0);
1216        assert_eq!(&cos[..4], &[1.0, 1.0, 1.0, 1.0]);
1217        assert_eq!(&sin[..4], &[0.0, 0.0, 0.0, 0.0]);
1218        assert!((cos[6] - 0.5403023).abs() < 1e-6);
1219        assert!((sin[6] - 0.8414710).abs() < 1e-6);
1220    }
1221
1222    #[test]
1223    fn exact_gelu_and_rms_are_finite() {
1224        assert!((gelu_exact(1.0) - 0.8413447).abs() < 2e-6);
1225        let mut out = [0.0; 2];
1226        rms_norm(&[3.0, 4.0], &[1.0, 2.0], 1e-6, &mut out);
1227        assert!(out.iter().all(|v| v.is_finite()));
1228        let scale = (12.5_f32 + 1e-6).sqrt().recip();
1229        assert!((out[0] - 3.0 * scale).abs() < 1e-4);
1230        assert!((out[1] - 8.0 * scale).abs() < 1e-4);
1231    }
1232
1233    #[test]
1234    fn aligner_zero_pads_nonmultiple_vit_grid() {
1235        // Python's `-n % r` is positive for a nonmultiple n.  Thus a 35x46
1236        // ViT grid becomes 36x48 before F.unfold and yields the same 12x16
1237        // image rows advertised by the processor, rather than dropping the
1238        // final partial cells.
1239        assert_eq!(padded_vit_grid(35, 46, 3), (36, 48));
1240        assert_eq!(llm_grid(36 * 14, 48 * 14, 14, 3), (12, 16));
1241        assert_eq!(
1242            12 * 16,
1243            image_token_types(12, 16)
1244                .iter()
1245                .filter(|&&v| v == IMAGE)
1246                .count()
1247        );
1248
1249        // Also pin the actual channel-major unfold at both right and bottom
1250        // boundaries.  The second window contains the final real column and
1251        // two zero columns; its bottom two rows are zero as well.
1252        let x: Vec<f32> = (0..8).map(|v| v as f32).collect();
1253        let (windows, out_h, out_w) = unfold_padded(&x, 2, 4, 1, 3);
1254        assert_eq!((out_h, out_w), (1, 2));
1255        assert_eq!(
1256            &windows[..9],
1257            &[0.0, 1.0, 2.0, 4.0, 5.0, 6.0, 0.0, 0.0, 0.0]
1258        );
1259        assert_eq!(
1260            &windows[9..],
1261            &[3.0, 0.0, 0.0, 7.0, 0.0, 0.0, 0.0, 0.0, 0.0]
1262        );
1263    }
1264}