Skip to main content

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}