metrale_server/tokenizer/chat_impl/vision_pads.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: Vision placeholder token ids, and the expansion of one placeholder per image
4//! or video into one per vision-encoder output row.
5//!
6//! Owner: server (tokenizer).
7//! Invariants:
8//! - `fan_out_pads` never fails: a placeholder beyond `pad_counts` stays a single token, and
9//! counts beyond the last placeholder are unused.
10
11use super::super::ChatTokenizer;
12
13impl ChatTokenizer {
14 /// 2026-09-26: The image placeholder token id.
15 ///
16 /// `declared` is the `image_token_id` from the checkpoint's config (top level or under
17 /// `vision_config`) and wins when non-zero. Otherwise the id is that of `<|image_pad|>`
18 /// when the string encodes to exactly one token, else `None`. With `None`,
19 /// `expand_vision_pads` leaves image placeholders unexpanded and reports nothing.
20 pub fn image_pad_token_id(&self, declared: u32) -> Option<u32> {
21 if declared != 0 {
22 return Some(declared);
23 }
24 self.encode("<|image_pad|>")
25 .ok()
26 .and_then(|ids| if ids.len() == 1 { Some(ids[0]) } else { None })
27 }
28
29 /// 2026-09-26: The video placeholder token id, with the same rule as
30 /// `image_pad_token_id`: `declared` when non-zero, else `<|video_pad|>` when it encodes
31 /// to one token, else `None`.
32 pub fn video_pad_token_id(&self, declared: u32) -> Option<u32> {
33 if declared != 0 {
34 return Some(declared);
35 }
36 self.encode("<|video_pad|>")
37 .ok()
38 .and_then(|ids| if ids.len() == 1 { Some(ids[0]) } else { None })
39 }
40
41 /// 2026-09-26: Expand the image and video placeholders of a rendered token sequence, the
42 /// i-th placeholder to `pad_counts[i]` copies (at least one), so the prompt holds one
43 /// placeholder per vision-encoder output row. The Qwen3.6 templates in
44 /// test_data/chat_templates write one placeholder per image or video. Returns `tokens`
45 /// unchanged when every count is at most 1; see `fan_out_pads` for mismatched counts.
46 pub fn expand_vision_pads(
47 &self,
48 tokens: Vec<u32>,
49 pad_counts: &[usize],
50 declared: (u32, u32),
51 ) -> Vec<u32> {
52 if pad_counts.is_empty() || pad_counts.iter().all(|&c| c <= 1) {
53 return tokens;
54 }
55 fan_out_pads(
56 tokens,
57 pad_counts,
58 self.image_pad_token_id(declared.0),
59 self.video_pad_token_id(declared.1),
60 )
61 }
62}
63
64/// 2026-09-26: The expansion over explicit ids, testable without a tokenizer.
65///
66/// `pad_counts[i]` is the number of tokens the i-th media item, image or video, produces.
67/// Placeholders take counts left to right, one each; a placeholder with no count left stays
68/// one token. Returns `tokens` unchanged when both ids are `None`.
69pub(crate) fn fan_out_pads(
70 tokens: Vec<u32>,
71 pad_counts: &[usize],
72 image_pad: Option<u32>,
73 video_pad: Option<u32>,
74) -> Vec<u32> {
75 if image_pad.is_none() && video_pad.is_none() {
76 return tokens;
77 }
78 let extra: usize = pad_counts.iter().map(|c| c.saturating_sub(1)).sum();
79 let mut out = Vec::with_capacity(tokens.len() + extra);
80 let mut img_idx = 0usize;
81 for t in tokens {
82 if Some(t) == image_pad || Some(t) == video_pad {
83 let count = pad_counts.get(img_idx).copied().unwrap_or(1).max(1);
84 for _ in 0..count {
85 out.push(t);
86 }
87 img_idx += 1;
88 } else {
89 out.push(t);
90 }
91 }
92 out
93}
94
95#[cfg(test)]
96mod tests {
97 use super::fan_out_pads;
98
99 /// 2026-09-26: 154854 is the `image_token_id` in the GLM-5.3 config fixture
100 /// (crates/model-engine/tests/fixtures/glm53-nvfp4-9e0d74e3-config.json). With that id
101 /// the placeholder expands; with no id nothing changes.
102 #[test]
103 fn a_declared_id_expands_where_a_literal_probe_would_not_have() {
104 let glm_image = 154_854u32;
105 let tokens = vec![1, 154_830, glm_image, 154_831, 2];
106 let out = fan_out_pads(tokens.clone(), &[256], Some(glm_image), None);
107 assert_eq!(out.len(), tokens.len() + 255);
108 assert_eq!(out.iter().filter(|&&t| t == glm_image).count(), 256);
109 assert_eq!(out[1], 154_830);
110 assert_eq!(*out.last().unwrap(), 2);
111 assert_eq!(out[out.len() - 2], 154_831);
112
113 assert_eq!(fan_out_pads(tokens.clone(), &[256], None, None), tokens);
114 }
115
116 /// 2026-09-26: Images and videos take counts from one left-to-right cursor.
117 #[test]
118 fn counts_are_consumed_in_order_across_both_modalities() {
119 let (img, vid) = (100u32, 101u32);
120 let out = fan_out_pads(vec![img, 7, vid, img], &[2, 3, 1], Some(img), Some(vid));
121 assert_eq!(out, vec![img, img, 7, vid, vid, vid, img]);
122 }
123
124 /// 2026-09-26: More placeholders than counts: the surplus stays one token each.
125 #[test]
126 fn a_short_count_list_does_not_reuse_the_last_count() {
127 let img = 100u32;
128 let out = fan_out_pads(vec![img, img], &[3], Some(img), None);
129 assert_eq!(out, vec![img, img, img, img]);
130 }
131}