ffai_argus/prompt.rs
1//! Sequence assembly: turning an image + a question into the exact token
2//! sequence the model was trained on.
3//!
4//! Step 4 of `docs/plans/argus-launch-plan.md`. §3.3 names this
5//!
6//! > "the actual 'multimodal' step and the one most likely to be silently
7//! > wrong (wrong offset = plausible but degraded output)"
8//!
9//! and §2.2 calls the chat template "the highest-risk silent failure in the
10//! whole build". That is not a guess — §7 measured it on this model: **43 of
11//! 50 answers changed on identical weights**, from prompt formatting alone,
12//! with nothing raising an error.
13//!
14//! So this module is gated on TOKEN IDS, not on a tolerance. Ids are integers:
15//! they match the reference exactly or they do not.
16//!
17//! # The layout, read off the reference rather than guessed
18//!
19//! `corpora/refs/dump_smolvlm_prompt.py` dumps what the real processor
20//! produces. For a 512x512 image at `scale_factor` 4 that is 1142 tokens:
21//!
22//! ```text
23//! <|im_start|>User:
24//! <fake_token_around_image><row_1_col_1>[64 x <image>]
25//! <fake_token_around_image><row_1_col_2>[64 x <image>]
26//! ... 16 tiles, ROW-MAJOR
27//! <fake_token_around_image><row_4_col_4>[64 x <image>]
28//! "\n\n"
29//! <fake_token_around_image><global-img>[64 x <image>] the thumbnail is LAST
30//! <fake_token_around_image>
31//! {question}<end_of_utterance>\nAssistant:
32//! ```
33//!
34//! Two details that a reasonable person would get wrong by guessing, and which
35//! would produce fluent-but-degraded output rather than an error:
36//!
37//! * **The global thumbnail comes LAST**, after all 16 tiles — not first, as
38//! "a thumbnail then the detail" would suggest.
39//! * **There is a bare `\n\n` between the tile grid and the thumbnail**, and a
40//! closing `<fake_token_around_image>` after the thumbnail before the text.
41//! The `<fake_token_around_image>` count is the check: 16 + 1 + 1 = **18**,
42//! which is what the reference dump reports.
43
44/// The pieces of the layout that vary by checkpoint, read from `config.json`
45/// and the processor rather than hard-coded.
46#[derive(Debug, Clone)]
47pub struct PromptLayout {
48 /// Repeated `tokens_per_tile` times per image block.
49 pub image_token: String,
50 /// Wraps every image block and closes the run.
51 pub fake_token: String,
52 /// Marks the global thumbnail's block.
53 pub global_token: String,
54 /// `<row_{r}_col_{c}>`, 1-based.
55 pub row_col_fmt: fn(usize, usize) -> String,
56 /// `(image_size / patch_size)^2 / scale_factor^2` — 64 for SmolVLM-256M.
57 pub tokens_per_tile: usize,
58}
59
60impl Default for PromptLayout {
61 fn default() -> Self {
62 Self {
63 image_token: "<image>".into(),
64 fake_token: "<fake_token_around_image>".into(),
65 global_token: "<global-img>".into(),
66 row_col_fmt: |r, c| format!("<row_{r}_col_{c}>"),
67 tokens_per_tile: 64,
68 }
69 }
70}
71
72impl PromptLayout {
73 /// Derive `tokens_per_tile` from the vision geometry.
74 ///
75 /// Computed rather than constant: it is
76 /// `(image_size / patch_size)^2 / scale_factor^2`, so a different `SmolVLM`
77 /// size changes it and a hard-coded 64 would be silently wrong there — the
78 /// same class of defect this whole module is guarding against.
79 #[must_use]
80 pub const fn with_geometry(
81 mut self,
82 image_size: usize,
83 patch_size: usize,
84 scale_factor: usize,
85 ) -> Self {
86 let side = image_size / patch_size;
87 self.tokens_per_tile = (side * side) / (scale_factor * scale_factor);
88 self
89 }
90
91 /// The image-block run: 16 tiles row-major, then `\n\n`, then the global
92 /// thumbnail, then a closing fake token.
93 ///
94 /// `rows`/`cols` describe the tile grid the preprocessor chose. `rows == 0`
95 /// means the image was small enough that only the thumbnail exists — the
96 /// reference emits just the global block then, with no grid and no `\n\n`.
97 #[must_use]
98 pub fn image_block(&self, rows: usize, cols: usize) -> String {
99 let imgs = self.image_token.repeat(self.tokens_per_tile);
100 let mut s = String::new();
101 for r in 1..=rows {
102 for c in 1..=cols {
103 s.push_str(&self.fake_token);
104 s.push_str(&(self.row_col_fmt)(r, c));
105 s.push_str(&imgs);
106 }
107 // EVERY row of the grid is newline-terminated, including the last.
108 //
109 // Found by the token gate rather than by reading. The first
110 // assembly omitted these and produced 1139 tokens against the
111 // reference's 1142 — three missing terminators, one each for rows
112 // 1..3. The fourth was invisible to a structural read because it
113 // MERGES with the separator below into a single `\n\n` token, so
114 // inspecting the reference's token stream showed one `ĊĊ` and
115 // suggested one separator where there are in fact two newlines.
116 s.push('\n');
117 }
118 if rows > 0 {
119 // One more before the thumbnail. Adjacent to the final row's
120 // terminator this becomes `\n\n`, which the tokenizer emits as the
121 // single token the earlier read saw.
122 s.push('\n');
123 }
124 s.push_str(&self.fake_token);
125 s.push_str(&self.global_token);
126 s.push_str(&imgs);
127 s.push_str(&self.fake_token);
128 s
129 }
130
131 /// The full user turn, chat template included.
132 ///
133 /// The template is `SmolVLM`'s own — `<|im_start|>User:` … `Assistant:` — and
134 /// it is written here only because the tokenizer's Jinja template is not
135 /// available to this crate. It is checked against the reference's own
136 /// output, which is the only thing that makes writing it acceptable at all.
137 #[must_use]
138 pub fn user_turn(&self, question: &str, rows: usize, cols: usize) -> String {
139 format!(
140 "<|im_start|>User:{}{question}<end_of_utterance>\nAssistant:",
141 self.image_block(rows, cols)
142 )
143 }
144}
145
146/// How many image tokens a prompt should contain for a given grid.
147///
148/// The arithmetic the assembly must satisfy, kept separate so a test can state
149/// it independently of the string building: every tile plus the global
150/// thumbnail contributes `tokens_per_tile`.
151#[must_use]
152pub const fn expected_image_tokens(layout: &PromptLayout, rows: usize, cols: usize) -> usize {
153 (rows * cols + 1) * layout.tokens_per_tile
154}
155
156/// How many `<fake_token_around_image>` a prompt should contain.
157///
158/// One before each tile, one before the thumbnail, one closing the run. The
159/// count is a cheap structural check that catches a dropped separator, which a
160/// token-count check alone would not.
161#[must_use]
162pub const fn expected_fake_tokens(rows: usize, cols: usize) -> usize {
163 rows * cols + 2
164}
165
166/// Splice image embeddings into the text embedding sequence.
167///
168/// This is §3.3's "actual multimodal step". The reference implements it as a
169/// `masked_scatter`: every position where `input_ids == image_token_id` takes
170/// the next vector from the image hidden states, in order.
171///
172/// # Why it is written as an explicit walk rather than a clever gather
173///
174/// The failure this guards against is an OFF-BY-ONE, and an off-by-one here
175/// does not crash — it shifts every image block by one position and yields
176/// fluent, plausible, degraded output. So the walk is deliberately literal,
177/// and it **fails loudly on any count mismatch** instead of truncating to the
178/// shorter of the two, which is exactly how a silent misalignment would enter.
179///
180/// `image_hidden` is `(tiles, tokens_per_tile, dim)` or any shape whose
181/// flattened row count equals the number of image positions; it is consumed in
182/// row-major order, matching `masked_scatter` on a contiguous tensor.
183///
184/// # Errors
185/// If the number of image positions differs from the number of supplied image
186/// vectors, or if the dimensions disagree.
187pub fn merge_image_embeddings(
188 text_embeds: &candle_core::Tensor,
189 image_hidden: &candle_core::Tensor,
190 input_ids: &[i64],
191 image_token_id: i64,
192) -> candle_core::Result<candle_core::Tensor> {
193 use candle_core::{IndexOp, Tensor};
194
195 let (batch, seq, dim) = text_embeds.dims3()?;
196 if batch != 1 {
197 candle_core::bail!("merge expects batch 1, got {batch}");
198 }
199 if seq != input_ids.len() {
200 candle_core::bail!(
201 "input_ids has {} tokens but text_embeds has {seq} positions",
202 input_ids.len()
203 );
204 }
205 // Flatten the image side to (n_vectors, dim) so tiles are consumed in
206 // order, exactly as a contiguous masked_scatter would.
207 let img = image_hidden.flatten_to(image_hidden.rank() - 2)?;
208 let (n_img, img_dim) = img.dims2()?;
209 if img_dim != dim {
210 candle_core::bail!("image vectors are {img_dim}-dim but text embeds are {dim}-dim");
211 }
212 let positions: Vec<usize> = input_ids
213 .iter()
214 .enumerate()
215 .filter(|&(_, &t)| t == image_token_id)
216 .map(|(i, _)| i)
217 .collect();
218 if positions.len() != n_img {
219 candle_core::bail!(
220 "{} image positions in the prompt but {n_img} image vectors supplied — \
221 a mismatch here would misalign every block that follows, so it is an \
222 error rather than a truncation",
223 positions.len()
224 );
225 }
226
227 // Build the merged sequence row by row. `index_select` on the text side
228 // plus a scatter would be terser; this is chosen for auditability, and the
229 // sequence is ~1k rows so the cost is irrelevant next to a vision tower.
230 let text = text_embeds.i(0)?;
231 let mut rows: Vec<Tensor> = Vec::with_capacity(seq);
232 let mut next = 0usize;
233 for (i, &tok) in input_ids.iter().enumerate() {
234 if tok == image_token_id {
235 rows.push(img.i(next)?);
236 next += 1;
237 } else {
238 rows.push(text.i(i)?);
239 }
240 }
241 debug_assert_eq!(next, n_img, "every image vector must be consumed");
242 Tensor::stack(&rows, 0)?.unsqueeze(0)
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248
249 #[test]
250 fn geometry_gives_smolvlm_64_tokens_per_tile() {
251 // 512 / 16 = 32 patches a side, 1024 patches, / 4^2 = 64 tokens.
252 let l = PromptLayout::default().with_geometry(512, 16, 4);
253 assert_eq!(l.tokens_per_tile, 64);
254 }
255
256 #[test]
257 fn the_block_has_the_counts_the_reference_reports() {
258 let l = PromptLayout::default();
259 let s = l.image_block(4, 4);
260 assert_eq!(
261 s.matches("<image>").count(),
262 expected_image_tokens(&l, 4, 4),
263 "17 blocks of 64"
264 );
265 assert_eq!(s.matches("<image>").count(), 1088);
266 assert_eq!(
267 s.matches("<fake_token_around_image>").count(),
268 expected_fake_tokens(4, 4),
269 "16 tiles + global + closing"
270 );
271 assert_eq!(s.matches("<fake_token_around_image>").count(), 18);
272 }
273
274 /// The ordering detail most likely to be guessed wrong.
275 #[test]
276 fn the_global_thumbnail_comes_after_every_tile() {
277 let s = PromptLayout::default().image_block(4, 4);
278 let global = s.find("<global-img>").expect("global marker");
279 let last_tile = s.find("<row_4_col_4>").expect("last tile marker");
280 assert!(
281 global > last_tile,
282 "the thumbnail must follow the grid — reversing it produces fluent, \
283 degraded output rather than an error"
284 );
285 // …and there is a bare newline pair between the grid and the thumbnail.
286 assert!(s[last_tile..global].contains("\n\n"));
287 }
288
289 #[test]
290 fn a_thumbnail_only_image_has_no_grid_and_no_separator() {
291 let s = PromptLayout::default().image_block(0, 0);
292 assert!(!s.contains("<row_"));
293 assert!(!s.contains("\n\n"));
294 assert_eq!(s.matches("<fake_token_around_image>").count(), 2);
295 }
296
297 #[test]
298 fn the_user_turn_carries_the_chat_markers() {
299 let t = PromptLayout::default().user_turn("What is written in this image?", 4, 4);
300 assert!(t.starts_with("<|im_start|>User:"));
301 assert!(t.ends_with("<end_of_utterance>\nAssistant:"));
302 }
303}