Skip to main content

ffai_core/
engine.rs

1//! One trait per task, many engines per trait.
2//!
3//! An *engine* is a named, swappable implementation of a task — exactly the
4//! role a codec plays in ffmpeg. Engines are registered in an
5//! [`crate::registry::EngineRegistry`] and selected by name.
6
7use std::fmt;
8use std::str::FromStr;
9
10use crate::error::Result;
11use crate::types::{
12    AudioBuffer, DetectOutput, ImageBuffer, OcrOutput, TimedSegment, Transcript, VideoFrame,
13};
14
15/// The tasks `FFai` knows about (the "stream types" of the toolkit).
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
17pub enum Task {
18    Asr,
19    Tts,
20    Ocr,
21    Vlm,
22    /// Object detection (Diana). The task exists ahead of its first engine so
23    /// `ffai bench detect` can baseline the world references (M-D0); the
24    /// `DetectEngine` trait and `DetectOutput` type land with the first
25    /// engine at M-D1.
26    Detect,
27    /// Monocular depth estimation (Diana). Shares Diana's backbone and neck
28    /// with [`Task::Detect`] — only the final head differs — but the output
29    /// is a dense metric map rather than boxes, so it is its own task.
30    Depth,
31}
32
33impl fmt::Display for Task {
34    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35        f.write_str(match self {
36            Self::Asr => "asr",
37            Self::Tts => "tts",
38            Self::Ocr => "ocr",
39            Self::Vlm => "vlm",
40            Self::Detect => "detect",
41            Self::Depth => "depth",
42        })
43    }
44}
45
46impl FromStr for Task {
47    type Err = String;
48
49    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
50        match s.to_ascii_lowercase().as_str() {
51            "asr" => Ok(Self::Asr),
52            "tts" => Ok(Self::Tts),
53            "ocr" => Ok(Self::Ocr),
54            "vlm" => Ok(Self::Vlm),
55            "detect" => Ok(Self::Detect),
56            other => Err(format!(
57                "unknown task `{other}` (expected asr, tts, ocr, vlm, or detect)"
58            )),
59        }
60    }
61}
62
63/// Honesty marker shown in `ffai engines` — stubs are visible, not hidden.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum EngineStatus {
66    /// Registered and selectable, returns `Error::NotImplemented`.
67    Stub,
68    /// Works, not yet oracle-gated against a reference implementation.
69    Experimental,
70    /// Oracle-gated against a reference implementation.
71    Stable,
72}
73
74impl fmt::Display for EngineStatus {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        f.write_str(match self {
77            Self::Stub => "stub",
78            Self::Experimental => "experimental",
79            Self::Stable => "stable",
80        })
81    }
82}
83
84/// Metadata every engine exposes for discovery (`ffai engines`).
85#[derive(Debug, Clone)]
86pub struct EngineInfo {
87    pub name: String,
88    pub task: Task,
89    pub status: EngineStatus,
90    pub description: String,
91}
92
93#[derive(Debug, Clone)]
94pub struct AsrOptions {
95    /// Force a language instead of auto-detecting.
96    pub language: Option<String>,
97    /// Word-level timestamps (WhisperX-style forced alignment).
98    pub word_timestamps: bool,
99    /// Speaker diarization (WhisperX-style).
100    pub diarize: bool,
101    /// Keep speaker identities across calls, so a voice heard in one chunk
102    /// keeps its label in the next.
103    ///
104    /// Off by default, and the default is the batch behaviour every
105    /// diarization system has: labels are arbitrary names for clusters within
106    /// ONE call, and `SPEAKER_00` in two separate calls need not be the same
107    /// person. That is fine for a file and useless for a stream.
108    ///
109    /// With this set, the engine keeps a speaker registry between calls. Call
110    /// `WhisperCandle::reset_speakers()` when a new recording begins — a new
111    /// session is a new set of people, and carrying identities across is
112    /// worse than starting fresh.
113    ///
114    /// Matching is deliberately stricter than in-call clustering: a registry
115    /// merge is permanent, and two people who share a centroid stay merged for
116    /// the rest of the session.
117    pub persist_speakers: bool,
118    /// Known speaker count, when the caller has one ("this is an interview,
119    /// two people"). Overrides the clustering threshold.
120    ///
121    /// Measured caution: with the threshold tuned this is **not** the safer
122    /// choice. Blind clustering scores 4.21 % DER against 5.00 % with the
123    /// true count supplied, because forcing a count forces a merge, and a bad
124    /// merge attributes one speaker's words to another. Set it when the count
125    /// is certain, not as insurance.
126    pub max_speakers: Option<usize>,
127    /// Cosine-distance threshold for merging speaker clusters.
128    ///
129    /// Swept against DER on a 6-conversation corpus: the minimum sits at
130    /// 0.85 (2.71 %), and 0.80 (4.21 %) ships instead because over-merging
131    /// fails catastrophically (44.7 % at 0.95) while over-splitting fails
132    /// gently. See `ffai_mercury::asr::diarize::DEFAULT_THRESHOLD`.
133    pub diarize_threshold: f32,
134    /// Translate to English instead of transcribing.
135    pub translate: bool,
136    /// Segment on speech before transcribing, so silence never reaches the
137    /// model.
138    ///
139    /// **On by default, for measured speed — not for quality.**
140    ///
141    /// - Audio with trailing silence: 2.2–4.2× faster, transcript byte-identical.
142    /// - Silent input: empty transcript, with no encoder pass at all.
143    /// - A live sliding window stops spending five encoder passes to produce
144    ///   nothing.
145    ///
146    /// Corpus WER *does* move with this on (test-clean 7.99 → 6.79,
147    /// test-other 16.79 → 16.43), and that is **not** a quality improvement —
148    /// do not cite it as one. Per-clip decomposition over 400 clips gives 38
149    /// improved and 38 worsened, a sign test of z = 0.00. VAD shifts where
150    /// speech sits inside Whisper's fixed 30 s context by ~0.2 s, which
151    /// re-rolls the decode on about a fifth of clips, half each way; the
152    /// aggregate moved because WER is dominated by a few high-delta clips.
153    /// Full descent: `docs/whys/vad-quality.md`.
154    ///
155    /// Set `false` for the unsegmented fixed-30 s-grid behaviour.
156    pub vad: bool,
157    /// Speech threshold, 0..1, higher being stricter. Only read when
158    /// [`Self::vad`] is set.
159    pub vad_threshold: f32,
160    /// Pack speech regions into windows of at most this many seconds.
161    pub vad_chunk_secs: f32,
162    /// Where this buffer starts in the wider stream, in seconds.
163    ///
164    /// Only meaningful for a streaming caller that re-sends a sliding window
165    /// (a live transcriber sending the trailing N seconds every tick). It
166    /// costs nothing to leave at `0.0`.
167    ///
168    /// **What it buys.** Diarization sub-segments each speech region into
169    /// 1.5 s windows and embeds each one — the dominant cost, ~172 ms apiece.
170    /// Those windows are placed relative to the region, and a region clipped
171    /// by the buffer's leading edge is anchored to the *buffer*, which moves.
172    /// So consecutive ticks re-cut the same audio at shifted offsets and every
173    /// embedding is recomputed. Measured on a 10 s window at a 1 s tick: the
174    /// window grids realign only every 3 s (`lcm(1.0, 0.75)`), and the cache
175    /// hit rate sat at ~24 %.
176    ///
177    /// Given this, windows are placed on an ABSOLUTE grid, so the same audio
178    /// yields the same window bounds no matter where the buffer happens to
179    /// start — which is what makes the embedding cache actually hit.
180    pub stream_offset_secs: f64,
181}
182
183impl Default for AsrOptions {
184    fn default() -> Self {
185        // Written out rather than derived: `vad_threshold` and
186        // `vad_chunk_secs` have meaningful defaults, and `#[derive(Default)]`
187        // would silently make them 0.0 — a threshold that calls everything
188        // speech and a window width that holds nothing.
189        Self {
190            language: None,
191            word_timestamps: false,
192            diarize: false,
193            persist_speakers: false,
194            max_speakers: None,
195            diarize_threshold: 0.80,
196            translate: false,
197            vad: true,
198            vad_threshold: 0.5,
199            vad_chunk_secs: 30.0,
200            stream_offset_secs: 0.0,
201        }
202    }
203}
204
205#[derive(Debug, Clone)]
206pub struct TtsOptions {
207    pub voice: Option<String>,
208    /// Playback-rate multiplier, 1.0 = normal.
209    pub speed: f32,
210    /// Acoustic variation (VITS prior noise); `None` = the voice's own
211    /// default. 0.0 is fully deterministic audio.
212    pub noise_scale: Option<f32>,
213    /// Duration variation (stochastic duration predictor noise); `None` =
214    /// voice default, 0.0 = deterministic timing.
215    pub noise_w: Option<f32>,
216    /// Seed for all sampled noise. Mercury synthesis is byte-stable per
217    /// (input, options, seed) — a capability the references do not offer.
218    pub seed: u64,
219    /// Silence inserted between sentences of long-form input, in seconds.
220    pub sentence_silence_s: f32,
221}
222
223impl Default for TtsOptions {
224    fn default() -> Self {
225        Self {
226            voice: None,
227            speed: 1.0,
228            noise_scale: None,
229            noise_w: None,
230            seed: 0,
231            sentence_silence_s: 0.2,
232        }
233    }
234}
235
236#[derive(Debug, Clone, Default)]
237pub struct OcrOptions {
238    /// Language hints (engine-specific tags); empty = engine default.
239    pub languages: Vec<String>,
240    /// The image IS one text line: skip detection, recognize the whole
241    /// frame as a single line (tesseract's `--psm 7`). LIVE's dirty-band
242    /// path sets this when a band's known geometry is a single line —
243    /// detection becomes async maintenance, recognition the only
244    /// synchronous work.
245    pub single_line: bool,
246}
247
248/// Detection options (Diana).
249///
250/// `confidence` defaults to 0.25 — the threshold a person looking at boxes
251/// wants. Benchmarks that need the low-confidence tail for mAP set it to
252/// ~0.001 explicitly, the way `corpora/refs/*_ref.py` do; the default is
253/// not tuned for the scorer, and the scorer does not inherit it silently.
254#[derive(Debug, Clone)]
255pub struct DetectOptions {
256    /// Minimum confidence to report.
257    pub confidence: f32,
258    /// Maximum detections returned, highest confidence first.
259    pub max_detections: usize,
260    /// Class-wise NMS `IoU`. `None` for the NMS-free one-to-one path, which
261    /// is YOLO26's default and needs no suppression.
262    pub iou: Option<f32>,
263    /// Restrict to these class ids; empty = every class.
264    pub classes: Vec<u32>,
265}
266
267impl Default for DetectOptions {
268    fn default() -> Self {
269        Self {
270            confidence: 0.25,
271            max_detections: 300,
272            iou: None,
273            classes: Vec::new(),
274        }
275    }
276}
277
278/// How a VLM decoder picks each next token.
279///
280/// **This is an enum rather than a bag of `Option` fields on purpose, and the
281/// reason is the whole of Gate 2's determinism requirement: there is no way to
282/// spell "sampling without a seed".** A `temperature: Option<f32>` beside a
283/// `seed: Option<u64>` lets a caller set one and forget the other, and the
284/// result is output that cannot be reproduced — silently, and only noticed
285/// when someone tries to re-run a ledger line.
286///
287/// Byte-stability is the one property every other `FFai` component already
288/// holds. `Mercury` TTS ships it as a competitive claim its reference
289/// structurally cannot match ([`TtsOptions::seed`]); Carmenta gates on
290/// byte-identity; `Diana` matches `PyTorch` detection-for-detection. `Argus` does
291/// not get to be the exception, so the type makes the exception
292/// unrepresentable.
293#[derive(Debug, Clone, PartialEq, Default)]
294pub enum Decoding {
295    /// Always the argmax. Deterministic by construction, and **the default**.
296    ///
297    /// `#[default]` rather than a hand-written `impl Default`: the default
298    /// belongs to the type, and a separate impl is one more place for it to
299    /// drift away from what this doc comment promises.
300    #[default]
301    Greedy,
302    /// Stochastic — and always seeded, because `seed` is not optional here.
303    ///
304    /// Same input + same options + same seed = same bytes.
305    Sampled {
306        /// Logit temperature. `1.0` is the model's own distribution.
307        temperature: f32,
308        /// Nucleus cutoff, `None` = disabled.
309        top_p: Option<f32>,
310        /// Top-k cutoff, `None` = disabled.
311        top_k: Option<usize>,
312        /// Not optional. See the type-level note above.
313        seed: u64,
314    },
315}
316
317/// One piece of a multimodal prompt.
318///
319/// Borrowed rather than owned: an `ImageBuffer` is the decoded raster, and a
320/// tiling VLM will re-encode it into a dozen-plus tiles anyway. Cloning it to
321/// build a prompt would copy megabytes for nothing.
322#[derive(Debug, Clone, PartialEq, Eq)]
323pub enum VlmPart<'a> {
324    Text(&'a str),
325    Image(&'a ImageBuffer),
326}
327
328/// An ordered, interleaved multimodal prompt — `text <img> text <img> text`.
329///
330/// **Order is the payload.** "Compare the first image to the second" is not
331/// expressible as a set of images plus a question, and a model that receives
332/// the images in the wrong order answers the wrong question fluently. So the
333/// prompt is a sequence and the engine splices its image-token blocks at the
334/// positions the sequence gives it.
335#[derive(Debug, Clone, Default, PartialEq, Eq)]
336pub struct VlmPrompt<'a> {
337    pub parts: Vec<VlmPart<'a>>,
338}
339
340impl<'a> VlmPrompt<'a> {
341    /// The single-image case: the image, then the instruction if there is one.
342    #[must_use]
343    pub fn single(image: &'a ImageBuffer, text: Option<&'a str>) -> Self {
344        let mut parts = vec![VlmPart::Image(image)];
345        if let Some(t) = text {
346            parts.push(VlmPart::Text(t));
347        }
348        Self { parts }
349    }
350
351    /// Number of images in the prompt — what an engine checks against the
352    /// image-token placeholders it is about to splice.
353    #[must_use]
354    pub fn image_count(&self) -> usize {
355        self.parts
356            .iter()
357            .filter(|p| matches!(p, VlmPart::Image(_)))
358            .count()
359    }
360}
361
362/// Options for a VLM call (Argus).
363///
364/// # Gate 2 — the v1 surface, and what is deliberately NOT here
365///
366/// Settled once, before implementation, because every field is cheap now and a
367/// breaking change later (`docs/plans/argus-launch-plan.md` §2 Gate 2).
368///
369/// **Excluded from v1, as decisions rather than oversights:**
370///
371/// - **Streaming.** Tokens as produced. It changes the return type of every
372///   method, so it is a trait redesign and not a field; it waits until there
373///   is a consumer that needs it.
374/// - **Grounding.** Region-in ("what is in this box") and grounded-out ("the
375///   dog `[x,y,w,h]`"). Diana already returns boxes; a second, weaker box
376///   source in the toolkit needs a reason beyond "the model can".
377/// - **Structured / JSON output.** Needs constrained decoding to be worth
378///   anything — an unconstrained "please reply in JSON" is a request, not a
379///   guarantee. High value for a tooling product, so it is v2 rather than
380///   never; mistral.rs already provides grammar-constrained decoding, which is
381///   what makes it cheap when it lands.
382/// - **Token-level confidence (logprobs).** Carmenta uses recognition
383///   confidence for its verifier and the analog is genuinely valuable, but it
384///   is only meaningful once an engine exists to produce it honestly.
385/// - **Conversation history / multi-turn.** [`VlmPrompt`] can already express
386///   an interleaved turn sequence; a typed history is a chat-session concern,
387///   and Argus is a media op.
388///
389/// **Not here because it belongs to the ENGINE, not the caller:** the chat
390/// template, the special vision tokens (`<image>`, `<|vision_start|>`), and
391/// the M-RoPE time axis for video. Those are properties of a specific
392/// checkpoint. Exposing them as options would invite a caller to set them
393/// wrong — and the launch plan measured what that costs: **43 of 50 answers
394/// changed** on identical weights purely from prompt formatting, with no error
395/// raised.
396#[derive(Debug, Clone, Default)]
397pub struct VlmOptions {
398    /// Instruction for the model; `None` = plain captioning.
399    ///
400    /// Read by [`VlmEngine::describe_image`] only. [`VlmEngine::describe`]
401    /// takes its text from the prompt's own [`VlmPart::Text`] parts, because
402    /// position matters there and a separate field could not say where the
403    /// text goes.
404    pub prompt: Option<String>,
405    /// Role/behaviour framing, when the checkpoint's template has a slot for
406    /// it. Engines without one must ignore it rather than concatenate it into
407    /// the user turn, which changes the prompt the model was tuned on.
408    pub system_prompt: Option<String>,
409    /// Token-selection strategy. Defaults to [`Decoding::Greedy`].
410    pub decoding: Decoding,
411    /// Token budget. `None` = the engine's own default.
412    pub max_new_tokens: Option<usize>,
413    /// Stop strings. Generation halts as soon as one is produced; the string
414    /// itself is not included in the returned text.
415    ///
416    /// EOS is not here — that is the tokenizer's, and an engine that needed to
417    /// be told its own EOS would be misconfigured.
418    pub stop: Vec<String>,
419    /// Frames shown to the model per video caption. `None` = the engine's own
420    /// default.
421    ///
422    /// This is the video knob that decides what a caption can be ABOUT, and
423    /// the arithmetic is worth stating because it is not obvious. A still
424    /// image is split into tiles so fine print survives — for `SmolVLM` that
425    /// is 17 tiles at 64 tokens, **1088 tokens per image**. A text tower with
426    /// 8192 positions therefore holds seven such frames, which is not a window
427    /// so much as a slideshow.
428    ///
429    /// Video engines turn splitting off, making a frame **one** tile, and the
430    /// same 8192 positions then hold a hundred. So this number trades fine
431    /// detail within a frame against temporal context across frames, and the
432    /// right value depends on the question being asked — which is why it is a
433    /// caller's option and not a constant.
434    ///
435    /// `1` degenerates to per-frame captioning: correct, and blind to motion.
436    pub frames_per_window: Option<usize>,
437    /// Repetition penalty, `None` = disabled.
438    ///
439    /// Sits beside [`Self::decoding`] rather than inside
440    /// [`Decoding::Sampled`] because it is a *logit* transform, not a
441    /// sampling one: it applies to greedy decoding too, and small models loop
442    /// under greedy more than under sampling.
443    pub repetition_penalty: Option<f32>,
444}
445
446/// Speech → text (Mercury).
447pub trait AsrEngine: Send + Sync {
448    fn info(&self) -> EngineInfo;
449    fn transcribe(&self, audio: &AudioBuffer, opts: &AsrOptions) -> Result<Transcript>;
450}
451
452/// Text → speech (Mercury).
453pub trait TtsEngine: Send + Sync {
454    fn info(&self) -> EngineInfo;
455    fn synthesize(&self, text: &str, opts: &TtsOptions) -> Result<AudioBuffer>;
456}
457
458/// Image → text (Carmenta).
459pub trait OcrEngine: Send + Sync {
460    fn info(&self) -> EngineInfo;
461    fn recognize(&self, image: &ImageBuffer, opts: &OcrOptions) -> Result<OcrOutput>;
462}
463
464/// Image → objects (Diana).
465/// What a depth engine returns: a dense map plus what is needed to place it
466/// back on the source image.
467#[derive(Debug, Clone)]
468pub struct DepthOutput {
469    /// Row-major depth, `height * width` values, in **metres**.
470    pub depth: Vec<f32>,
471    pub width: usize,
472    pub height: usize,
473    /// The letterbox that produced this map, when one was applied — the same
474    /// role it plays in [`DetectOutput`], and needed for the same reason: the
475    /// map is in letterboxed space and means nothing without it.
476    pub letterbox: Option<crate::types::Letterbox>,
477}
478
479impl DepthOutput {
480    /// Depth at a pixel of the map. `None` when out of range.
481    #[must_use]
482    pub fn at(&self, x: usize, y: usize) -> Option<f32> {
483        if x >= self.width || y >= self.height {
484            return None;
485        }
486        self.depth.get(y * self.width + x).copied()
487    }
488
489    /// `(min, max)` over the map, for callers normalising it for display.
490    /// Returns `None` for an empty map rather than a nonsense range.
491    #[must_use]
492    pub fn range(&self) -> Option<(f32, f32)> {
493        if self.depth.is_empty() {
494            return None;
495        }
496        let mut lo = f32::MAX;
497        let mut hi = f32::MIN;
498        for &v in &self.depth {
499            if v.is_finite() {
500                lo = lo.min(v);
501                hi = hi.max(v);
502            }
503        }
504        (lo <= hi).then_some((lo, hi))
505    }
506}
507
508/// Options for a depth run.
509#[derive(Debug, Clone, Default)]
510pub struct DepthOptions {
511    /// Resize the map to the SOURCE image's resolution and undo the
512    /// letterbox, instead of returning the raw network output at stride 4.
513    ///
514    /// Off by default: the raw map is what the model computed, and resizing
515    /// is a lossy convenience the caller may want to do differently.
516    pub full_resolution: bool,
517}
518
519/// Monocular depth estimation.
520pub trait DepthEngine: Send + Sync {
521    fn info(&self) -> EngineInfo;
522    fn depth(&self, image: &ImageBuffer, opts: &DepthOptions) -> Result<DepthOutput>;
523}
524
525pub trait DetectEngine: Send + Sync {
526    fn info(&self) -> EngineInfo;
527    fn detect(&self, image: &ImageBuffer, opts: &DetectOptions) -> Result<DetectOutput>;
528
529    /// Detect over many images, using the whole machine.
530    ///
531    /// **This is a first-class path, not a convenience wrapper**, because it
532    /// is where a detector's throughput actually lives. Measured on Diana:
533    /// running images concurrently is worth **3.6-5.3x** over calling
534    /// [`Self::detect`] in a loop, with byte-identical output and no change
535    /// whatever to the per-image path — because intra-image parallelism is
536    /// nearly exhausted (24 cores buy one image only 1.42x) while the images
537    /// themselves are independent.
538    ///
539    /// The trait is `Send + Sync`, so one loaded model serves every thread.
540    /// That is the structural advantage over the Python reference: measured
541    /// on the same machine, `PyTorch` gets *slower* under threading (0.68-0.72x)
542    /// because of the GIL, and its escape hatch — multiprocessing — pays a
543    /// full model copy per worker.
544    ///
545    /// The default implementation is sequential so existing engines keep
546    /// working; an engine that can do better should override it.
547    fn detect_batch(
548        &self,
549        images: &[ImageBuffer],
550        opts: &DetectOptions,
551    ) -> Result<Vec<DetectOutput>> {
552        images.iter().map(|i| self.detect(i, opts)).collect()
553    }
554    /// Class names for the ids in [`DetectOutput`], in id order. They come
555    /// from the weight manifest, so they belong to the engine rather than
556    /// to every output it produces.
557    fn class_names(&self) -> &[String];
558}
559
560/// Image/video → description (Argus).
561///
562/// See [`VlmOptions`] for the Gate-2 surface decision and the written-down v1
563/// exclusions.
564pub trait VlmEngine: Send + Sync {
565    fn info(&self) -> EngineInfo;
566
567    /// The general path: an ordered, interleaved multimodal prompt.
568    ///
569    /// **This is the required method, and `describe_image` is derived from
570    /// it, rather than the other way round.** An engine that implemented only
571    /// the single-image case would still compile against a multi-image
572    /// prompt — and would then silently answer using the first image, or the
573    /// last, or a concatenation. Making the general case the one an
574    /// implementor must write means multi-image support is a compile-time
575    /// obligation instead of a runtime surprise.
576    fn describe(&self, prompt: &VlmPrompt<'_>, opts: &VlmOptions) -> Result<String>;
577
578    /// One image, with [`VlmOptions::prompt`] as the instruction.
579    ///
580    /// Provided: builds a single-image [`VlmPrompt`] and calls
581    /// [`Self::describe`]. Zero-copy — the prompt borrows the image.
582    fn describe_image(&self, image: &ImageBuffer, opts: &VlmOptions) -> Result<String> {
583        self.describe(&VlmPrompt::single(image, opts.prompt.as_deref()), opts)
584    }
585
586    /// Video understanding over sampled frames → a timed caption track.
587    ///
588    /// Frames arrive with their timestamps ([`VideoFrame::timestamp`]) so an
589    /// engine can encode *time* and not merely order — M-RoPE's third axis.
590    /// Whether it does is the engine's business; the trait's job is to make
591    /// sure the information reaches it.
592    fn describe_video(
593        &self,
594        frames: &[VideoFrame],
595        opts: &VlmOptions,
596    ) -> Result<Vec<TimedSegment<String>>>;
597}
598
599#[cfg(test)]
600mod vlm_surface_tests {
601    use super::*;
602    use crate::types::PixelFormat;
603
604    fn img(byte: u8) -> ImageBuffer {
605        ImageBuffer {
606            width: 1,
607            height: 1,
608            format: PixelFormat::Rgb8,
609            data: vec![byte, byte, byte],
610        }
611    }
612
613    /// An engine that implements ONLY the required method and records what it
614    /// was handed — which is how we check `describe_image` really routes
615    /// through `describe` rather than being a second, divergent path.
616    struct Recorder(std::sync::Mutex<Vec<String>>);
617
618    impl VlmEngine for Recorder {
619        fn info(&self) -> EngineInfo {
620            EngineInfo {
621                name: "recorder".into(),
622                task: Task::Vlm,
623                status: EngineStatus::Stub,
624                description: String::new(),
625            }
626        }
627        fn describe(&self, prompt: &VlmPrompt<'_>, _opts: &VlmOptions) -> Result<String> {
628            let shape: Vec<String> = prompt
629                .parts
630                .iter()
631                .map(|p| match p {
632                    VlmPart::Text(t) => format!("text:{t}"),
633                    VlmPart::Image(i) => format!("image:{}", i.data[0]),
634                })
635                .collect();
636            self.0.lock().unwrap().push(shape.join("|"));
637            Ok(shape.join("|"))
638        }
639        fn describe_video(
640            &self,
641            _frames: &[VideoFrame],
642            _opts: &VlmOptions,
643        ) -> Result<Vec<TimedSegment<String>>> {
644            Ok(Vec::new())
645        }
646    }
647
648    /// Gate 2's determinism requirement, checked rather than asserted in prose:
649    /// a caller who sets nothing gets deterministic decoding.
650    #[test]
651    fn the_default_decoding_is_deterministic() {
652        assert_eq!(VlmOptions::default().decoding, Decoding::Greedy);
653        // ...and the default options carry no stochasticity anywhere else.
654        assert!(VlmOptions::default().stop.is_empty());
655        assert_eq!(VlmOptions::default().repetition_penalty, None);
656    }
657
658    /// There is no way to construct stochastic decoding without a seed. This
659    /// test cannot fail at runtime — the point is that the alternative does
660    /// not COMPILE, and this is where that intent is recorded.
661    #[test]
662    fn sampling_always_carries_a_seed() {
663        let d = Decoding::Sampled {
664            temperature: 0.7,
665            top_p: Some(0.9),
666            top_k: None,
667            seed: 42,
668        };
669        // Every Sampled value has a seed by construction; matching proves it.
670        let Decoding::Sampled { seed, .. } = d else {
671            panic!("expected Sampled")
672        };
673        assert_eq!(seed, 42);
674    }
675
676    #[test]
677    fn describe_image_routes_through_describe() {
678        let e = Recorder(std::sync::Mutex::new(Vec::new()));
679        let opts = VlmOptions {
680            prompt: Some("what is this?".into()),
681            ..VlmOptions::default()
682        };
683        let out = e.describe_image(&img(7), &opts).unwrap();
684        // Image FIRST, then the instruction — the order most VLM chat
685        // templates expect, and the order `VlmPrompt::single` documents.
686        assert_eq!(out, "image:7|text:what is this?");
687        assert_eq!(e.0.lock().unwrap().len(), 1, "describe must have been used");
688    }
689
690    #[test]
691    fn a_prompt_with_no_instruction_is_just_the_image() {
692        let e = Recorder(std::sync::Mutex::new(Vec::new()));
693        let out = e.describe_image(&img(3), &VlmOptions::default()).unwrap();
694        assert_eq!(out, "image:3");
695    }
696
697    /// Order is the payload: "compare the first to the second" is not
698    /// expressible as a set, so the sequence must survive intact.
699    #[test]
700    fn interleaved_order_is_preserved() {
701        let (a, b) = (img(1), img(2));
702        let e = Recorder(std::sync::Mutex::new(Vec::new()));
703        let prompt = VlmPrompt {
704            parts: vec![
705                VlmPart::Text("before"),
706                VlmPart::Image(&a),
707                VlmPart::Text("between"),
708                VlmPart::Image(&b),
709                VlmPart::Text("after"),
710            ],
711        };
712        assert_eq!(prompt.image_count(), 2);
713        let out = e.describe(&prompt, &VlmOptions::default()).unwrap();
714        assert_eq!(
715            out, "text:before|image:1|text:between|image:2|text:after",
716            "the sequence an engine receives must be the sequence it was given"
717        );
718    }
719}