Skip to main content

candle_transformers/models/paddleocr_vl/
text.rs

1//! PaddleOCR-VL Text Model.
2//!
3//! ERNIE-4.5-0.3B based decoder with RMSNorm, GQA, and M-RoPE (Multimodal RoPE).
4//!
5//! M-RoPE uses 3D position IDs (temporal, height, width) for vision tokens,
6//! allowing the model to encode spatial structure of images.
7
8use std::sync::Arc;
9
10use candle::{DType, Device, IndexOp, Result, Tensor, D};
11use candle_nn::{embedding, linear_b, rms_norm, Embedding, Linear, Module, RmsNorm, VarBuilder};
12
13use super::config::TextConfig;
14
15/// Multimodal Rotary Position Embedding (M-RoPE).
16///
17/// Unlike standard 1D RoPE, M-RoPE supports 3D position IDs for vision tokens:
18/// - Temporal position (for video frames, always 0 for images)
19/// - Height position (row in the image grid)
20/// - Width position (column in the image grid)
21///
22/// Text tokens use the same position for all 3 dimensions (equivalent to 1D RoPE).
23#[derive(Debug, Clone)]
24pub struct RotaryEmbedding {
25    /// Precomputed cos values for all positions: [max_seq_len, head_dim/2]
26    cos: Tensor,
27    /// Precomputed sin values for all positions: [max_seq_len, head_dim/2]
28    sin: Tensor,
29    /// M-RoPE section sizes: [temporal, height, width]
30    mrope_section: Vec<usize>,
31    head_dim: usize,
32}
33
34impl RotaryEmbedding {
35    pub fn new(cfg: &TextConfig, device: &Device, dtype: DType) -> Result<Self> {
36        let dim = cfg.head_dim;
37        let max_seq_len = cfg.max_position_embeddings;
38
39        // Compute inverse frequencies
40        let inv_freq: Vec<f32> = (0..dim)
41            .step_by(2)
42            .map(|i| 1f32 / (cfg.rope_theta as f32).powf(i as f32 / dim as f32))
43            .collect();
44        let inv_freq_len = inv_freq.len();
45        let inv_freq = Tensor::from_vec(inv_freq, (1, inv_freq_len), device)?;
46
47        // Compute cos/sin for all positions
48        let t = Tensor::arange(0u32, max_seq_len as u32, device)?
49            .to_dtype(DType::F32)?
50            .reshape((max_seq_len, 1))?;
51        let freqs = t.matmul(&inv_freq)?;
52        let sin = freqs.sin()?.to_dtype(dtype)?;
53        let cos = freqs.cos()?.to_dtype(dtype)?;
54
55        Ok(Self {
56            cos,
57            sin,
58            mrope_section: cfg.mrope_section.clone(),
59            head_dim: dim,
60        })
61    }
62
63    /// Apply Multimodal RoPE with 3D position IDs.
64    ///
65    /// This follows the PyTorch implementation where:
66    /// 1. Compute cos/sin for each of the 3 position dimensions (temporal, height, width)
67    /// 2. Split the head_dim into sections based on mrope_section
68    /// 3. Use temporal positions for first section, height for second, width for third
69    ///
70    /// # Arguments
71    /// * `q` - Query tensor [batch, heads, seq_len, head_dim]
72    /// * `k` - Key tensor [batch, kv_heads, seq_len, head_dim]
73    /// * `position_ids` - 3D position IDs [3, batch, seq_len] where dim 0 is [temporal, height, width]
74    pub fn apply_multimodal_rotary_emb(
75        &self,
76        q: &Tensor,
77        k: &Tensor,
78        position_ids: &Tensor,
79    ) -> Result<(Tensor, Tensor)> {
80        // position_ids: [3, batch, seq_len]
81        let (three, _batch, _seq_len) = position_ids.dims3()?;
82        assert_eq!(three, 3, "position_ids must have 3 dimensions");
83
84        // Compute cos/sin for each position dimension
85        // Each returns [batch, seq_len, head_dim] with cos/sin of (inv_freq * position)
86        let (cos_3d, sin_3d) = self.compute_3d_rope_embeddings(position_ids)?;
87        // cos_3d/sin_3d: [3, batch, seq_len, head_dim]
88
89        // Apply mrope_section to select appropriate bands from each dimension
90        // mrope_section = [16, 24, 24] splits head_dim=128 into [16, 24, 24, 64] chunks
91        // where 64 is the remainder. Chunk i uses dimension i % 3.
92        let (cos, sin) = self.apply_mrope_sections(&cos_3d, &sin_3d)?;
93        // cos/sin: [batch, seq_len, head_dim]
94
95        // Reshape for broadcasting: [batch, 1, seq_len, head_dim]
96        let cos = cos.unsqueeze(1)?;
97        let sin = sin.unsqueeze(1)?;
98
99        // Apply RoPE to q and k
100        let q_embed = self.apply_rope_to_tensor(q, &cos, &sin)?;
101        let k_embed = self.apply_rope_to_tensor(k, &cos, &sin)?;
102
103        Ok((q_embed, k_embed))
104    }
105
106    /// Compute cos/sin embeddings for 3D position IDs.
107    /// position_ids: [3, batch, seq_len]
108    /// Returns: (cos, sin) each with shape [3, batch, seq_len, head_dim]
109    fn compute_3d_rope_embeddings(&self, position_ids: &Tensor) -> Result<(Tensor, Tensor)> {
110        let (three, batch, seq_len) = position_ids.dims3()?;
111        let half_dim = self.head_dim / 2;
112
113        // For each of the 3 dimensions, gather cos/sin based on positions
114        let mut cos_parts = Vec::new();
115        let mut sin_parts = Vec::new();
116
117        for dim_idx in 0..three {
118            let pos = position_ids.i(dim_idx)?; // [batch, seq_len]
119            let pos_flat = pos.flatten_all()?; // [batch * seq_len]
120
121            // Gather from precomputed cos/sin
122            let cos_gathered = self.cos.index_select(&pos_flat, 0)?; // [batch*seq_len, half_dim]
123            let sin_gathered = self.sin.index_select(&pos_flat, 0)?;
124
125            // Reshape to [batch, seq_len, half_dim]
126            let cos_dim = cos_gathered.reshape((batch, seq_len, half_dim))?;
127            let sin_dim = sin_gathered.reshape((batch, seq_len, half_dim))?;
128
129            // Duplicate to full head_dim: [batch, seq_len, head_dim]
130            let cos_full = Tensor::cat(&[&cos_dim, &cos_dim], D::Minus1)?;
131            let sin_full = Tensor::cat(&[&sin_dim, &sin_dim], D::Minus1)?;
132
133            cos_parts.push(cos_full);
134            sin_parts.push(sin_full);
135        }
136
137        // Stack to [3, batch, seq_len, head_dim]
138        let cos_3d = Tensor::stack(&cos_parts, 0)?;
139        let sin_3d = Tensor::stack(&sin_parts, 0)?;
140
141        Ok((cos_3d, sin_3d))
142    }
143
144    /// Apply mrope_section to select bands from each dimension.
145    ///
146    /// PyTorch behavior: `cos.split(mrope_section * 2, dim=-1)` where `* 2` is **list repetition**!
147    /// In Python: `[16, 24, 24] * 2 = [16, 24, 24, 16, 24, 24]` (6 chunks totaling 128)
148    ///
149    /// Then `[m[i % 3] for i, m in enumerate(splits)]` selects from the 3D position embeddings:
150    /// - chunk 0 (dims 0-15):    from temporal (i=0, i%3=0)
151    /// - chunk 1 (dims 16-39):   from height (i=1, i%3=1)
152    /// - chunk 2 (dims 40-63):   from width (i=2, i%3=2)
153    /// - chunk 3 (dims 64-79):   from temporal (i=3, i%3=0)
154    /// - chunk 4 (dims 80-103):  from height (i=4, i%3=1)
155    /// - chunk 5 (dims 104-127): from width (i=5, i%3=2)
156    ///
157    /// Final layout: [T:16, H:24, W:24, T:16, H:24, W:24]
158    fn apply_mrope_sections(&self, cos_3d: &Tensor, sin_3d: &Tensor) -> Result<(Tensor, Tensor)> {
159        // cos_3d/sin_3d: [3, batch, seq_len, head_dim]
160        // mrope_section = [16, 24, 24]
161        //
162        // In Python: mrope_section * 2 = [16, 24, 24, 16, 24, 24] (list repetition!)
163        // This creates 6 splits, cycling through temporal/height/width twice
164        let mut sections_repeated: Vec<usize> = Vec::new();
165        sections_repeated.extend_from_slice(&self.mrope_section);
166        sections_repeated.extend_from_slice(&self.mrope_section);
167        // sections_repeated = [16, 24, 24, 16, 24, 24]
168
169        // Split the head_dim and take from appropriate dimension (i % 3)
170        let mut cos_parts = Vec::new();
171        let mut sin_parts = Vec::new();
172        let mut offset = 0;
173
174        for (i, &sec_size) in sections_repeated.iter().enumerate() {
175            let dim_idx = i % 3; // Cycles: temporal(0), height(1), width(2), temporal(0), ...
176                                 // Take slice from dimension dim_idx at the current offset
177            let cos_slice = cos_3d.i(dim_idx)?.narrow(D::Minus1, offset, sec_size)?;
178            let sin_slice = sin_3d.i(dim_idx)?.narrow(D::Minus1, offset, sec_size)?;
179            cos_parts.push(cos_slice);
180            sin_parts.push(sin_slice);
181            offset += sec_size;
182        }
183
184        // Concatenate along head_dim: [batch, seq_len, head_dim]
185        let cos = Tensor::cat(&cos_parts, D::Minus1)?;
186        let sin = Tensor::cat(&sin_parts, D::Minus1)?;
187
188        Ok((cos, sin))
189    }
190
191    /// Apply rotary embedding to a tensor.
192    /// x: [batch, heads, seq_len, head_dim]
193    /// cos/sin: [batch, 1, seq_len, head_dim]
194    fn apply_rope_to_tensor(&self, x: &Tensor, cos: &Tensor, sin: &Tensor) -> Result<Tensor> {
195        let x = x.contiguous()?;
196
197        // rotate_half: split x into two halves and rotate
198        let head_dim = x.dim(D::Minus1)?;
199        let half_dim = head_dim / 2;
200
201        let x1 = x.narrow(D::Minus1, 0, half_dim)?;
202        let x2 = x.narrow(D::Minus1, half_dim, half_dim)?;
203
204        // rotate_half gives [-x2, x1]
205        let x_rotated = Tensor::cat(&[&x2.neg()?, &x1], D::Minus1)?;
206
207        // Apply: x * cos + rotate_half(x) * sin
208        x.broadcast_mul(cos)? + x_rotated.broadcast_mul(sin)?
209    }
210
211    /// Apply Multimodal RoPE with export of intermediate tensors for debugging.
212    pub fn apply_multimodal_rotary_emb_with_export(
213        &self,
214        q: &Tensor,
215        k: &Tensor,
216        position_ids: &Tensor,
217    ) -> Result<(Tensor, Tensor, std::collections::HashMap<String, Tensor>)> {
218        use std::collections::HashMap;
219        let mut tensors: HashMap<String, Tensor> = HashMap::new();
220
221        let (three, _batch, _seq_len) = position_ids.dims3()?;
222        assert_eq!(three, 3, "position_ids must have 3 dimensions");
223
224        // Export position_ids
225        tensors.insert("position_ids".to_string(), position_ids.clone());
226
227        // Compute cos/sin for each position dimension
228        let (cos_3d, sin_3d) = self.compute_3d_rope_embeddings(position_ids)?;
229        tensors.insert("cos_3d".to_string(), cos_3d.clone());
230        tensors.insert("sin_3d".to_string(), sin_3d.clone());
231
232        // Apply mrope_section to select appropriate bands
233        let (cos, sin) = self.apply_mrope_sections(&cos_3d, &sin_3d)?;
234        tensors.insert("cos_after_mrope".to_string(), cos.clone());
235        tensors.insert("sin_after_mrope".to_string(), sin.clone());
236
237        // Export specific position for debugging (position 947 if available)
238        let seq_len = cos.dim(1)?;
239        if seq_len > 947 {
240            tensors.insert("cos_pos947".to_string(), cos.i((.., 947, ..))?.squeeze(1)?);
241            tensors.insert("sin_pos947".to_string(), sin.i((.., 947, ..))?.squeeze(1)?);
242        }
243
244        // Reshape for broadcasting: [batch, 1, seq_len, head_dim]
245        let cos = cos.unsqueeze(1)?;
246        let sin = sin.unsqueeze(1)?;
247
248        // Apply RoPE to q and k
249        let q_embed = self.apply_rope_to_tensor(q, &cos, &sin)?;
250        let k_embed = self.apply_rope_to_tensor(k, &cos, &sin)?;
251
252        Ok((q_embed, k_embed, tensors))
253    }
254}
255
256/// Image grid specification for multi-image M-RoPE position computation.
257#[derive(Debug, Clone)]
258pub struct ImageGrid {
259    /// Grid height (number of patches in height dimension, after spatial merge)
260    pub grid_h: usize,
261    /// Grid width (number of patches in width dimension, after spatial merge)
262    pub grid_w: usize,
263}
264
265/// Compute 3D M-RoPE position IDs for multi-image multimodal input.
266///
267/// This function creates position IDs of shape [3, batch, seq_len] for inputs
268/// containing multiple images. Each image's tokens get 2D spatial positions,
269/// while text tokens get sequential 1D positions.
270///
271/// # Position Layout
272/// ```text
273/// Text tokens: all 3 dims same (t=h=w=pos)
274/// Image tokens: 2D grid positions offset by preceding text
275///   - pos_t = offset (temporal = 0 for images)
276///   - pos_h = row_in_grid + offset
277///   - pos_w = col_in_grid + offset
278/// ```
279///
280/// # Arguments
281/// * `input_ids` - Token IDs of shape (batch, seq_len)
282/// * `image_token_id` - The token ID used for image placeholders
283/// * `image_grids` - Grid dimensions for each image (in order of appearance)
284/// * `device` - Device to create tensors on
285///
286/// # Returns
287/// Position IDs tensor of shape [3, batch, seq_len]
288pub fn compute_mrope_position_ids_multi(
289    input_ids: &Tensor,
290    image_token_id: u32,
291    image_grids: &[ImageGrid],
292    device: &Device,
293) -> Result<Tensor> {
294    let (batch, seq_len) = input_ids.dims2()?;
295    let input_ids_vec: Vec<u32> = input_ids.flatten_all()?.to_vec1()?;
296
297    // Create position IDs for all 3 dimensions
298    let mut pos_t = vec![0i64; batch * seq_len];
299    let mut pos_h = vec![0i64; batch * seq_len];
300    let mut pos_w = vec![0i64; batch * seq_len];
301
302    for b in 0..batch {
303        let batch_start = b * seq_len;
304
305        // Find all image token ranges
306        let mut image_ranges: Vec<(usize, usize)> = Vec::new(); // (start, end) exclusive
307        let mut in_image = false;
308        let mut image_start = 0usize;
309
310        for s in 0..seq_len {
311            let token_id = input_ids_vec[batch_start + s];
312            if token_id == image_token_id {
313                if !in_image {
314                    in_image = true;
315                    image_start = s;
316                }
317            } else if in_image {
318                image_ranges.push((image_start, s));
319                in_image = false;
320            }
321        }
322        // Handle case where image tokens extend to end of sequence
323        if in_image {
324            image_ranges.push((image_start, seq_len));
325        }
326
327        // Verify we have the right number of image ranges
328        if image_ranges.len() != image_grids.len() {
329            return Err(candle::Error::Msg(format!(
330                "Mismatch: found {} image ranges but {} grids provided",
331                image_ranges.len(),
332                image_grids.len()
333            )));
334        }
335
336        // Compute positions
337        let mut current_pos = 0i64;
338        let mut range_idx = 0usize;
339
340        for s in 0..seq_len {
341            let idx = batch_start + s;
342
343            // Check if we're at the start of an image range
344            if range_idx < image_ranges.len() && s == image_ranges[range_idx].0 {
345                // Process entire image range
346                let (img_start, img_end) = image_ranges[range_idx];
347                let grid = &image_grids[range_idx];
348                let num_vision_tokens = grid.grid_h * grid.grid_w;
349
350                // Verify token count matches grid
351                let actual_tokens = img_end - img_start;
352                if actual_tokens != num_vision_tokens {
353                    return Err(candle::Error::Msg(format!(
354                        "Image {} has {} tokens but grid {}x{} = {} expected",
355                        range_idx, actual_tokens, grid.grid_h, grid.grid_w, num_vision_tokens
356                    )));
357                }
358
359                // Assign spatial positions to vision tokens
360                let offset = current_pos;
361                for vision_idx in 0..num_vision_tokens {
362                    let token_s = img_start + vision_idx;
363                    let token_idx = batch_start + token_s;
364
365                    let t_pos = 0i64; // Temporal is 0 for images
366                    let h_pos = (vision_idx / grid.grid_w) as i64;
367                    let w_pos = (vision_idx % grid.grid_w) as i64;
368
369                    pos_t[token_idx] = t_pos + offset;
370                    pos_h[token_idx] = h_pos + offset;
371                    pos_w[token_idx] = w_pos + offset;
372                }
373
374                // Update current_pos to max position in this image + 1
375                let max_h = (grid.grid_h - 1) as i64;
376                let max_w = (grid.grid_w - 1) as i64;
377                current_pos = offset + max_h.max(max_w) + 1;
378
379                range_idx += 1;
380                continue;
381            }
382
383            // Skip if we're inside an image range (already processed)
384            if range_idx > 0 {
385                let prev_range = image_ranges[range_idx - 1];
386                if s >= prev_range.0 && s < prev_range.1 {
387                    continue;
388                }
389            }
390            if range_idx < image_ranges.len() {
391                let curr_range = image_ranges[range_idx];
392                if s >= curr_range.0 && s < curr_range.1 {
393                    continue;
394                }
395            }
396
397            // Text token: all dimensions same
398            pos_t[idx] = current_pos;
399            pos_h[idx] = current_pos;
400            pos_w[idx] = current_pos;
401            current_pos += 1;
402        }
403    }
404
405    // Create tensors and stack
406    let pos_t = Tensor::from_vec(pos_t, (batch, seq_len), device)?;
407    let pos_h = Tensor::from_vec(pos_h, (batch, seq_len), device)?;
408    let pos_w = Tensor::from_vec(pos_w, (batch, seq_len), device)?;
409
410    Tensor::stack(&[pos_t, pos_h, pos_w], 0)
411}
412
413/// Compute 3D M-RoPE position IDs for multimodal input.
414///
415/// This function creates position IDs of shape [3, batch, seq_len] following PyTorch's
416/// get_rope_index() algorithm:
417/// - Text tokens before vision: all 3 dims same, starting from 0
418/// - Vision tokens: (temporal + offset, height + offset, width + offset)
419/// - Text tokens after vision: all 3 dims same, continuing from max vision position + 1
420///
421/// For vision tokens, positions encode the 2D spatial structure offset by preceding text.
422pub fn compute_mrope_position_ids(
423    input_ids: &Tensor,
424    image_token_id: u32,
425    grid_h: usize,
426    grid_w: usize,
427    device: &Device,
428) -> Result<Tensor> {
429    let (batch, seq_len) = input_ids.dims2()?;
430    let input_ids_vec: Vec<u32> = input_ids.flatten_all()?.to_vec1()?;
431
432    // Create position IDs for all 3 dimensions
433    let mut pos_t = vec![0i64; batch * seq_len];
434    let mut pos_h = vec![0i64; batch * seq_len];
435    let mut pos_w = vec![0i64; batch * seq_len];
436
437    for b in 0..batch {
438        // Find the first image token position
439        let batch_start = b * seq_len;
440        let mut first_image_pos = None;
441        for s in 0..seq_len {
442            if input_ids_vec[batch_start + s] == image_token_id {
443                first_image_pos = Some(s);
444                break;
445            }
446        }
447
448        // Compute positions following PyTorch's algorithm
449        let num_vision_tokens = grid_h * grid_w;
450
451        // Text tokens before vision get sequential positions
452        let text_before = first_image_pos.unwrap_or(seq_len);
453        for s in 0..text_before {
454            let idx = batch_start + s;
455            pos_t[idx] = s as i64;
456            pos_h[idx] = s as i64;
457            pos_w[idx] = s as i64;
458        }
459
460        // Vision tokens: (temporal, height, width) + text_before offset
461        let offset = text_before as i64;
462        let mut vision_idx = 0usize;
463        let mut max_vision_pos = offset - 1; // Will be updated
464
465        for s in text_before..seq_len {
466            let idx = batch_start + s;
467            let token_id = input_ids_vec[idx];
468
469            if token_id == image_token_id && vision_idx < num_vision_tokens {
470                // Vision token: spatial position + offset
471                let t_pos = 0i64; // Temporal is 0 for images
472                let h_pos = (vision_idx / grid_w) as i64;
473                let w_pos = (vision_idx % grid_w) as i64;
474
475                pos_t[idx] = t_pos + offset;
476                pos_h[idx] = h_pos + offset;
477                pos_w[idx] = w_pos + offset;
478
479                // Track max position for text tokens that follow
480                max_vision_pos = max_vision_pos
481                    .max(pos_t[idx])
482                    .max(pos_h[idx])
483                    .max(pos_w[idx]);
484
485                vision_idx += 1;
486            } else {
487                // Text token after vision: continue from max_vision_pos + 1
488                max_vision_pos += 1;
489                pos_t[idx] = max_vision_pos;
490                pos_h[idx] = max_vision_pos;
491                pos_w[idx] = max_vision_pos;
492            }
493        }
494    }
495
496    // Create tensors and stack
497    let pos_t = Tensor::from_vec(pos_t, (batch, seq_len), device)?;
498    let pos_h = Tensor::from_vec(pos_h, (batch, seq_len), device)?;
499    let pos_w = Tensor::from_vec(pos_w, (batch, seq_len), device)?;
500
501    Tensor::stack(&[pos_t, pos_h, pos_w], 0)
502}
503
504/// Grid specification for video input.
505///
506/// Unlike images which have only spatial dimensions (h, w),
507/// video has temporal (t), height (h), and width (w) dimensions.
508#[derive(Debug, Clone)]
509pub struct VideoGrid {
510    /// Number of temporal frames (after any temporal patching)
511    pub grid_t: usize,
512    /// Number of height patches (after spatial merge)
513    pub grid_h: usize,
514    /// Number of width patches (after spatial merge)
515    pub grid_w: usize,
516}
517
518/// Compute 3D M-RoPE position IDs for video input.
519///
520/// Unlike multi-image (where t=0 for all images), video uses sequential
521/// temporal positions (t=frame_index) to encode temporal relationships
522/// between frames.
523///
524/// Position encoding pattern for video with grid_t=3, grid_h=2, grid_w=2:
525/// ```text
526/// t_index = [0,0,0,0, 1,1,1,1, 2,2,2,2]  // Temporal: repeats for h*w per frame
527/// h_index = [0,0,1,1, 0,0,1,1, 0,0,1,1]  // Height: repeats w times per t
528/// w_index = [0,1,0,1, 0,1,0,1, 0,1,0,1]  // Width: cycles fastest
529/// ```
530///
531/// # Arguments
532/// * `input_ids` - Token IDs of shape (batch, seq_len)
533/// * `video_token_id` - The token ID used for video placeholders (different from image_token_id!)
534/// * `video_grid` - Grid dimensions for the video (temporal, height, width)
535/// * `second_per_grid_t` - Time interval per temporal grid unit (= temporal_patch_size / fps)
536/// * `tokens_per_second` - Temporal position scaling factor (use 2 for video, matching HuggingFace)
537/// * `device` - Device to create tensors on
538///
539/// # Returns
540/// Position IDs tensor of shape [3, batch, seq_len]
541pub fn compute_mrope_position_ids_video(
542    input_ids: &Tensor,
543    video_token_id: u32,
544    video_grid: &VideoGrid,
545    second_per_grid_t: f32,
546    tokens_per_second: usize,
547    device: &Device,
548) -> Result<Tensor> {
549    let (batch, seq_len) = input_ids.dims2()?;
550    let input_ids_vec: Vec<u32> = input_ids.flatten_all()?.to_vec1()?;
551
552    let grid_t = video_grid.grid_t;
553    let grid_h = video_grid.grid_h;
554    let grid_w = video_grid.grid_w;
555    let num_vision_tokens = grid_t * grid_h * grid_w;
556
557    // Create position IDs for all 3 dimensions
558    let mut pos_t = vec![0i64; batch * seq_len];
559    let mut pos_h = vec![0i64; batch * seq_len];
560    let mut pos_w = vec![0i64; batch * seq_len];
561
562    for b in 0..batch {
563        let batch_start = b * seq_len;
564
565        // Find the video token range
566        let mut video_start = None;
567        let mut video_end = None;
568        let mut in_video = false;
569
570        for s in 0..seq_len {
571            let token_id = input_ids_vec[batch_start + s];
572            if token_id == video_token_id {
573                if !in_video {
574                    in_video = true;
575                    video_start = Some(s);
576                }
577            } else if in_video {
578                video_end = Some(s);
579                break;
580            }
581        }
582        // Handle case where video tokens extend to end of sequence
583        if in_video && video_end.is_none() {
584            video_end = Some(seq_len);
585        }
586
587        // Verify video token count matches grid
588        if let (Some(start), Some(end)) = (video_start, video_end) {
589            let actual_tokens = end - start;
590            if actual_tokens != num_vision_tokens {
591                return Err(candle::Error::Msg(format!(
592                    "Video has {} tokens but grid {}x{}x{} = {} expected",
593                    actual_tokens, grid_t, grid_h, grid_w, num_vision_tokens
594                )));
595            }
596        }
597
598        // Compute positions
599        let mut current_pos = 0i64;
600        let video_range = video_start.zip(video_end);
601
602        for s in 0..seq_len {
603            let idx = batch_start + s;
604
605            // Check if we're at the start of the video range
606            if let Some((v_start, v_end)) = video_range {
607                if s == v_start {
608                    // Process entire video range with 3D positions
609                    let offset = current_pos;
610
611                    for vision_idx in 0..num_vision_tokens {
612                        let token_s = v_start + vision_idx;
613                        let token_idx = batch_start + token_s;
614
615                        // 3D position: t uses temporal scaling for proper frame spacing
616                        // Formula: t_pos = frame_index * second_per_grid_t * tokens_per_second
617                        // This matches HuggingFace Qwen2-VL processor behavior
618                        let frame_index = vision_idx / (grid_h * grid_w);
619                        let t_pos = (frame_index as f32
620                            * second_per_grid_t
621                            * tokens_per_second as f32) as i64;
622                        let spatial_idx = vision_idx % (grid_h * grid_w);
623                        let h_pos = (spatial_idx / grid_w) as i64;
624                        let w_pos = (spatial_idx % grid_w) as i64;
625
626                        pos_t[token_idx] = t_pos + offset;
627                        pos_h[token_idx] = h_pos + offset;
628                        pos_w[token_idx] = w_pos + offset;
629                    }
630
631                    // Update current_pos to max position in video + 1
632                    // max_t also needs temporal scaling to match the scaled positions
633                    let max_t =
634                        ((grid_t - 1) as f32 * second_per_grid_t * tokens_per_second as f32) as i64;
635                    let max_h = (grid_h - 1) as i64;
636                    let max_w = (grid_w - 1) as i64;
637                    current_pos = offset + max_t.max(max_h).max(max_w) + 1;
638
639                    continue;
640                }
641
642                // Skip if we're inside the video range (already processed)
643                if s > v_start && s < v_end {
644                    continue;
645                }
646            }
647
648            // Text token: all dimensions same
649            pos_t[idx] = current_pos;
650            pos_h[idx] = current_pos;
651            pos_w[idx] = current_pos;
652            current_pos += 1;
653        }
654    }
655
656    // Create tensors and stack
657    let pos_t = Tensor::from_vec(pos_t, (batch, seq_len), device)?;
658    let pos_h = Tensor::from_vec(pos_h, (batch, seq_len), device)?;
659    let pos_w = Tensor::from_vec(pos_w, (batch, seq_len), device)?;
660
661    Tensor::stack(&[pos_t, pos_h, pos_w], 0)
662}
663
664/// Gated MLP block (SwiGLU-style).
665struct Mlp {
666    gate_proj: Linear,
667    up_proj: Linear,
668    down_proj: Linear,
669    act_fn: candle_nn::Activation,
670}
671
672impl Mlp {
673    fn new(cfg: &TextConfig, vb: VarBuilder) -> Result<Self> {
674        let hidden_sz = cfg.hidden_size;
675        let intermediate_sz = cfg.intermediate_size;
676        let gate_proj = linear_b(hidden_sz, intermediate_sz, cfg.use_bias, vb.pp("gate_proj"))?;
677        let up_proj = linear_b(hidden_sz, intermediate_sz, cfg.use_bias, vb.pp("up_proj"))?;
678        let down_proj = linear_b(intermediate_sz, hidden_sz, cfg.use_bias, vb.pp("down_proj"))?;
679        Ok(Self {
680            gate_proj,
681            up_proj,
682            down_proj,
683            act_fn: cfg.hidden_act,
684        })
685    }
686
687    fn forward(&self, xs: &Tensor) -> Result<Tensor> {
688        let lhs = self.gate_proj.forward(xs)?.apply(&self.act_fn)?;
689        let rhs = self.up_proj.forward(xs)?;
690        self.down_proj.forward(&(lhs * rhs)?)
691    }
692
693    /// Forward with intermediate tensor export for debugging.
694    fn forward_with_export(
695        &self,
696        xs: &Tensor,
697    ) -> Result<(Tensor, std::collections::HashMap<String, Tensor>)> {
698        use std::collections::HashMap;
699        let mut tensors: HashMap<String, Tensor> = HashMap::new();
700
701        // gate_proj: hidden_size -> intermediate_size
702        let gate_out = self.gate_proj.forward(xs)?;
703        tensors.insert("gate_proj_out".to_string(), gate_out.clone());
704
705        // Activation (SiLU)
706        let gate_act = gate_out.apply(&self.act_fn)?;
707        tensors.insert("gate_act_out".to_string(), gate_act.clone());
708
709        // up_proj: hidden_size -> intermediate_size
710        let up_out = self.up_proj.forward(xs)?;
711        tensors.insert("up_proj_out".to_string(), up_out.clone());
712
713        // Element-wise multiplication
714        let mul_out = (&gate_act * &up_out)?;
715        tensors.insert("gate_up_mul".to_string(), mul_out.clone());
716
717        // down_proj: intermediate_size -> hidden_size
718        let output = self.down_proj.forward(&mul_out)?;
719        tensors.insert("down_proj_out".to_string(), output.clone());
720
721        Ok((output, tensors))
722    }
723}
724
725/// Multi-head attention with Grouped Query Attention (GQA).
726struct Attention {
727    q_proj: Linear,
728    k_proj: Linear,
729    v_proj: Linear,
730    o_proj: Linear,
731    num_heads: usize,
732    num_kv_heads: usize,
733    num_kv_groups: usize,
734    head_dim: usize,
735    rotary_emb: Arc<RotaryEmbedding>,
736    kv_cache: Option<(Tensor, Tensor)>,
737    softmax_scale: f64,
738}
739
740impl Attention {
741    fn new(rotary_emb: Arc<RotaryEmbedding>, cfg: &TextConfig, vb: VarBuilder) -> Result<Self> {
742        let hidden_sz = cfg.hidden_size;
743        let num_heads = cfg.num_attention_heads;
744        let num_kv_heads = cfg.num_key_value_heads;
745        let head_dim = cfg.head_dim;
746        let num_kv_groups = num_heads / num_kv_heads;
747
748        let q_proj = linear_b(
749            hidden_sz,
750            num_heads * head_dim,
751            cfg.use_bias,
752            vb.pp("q_proj"),
753        )?;
754        let k_proj = linear_b(
755            hidden_sz,
756            num_kv_heads * head_dim,
757            cfg.use_bias,
758            vb.pp("k_proj"),
759        )?;
760        let v_proj = linear_b(
761            hidden_sz,
762            num_kv_heads * head_dim,
763            cfg.use_bias,
764            vb.pp("v_proj"),
765        )?;
766        let o_proj = linear_b(
767            num_heads * head_dim,
768            hidden_sz,
769            cfg.use_bias,
770            vb.pp("o_proj"),
771        )?;
772
773        Ok(Self {
774            q_proj,
775            k_proj,
776            v_proj,
777            o_proj,
778            num_heads,
779            num_kv_heads,
780            num_kv_groups,
781            head_dim,
782            rotary_emb,
783            kv_cache: None,
784            softmax_scale: 1.0 / (head_dim as f64).sqrt(),
785        })
786    }
787
788    /// Forward with 3D M-RoPE.
789    fn forward_with_mrope(
790        &mut self,
791        xs: &Tensor,
792        attention_mask: Option<&Tensor>,
793        position_ids: &Tensor,
794    ) -> Result<Tensor> {
795        let (b_sz, q_len, _) = xs.dims3()?;
796
797        let query_states = self.q_proj.forward(xs)?;
798        let key_states = self.k_proj.forward(xs)?;
799        let value_states = self.v_proj.forward(xs)?;
800
801        let query_states = query_states
802            .reshape((b_sz, q_len, self.num_heads, self.head_dim))?
803            .transpose(1, 2)?;
804        let key_states = key_states
805            .reshape((b_sz, q_len, self.num_kv_heads, self.head_dim))?
806            .transpose(1, 2)?;
807        let value_states = value_states
808            .reshape((b_sz, q_len, self.num_kv_heads, self.head_dim))?
809            .transpose(1, 2)?;
810
811        // Apply M-RoPE (3D position IDs)
812        let (query_states, key_states) = self.rotary_emb.apply_multimodal_rotary_emb(
813            &query_states,
814            &key_states,
815            position_ids,
816        )?;
817
818        self.compute_attention(
819            query_states,
820            key_states,
821            value_states,
822            attention_mask,
823            b_sz,
824            q_len,
825        )
826    }
827
828    /// Shared attention computation.
829    fn compute_attention(
830        &mut self,
831        query_states: Tensor,
832        key_states: Tensor,
833        value_states: Tensor,
834        attention_mask: Option<&Tensor>,
835        b_sz: usize,
836        q_len: usize,
837    ) -> Result<Tensor> {
838        // KV cache handling
839        let (key_states, value_states) = match &self.kv_cache {
840            None => (key_states, value_states),
841            Some((prev_k, prev_v)) => {
842                let key_states = Tensor::cat(&[prev_k, &key_states], 2)?;
843                let value_states = Tensor::cat(&[prev_v, &value_states], 2)?;
844                (key_states, value_states)
845            }
846        };
847        self.kv_cache = Some((key_states.clone(), value_states.clone()));
848
849        // Repeat KV heads for GQA (matches PyTorch's repeat_kv)
850        let key_states = crate::utils::repeat_kv(key_states, self.num_kv_groups)?.contiguous()?;
851        let value_states =
852            crate::utils::repeat_kv(value_states, self.num_kv_groups)?.contiguous()?;
853
854        // Compute attention (matches eager_attention_forward_ernie)
855        let attn_output = {
856            // attn_weights = query @ key^T * scaling
857            let attn_weights =
858                (query_states.matmul(&key_states.transpose(2, 3)?)? * self.softmax_scale)?;
859
860            // Apply causal mask
861            let attn_weights = match attention_mask {
862                None => attn_weights,
863                Some(mask) => attn_weights.broadcast_add(mask)?,
864            };
865            // Softmax in F32 for stability (matches PyTorch's softmax(..., dtype=torch.float32).to(query.dtype))
866            let original_dtype = attn_weights.dtype();
867            let attn_weights = if original_dtype != DType::F32 {
868                let attn_weights = attn_weights.to_dtype(DType::F32)?;
869                let attn_weights = candle_nn::ops::softmax_last_dim(&attn_weights)?;
870                attn_weights.to_dtype(original_dtype)?
871            } else {
872                candle_nn::ops::softmax_last_dim(&attn_weights)?
873            };
874            // attn_output = attn_weights @ value
875            attn_weights.matmul(&value_states)?
876        };
877
878        // attn_output.transpose(1, 2).contiguous().reshape(...)
879        attn_output
880            .transpose(1, 2)?
881            .contiguous()?
882            .reshape((b_sz, q_len, self.num_heads * self.head_dim))?
883            .apply(&self.o_proj)
884    }
885
886    /// Forward with 3D M-RoPE and export attention intermediates (for debugging).
887    /// Matches PyTorch's Ernie4_5Attention.forward + eager_attention_forward_ernie exactly.
888    pub fn forward_with_mrope_export(
889        &mut self,
890        xs: &Tensor,
891        attention_mask: Option<&Tensor>,
892        position_ids: &Tensor,
893    ) -> Result<(Tensor, std::collections::HashMap<String, Tensor>)> {
894        use std::collections::HashMap;
895        let mut tensors: HashMap<String, Tensor> = HashMap::new();
896
897        let (b_sz, q_len, _) = xs.dims3()?;
898
899        // Q, K, V projections (matches: query_states = self.q_proj(hidden_states))
900        let query_states = self.q_proj.forward(xs)?;
901        let key_states = self.k_proj.forward(xs)?;
902        let value_states = self.v_proj.forward(xs)?;
903
904        // Reshape to [batch, seq, heads, head_dim] then transpose to [batch, heads, seq, head_dim]
905        // matches: .view(hidden_shape).transpose(1, 2)
906        let query_states = query_states
907            .reshape((b_sz, q_len, self.num_heads, self.head_dim))?
908            .transpose(1, 2)?;
909        let key_states = key_states
910            .reshape((b_sz, q_len, self.num_kv_heads, self.head_dim))?
911            .transpose(1, 2)?;
912        let value_states = value_states
913            .reshape((b_sz, q_len, self.num_kv_heads, self.head_dim))?
914            .transpose(1, 2)?;
915
916        tensors.insert("q_pre_rope".to_string(), query_states.clone());
917        tensors.insert("k_pre_rope".to_string(), key_states.clone());
918        tensors.insert("v".to_string(), value_states.clone());
919
920        // Apply M-RoPE with export (matches: apply_multimodal_rotary_pos_emb)
921        let (query_states, key_states, rope_tensors) = self
922            .rotary_emb
923            .apply_multimodal_rotary_emb_with_export(&query_states, &key_states, position_ids)?;
924
925        // Merge RoPE tensors with prefix
926        for (k, v) in rope_tensors {
927            tensors.insert(format!("rope_{}", k), v);
928        }
929
930        tensors.insert("q_post_rope".to_string(), query_states.clone());
931        tensors.insert("k_post_rope".to_string(), key_states.clone());
932
933        // No KV cache during prefill
934        // Repeat KV heads for GQA (matches: repeat_kv in eager_attention_forward_ernie)
935        let key_states_repeated =
936            crate::utils::repeat_kv(key_states.clone(), self.num_kv_groups)?.contiguous()?;
937        let value_states_repeated =
938            crate::utils::repeat_kv(value_states.clone(), self.num_kv_groups)?.contiguous()?;
939
940        tensors.insert("k_repeated".to_string(), key_states_repeated.clone());
941        tensors.insert("v_repeated".to_string(), value_states_repeated.clone());
942
943        // Attention scores: Q @ K^T * scaling (matches: torch.matmul(query, key_states.transpose(2, 3)) * scaling)
944        let attn_weights_pre =
945            (query_states.matmul(&key_states_repeated.transpose(2, 3)?)? * self.softmax_scale)?;
946        // Skip exporting full attention matrices - too large ([1, 16, 1357, 1357])
947        // Just export a slice for verification: last row of attention for each head
948        let seq_len = attn_weights_pre.dim(2)?;
949        let attn_last_row = attn_weights_pre.narrow(2, seq_len - 1, 1)?;
950        tensors.insert("attn_weights_last_row".to_string(), attn_last_row);
951
952        // Apply mask (matches: attn_weights = attn_weights + causal_mask)
953        let attn_weights_masked = match attention_mask {
954            None => attn_weights_pre,
955            Some(mask) => attn_weights_pre.broadcast_add(mask)?,
956        };
957
958        // Softmax (matches: softmax(..., dtype=torch.float32).to(query.dtype))
959        let original_dtype = attn_weights_masked.dtype();
960        let attn_weights = if original_dtype != DType::F32 {
961            let attn_weights = attn_weights_masked.to_dtype(DType::F32)?;
962            let attn_weights = candle_nn::ops::softmax_last_dim(&attn_weights)?;
963            attn_weights.to_dtype(original_dtype)?
964        } else {
965            candle_nn::ops::softmax_last_dim(&attn_weights_masked)?
966        };
967        // Export last row of softmax attention weights
968        let attn_softmax_last_row = attn_weights.narrow(2, seq_len - 1, 1)?;
969        tensors.insert(
970            "attn_weights_softmax_last_row".to_string(),
971            attn_softmax_last_row,
972        );
973
974        // Attention output (matches: torch.matmul(attn_weights, value_states))
975        let attn_output = attn_weights.matmul(&value_states_repeated)?;
976        tensors.insert("attn_output_pre_transpose".to_string(), attn_output.clone());
977
978        // Reshape (matches: .transpose(1, 2).contiguous())
979        let attn_output = attn_output.transpose(1, 2)?.contiguous()?.reshape((
980            b_sz,
981            q_len,
982            self.num_heads * self.head_dim,
983        ))?;
984
985        // Output projection (matches: self.o_proj(attn_output))
986        let output = self.o_proj.forward(&attn_output)?;
987        tensors.insert("attn_output".to_string(), output.clone());
988
989        Ok((output, tensors))
990    }
991
992    fn clear_kv_cache(&mut self) {
993        self.kv_cache = None;
994    }
995}
996
997/// Decoder layer with pre-norm architecture.
998struct DecoderLayer {
999    self_attn: Attention,
1000    mlp: Mlp,
1001    input_layernorm: RmsNorm,
1002    post_attention_layernorm: RmsNorm,
1003}
1004
1005impl DecoderLayer {
1006    fn new(rotary_emb: Arc<RotaryEmbedding>, cfg: &TextConfig, vb: VarBuilder) -> Result<Self> {
1007        let self_attn = Attention::new(rotary_emb, cfg, vb.pp("self_attn"))?;
1008        let mlp = Mlp::new(cfg, vb.pp("mlp"))?;
1009        let input_layernorm =
1010            rms_norm(cfg.hidden_size, cfg.rms_norm_eps, vb.pp("input_layernorm"))?;
1011        let post_attention_layernorm = rms_norm(
1012            cfg.hidden_size,
1013            cfg.rms_norm_eps,
1014            vb.pp("post_attention_layernorm"),
1015        )?;
1016        Ok(Self {
1017            self_attn,
1018            mlp,
1019            input_layernorm,
1020            post_attention_layernorm,
1021        })
1022    }
1023
1024    /// Forward with 3D M-RoPE.
1025    fn forward_with_mrope(
1026        &mut self,
1027        xs: &Tensor,
1028        attention_mask: Option<&Tensor>,
1029        position_ids: &Tensor,
1030    ) -> Result<Tensor> {
1031        let residual = xs;
1032        let xs = self.input_layernorm.forward(xs)?;
1033        let xs = self
1034            .self_attn
1035            .forward_with_mrope(&xs, attention_mask, position_ids)?;
1036        let xs = (xs + residual)?;
1037        let residual = &xs;
1038        let xs = self
1039            .mlp
1040            .forward(&xs.apply(&self.post_attention_layernorm)?)?;
1041        residual + xs
1042    }
1043
1044    /// Forward with 3D M-RoPE and export attention intermediates.
1045    fn forward_with_mrope_export(
1046        &mut self,
1047        xs: &Tensor,
1048        attention_mask: Option<&Tensor>,
1049        position_ids: &Tensor,
1050    ) -> Result<(Tensor, std::collections::HashMap<String, Tensor>)> {
1051        use std::collections::HashMap;
1052        let mut tensors: HashMap<String, Tensor> = HashMap::new();
1053
1054        let residual = xs;
1055        tensors.insert("layer_input".to_string(), xs.clone());
1056
1057        let xs = self.input_layernorm.forward(xs)?;
1058        tensors.insert("post_input_layernorm".to_string(), xs.clone());
1059
1060        let (attn_out, attn_tensors) =
1061            self.self_attn
1062                .forward_with_mrope_export(&xs, attention_mask, position_ids)?;
1063
1064        // Merge attention tensors with prefix
1065        for (k, v) in attn_tensors {
1066            tensors.insert(format!("attn_{}", k), v);
1067        }
1068
1069        let xs = (attn_out + residual)?;
1070        tensors.insert("post_attn_residual".to_string(), xs.clone());
1071
1072        let residual = &xs;
1073        let post_norm = xs.apply(&self.post_attention_layernorm)?;
1074        tensors.insert("post_attention_layernorm".to_string(), post_norm.clone());
1075
1076        // Use MLP forward with export to capture intermediate values
1077        let (mlp_out, mlp_tensors) = self.mlp.forward_with_export(&post_norm)?;
1078
1079        // Merge MLP tensors with prefix
1080        for (k, v) in mlp_tensors {
1081            tensors.insert(format!("mlp_{}", k), v);
1082        }
1083
1084        tensors.insert("mlp_output".to_string(), mlp_out.clone());
1085
1086        let output = (residual + mlp_out)?;
1087        tensors.insert("layer_output".to_string(), output.clone());
1088
1089        Ok((output, tensors))
1090    }
1091
1092    fn clear_kv_cache(&mut self) {
1093        self.self_attn.clear_kv_cache();
1094    }
1095}
1096
1097/// PaddleOCR-VL Text Model (ERNIE-4.5 based).
1098pub struct TextModel {
1099    embed_tokens: Embedding,
1100    layers: Vec<DecoderLayer>,
1101    norm: RmsNorm,
1102    lm_head: Linear,
1103    pub dtype: DType,
1104    pub hidden_size: usize,
1105    device: Device,
1106}
1107
1108impl TextModel {
1109    pub fn new(cfg: &TextConfig, vb: VarBuilder) -> Result<Self> {
1110        let vb_m = vb.pp("model");
1111
1112        let embed_tokens = embedding(cfg.vocab_size, cfg.hidden_size, vb_m.pp("embed_tokens"))?;
1113
1114        let rotary_emb = Arc::new(RotaryEmbedding::new(cfg, vb.device(), vb.dtype())?);
1115
1116        let mut layers = Vec::with_capacity(cfg.num_hidden_layers);
1117        let vb_l = vb_m.pp("layers");
1118        for layer_idx in 0..cfg.num_hidden_layers {
1119            let layer = DecoderLayer::new(rotary_emb.clone(), cfg, vb_l.pp(layer_idx))?;
1120            layers.push(layer);
1121        }
1122
1123        let norm = rms_norm(cfg.hidden_size, cfg.rms_norm_eps, vb_m.pp("norm"))?;
1124
1125        let lm_head = if cfg.tie_word_embeddings {
1126            Linear::new(embed_tokens.embeddings().clone(), None)
1127        } else {
1128            linear_b(cfg.hidden_size, cfg.vocab_size, false, vb.pp("lm_head"))?
1129        };
1130
1131        Ok(Self {
1132            embed_tokens,
1133            layers,
1134            norm,
1135            lm_head,
1136            dtype: vb.dtype(),
1137            hidden_size: cfg.hidden_size,
1138            device: vb.device().clone(),
1139        })
1140    }
1141
1142    /// Get token embeddings.
1143    pub fn embed_tokens(&self, input_ids: &Tensor) -> Result<Tensor> {
1144        self.embed_tokens.forward(input_ids)
1145    }
1146
1147    /// Prepare causal attention mask.
1148    fn prepare_causal_attention_mask(
1149        &self,
1150        b_size: usize,
1151        tgt_len: usize,
1152        seqlen_offset: usize,
1153    ) -> Result<Tensor> {
1154        let mask: Vec<f32> = (0..tgt_len)
1155            .flat_map(|i| (0..tgt_len).map(move |j| if i < j { f32::NEG_INFINITY } else { 0f32 }))
1156            .collect();
1157        let mask = Tensor::from_slice(&mask, (tgt_len, tgt_len), &self.device)?;
1158        let mask = if seqlen_offset > 0 {
1159            let mask0 = Tensor::zeros((tgt_len, seqlen_offset), DType::F32, &self.device)?;
1160            Tensor::cat(&[&mask0, &mask], D::Minus1)?
1161        } else {
1162            mask
1163        };
1164        mask.expand((b_size, 1, tgt_len, tgt_len + seqlen_offset))?
1165            .to_dtype(self.dtype)
1166    }
1167
1168    /// Forward pass with embeddings using 3D M-RoPE.
1169    ///
1170    /// This method is used for all forward passes (both prefill and generation).
1171    /// M-RoPE must always be used to maintain consistency with the prefill positions.
1172    pub fn forward_embeds_with_mrope(
1173        &mut self,
1174        mut xs: Tensor,
1175        position_ids: &Tensor,
1176    ) -> Result<Tensor> {
1177        let (b_sz, seq_len, _) = xs.dims3()?;
1178
1179        // Create causal attention mask for prefill
1180        let attention_mask = if seq_len <= 1 {
1181            None
1182        } else {
1183            Some(self.prepare_causal_attention_mask(b_sz, seq_len, 0)?)
1184        };
1185
1186        for layer in self.layers.iter_mut() {
1187            xs = layer.forward_with_mrope(&xs, attention_mask.as_ref(), position_ids)?;
1188        }
1189
1190        xs = xs.apply(&self.norm)?;
1191
1192        // Only compute logits for last token
1193        self.lm_head
1194            .forward(&xs)?
1195            .i((.., seq_len - 1, ..))?
1196            .contiguous()
1197    }
1198
1199    /// Clear all KV caches.
1200    pub fn clear_kv_cache(&mut self) {
1201        for layer in self.layers.iter_mut() {
1202            layer.clear_kv_cache();
1203        }
1204    }
1205
1206    /// Forward pass with M-RoPE and tensor export for debugging.
1207    ///
1208    /// Captures intermediate tensors at key checkpoints for comparison with PyTorch.
1209    /// Layer 1 exports detailed attention intermediates for GQA repeat_kv debugging.
1210    pub fn forward_embeds_with_mrope_export(
1211        &mut self,
1212        mut xs: Tensor,
1213        position_ids: &Tensor,
1214    ) -> Result<(Tensor, std::collections::HashMap<String, Tensor>)> {
1215        use std::collections::HashMap;
1216
1217        let mut tensors: HashMap<String, Tensor> = HashMap::new();
1218        let (b_sz, seq_len, _) = xs.dims3()?;
1219
1220        // Causal attention mask
1221        let attention_mask = if seq_len <= 1 {
1222            None
1223        } else {
1224            let mask = self.prepare_causal_attention_mask(b_sz, seq_len, 0)?;
1225            tensors.insert("causal_mask".to_string(), mask.clone());
1226            Some(mask)
1227        };
1228
1229        tensors.insert("layer0_input".to_string(), xs.clone());
1230
1231        // Forward through ALL layers, capturing each output
1232        // Layer 1 gets detailed attention export for debugging
1233        for (i, layer) in self.layers.iter_mut().enumerate() {
1234            if i == 1 {
1235                // Layer 1: export all attention intermediates
1236                let (layer_out, layer_tensors) =
1237                    layer.forward_with_mrope_export(&xs, attention_mask.as_ref(), position_ids)?;
1238                xs = layer_out;
1239                // Add layer 1 tensors with prefix
1240                for (k, v) in layer_tensors {
1241                    tensors.insert(format!("layer1_{}", k), v);
1242                }
1243            } else {
1244                xs = layer.forward_with_mrope(&xs, attention_mask.as_ref(), position_ids)?;
1245            }
1246            // Capture EVERY layer output for detailed comparison
1247            tensors.insert(format!("layer_{}_output", i), xs.clone());
1248        }
1249
1250        // Final layer norm
1251        xs = xs.apply(&self.norm)?;
1252        tensors.insert("final_hidden_state".to_string(), xs.clone());
1253
1254        // LM head - compute full logits
1255        let logits = self.lm_head.forward(&xs)?;
1256        tensors.insert("logits".to_string(), logits.clone());
1257
1258        Ok((logits, tensors))
1259    }
1260}