ffai_argus/engine.rs
1//! The `VlmEngine` implementation: an `ImageBuffer` in, a caption out.
2//!
3//! Step 6 of `docs/plans/argus-launch-plan.md`. Steps 3, 4 and 5 each gated one
4//! brick against the reference in isolation; this is the one that composes them
5//! and is therefore the one where a *plumbing* mistake — a stale cache, a
6//! dropped alpha channel, a grid the prompt and the pixels disagree about —
7//! can appear without any brick being wrong.
8//!
9//! # The four things this file does, in order
10//!
11//! 1. **RGB8.** Every image arrives as an [`ImageBuffer`] in one of three pixel
12//! formats. The tower takes one. Converting here, once, means no other stage
13//! has to know that grayscale exists.
14//! 2. **Preprocess + tower**, per tile, into a `(tiles, tokens_per_tile, d)`
15//! block of image embeddings.
16//! 3. **Assemble** the chat turn, tokenize it, embed the ids, and splice the
17//! image embeddings over the `<image>` placeholders.
18//! 4. **Decode** greedily (or sampled, per [`Decoding`]) and detokenize.
19//!
20//! # Why the geometry is carried and not recomputed
21//!
22//! [`preprocess_rgb8`] returns the tile grid it chose, and that grid is what
23//! builds the `<row_r_col_c>` markers. The alternative — deriving the grid
24//! twice, once for pixels and once for text — is how the two silently disagree,
25//! and the failure mode is not an error but a caption of the wrong thing.
26
27use std::path::{Path, PathBuf};
28use std::sync::{Mutex, OnceLock};
29
30use candle_core::{Device, Tensor};
31use ffai_core::engine::{
32 EngineInfo, EngineStatus, Task, VlmEngine, VlmOptions, VlmPart, VlmPrompt,
33};
34use ffai_core::error::{Error, Result};
35use ffai_core::types::{ImageBuffer, PixelFormat, TimedSegment, VideoFrame};
36
37use crate::decode::TextDecoder;
38use crate::preprocess::preprocess_rgb8_opts;
39use crate::prompt::{merge_image_embeddings, PromptLayout};
40use crate::vision::SmolVlmVision;
41
42/// The checkpoint this engine is gated against.
43///
44/// Named as a constant because it is not a default that can drift: every
45/// number in the launch plan's steps 3-6 was measured on this exact
46/// checkpoint, and the prompt template in `prompt.rs` is *its* template.
47pub const MODEL: &str = "smolvlm-256m-instruct";
48
49/// Generation budget when the caller gives none.
50const DEFAULT_MAX_NEW_TOKENS: usize = 256;
51
52/// Frames per video caption when the caller gives none.
53///
54/// Eight unsplit frames is 512 image tokens — comfortably inside the tower's
55/// 8192 positions with room for the question and the answer — and eight
56/// samples is enough to see a change rather than a moment. It is a default,
57/// not a limit: [`VlmOptions::frames_per_window`] overrides it, and the error
58/// on overflow names this knob.
59const DEFAULT_FRAMES_PER_WINDOW: usize = 8;
60
61/// Where the time went, stage by stage, for one caption.
62///
63/// # Why this exists on the engine rather than in the caller
64///
65/// A caller *can* time `describe_image` and get one number. That number is
66/// almost useless for a VLM, because it is dominated by a cost the caller
67/// cannot see: **the vision tower runs once per tile, and a still image is
68/// seventeen tiles.** A reader looking at "4.2 seconds" concludes the language
69/// model is slow. The truth is usually that they handed it a picture.
70///
71/// So the split is reported by the code that knows it, and the alternative —
72/// a demo or a profiler reassembling the pipeline out of the public pieces to
73/// time each one — is exactly the drift this crate spent §16 avoiding.
74#[derive(Debug, Clone, Default, PartialEq)]
75pub struct CaptionTrace {
76 /// Image -> `pixel_values`: two Lanczos resizes, the tile cut, normalise.
77 pub preprocess_ms: f64,
78 /// `SigLIP` + connector, summed over every tile of every image.
79 pub tower_ms: f64,
80 /// One entry per tile, in the order the tower saw them. The last belongs
81 /// to the global thumbnail.
82 pub tower_per_tile_ms: Vec<f64>,
83 /// Chat template, tokenize, embed, splice.
84 pub assemble_ms: f64,
85 /// One forward pass over the whole prompt.
86 pub prefill_ms: f64,
87 /// One entry per generated token.
88 pub step_ms: Vec<f64>,
89 /// Detokenize and trim.
90 pub detokenize_ms: f64,
91
92 pub tiles: usize,
93 pub rows: usize,
94 pub cols: usize,
95 /// Tile edge in pixels (512 for this checkpoint).
96 pub tile: usize,
97 /// `tiles * tokens_per_tile` — what the picture costs the prompt.
98 pub image_tokens: usize,
99 /// Whatever is left of the prompt once the images are counted out.
100 pub text_tokens: usize,
101 pub prompt_tokens: usize,
102 /// The tower's own token budget, so a reader can see the headroom.
103 pub max_positions: usize,
104 /// Was tile splitting on? Off means video framing — 1 tile per image.
105 pub split: bool,
106 /// The size each image was resized to before tiling, in order.
107 pub resized_to: Vec<(usize, usize)>,
108}
109
110impl CaptionTrace {
111 /// Time in the decode loop, excluding prefill.
112 #[must_use]
113 pub fn decode_ms(&self) -> f64 {
114 self.step_ms.iter().sum()
115 }
116
117 /// Everything this engine spent, decode of the source file excluded —
118 /// that happens before the engine is called.
119 #[must_use]
120 pub fn total_ms(&self) -> f64 {
121 self.preprocess_ms
122 + self.tower_ms
123 + self.assemble_ms
124 + self.prefill_ms
125 + self.decode_ms()
126 + self.detokenize_ms
127 }
128
129 /// Generated tokens per second, prefill excluded — see [`DecodeTrace`].
130 #[must_use]
131 pub fn tokens_per_sec(&self) -> f64 {
132 let ms = self.decode_ms();
133 if ms <= 0.0 {
134 return 0.0;
135 }
136 self.step_ms.len() as f64 / (ms / 1e3)
137 }
138}
139
140/// `SmolVLM`-256M-Instruct on candle: `SigLIP` tower, pixel-shuffle connector,
141/// Llama decoder.
142///
143/// Chosen in Gate 1.2 over `SmolVLM2` for one decidable reason: only v1 has a
144/// published `OCRBench` row to be scored against, which is what makes Arm 1 an
145/// external number rather than our own claim.
146pub struct SmolVlm {
147 manifest_dir: PathBuf,
148 /// Loaded on first use. A registry installs every engine at startup, and
149 /// loading ~500 MB of weights because someone ran `ffai --help` is not a
150 /// cost the user asked for.
151 model: OnceLock<std::result::Result<Model, String>>,
152}
153
154struct Model {
155 vision: SmolVlmVision,
156 /// `&mut` because generation walks a `KV` cache; `VlmEngine::describe`
157 /// takes `&self`, so the interior mutability lives here rather than
158 /// forcing every caller to own the engine mutably.
159 decoder: Mutex<TextDecoder>,
160 tokenizer: tokenizers::Tokenizer,
161 layout: PromptLayout,
162 image_token_id: i64,
163 /// Every id that ends a turn. `<end_of_utterance>` is the one that
164 /// actually fires for this checkpoint; the config's `eos_token_id` is kept
165 /// beside it because a checkpoint that disagreed with its own tokenizer
166 /// would otherwise run to the token budget every time.
167 stop_ids: Vec<u32>,
168 /// The text tower's position budget. Read from the checkpoint so the
169 /// overflow error can name the real number rather than a guess, and so a
170 /// larger SmolVLM raises the ceiling without a code change.
171 max_positions: usize,
172 device: Device,
173}
174
175impl SmolVlm {
176 /// An engine reading manifests from the workspace's `models/` directory.
177 #[must_use]
178 pub fn new() -> Self {
179 Self::with_manifest_dir(PathBuf::from("models"))
180 }
181
182 #[must_use]
183 pub const fn with_manifest_dir(dir: PathBuf) -> Self {
184 Self {
185 manifest_dir: dir,
186 model: OnceLock::new(),
187 }
188 }
189
190 /// Build from weights the caller already holds — **no `std::fs`, no `mmap`
191 /// and no manifest on this path at all.**
192 ///
193 /// `new()` and `with_manifest_dir` defer loading to first use, where they
194 /// reach a manifest, the model cache and an mmap. A browser has none of the
195 /// three, so this is the constructor a wasm build uses.
196 ///
197 /// **Eager, not lazy**: the caller has already paid to get the bytes here,
198 /// so there is nothing left to defer, and a malformed checkpoint surfaces
199 /// at construction rather than on the first image.
200 pub fn from_bytes(w: ArgusBytes) -> Result<Self> {
201 let tokenizer = tokenizers::Tokenizer::from_bytes(&w.tokenizer)
202 .map_err(|e| Error::Model(format!("tokenizer: {e}")))?;
203 let device = Device::Cpu;
204 // `from_buffered_safetensors` TAKES the Vec: the caller has already
205 // paid to get these bytes into memory, and on wasm32 that memory is
206 // the whole budget, so it is moved rather than copied.
207 let vb = candle_nn::VarBuilder::from_buffered_safetensors(
208 w.weights,
209 candle_core::DType::F32,
210 &device,
211 )
212 .map_err(|e| Error::Model(format!("reading safetensors: {e}")))?;
213 let model = build(vb, &w.config, tokenizer, device)?;
214 let cell = OnceLock::new();
215 let _ = cell.set(Ok(model));
216 Ok(Self {
217 manifest_dir: PathBuf::new(),
218 model: cell,
219 })
220 }
221
222 /// Are the weights already resident?
223 ///
224 /// Loading is ~1 GB of safetensors and happens once, on first use. A
225 /// caller timing a caption needs to know whether that cost landed inside
226 /// the measurement, because a first call that includes it is not the same
227 /// event as a warm one and averaging the two describes neither.
228 #[must_use]
229 pub fn is_loaded(&self) -> bool {
230 self.model.get().is_some()
231 }
232
233 /// Force the load now, so a later timed call does not pay for it.
234 ///
235 /// # Errors
236 /// Whatever loading the checkpoint would return.
237 pub fn warm(&self) -> Result<()> {
238 self.model().map(|_| ())
239 }
240
241 fn model(&self) -> Result<&Model> {
242 match self
243 .model
244 .get_or_init(|| load(&self.manifest_dir).map_err(|e| e.to_string()))
245 {
246 Ok(m) => Ok(m),
247 Err(e) => Err(Error::Model(e.clone())),
248 }
249 }
250}
251
252impl SmolVlm {
253 /// [`VlmEngine::describe_image`], with a stage-by-stage timing trace.
254 ///
255 /// Same code path as the untraced call — it *is* the untraced call, with a
256 /// trace threaded through — so the numbers describe what actually runs
257 /// rather than a parallel implementation that exists to be measured.
258 ///
259 /// # Errors
260 /// Whatever `describe_image` would return.
261 /// Caption a still with tile splitting **off** — one tile, not seventeen.
262 ///
263 /// [`VlmEngine::describe_image`] splits, because seventeen tiles is what
264 /// lets the model read fine print and a single still has all 8192 text
265 /// positions to itself. That is the right default when the compute is
266 /// free. It is not free everywhere.
267 ///
268 /// A split still is 17 tiles / 1088 image tokens; unsplit it is **1 tile /
269 /// 64 tokens**, so the vision tower does roughly a seventeenth of the work.
270 /// `describe_video` has always taken this path — not to trade quality for
271 /// speed, but because a split frame caps a window at seven frames against
272 /// the tower's 8192 positions. Wasm wants it for the other reason: the
273 /// browser has no threads, and 17 tiles of scalar-plus-gemm arithmetic is
274 /// minutes per image.
275 ///
276 /// **The detail loss is bounded and known.** The unsplit tile is exactly
277 /// the global thumbnail the split path already computes and prepends, so
278 /// this is not a different preprocessing path with its own oracle — it is
279 /// the same path, stopping before the tiles. What goes is fine print and
280 /// small objects; what stays is the whole-image gist the thumbnail carries.
281 ///
282 /// Prefer [`VlmEngine::describe_image`] wherever the seventeen tiles are
283 /// affordable.
284 pub fn describe_image_unsplit(
285 &self,
286 image: &ImageBuffer,
287 opts: &VlmOptions,
288 ) -> Result<String> {
289 let mut pieces = vec![Piece::Image(image)];
290 if let Some(t) = opts.prompt.as_deref() {
291 pieces.push(Piece::Text(t));
292 }
293 self.caption(&pieces, false, opts)
294 }
295
296 pub fn describe_image_traced(
297 &self,
298 image: &ImageBuffer,
299 opts: &VlmOptions,
300 ) -> Result<(String, CaptionTrace)> {
301 let mut trace = CaptionTrace::default();
302 let mut pieces = vec![Piece::Image(image)];
303 if let Some(t) = opts.prompt.as_deref() {
304 pieces.push(Piece::Text(t));
305 }
306 let text = self.caption_traced(&pieces, true, opts, Some(&mut trace))?;
307 Ok((text, trace))
308 }
309}
310
311impl Default for SmolVlm {
312 fn default() -> Self {
313 Self::new()
314 }
315}
316
317impl VlmEngine for SmolVlm {
318 fn info(&self) -> EngineInfo {
319 EngineInfo {
320 name: "smolvlm".into(),
321 task: Task::Vlm,
322 // `Stable` means "oracle-gated against a reference
323 // implementation", and that is precisely what steps 3-6 bought:
324 // the whole content path reproduces the reference's tokens 32/32
325 // from a raw image. Anything less would be `Experimental`.
326 status: EngineStatus::Stable,
327 description:
328 "SmolVLM-256M-Instruct on candle — SigLIP tower, pixel-shuffle connector, Llama decoder"
329 .into(),
330 }
331 }
332
333 fn describe(&self, prompt: &VlmPrompt<'_>, opts: &VlmOptions) -> Result<String> {
334 if prompt.image_count() == 0 {
335 return Err(Error::Other(
336 "argus: describe() needs at least one image — this is a VLM, not a chat model"
337 .into(),
338 ));
339 }
340 let pieces: Vec<Piece<'_>> = prompt
341 .parts
342 .iter()
343 .map(|p| match p {
344 VlmPart::Text(t) => Piece::Text(t),
345 VlmPart::Image(i) => Piece::Image(i),
346 })
347 .collect();
348 // Stills SPLIT: seventeen tiles is what lets the model read fine
349 // print, and a single still has all 8192 positions to itself.
350 self.caption(&pieces, true, opts)
351 }
352
353 fn describe_video(
354 &self,
355 frames: &[VideoFrame],
356 opts: &VlmOptions,
357 ) -> Result<Vec<TimedSegment<String>>> {
358 if frames.is_empty() {
359 return Ok(Vec::new());
360 }
361 let window = opts
362 .frames_per_window
363 .unwrap_or(DEFAULT_FRAMES_PER_WINDOW)
364 .max(1);
365 let step = median_step(frames);
366 let mut out = Vec::with_capacity(frames.len().div_ceil(window));
367
368 for (w, chunk) in frames.chunks(window).enumerate() {
369 // Every frame of the window in ONE prompt, then the question.
370 // This is the difference between video understanding and captioning
371 // stills in a loop: the model sees the frames together, so it can
372 // answer about change rather than about one moment.
373 let mut pieces: Vec<Piece<'_>> =
374 chunk.iter().map(|f| Piece::Image(&f.image)).collect();
375 if let Some(t) = opts.prompt.as_deref() {
376 pieces.push(Piece::Text(t));
377 }
378 // Video does NOT split — see `preprocess_rgb8_opts`. With splitting
379 // on, a four-frame window is 4352 image tokens and an eight-frame
380 // window does not fit at all.
381 let value = self.caption(&pieces, false, opts)?;
382
383 let start = chunk[0].timestamp;
384 // A window runs until the next window starts. The last one has no
385 // successor, so it takes the sampling step rather than claiming a
386 // zero-length segment no player would show.
387 let end = frames
388 .get((w + 1) * window)
389 .map_or_else(|| chunk[chunk.len() - 1].timestamp + step, |f| f.timestamp);
390 out.push(TimedSegment {
391 start,
392 end,
393 value,
394 confidence: None,
395 });
396 }
397 Ok(out)
398 }
399}
400
401/// How many tiles to run at once.
402///
403/// `FFAI_ARGUS_TILE_WORKERS` overrides; 0 or unset picks the default below.
404fn tile_workers(tiles: usize) -> usize {
405 if let Some(n) = std::env::var("FFAI_ARGUS_TILE_WORKERS")
406 .ok()
407 .and_then(|v| v.parse::<usize>().ok())
408 .filter(|&n| n > 0)
409 {
410 return n.min(tiles).max(1);
411 }
412 // Measured on a 24-core box, 17 tiles, min-of-3, bit-identical throughout:
413 //
414 // workers | 1 2 4 6 8 12 17
415 // speedup | 1.03 1.64 2.19 2.50 2.37 2.44 2.69
416 //
417 // 17 is fastest but every concurrent tower materialises its own
418 // `(1, 12, 1024, 1024)` attention matrix — **50 MiB each** — so 17 workers
419 // is ~850 MiB of transient peak against a footprint gate this engine
420 // currently PASSES at 0.71x. Six workers keeps 93 % of the win for ~35 % of
421 // that memory, which is the right side of a trade between a gate we win and
422 // a gate we lose.
423 //
424 // Scaled by cores rather than fixed, so a 4-core laptop does not spawn six
425 // towers into four cores.
426 let cores = std::thread::available_parallelism().map_or(1, std::num::NonZero::get);
427 (cores / 4).clamp(1, 6).min(tiles.max(1))
428}
429
430/// Run the vision tower over every tile, concurrently.
431///
432/// # Why threads and not a bigger batch
433///
434/// candle's CPU backend uses rayon for `conv2d` and nothing else — its
435/// elementwise and layout kernels are **single-threaded**. Half of a `SigLIP`
436/// encoder layer is exactly those ops (`GELU` alone is 19.7 % of one, measured
437/// by `examples/vision_ops_probe`), so for half the tower a 24-core box runs
438/// one core.
439///
440/// Batching cannot fix that: the kernel stays single-threaded however long the
441/// array is, which is why `examples/tile_batching_ab` measured only 1.07x.
442/// Threading can, because seventeen tiles are seventeen independent
443/// single-threaded workloads.
444///
445/// **Bit-identical**, and not by assumption: `examples/tile_parallel_ab`
446/// compares the threaded result against the sequential one tensor by tensor and
447/// asserts `max_abs == 0`. Each tile is its own forward pass over shared,
448/// immutable weights — there is no accumulation order to change.
449fn run_tower(
450 vision: &crate::vision::SmolVlmVision,
451 pre: &crate::preprocess::Preprocessed,
452 device: &Device,
453) -> Result<(Vec<Tensor>, Vec<f64>)> {
454 let per = 3 * pre.tile * pre.tile;
455 let workers = tile_workers(pre.tiles);
456 if workers <= 1 || pre.tiles <= 1 {
457 let mut out = Vec::with_capacity(pre.tiles);
458 let mut ms = Vec::with_capacity(pre.tiles);
459 for t in 0..pre.tiles {
460 let t0 = crate::clock::Instant::now();
461 let px = pre.pixel_values[t * per..(t + 1) * per].to_vec();
462 let tensor = Tensor::from_vec(px, (1, 3, pre.tile, pre.tile), device)?;
463 out.push(vision.forward(&tensor)?.squeeze(0)?);
464 ms.push(t0.elapsed().as_secs_f64() * 1e3);
465 }
466 return Ok((out, ms));
467 }
468
469 // The kernels stand down: this loop is the parallelism now. Restored
470 // before returning so a later single-tile call gets them back.
471 //
472 // ...but only when the tile loop actually saturates the machine.
473 //
474 // This used to be an unconditional `false`, on the reasoning that "one tile
475 // parallelises inside; seventeen parallelise across". That is right at the
476 // default six workers and WRONG below it: six workers on a 24-core box is
477 // six-way outer parallelism, and the idle cores are why a smaller worker
478 // count looked so expensive — nothing took up the slack.
479 //
480 // Measured back-to-back on 17 tiles (kernels ON vs OFF at the same worker
481 // count, so the ratio is immune to this box's drift):
482 //
483 // workers | 6 4
484 // ON/OFF | 1.01x 1.34x
485 //
486 // At six there is nothing to gain; at four there was **34 %** sitting
487 // unused. That matters because worker count is also the footprint knob —
488 // each concurrent tower holds its own 50 MiB attention matrix — so making
489 // the low end cheap is what lets memory be traded without paying for it
490 // twice.
491 //
492 // `workers * 6 <= cores` is the measured crossover: 4*6 = 24 fits a
493 // 24-core box, 6*6 = 36 does not. `FFAI_ARGUS_KERNELS_PARALLEL` forces
494 // either way for re-measurement.
495 let cores = std::thread::available_parallelism().map_or(1, std::num::NonZero::get);
496 let kernels_on = match std::env::var("FFAI_ARGUS_KERNELS_PARALLEL").ok().as_deref() {
497 Some("1") => true,
498 Some("0") => false,
499 _ => workers.saturating_mul(6) <= cores,
500 };
501 let prev = crate::siglip::set_kernels_parallel(kernels_on);
502 let next = std::sync::atomic::AtomicUsize::new(0);
503 let slots: Mutex<Vec<Option<(Tensor, f64)>>> = Mutex::new((0..pre.tiles).map(|_| None).collect());
504 let failed: Mutex<Option<String>> = Mutex::new(None);
505
506 std::thread::scope(|scope| {
507 for _ in 0..workers {
508 scope.spawn(|| loop {
509 // Claimed by an atomic counter rather than a fixed split: the
510 // tiles cost the same in principle, and in practice the last
511 // worker of a fixed split waits on whichever core the OS
512 // descheduled.
513 let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
514 if i >= pre.tiles {
515 break;
516 }
517 let t0 = crate::clock::Instant::now();
518 let px = pre.pixel_values[i * per..(i + 1) * per].to_vec();
519 let done = Tensor::from_vec(px, (1, 3, pre.tile, pre.tile), device)
520 .and_then(|t| vision.forward(&t))
521 .and_then(|o| o.squeeze(0));
522 match done {
523 Ok(block) => {
524 if let Ok(mut g) = slots.lock() {
525 g[i] = Some((block, t0.elapsed().as_secs_f64() * 1e3));
526 }
527 }
528 Err(e) => {
529 // Record and KEEP DRAINING. Returning early would leave
530 // the other workers writing into a scope that is trying
531 // to unwind, and the first error is the informative one
532 // anyway.
533 if let Ok(mut f) = failed.lock() {
534 f.get_or_insert_with(|| e.to_string());
535 }
536 }
537 }
538 });
539 }
540 });
541
542 crate::siglip::set_kernels_parallel(prev);
543 if let Some(e) = failed.into_inner().ok().flatten() {
544 return Err(Error::Model(format!("vision tower: {e}")));
545 }
546 let done = slots
547 .into_inner()
548 .map_err(|_| Error::Other("argus: tile results poisoned".into()))?;
549 let mut out = Vec::with_capacity(pre.tiles);
550 let mut ms = Vec::with_capacity(pre.tiles);
551 for (i, slot) in done.into_iter().enumerate() {
552 let (block, t) = slot.ok_or_else(|| Error::Model(format!("tile {i} produced nothing")))?;
553 out.push(block);
554 ms.push(t);
555 }
556 Ok((out, ms))
557}
558
559/// One element of an assembled prompt.
560///
561/// A private mirror of [`VlmPart`] so the shared assembly path can be driven
562/// by `describe_video`, whose inputs are [`VideoFrame`]s rather than a
563/// [`VlmPrompt`]. Copying two lines here is cheaper than making the trait's
564/// prompt type do double duty.
565enum Piece<'a> {
566 Text(&'a str),
567 Image(&'a ImageBuffer),
568}
569
570impl SmolVlm {
571 /// The one assembly path: images through the tower, text interleaved in
572 /// place, spliced, decoded.
573 ///
574 /// Both entry points funnel through here so they cannot drift in the
575 /// things that are easy to get subtly different — the chat template, the
576 /// order images are consumed in, the stop handling — while still differing
577 /// in the one thing that should differ, `split`.
578 fn caption(&self, pieces: &[Piece<'_>], split: bool, opts: &VlmOptions) -> Result<String> {
579 self.caption_traced(pieces, split, opts, None)
580 }
581
582 fn caption_traced(
583 &self,
584 pieces: &[Piece<'_>],
585 split: bool,
586 opts: &VlmOptions,
587 mut trace: Option<&mut CaptionTrace>,
588 ) -> Result<String> {
589 let m = self.model()?;
590
591 // Price the prompt FIRST, from geometry alone. Every image here costs
592 // a resize and a vision-tower pass, so discovering the prompt does not
593 // fit after paying for two hundred of them is four minutes spent to
594 // learn something two integers could have said.
595 let images: Vec<&ImageBuffer> = pieces
596 .iter()
597 .filter_map(|p| match p {
598 Piece::Image(i) => Some(*i),
599 Piece::Text(_) => None,
600 })
601 .collect();
602 let planned: usize = images
603 .iter()
604 .map(|i| {
605 crate::preprocess::tile_geometry(i.width as usize, i.height as usize, split).0
606 })
607 .sum();
608 let planned_tokens = planned * m.layout.tokens_per_tile;
609 if planned_tokens >= m.max_positions {
610 return Err(Error::Other(format!(
611 "argus: {} image(s) would contribute {planned_tokens} image tokens \
612 ({planned} tiles x {}), and the text tower holds {}. {}",
613 images.len(),
614 m.layout.tokens_per_tile,
615 m.max_positions,
616 if split {
617 "Stills are split into 17 tiles each; pass fewer images."
618 } else {
619 "Reduce frames_per_window (--window)."
620 }
621 )));
622 }
623
624 // Run the tower and build the text in the SAME walk, so the Nth image
625 // block in the string is the Nth image's blocks in the tensor. Two
626 // separate walks is how those get out of order.
627 let mut blocks: Vec<Tensor> = Vec::new();
628 let mut text = String::new();
629 for piece in pieces {
630 match piece {
631 Piece::Text(t) => text.push_str(t),
632 Piece::Image(img) => {
633 let t_pre = crate::clock::Instant::now();
634 let rgb = to_rgb8(img)?;
635 let pre = preprocess_rgb8_opts(
636 &rgb,
637 img.width as usize,
638 img.height as usize,
639 split,
640 );
641 if let Some(tr) = trace.as_deref_mut() {
642 tr.preprocess_ms += t_pre.elapsed().as_secs_f64() * 1e3;
643 tr.tiles += pre.tiles;
644 tr.rows = pre.rows;
645 tr.cols = pre.cols;
646 tr.tile = pre.tile;
647 tr.resized_to.push(crate::preprocess::resized_size(
648 img.width as usize,
649 img.height as usize,
650 ));
651 }
652 let t_tower = crate::clock::Instant::now();
653 let (tiles, per_tile_ms) = run_tower(&m.vision, &pre, &m.device)?;
654 blocks.extend(tiles);
655 if let Some(tr) = trace.as_deref_mut() {
656 // WALL time, not the sum of the per-tile timings. The
657 // tiles now overlap, so summing them would report more
658 // milliseconds than actually elapsed — a timeline that
659 // adds up to more than the clock is worse than no
660 // timeline. The per-tile figures are kept for the
661 // distribution and are explicitly concurrent.
662 tr.tower_ms += t_tower.elapsed().as_secs_f64() * 1e3;
663 tr.tower_per_tile_ms.extend(per_tile_ms);
664 }
665 text.push_str(&m.layout.image_block(pre.rows, pre.cols));
666 }
667 }
668 }
669
670 // The chat turn. `user_turn` would rebuild the image block, so the
671 // template is applied to the text already assembled.
672 let t_asm = crate::clock::Instant::now();
673 let templated = format!("<|im_start|>User:{text}<end_of_utterance>\nAssistant:");
674 let enc = m
675 .tokenizer
676 .encode(templated.as_str(), true)
677 .map_err(|e| Error::Model(format!("tokenize: {e}")))?;
678 let ids: Vec<i64> = enc.get_ids().iter().map(|&i| i64::from(i)).collect();
679 // Backstop. The geometry check above bounds the IMAGE tokens; this
680 // catches a prompt whose TEXT pushes it over — a long question, or a
681 // template that grew. Kept because the two failures have different
682 // causes and a caller shouldn't have to guess which one they hit.
683 if ids.len() >= m.max_positions {
684 return Err(Error::Other(format!(
685 "argus: assembled prompt is {} tokens but the text tower holds {} — \
686 {planned_tokens} of those are image tokens, so the rest is text",
687 ids.len(),
688 m.max_positions
689 )));
690 }
691
692 let image_hidden = Tensor::stack(&blocks, 0)?;
693 let id_tensor = Tensor::from_vec(enc.get_ids().to_vec(), (1, ids.len()), &m.device)?;
694
695 let mut dec = m
696 .decoder
697 .lock()
698 .map_err(|_| Error::Other("argus: decoder mutex poisoned".into()))?;
699 let text_embeds = dec.embed(&id_tensor)?;
700 let merged = merge_image_embeddings(&text_embeds, &image_hidden, &ids, m.image_token_id)?;
701
702 let mut dtrace = crate::decode::DecodeTrace::default();
703 if let Some(tr) = trace.as_deref_mut() {
704 tr.assemble_ms += t_asm.elapsed().as_secs_f64() * 1e3;
705 tr.image_tokens = planned_tokens;
706 tr.prompt_tokens = ids.len();
707 tr.text_tokens = ids.len().saturating_sub(planned_tokens);
708 tr.max_positions = m.max_positions;
709 tr.split = split;
710 }
711 let out = dec.generate_traced(
712 &merged,
713 opts.max_new_tokens.unwrap_or(DEFAULT_MAX_NEW_TOKENS),
714 &m.stop_ids,
715 &opts.decoding,
716 opts.repetition_penalty,
717 Some(&mut dtrace),
718 )?;
719 drop(dec);
720
721 let t_detok = crate::clock::Instant::now();
722 let decoded = m
723 .tokenizer
724 .decode(&out, true)
725 .map_err(|e| Error::Model(format!("detokenize: {e}")))?;
726 let answer = truncate_at_stop(&decoded, &opts.stop).trim().to_string();
727 if let Some(tr) = &mut trace {
728 tr.prefill_ms = dtrace.prefill_ms;
729 tr.step_ms = dtrace.steps_ms;
730 tr.detokenize_ms = t_detok.elapsed().as_secs_f64() * 1e3;
731 }
732 Ok(answer)
733 }
734}
735
736/// Median spacing between sampled frames, for the final segment's duration.
737fn median_step(frames: &[VideoFrame]) -> f64 {
738 if frames.len() < 2 {
739 return 1.0;
740 }
741 let mut steps: Vec<f64> = frames.windows(2).map(|w| w[1].timestamp - w[0].timestamp).collect();
742 steps.sort_by(f64::total_cmp);
743 steps[steps.len() / 2]
744}
745
746/// Any supported pixel format to packed RGB8.
747///
748/// Grayscale is replicated across the three channels rather than being fed to
749/// a one-channel tower: `SigLIP` has three input channels and there is no
750/// grayscale variant of it. Alpha is DROPPED, not composited — compositing
751/// would need a background colour, and inventing one changes the picture.
752fn to_rgb8(img: &ImageBuffer) -> Result<Vec<u8>> {
753 if img.width == 0 || img.height == 0 {
754 // Traced rather than guessed: a 0x0 buffer survives the whole content
755 // path (every resample window is empty, so nothing is indexed) and
756 // comes out as a black 512x512 thumbnail the model captions happily.
757 // A confident caption of a nonexistent image is worse than an error.
758 return Err(Error::Media(format!(
759 "argus: image is {}x{} — nothing to describe",
760 img.width, img.height
761 )));
762 }
763 let n = img.width as usize * img.height as usize;
764 let want = n * img.format.bytes_per_pixel();
765 if img.data.len() < want {
766 return Err(Error::Media(format!(
767 "argus: {}x{} {:?} needs {want} bytes, buffer has {}",
768 img.width,
769 img.height,
770 img.format,
771 img.data.len()
772 )));
773 }
774 Ok(match img.format {
775 PixelFormat::Rgb8 => img.data[..want].to_vec(),
776 PixelFormat::Rgba8 => img.data[..want]
777 .chunks_exact(4)
778 .flat_map(|p| [p[0], p[1], p[2]])
779 .collect(),
780 PixelFormat::Gray8 => img.data[..want].iter().flat_map(|&g| [g, g, g]).collect(),
781 })
782}
783
784/// Cut the text at the first stop string.
785///
786/// The stop string itself is not included, which is what [`VlmOptions::stop`]
787/// documents. Applied to the DECODED text rather than to token ids because a
788/// stop string need not be a token boundary.
789fn truncate_at_stop<'a>(text: &'a str, stops: &[String]) -> &'a str {
790 let cut = stops
791 .iter()
792 .filter(|s| !s.is_empty())
793 .filter_map(|s| text.find(s.as_str()))
794 .min();
795 cut.map_or(text, |i| &text[..i])
796}
797
798fn load(manifest_dir: &Path) -> Result<Model> {
799 let manifests = ffai_models::load_dir(manifest_dir)?;
800 let manifest = manifests
801 .iter()
802 .find(|m| m.name == MODEL)
803 .ok_or_else(|| {
804 Error::Model(format!(
805 "no model manifest named `{MODEL}` in {}",
806 manifest_dir.display()
807 ))
808 })?;
809 let resolved = manifest.fetch()?;
810 let weights = resolved.file("model.safetensors")?.to_path_buf();
811 let config_path = resolved.file("config.json")?;
812 let tokenizer_path = resolved.file("tokenizer.json")?;
813 let config_json = std::fs::read_to_string(config_path)?;
814
815 let device = Device::Cpu;
816 // SAFETY: the mapped file is owned by the model cache and is not mutated
817 // while this process holds it.
818 #[allow(unsafe_code)]
819 let vb = unsafe {
820 candle_nn::VarBuilder::from_mmaped_safetensors(
821 std::slice::from_ref(&weights),
822 candle_core::DType::F32,
823 &device,
824 )
825 }
826 .map_err(|e| Error::Model(format!("load {}: {e}", weights.display())))?;
827 let tokenizer = tokenizers::Tokenizer::from_file(tokenizer_path)
828 .map_err(|e| Error::Model(format!("tokenizer: {e}")))?;
829 build(vb, &config_json, tokenizer, device)
830}
831
832/// The three artefacts a SmolVLM load needs, supplied by the caller.
833///
834/// Every field is the byte content of the file the manifest resolves, so
835/// [`SmolVlm::from_bytes`] and the manifest path parse identical inputs.
836pub struct ArgusBytes {
837 /// `model.safetensors`.
838 pub weights: Vec<u8>,
839 /// The text of `config.json`.
840 pub config: String,
841 /// The bytes of `tokenizer.json`.
842 pub tokenizer: Vec<u8>,
843}
844
845/// Everything downstream of "we have a `VarBuilder`, a config and a tokenizer".
846///
847/// Both constructors funnel through here, so a browser and a server assemble
848/// the same model — the geometry, the image token and the stop-token set are
849/// all read from the checkpoint's own config rather than assumed, on both
850/// paths.
851fn build(
852 vb: candle_nn::VarBuilder<'static>,
853 config_json: &str,
854 tokenizer: tokenizers::Tokenizer,
855 device: Device,
856) -> Result<Model> {
857 let vision = crate::vision::load_vb(vb.clone(), config_json).map_err(Error::Model)?;
858 let decoder = TextDecoder::load_vb(vb, config_json, &device).map_err(Error::Model)?;
859
860 // Geometry from the checkpoint, never constants: a different SmolVLM size
861 // changes tokens_per_tile, and a hard-coded 64 would be silently wrong
862 // there — the same defect class prompt.rs exists to guard against.
863 let cfg: serde_json::Value = serde_json::from_str(config_json)
864 .map_err(|e| Error::Model(format!("config.json: {e}")))?;
865 let vision_cfg = cfg.get("vision_config");
866 let get = |k: &str, d: usize| -> usize {
867 vision_cfg
868 .and_then(|v| v.get(k))
869 .and_then(serde_json::Value::as_u64)
870 .map_or(d, |x| x as usize)
871 };
872 let scale_factor = cfg
873 .get("scale_factor")
874 .and_then(serde_json::Value::as_u64)
875 .map_or(4, |x| x as usize);
876 let layout = PromptLayout::default().with_geometry(
877 get("image_size", 512),
878 get("patch_size", 16),
879 scale_factor,
880 );
881
882 let id_of = |t: &str| tokenizer.token_to_id(t).map(i64::from);
883 let image_token_id = id_of("<image>")
884 .ok_or_else(|| Error::Model("tokenizer has no `<image>` token".into()))?;
885
886 let mut stop_ids: Vec<u32> = Vec::new();
887 for t in ["<end_of_utterance>", "<|im_end|>", "<|endoftext|>"] {
888 if let Some(id) = tokenizer.token_to_id(t) {
889 stop_ids.push(id);
890 }
891 }
892 if let Some(id) = cfg
893 .get("text_config")
894 .and_then(|t| t.get("eos_token_id"))
895 .and_then(serde_json::Value::as_u64)
896 {
897 let id = id as u32;
898 if !stop_ids.contains(&id) {
899 stop_ids.push(id);
900 }
901 }
902 if stop_ids.is_empty() {
903 return Err(Error::Model(
904 "no end-of-turn token found in the tokenizer or config — every caption would run to the token budget".into(),
905 ));
906 }
907
908 let max_positions = cfg
909 .get("text_config")
910 .and_then(|t| t.get("max_position_embeddings"))
911 .and_then(serde_json::Value::as_u64)
912 .map_or(8192, |x| x as usize);
913
914 Ok(Model {
915 vision,
916 decoder: Mutex::new(decoder),
917 tokenizer,
918 layout,
919 image_token_id,
920 stop_ids,
921 max_positions,
922 device,
923 })
924}
925
926#[cfg(test)]
927mod tests {
928 use super::*;
929 use ffai_core::engine::Decoding;
930
931 fn img(format: PixelFormat, data: Vec<u8>) -> ImageBuffer {
932 ImageBuffer {
933 width: 2,
934 height: 1,
935 format,
936 data,
937 }
938 }
939
940 #[test]
941 fn grayscale_is_replicated_and_alpha_is_dropped() {
942 assert_eq!(
943 to_rgb8(&img(PixelFormat::Gray8, vec![10, 200])).unwrap(),
944 vec![10, 10, 10, 200, 200, 200]
945 );
946 assert_eq!(
947 to_rgb8(&img(PixelFormat::Rgba8, vec![1, 2, 3, 255, 4, 5, 6, 0])).unwrap(),
948 vec![1, 2, 3, 4, 5, 6],
949 "alpha is dropped, not composited — compositing needs a background \
950 colour and inventing one changes the picture"
951 );
952 assert_eq!(
953 to_rgb8(&img(PixelFormat::Rgb8, vec![1, 2, 3, 4, 5, 6])).unwrap(),
954 vec![1, 2, 3, 4, 5, 6]
955 );
956 }
957
958 #[test]
959 fn a_zero_dimension_image_is_refused() {
960 let e = to_rgb8(&ImageBuffer {
961 width: 0,
962 height: 0,
963 format: PixelFormat::Rgb8,
964 data: Vec::new(),
965 })
966 .unwrap_err();
967 assert!(format!("{e}").contains("nothing to describe"), "{e}");
968 }
969
970 #[test]
971 fn a_short_buffer_is_an_error_not_a_panic() {
972 // A truncated decode must name the shortfall rather than slice-panic
973 // three stages later inside the resampler.
974 let e = to_rgb8(&img(PixelFormat::Rgb8, vec![1, 2, 3])).unwrap_err();
975 assert!(format!("{e}").contains("needs 6 bytes"), "{e}");
976 }
977
978 #[test]
979 fn stops_cut_before_the_marker_and_take_the_earliest() {
980 let stops = vec!["\nUser:".to_string(), "###".to_string()];
981 assert_eq!(truncate_at_stop("a cat ### b", &stops), "a cat ");
982 assert_eq!(truncate_at_stop("x\nUser: y ### z", &stops), "x");
983 assert_eq!(truncate_at_stop("no marker", &stops), "no marker");
984 // An empty stop string would match at 0 and blank every caption.
985 assert_eq!(truncate_at_stop("keep me", &[String::new()]), "keep me");
986 }
987
988 #[test]
989 fn the_last_video_segment_gets_the_median_spacing() {
990 let f = |t: f64| VideoFrame {
991 image: img(PixelFormat::Gray8, vec![0, 0]),
992 timestamp: t,
993 };
994 assert_eq!(median_step(&[f(0.0), f(0.5), f(1.0)]), 0.5);
995 // One frame has no spacing to take a median of; 1.0 s beats a
996 // zero-length segment that no player would show.
997 assert_eq!(median_step(&[f(0.0)]), 1.0);
998 }
999
1000 #[test]
1001 fn the_default_decoding_is_greedy_so_a_caption_is_reproducible() {
1002 assert_eq!(VlmOptions::default().decoding, Decoding::Greedy);
1003 }
1004}