Skip to main content

cortiq_engine/
qwen_image_encoder.rs

1//! Native Qwen2.5-VL conditioning for Qwen-Image-Edit-2509.
2//!
3//! This module deliberately owns the complete prompt path: tokenizer JSON,
4//! the official Qwen Image prompt template, Qwen2VL image placeholders and
5//! vision tower, followed by the causal Qwen2.5-VL language tower.  The
6//! final hidden state is the tensor consumed by the Qwen Image transformer;
7//! there is no Qwen3 substitution or text-only approximation here.
8
9use crate::qtensor::QTensor;
10use crate::qwen_image_ops::Linear;
11use crate::qwen_image_vision::{prepare_image, PreparedImage, ProcessorConfig, VisionTower};
12use crate::tokenizer::Tokenizer;
13use cortiq_core::{CmfModel, TensorDtype};
14use image::RgbImage;
15use serde_json::Value;
16use std::path::Path;
17use std::sync::Arc;
18
19const PROMPT_TEMPLATE_HEAD: &str =
20    "<|im_start|>system\nDescribe the key features of the input image (color, shape, size, texture, objects, background), then explain how the user's text instruction should alter or modify the image. Generate a new image that meets the user's requirements while maintaining consistency with the original input where appropriate.<|im_end|>\n<|im_start|>user\n";
21const PROMPT_TEMPLATE_TAIL: &str = "<|im_end|>\n<|im_start|>assistant\n";
22const DROP_PREFIX: usize = 64;
23const EPS: f64 = 1e-6;
24
25/// Conditioning rows for one prompt.  `hidden` is row-major and contains
26/// only valid rows after the official 64-token instruction prefix is dropped.
27#[derive(Clone, Debug)]
28pub struct Conditioning {
29    pub hidden: Vec<f32>,
30    pub seq_len: usize,
31    pub hidden_size: usize,
32}
33
34struct MappedEmbedding {
35    model: Arc<CmfModel>,
36    idx: usize,
37    dtype: TensorDtype,
38    rows: usize,
39    cols: usize,
40}
41
42enum Embedding {
43    Mapped(MappedEmbedding),
44    Quant(QTensor),
45}
46
47impl Embedding {
48    fn load(model: &Arc<CmfModel>, name: &str) -> Result<Self, String> {
49        let idx = model
50            .tensor_index(name)
51            .ok_or_else(|| format!("missing embedding tensor '{name}'"))?;
52        let entry = &model.tensors[idx];
53        if entry.shape.len() != 2 {
54            return Err(format!(
55                "embedding tensor '{name}' must be rank 2, got shape {:?}",
56                entry.shape
57            ));
58        }
59        let rows = entry.shape[0];
60        let cols = entry.shape[1];
61        match entry.dtype {
62            TensorDtype::F32 | TensorDtype::F16 | TensorDtype::Bf16 => {
63                Ok(Self::Mapped(MappedEmbedding {
64                    model: model.clone(),
65                    idx,
66                    dtype: entry.dtype,
67                    rows,
68                    cols,
69                }))
70            }
71            _ => Ok(Self::Quant(QTensor::from_model(model, name)?)),
72        }
73    }
74
75    fn rows(&self) -> usize {
76        match self {
77            Self::Mapped(e) => e.rows,
78            Self::Quant(q) => q.rows(),
79        }
80    }
81
82    fn cols(&self) -> usize {
83        match self {
84            Self::Mapped(e) => e.cols,
85            Self::Quant(q) => q.cols(),
86        }
87    }
88
89    fn row(&self, id: usize, dst: &mut [f32]) -> Result<(), String> {
90        if id >= self.rows() {
91            return Err(format!(
92                "token id {id} exceeds embedding rows {}",
93                self.rows()
94            ));
95        }
96        if dst.len() != self.cols() {
97            return Err(format!(
98                "embedding destination width {} != {}",
99                dst.len(),
100                self.cols()
101            ));
102        }
103        match self {
104            Self::Quant(q) => {
105                q.row_f32(id, dst);
106                Ok(())
107            }
108            Self::Mapped(e) => {
109                let entry = &e.model.tensors[e.idx];
110                let bytes = e.model.entry_bytes(entry);
111                let elem_bytes = match e.dtype {
112                    TensorDtype::F32 => 4,
113                    TensorDtype::F16 | TensorDtype::Bf16 => 2,
114                    _ => unreachable!(),
115                };
116                let expected = e
117                    .rows
118                    .checked_mul(e.cols)
119                    .and_then(|n| n.checked_mul(elem_bytes))
120                    .ok_or_else(|| {
121                        format!("embedding tensor '{}' byte size overflow", entry.name)
122                    })?;
123                if bytes.len() != expected {
124                    return Err(format!(
125                        "embedding tensor '{}' has {} bytes, expected {}",
126                        entry.name,
127                        bytes.len(),
128                        expected
129                    ));
130                }
131                let start = id * e.cols * elem_bytes;
132                let src = &bytes[start..start + e.cols * elem_bytes];
133                for i in 0..e.cols {
134                    let off = i * elem_bytes;
135                    dst[i] = match e.dtype {
136                        TensorDtype::F32 => {
137                            f32::from_le_bytes(src[off..off + 4].try_into().unwrap())
138                        }
139                        TensorDtype::F16 => cortiq_core::quant::f16_to_f32(u16::from_le_bytes([
140                            src[off],
141                            src[off + 1],
142                        ])),
143                        TensorDtype::Bf16 => cortiq_core::quant::bf16_to_f32(u16::from_le_bytes([
144                            src[off],
145                            src[off + 1],
146                        ])),
147                        _ => unreachable!(),
148                    };
149                }
150                Ok(())
151            }
152        }
153    }
154}
155
156struct TextLayer {
157    input_norm: Vec<f32>,
158    q: Linear,
159    q_bias: Vec<f32>,
160    k: Linear,
161    k_bias: Vec<f32>,
162    v: Linear,
163    v_bias: Vec<f32>,
164    o: Linear,
165    post_norm: Vec<f32>,
166    gate: Linear,
167    up: Linear,
168    down: Linear,
169    sliding: bool,
170}
171
172/// Qwen2.5-VL prompt encoder.  The component CMF retains the official
173/// `model.*` and `visual.*` tensor names, plus U8 `image.*_json` assets.
174pub struct QwenImageEncoder {
175    model: Arc<CmfModel>,
176    tokenizer: Tokenizer,
177    processor: ProcessorConfig,
178    embed: Embedding,
179    layers: Vec<TextLayer>,
180    final_norm: Vec<f32>,
181    vision: VisionTower,
182    hidden: usize,
183    heads: usize,
184    kv_heads: usize,
185    head_dim: usize,
186    intermediate: usize,
187    eps: f64,
188    rope_theta: f64,
189    mrope_section: [usize; 3],
190    sliding_window: usize,
191    image_token_id: u32,
192    vision_start_token_id: u32,
193    vision_end_token_id: u32,
194}
195
196/// Short contract name retained for callers that use the phase document's
197/// `Encoder::open` spelling.
198pub type Encoder = QwenImageEncoder;
199
200fn json_blob<'a>(model: &'a CmfModel, name: &str) -> Result<&'a [u8], String> {
201    let entry = model
202        .tensor(name)
203        .ok_or_else(|| format!("missing required U8 asset '{name}'"))?;
204    if entry.dtype != TensorDtype::U8 {
205        return Err(format!(
206            "asset '{name}' must use U8 storage, got {}",
207            entry.dtype.name()
208        ));
209    }
210    if entry.shape.len() != 1 || entry.shape[0] != entry.n_elems() {
211        return Err(format!("asset '{name}' must be a one-dimensional U8 blob"));
212    }
213    Ok(model.entry_bytes(entry))
214}
215
216/// Standalone encoder CMFs keep the historical `image.config_json` name.
217/// Bundles retain the transformer config at that name and carry this
218/// component's config under an explicit alias so all three loaders can read
219/// one mmap without colliding directory entries.
220fn json_blob_any<'a>(model: &'a CmfModel, names: &[&str]) -> Result<&'a [u8], String> {
221    for name in names {
222        if model.tensor(name).is_some() {
223            return json_blob(model, name);
224        }
225    }
226    Err(format!("missing required U8 asset '{}'", names[0]))
227}
228
229fn value_usize(v: &Value, key: &str) -> Result<usize, String> {
230    v.get(key)
231        .and_then(Value::as_u64)
232        .map(|x| x as usize)
233        .ok_or_else(|| format!("Qwen config missing integer '{key}'"))
234}
235
236fn value_usize_default(v: &Value, key: &str, default: usize) -> usize {
237    v.get(key)
238        .and_then(Value::as_u64)
239        .map(|x| x as usize)
240        .unwrap_or(default)
241}
242
243fn value_bool(v: &Value, key: &str, default: bool) -> bool {
244    v.get(key).and_then(Value::as_bool).unwrap_or(default)
245}
246
247fn text_config<'a>(root: &'a Value) -> &'a Value {
248    root.get("text_config").unwrap_or(root)
249}
250
251fn vision_config<'a>(root: &'a Value) -> Result<&'a Value, String> {
252    root.get("vision_config")
253        .ok_or_else(|| "Qwen2.5-VL config has no vision_config".into())
254}
255
256fn vector(model: &CmfModel, name: &str, expected: usize) -> Result<Vec<f32>, String> {
257    let v = crate::dit::cmf_f32(model, name)?;
258    if v.len() != expected {
259        return Err(format!(
260            "tensor '{name}' has {} values, expected {expected}",
261            v.len()
262        ));
263    }
264    Ok(v)
265}
266
267fn linear(model: &Arc<CmfModel>, name: &str, rows: usize, cols: usize) -> Result<Linear, String> {
268    let p = Linear::load(model, name)?;
269    if p.rows() != rows || p.cols() != cols {
270        return Err(format!(
271            "tensor '{name}' shape [{},{}] != expected [{rows},{cols}]",
272            p.rows(),
273            p.cols()
274        ));
275    }
276    Ok(p)
277}
278
279fn special_id(
280    root: &Value,
281    text: &Value,
282    tokenizer: &Tokenizer,
283    config_key: &str,
284    token: &str,
285) -> Result<u32, String> {
286    root.get(config_key)
287        .and_then(Value::as_u64)
288        .or_else(|| text.get(config_key).and_then(Value::as_u64))
289        .map(|v| v as u32)
290        .or_else(|| tokenizer.token_to_id(token))
291        .ok_or_else(|| format!("missing Qwen special token '{token}' / config key '{config_key}'"))
292}
293
294fn parse_mrope(text: &Value, head_dim: usize) -> Result<[usize; 3], String> {
295    let values = text
296        .get("rope_scaling")
297        .and_then(|v| v.get("mrope_section"))
298        .and_then(Value::as_array);
299    let mut out = if let Some(values) = values {
300        if values.len() != 3 {
301            return Err("rope_scaling.mrope_section must contain three integers".into());
302        }
303        [
304            values[0]
305                .as_u64()
306                .ok_or("mrope_section[0] is not an integer")? as usize,
307            values[1]
308                .as_u64()
309                .ok_or("mrope_section[1] is not an integer")? as usize,
310            values[2]
311                .as_u64()
312                .ok_or("mrope_section[2] is not an integer")? as usize,
313        ]
314    } else if head_dim == 128 {
315        // The canonical Qwen2.5-VL config carries this field.  Retaining the
316        // known value also lets a minimal seeded fixture omit only metadata
317        // that is fixed by the canonical head width.
318        [16, 24, 24]
319    } else {
320        return Err("Qwen2.5-VL config missing rope_scaling.mrope_section".into());
321    };
322    if out.iter().any(|&x| x == 0) || out.iter().sum::<usize>() != head_dim / 2 {
323        return Err(format!(
324            "mrope_section {:?} must be positive and sum to head_dim/2={}",
325            out,
326            head_dim / 2
327        ));
328    }
329    Ok(out)
330}
331
332impl QwenImageEncoder {
333    /// Open a standalone Qwen2.5-VL component CMF.  Sharded CMFs are opened
334    /// through the core loader so the language and vision mappings remain
335    /// lazy and no giant F32 embedding copy is created.
336    pub fn open(path: &Path) -> Result<Self, String> {
337        let model = Arc::new(CmfModel::open_sharded(path).map_err(|e| e.to_string())?);
338        let config_bytes = json_blob_any(
339            &model,
340            &["image.text_encoder.config_json", "image.config_json"],
341        )?;
342        let root: Value =
343            serde_json::from_slice(config_bytes).map_err(|e| format!("image.config_json: {e}"))?;
344        let text = text_config(&root);
345        let vision_cfg = vision_config(&root)?;
346        let processor =
347            ProcessorConfig::from_json(json_blob(&model, "image.processor_config_json")?)?;
348        let tokenizer = Tokenizer::from_bytes(json_blob(&model, "image.tokenizer_json")?)
349            .map_err(|e| format!("image.tokenizer_json: {e}"))?;
350        // An optional tokenizer config is retained as a load-time contract:
351        // if present it must be valid JSON, but prompt behavior comes from
352        // the pipeline's pinned template rather than a guessed chat template.
353        if let Some(entry) = model.tensor("image.tokenizer_config_json") {
354            if entry.dtype != TensorDtype::U8 {
355                return Err("image.tokenizer_config_json must use U8 storage".into());
356            }
357            serde_json::from_slice::<Value>(model.entry_bytes(entry))
358                .map_err(|e| format!("image.tokenizer_config_json: {e}"))?;
359        }
360
361        let hidden = value_usize(text, "hidden_size")?;
362        let intermediate = value_usize(text, "intermediate_size")?;
363        let n_layers = value_usize(text, "num_hidden_layers")?;
364        let heads = value_usize(text, "num_attention_heads")?;
365        let kv_heads = value_usize_default(text, "num_key_value_heads", heads);
366        let head_dim = hidden
367            .checked_div(heads)
368            .ok_or_else(|| "Qwen text heads must be positive".to_string())?;
369        if hidden == 0
370            || heads == 0
371            || hidden % heads != 0
372            || kv_heads == 0
373            || heads % kv_heads != 0
374            || head_dim < 2
375            || head_dim % 2 != 0
376        {
377            return Err(format!(
378                "invalid Qwen text geometry hidden={hidden}, heads={heads}, kv_heads={kv_heads}"
379            ));
380        }
381        if text
382            .get("hidden_act")
383            .and_then(Value::as_str)
384            .unwrap_or("silu")
385            != "silu"
386        {
387            return Err("Qwen2.5-VL text MLP requires hidden_act='silu'".into());
388        }
389        let eps = text
390            .get("rms_norm_eps")
391            .and_then(Value::as_f64)
392            .unwrap_or(EPS);
393        let rope_theta = text
394            .get("rope_theta")
395            .and_then(Value::as_f64)
396            .unwrap_or(1_000_000.0);
397        if !eps.is_finite() || eps <= 0.0 || !rope_theta.is_finite() || rope_theta <= 0.0 {
398            return Err("Qwen text epsilon/rope_theta must be finite and positive".into());
399        }
400        let mrope_section = parse_mrope(text, head_dim)?;
401        let layer_types = if let Some(a) = text.get("layer_types").and_then(Value::as_array) {
402            if a.len() != n_layers {
403                return Err(format!(
404                    "layer_types length {} != num_hidden_layers {n_layers}",
405                    a.len()
406                ));
407            }
408            a.iter()
409                .map(|v| {
410                    v.as_str()
411                        .map(str::to_owned)
412                        .ok_or_else(|| "layer_types contains a non-string".to_string())
413                })
414                .collect::<Result<Vec<_>, _>>()?
415        } else {
416            let use_sliding = value_bool(text, "use_sliding_window", false);
417            let max_window_layers = value_usize_default(text, "max_window_layers", n_layers);
418            (0..n_layers)
419                .map(|i| {
420                    if use_sliding && i >= max_window_layers {
421                        "sliding_attention".into()
422                    } else {
423                        "full_attention".into()
424                    }
425                })
426                .collect()
427        };
428        if layer_types
429            .iter()
430            .any(|kind| kind != "full_attention" && kind != "sliding_attention")
431        {
432            return Err("Qwen layer_types contains an unsupported attention type".into());
433        }
434        let sliding_window = value_usize_default(text, "sliding_window", 4096);
435        if layer_types.iter().any(|x| x == "sliding_attention") && sliding_window == 0 {
436            return Err("Qwen sliding_window must be positive".into());
437        }
438
439        let image_token_id =
440            special_id(&root, text, &tokenizer, "image_token_id", "<|image_pad|>")?;
441        let vision_start_token_id = special_id(
442            &root,
443            text,
444            &tokenizer,
445            "vision_start_token_id",
446            "<|vision_start|>",
447        )?;
448        let vision_end_token_id = special_id(
449            &root,
450            text,
451            &tokenizer,
452            "vision_end_token_id",
453            "<|vision_end|>",
454        )?;
455        for (token, id) in [
456            ("<|image_pad|>", image_token_id),
457            ("<|vision_start|>", vision_start_token_id),
458            ("<|vision_end|>", vision_end_token_id),
459        ] {
460            if let Some(tok_id) = tokenizer.token_to_id(token) {
461                if tok_id != id {
462                    return Err(format!(
463                        "config token id {id} for '{token}' disagrees with tokenizer id {tok_id}"
464                    ));
465                }
466            }
467        }
468
469        let embed = Embedding::load(&model, "model.embed_tokens.weight")?;
470        if embed.cols() != hidden {
471            return Err(format!(
472                "model.embed_tokens width {} != text hidden {hidden}",
473                embed.cols()
474            ));
475        }
476        let mut layers = Vec::with_capacity(n_layers);
477        for i in 0..n_layers {
478            let p = format!("model.layers.{i}");
479            let q_rows = heads * head_dim;
480            let kv_rows = kv_heads * head_dim;
481            layers.push(TextLayer {
482                input_norm: vector(&model, &format!("{p}.input_layernorm.weight"), hidden)?,
483                q: linear(
484                    &model,
485                    &format!("{p}.self_attn.q_proj.weight"),
486                    q_rows,
487                    hidden,
488                )?,
489                q_bias: vector(&model, &format!("{p}.self_attn.q_proj.bias"), q_rows)?,
490                k: linear(
491                    &model,
492                    &format!("{p}.self_attn.k_proj.weight"),
493                    kv_rows,
494                    hidden,
495                )?,
496                k_bias: vector(&model, &format!("{p}.self_attn.k_proj.bias"), kv_rows)?,
497                v: linear(
498                    &model,
499                    &format!("{p}.self_attn.v_proj.weight"),
500                    kv_rows,
501                    hidden,
502                )?,
503                v_bias: vector(&model, &format!("{p}.self_attn.v_proj.bias"), kv_rows)?,
504                o: linear(
505                    &model,
506                    &format!("{p}.self_attn.o_proj.weight"),
507                    hidden,
508                    q_rows,
509                )?,
510                post_norm: vector(
511                    &model,
512                    &format!("{p}.post_attention_layernorm.weight"),
513                    hidden,
514                )?,
515                gate: linear(
516                    &model,
517                    &format!("{p}.mlp.gate_proj.weight"),
518                    intermediate,
519                    hidden,
520                )?,
521                up: linear(
522                    &model,
523                    &format!("{p}.mlp.up_proj.weight"),
524                    intermediate,
525                    hidden,
526                )?,
527                down: linear(
528                    &model,
529                    &format!("{p}.mlp.down_proj.weight"),
530                    hidden,
531                    intermediate,
532                )?,
533                sliding: layer_types[i] == "sliding_attention",
534            });
535        }
536        let final_norm = vector(&model, "model.norm.weight", hidden)?;
537        let vision = VisionTower::from_cmf(&model, vision_cfg)?;
538        if vision.out_hidden != hidden {
539            return Err(format!(
540                "vision merger output {} != text hidden {hidden}; image features cannot be spliced",
541                vision.out_hidden
542            ));
543        }
544        Ok(Self {
545            model,
546            tokenizer,
547            processor,
548            embed,
549            layers,
550            final_norm,
551            vision,
552            hidden,
553            heads,
554            kv_heads,
555            head_dim,
556            intermediate,
557            eps,
558            rope_theta,
559            mrope_section,
560            sliding_window,
561            image_token_id,
562            vision_start_token_id,
563            vision_end_token_id,
564        })
565    }
566
567    pub fn hidden_size(&self) -> usize {
568        self.hidden
569    }
570
571    /// Stable CMF identity for the caller's targeted GPU buffer release
572    /// after this encoder's stage scope ends.  The encoder keeps this value
573    /// available without exposing its internal model mapping.
574    pub fn model_uid(&self) -> u64 {
575        self.model.uid()
576    }
577
578    fn rms_norm(&self, x: &[f32], w: &[f32], dst: &mut [f32]) {
579        let ss = x.iter().map(|&v| (v as f64) * (v as f64)).sum::<f64>() / x.len() as f64;
580        let inv = 1.0 / (ss + self.eps).sqrt();
581        for ((d, &v), &g) in dst.iter_mut().zip(x).zip(w) {
582            *d = (v as f64 * inv) as f32 * g;
583        }
584    }
585
586    fn build_prompt(
587        &self,
588        prompt: &str,
589        images: &[PreparedImage],
590    ) -> Result<(String, Vec<usize>), String> {
591        let mut image_text = String::new();
592        let mut token_counts = Vec::with_capacity(images.len());
593        for (i, image) in images.iter().enumerate() {
594            let count = image.image_tokens(self.processor.merge_size)?;
595            if count == 0 {
596                return Err(format!("image {} produced zero image tokens", i + 1));
597            }
598            token_counts.push(count);
599            image_text.push_str(&format!("Picture {}: <|vision_start|>", i + 1));
600            for _ in 0..count {
601                image_text.push_str("<|image_pad|>");
602            }
603            image_text.push_str("<|vision_end|>");
604        }
605        let mut out = String::with_capacity(
606            PROMPT_TEMPLATE_HEAD.len()
607                + image_text.len()
608                + prompt.len()
609                + PROMPT_TEMPLATE_TAIL.len(),
610        );
611        out.push_str(PROMPT_TEMPLATE_HEAD);
612        out.push_str(&image_text);
613        out.push_str(prompt);
614        out.push_str(PROMPT_TEMPLATE_TAIL);
615        Ok((out, token_counts))
616    }
617
618    fn locate_images(
619        &self,
620        ids: &[u32],
621        token_counts: &[usize],
622        features: &[Vec<f32>],
623    ) -> Result<Vec<ImagePlacement>, String> {
624        if token_counts.len() != features.len() {
625            return Err("image token/feature count mismatch".into());
626        }
627        let mut found = Vec::with_capacity(token_counts.len());
628        let mut cursor = 0usize;
629        for (image_idx, (&count, feature)) in token_counts.iter().zip(features).enumerate() {
630            if cursor >= ids.len() {
631                return Err(format!(
632                    "image {} is missing vision_start token",
633                    image_idx + 1
634                ));
635            }
636            let start_marker = ids[cursor..]
637                .iter()
638                .position(|&id| id == self.vision_start_token_id)
639                .map(|p| cursor + p)
640                .ok_or_else(|| format!("image {} is missing vision_start token", image_idx + 1))?;
641            let start = start_marker + 1;
642            if start + count >= ids.len() {
643                return Err(format!(
644                    "image {} placeholder exceeds token sequence",
645                    image_idx + 1
646                ));
647            }
648            if ids[start..start + count]
649                .iter()
650                .any(|&id| id != self.image_token_id)
651            {
652                return Err(format!(
653                    "image {} placeholder run has a non-image token",
654                    image_idx + 1
655                ));
656            }
657            if ids[start + count] != self.vision_end_token_id {
658                return Err(format!(
659                    "image {} is missing vision_end after {} image tokens",
660                    image_idx + 1,
661                    count
662                ));
663            }
664            if feature.len() != count * self.hidden {
665                return Err(format!(
666                    "image {} feature width {} != {} × {}",
667                    image_idx + 1,
668                    feature.len(),
669                    count,
670                    self.hidden
671                ));
672            }
673            found.push(ImagePlacement {
674                start,
675                count,
676                feature: feature.clone(),
677            });
678            cursor = start + count + 1;
679        }
680        let extra = ids[cursor..]
681            .iter()
682            .filter(|&&id| id == self.image_token_id)
683            .count();
684        if extra != 0 {
685            return Err("prompt contains more image placeholders than supplied images".into());
686        }
687        Ok(found)
688    }
689
690    fn mrope_positions(
691        &self,
692        ids: &[u32],
693        grids: &[Grid],
694        placements: &[ImagePlacement],
695    ) -> Result<Vec<[i64; 3]>, String> {
696        if grids.len() != placements.len() {
697            return Err("grid/placement count mismatch".into());
698        }
699        let mut pos = vec![[0i64; 3]; ids.len()];
700        let mut cursor = 0usize;
701        let mut prior_max = -1i64;
702        for (image_idx, (grid, placement)) in grids.iter().zip(placements).enumerate() {
703            if placement.start < cursor || placement.start + placement.count > ids.len() {
704                return Err(format!("image {image_idx} placement is outside prompt"));
705            }
706            let text_len = placement.start - cursor;
707            let st_idx = prior_max + 1;
708            for j in 0..text_len {
709                let v = st_idx + j as i64;
710                pos[cursor + j] = [v; 3];
711            }
712            let llm_h = grid.h / self.processor.merge_size;
713            let llm_w = grid.w / self.processor.merge_size;
714            let expected = grid
715                .t
716                .checked_mul(llm_h)
717                .and_then(|n| n.checked_mul(llm_w))
718                .ok_or_else(|| "image MRoPE grid overflow".to_string())?;
719            if expected != placement.count {
720                return Err(format!(
721                    "image {image_idx} grid yields {expected} tokens, placeholder has {}",
722                    placement.count
723                ));
724            }
725            for local in 0..placement.count {
726                let per_t = llm_h * llm_w;
727                let t = local / per_t;
728                let rem = local % per_t;
729                let h = rem / llm_w;
730                let w = rem % llm_w;
731                let base = text_len as i64 + st_idx;
732                pos[placement.start + local] = [base + t as i64, base + h as i64, base + w as i64];
733            }
734            let end = placement.start + placement.count;
735            prior_max = pos[cursor..end]
736                .iter()
737                .flat_map(|p| p.iter())
738                .copied()
739                .max()
740                .ok_or_else(|| "empty Qwen prompt segment".to_string())?;
741            // Keep the vision_end token in the next text segment, exactly as
742            // Qwen2.5-VL's `st = ed + image_count` does.
743            cursor = end;
744        }
745        if cursor < ids.len() {
746            let st_idx = prior_max + 1;
747            for j in cursor..ids.len() {
748                let v = st_idx + (j - cursor) as i64;
749                pos[j] = [v; 3];
750            }
751        }
752        Ok(pos)
753    }
754
755    fn apply_text_rope(&self, q: &mut [f32], k: &mut [f32], positions: &[[i64; 3]]) {
756        Self::apply_mrope_rotary(
757            q,
758            k,
759            positions,
760            self.hidden,
761            self.heads,
762            self.kv_heads,
763            self.head_dim,
764            self.rope_theta,
765            self.mrope_section,
766        );
767    }
768    /// Apply Qwen2.5-VL's three-axis rotary embedding to Q/K in place.
769    ///
770    /// The configured mRoPE section lengths describe one half of the rotary
771    /// vector.  Transformers duplicates that list before selecting axes, so the
772    /// canonical `[16, 24, 24]` config produces six sections
773    /// `[16, 24, 24, 16, 24, 24]`.  Keeping the axis selection here (rather than
774    /// assigning one axis to each whole half) is required for anisotropic image
775    /// positions and preserves an orthogonal rotation for every pair.
776    fn apply_mrope_rotary(
777        q: &mut [f32],
778        k: &mut [f32],
779        positions: &[[i64; 3]],
780        hidden: usize,
781        heads: usize,
782        kv_heads: usize,
783        head_dim: usize,
784        rope_theta: f64,
785        mrope_section: [usize; 3],
786    ) {
787        let half = head_dim / 2;
788        debug_assert_eq!(hidden, heads * head_dim);
789        debug_assert_eq!(q.len(), positions.len() * hidden);
790        debug_assert_eq!(k.len(), positions.len() * kv_heads * head_dim);
791        for (token, p) in positions.iter().enumerate() {
792            let mut cos = vec![0f32; head_dim];
793            let mut sin = vec![0f32; head_dim];
794            // Transformers builds `freqs = position @ inv_freq`, duplicates
795            // it (`cat(freqs, freqs)`), then replaces each mRoPE section with
796            // the corresponding temporal/height/width section.  Preserve
797            // that split before rotate-half; a direct `[T,H,W]` assignment
798            // to the first/second head halves is a different rotation.
799            let mut axis_emb = vec![vec![0f32; head_dim]; 3];
800            let mut axis_sin = vec![vec![0f32; head_dim]; 3];
801            for axis in 0..3 {
802                for j in 0..half {
803                    let freq = 1.0 / rope_theta.powf(2.0 * j as f64 / head_dim as f64);
804                    let (s, c) = (p[axis] as f64 * freq).sin_cos();
805                    axis_emb[axis][j] = c as f32;
806                    axis_sin[axis][j] = s as f32;
807                    axis_emb[axis][j + half] = c as f32;
808                    axis_sin[axis][j + half] = s as f32;
809                }
810            }
811            let section_widths = [
812                mrope_section[0],
813                mrope_section[1],
814                mrope_section[2],
815                mrope_section[0],
816                mrope_section[1],
817                mrope_section[2],
818            ];
819            let mut offset = 0usize;
820            for (section, &width) in section_widths.iter().enumerate() {
821                let axis = section % 3;
822                let end = offset + width;
823                cos[offset..end].copy_from_slice(&axis_emb[axis][offset..end]);
824                sin[offset..end].copy_from_slice(&axis_sin[axis][offset..end]);
825                offset += width;
826            }
827            debug_assert_eq!(offset, head_dim);
828            for head in 0..heads {
829                let qv = &mut q
830                    [token * hidden + head * head_dim..token * hidden + (head + 1) * head_dim];
831                for d in 0..half {
832                    let (a, b) = (qv[d], qv[d + half]);
833                    qv[d] = a * cos[d] - b * sin[d];
834                    qv[d + half] = a * sin[d + half] + b * cos[d + half];
835                }
836            }
837            for head in 0..kv_heads {
838                let kv_offset = token * kv_heads * head_dim + head * head_dim;
839                let kv = &mut k[kv_offset..kv_offset + head_dim];
840                for d in 0..half {
841                    let (a, b) = (kv[d], kv[d + half]);
842                    kv[d] = a * cos[d] - b * sin[d];
843                    kv[d + half] = a * sin[d + half] + b * cos[d + half];
844                }
845            }
846        }
847    }
848
849    fn text_forward(
850        &self,
851        ids: &[u32],
852        positions: &[[i64; 3]],
853        placements: &[ImagePlacement],
854    ) -> Result<Vec<f32>, String> {
855        if ids.is_empty() || ids.len() != positions.len() {
856            return Err("Qwen prompt token/position lengths do not match".into());
857        }
858        let n = ids.len();
859        let mut h = vec![0f32; n * self.hidden];
860        for (i, &id) in ids.iter().enumerate() {
861            self.embed
862                .row(id as usize, &mut h[i * self.hidden..(i + 1) * self.hidden])?;
863        }
864        for placement in placements {
865            h[placement.start * self.hidden..(placement.start + placement.count) * self.hidden]
866                .copy_from_slice(&placement.feature);
867        }
868        let pool = crate::pool::Pool::from_env();
869        let pool_ref = pool.as_deref();
870        let mut norm = vec![0f32; n * self.hidden];
871        let mut q = vec![0f32; n * self.hidden];
872        let mut k = vec![0f32; n * self.kv_heads * self.head_dim];
873        let mut v = vec![0f32; n * self.kv_heads * self.head_dim];
874        let mut attn = vec![0f32; n * self.hidden];
875        let mut proj = vec![0f32; n * self.hidden];
876        let mut gate = vec![0f32; n * self.intermediate];
877        let mut up = vec![0f32; n * self.intermediate];
878        let mut down = vec![0f32; n * self.hidden];
879        for layer in &self.layers {
880            for i in 0..n {
881                self.rms_norm(
882                    &h[i * self.hidden..(i + 1) * self.hidden],
883                    &layer.input_norm,
884                    &mut norm[i * self.hidden..(i + 1) * self.hidden],
885                );
886            }
887            layer.q.forward(&norm, n, &mut q, pool_ref)?;
888            layer.k.forward(&norm, n, &mut k, pool_ref)?;
889            layer.v.forward(&norm, n, &mut v, pool_ref)?;
890            crate::qwen_image_ops::add_bias(&mut q, n, &layer.q_bias)?;
891            crate::qwen_image_ops::add_bias(&mut k, n, &layer.k_bias)?;
892            crate::qwen_image_ops::add_bias(&mut v, n, &layer.v_bias)?;
893            self.apply_text_rope(&mut q, &mut k, positions);
894            attn.fill(0.0);
895            let groups = self.heads / self.kv_heads;
896            for head in 0..self.heads {
897                let kv_head = head / groups;
898                for i in 0..n {
899                    let first = if layer.sliding {
900                        i.saturating_add(1).saturating_sub(self.sliding_window)
901                    } else {
902                        0
903                    };
904                    let qv = &q[i * self.hidden + head * self.head_dim
905                        ..i * self.hidden + (head + 1) * self.head_dim];
906                    let mut scores = vec![0f32; i - first + 1];
907                    let mut mx = f32::NEG_INFINITY;
908                    for (slot, j) in (first..=i).enumerate() {
909                        let kv = &k[j * self.kv_heads * self.head_dim + kv_head * self.head_dim
910                            ..j * self.kv_heads * self.head_dim + (kv_head + 1) * self.head_dim];
911                        let score =
912                            crate::attention::dot_f32(qv, kv) / (self.head_dim as f32).sqrt();
913                        scores[slot] = score;
914                        mx = mx.max(score);
915                    }
916                    let mut den = 0.0f32;
917                    for score in &mut scores {
918                        *score = (*score - mx).exp();
919                        den += *score;
920                    }
921                    if den == 0.0 {
922                        return Err("Qwen text attention softmax underflowed".into());
923                    }
924                    let inv = 1.0 / den;
925                    let out = &mut attn[i * self.hidden + head * self.head_dim
926                        ..i * self.hidden + (head + 1) * self.head_dim];
927                    for (slot, j) in (first..=i).enumerate() {
928                        let vv = &v[j * self.kv_heads * self.head_dim + kv_head * self.head_dim
929                            ..j * self.kv_heads * self.head_dim + (kv_head + 1) * self.head_dim];
930                        let weight = scores[slot] * inv;
931                        for (o, &vv) in out.iter_mut().zip(vv) {
932                            *o += weight * vv;
933                        }
934                    }
935                }
936            }
937            layer.o.forward(&attn, n, &mut proj, pool_ref)?;
938            for (a, &b) in h.iter_mut().zip(&proj) {
939                *a += b;
940            }
941            for i in 0..n {
942                self.rms_norm(
943                    &h[i * self.hidden..(i + 1) * self.hidden],
944                    &layer.post_norm,
945                    &mut norm[i * self.hidden..(i + 1) * self.hidden],
946                );
947            }
948            layer.gate.forward(&norm, n, &mut gate, pool_ref)?;
949            layer.up.forward(&norm, n, &mut up, pool_ref)?;
950            for (g, &u) in gate.iter_mut().zip(&up) {
951                *g = (*g / (1.0 + (-*g).exp())) * u;
952            }
953            layer.down.forward(&gate, n, &mut down, pool_ref)?;
954            for (a, &b) in h.iter_mut().zip(&down) {
955                *a += b;
956            }
957        }
958        for i in 0..n {
959            self.rms_norm(
960                &h[i * self.hidden..(i + 1) * self.hidden],
961                &self.final_norm,
962                &mut norm[i * self.hidden..(i + 1) * self.hidden],
963            );
964        }
965        Ok(norm)
966    }
967
968    /// Encode a positive or negative prompt with the same reference image
969    /// list.  The caller invokes this once for each true-CFG branch; image
970    /// preprocessing and vision placeholders therefore remain identical.
971    pub fn encode(&self, prompt: &str, images: &[RgbImage]) -> Result<Conditioning, String> {
972        let prepared: Vec<PreparedImage> = images
973            .iter()
974            .map(|image| prepare_image(image, &self.processor))
975            .collect::<Result<_, _>>()?;
976        let features = self.vision.forward(&prepared)?;
977        let (prompt_text, token_counts) = self.build_prompt(prompt, &prepared)?;
978        let ids = self.tokenizer.encode(&prompt_text);
979        if ids.iter().any(|&id| id as usize >= self.embed.rows()) {
980            return Err("Qwen tokenizer emitted an id outside the embedding table".into());
981        }
982        let mut grids = Vec::with_capacity(prepared.len());
983        for image in &prepared {
984            grids.push(Grid {
985                t: image.grid_t,
986                h: image.grid_h,
987                w: image.grid_w,
988            });
989        }
990        let placements = self.locate_images(&ids, &token_counts, &features)?;
991        let positions = self.mrope_positions(&ids, &grids, &placements)?;
992        let all = self.text_forward(&ids, &positions, &placements)?;
993        if all.len() != ids.len() * self.hidden || ids.len() <= DROP_PREFIX {
994            return Err(format!(
995                "Qwen final hidden shape {}×{} cannot drop required {}-token prefix",
996                ids.len(),
997                self.hidden,
998                DROP_PREFIX
999            ));
1000        }
1001        let hidden = all[DROP_PREFIX * self.hidden..].to_vec();
1002        Ok(Conditioning {
1003            seq_len: hidden.len() / self.hidden,
1004            hidden_size: self.hidden,
1005            hidden,
1006        })
1007    }
1008
1009    /// Name matching the pipeline's positive/negative branch operation while
1010    /// keeping one shared image list and one immutable encoder mapping.
1011    pub fn encode_pair(
1012        &self,
1013        prompt: &str,
1014        negative_prompt: &str,
1015        images: &[RgbImage],
1016    ) -> Result<(Conditioning, Conditioning), String> {
1017        Ok((
1018            self.encode(prompt, images)?,
1019            self.encode(negative_prompt, images)?,
1020        ))
1021    }
1022}
1023
1024struct ImagePlacement {
1025    start: usize,
1026    count: usize,
1027    feature: Vec<f32>,
1028}
1029
1030#[derive(Clone, Copy)]
1031struct Grid {
1032    t: usize,
1033    h: usize,
1034    w: usize,
1035}
1036
1037#[cfg(test)]
1038mod tests {
1039    use super::{
1040        parse_mrope, QwenImageEncoder, DROP_PREFIX, PROMPT_TEMPLATE_HEAD, PROMPT_TEMPLATE_TAIL,
1041    };
1042    use serde_json::json;
1043
1044    #[test]
1045    fn canonical_template_and_prefix_contract_are_fixed() {
1046        assert!(PROMPT_TEMPLATE_HEAD.starts_with("<|im_start|>system\nDescribe"));
1047        assert!(PROMPT_TEMPLATE_TAIL.ends_with("<|im_start|>assistant\n"));
1048        assert_eq!(DROP_PREFIX, 64);
1049    }
1050
1051    #[test]
1052    fn tiny_hidden_width_uses_explicit_mrope_sections() {
1053        let cfg = json!({"rope_scaling":{"mrope_section":[1,1,2]}});
1054        assert_eq!(parse_mrope(&cfg, 8).unwrap(), [1, 1, 2]);
1055        assert!(parse_mrope(&json!({}), 8).is_err());
1056    }
1057
1058    #[test]
1059    fn canonical_mrope_repeats_sections_for_anisotropic_positions() {
1060        let hidden = 128;
1061        let heads = 1;
1062        let kv_heads = 1;
1063        let head_dim = 128;
1064        let rope_theta = 1_000_000.0;
1065        let sections = [16, 24, 24];
1066        let positions = [[3_i64, 5_i64, 7_i64]];
1067        let mut q: Vec<f32> = (0..hidden).map(|i| (i as f32 - 63.5) * 0.03125).collect();
1068        let mut k: Vec<f32> = (0..hidden)
1069            .map(|i| (63.5 - i as f32) * 0.017578125)
1070            .collect();
1071        let q_before = q.clone();
1072        let k_before = k.clone();
1073
1074        QwenImageEncoder::apply_mrope_rotary(
1075            &mut q, &mut k, &positions, hidden, heads, kv_heads, head_dim, rope_theta, sections,
1076        );
1077
1078        // In the official implementation, the repeated list assigns axes to
1079        // [0..16), [16..40), and [40..64) in both rotary halves.  Check one
1080        // pair from each interval with distinct T/H/W positions so a numeric
1081        // [32,48,48] split cannot satisfy this regression.
1082        for &(d, axis) in &[(0usize, 0usize), (16, 1), (40, 2)] {
1083            let j = d as f64;
1084            let freq = 1.0 / rope_theta.powf(2.0 * j / head_dim as f64);
1085            let angle = positions[0][axis] as f64 * freq;
1086            let (sin, cos) = angle.sin_cos();
1087            let (a, b) = (q_before[d], q_before[d + head_dim / 2]);
1088            let expected_a = a as f64 * cos - b as f64 * sin;
1089            let expected_b = a as f64 * sin + b as f64 * cos;
1090            assert!((q[d] as f64 - expected_a).abs() < 2e-6);
1091            assert!((q[d + head_dim / 2] as f64 - expected_b).abs() < 2e-6);
1092        }
1093
1094        let norm_sq = |x: &[f32]| x.iter().map(|&v| (v as f64) * (v as f64)).sum::<f64>();
1095        let q_norm_error = (norm_sq(&q) - norm_sq(&q_before)).abs() / norm_sq(&q_before);
1096        let k_norm_error = (norm_sq(&k) - norm_sq(&k_before)).abs() / norm_sq(&k_before);
1097        assert!(
1098            q_norm_error < 2e-6,
1099            "Q rotation changed norm by {q_norm_error:e}"
1100        );
1101        assert!(
1102            k_norm_error < 2e-6,
1103            "K rotation changed norm by {k_norm_error:e}"
1104        );
1105    }
1106}