Skip to main content

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}