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 /// Restrict recognition to exactly these characters — `"0123456789$,.-"`
247 /// for an amount field, `"0123456789/"` for a date. `None` reads freely.
248 ///
249 /// For a field whose content class is known this removes a whole class of
250 /// look-alike errors (`1`→`T`, `0`→`O`, `5`→`S`) instead of leaving them
251 /// for the caller to guess at. The reported confidence is the model's own
252 /// probability for the character it was allowed to emit, NOT renormalised
253 /// over the allowed set, so a line the model wanted to read as letters
254 /// comes back with LOW confidence rather than as a confident digit.
255 ///
256 /// An engine that cannot constrain its decoder must return an error when
257 /// this is set — never read freely and ignore it. A character the model
258 /// cannot emit at all is also an error, naming the character.
259 pub charset: Option<String>,
260 /// Erase horizontal form rules (field lines, strike-throughs, table
261 /// rules) before reading. A typed value sitting on a field line fuses with
262 /// it — `1` reads as `T`, `0` as `U` — and the recognizer is confident
263 /// about the result. Off by default: it changes what every ruled page
264 /// reads, so it is asked for per call, by callers reading forms.
265 ///
266 /// Engines that do not implement it must return an error when it is set.
267 pub remove_rules: bool,
268 /// Detect which quarter turn makes the page upright, and read it that
269 /// way. Scanners store portrait forms sideways and phones photograph in
270 /// any orientation; read sideways, a page silently comes back as a few
271 /// low-confidence lines. Returned boxes stay in the INPUT image's
272 /// coordinates. Off by default. An engine may read the page as given
273 /// first and decide orientation only when that read is unconvincing, so
274 /// on an upright page it can cost nothing beyond the read itself.
275 ///
276 /// Engines that do not implement it must return an error when it is set.
277 pub auto_orient: bool,
278 /// Measure how far the page's text runs off horizontal and, when it is
279 /// skewed, straighten the page before reading it. A scan a couple of
280 /// degrees off square otherwise reads as noise: an axis-aligned crop of a
281 /// long tilted line takes in the lines above and below it. A square page
282 /// is read exactly as without it. Boxes stay in the INPUT image's
283 /// coordinates; `OcrOutput::skew_degrees` reports the angle when the page
284 /// was straightened.
285 ///
286 /// Engines that do not implement it must return an error when it is set.
287 pub deskew: bool,
288}
289
290/// Detection options (Diana).
291///
292/// `confidence` defaults to 0.25 — the threshold a person looking at boxes
293/// wants. Benchmarks that need the low-confidence tail for mAP set it to
294/// ~0.001 explicitly, the way `corpora/refs/*_ref.py` do; the default is
295/// not tuned for the scorer, and the scorer does not inherit it silently.
296#[derive(Debug, Clone)]
297pub struct DetectOptions {
298 /// Minimum confidence to report.
299 pub confidence: f32,
300 /// Maximum detections returned, highest confidence first.
301 pub max_detections: usize,
302 /// Class-wise NMS `IoU`. `None` for the NMS-free one-to-one path, which
303 /// is YOLO26's default and needs no suppression.
304 pub iou: Option<f32>,
305 /// Restrict to these class ids; empty = every class.
306 pub classes: Vec<u32>,
307}
308
309impl Default for DetectOptions {
310 fn default() -> Self {
311 Self {
312 confidence: 0.25,
313 max_detections: 300,
314 iou: None,
315 classes: Vec::new(),
316 }
317 }
318}
319
320/// How a VLM decoder picks each next token.
321///
322/// **This is an enum rather than a bag of `Option` fields on purpose, and the
323/// reason is the whole of Gate 2's determinism requirement: there is no way to
324/// spell "sampling without a seed".** A `temperature: Option<f32>` beside a
325/// `seed: Option<u64>` lets a caller set one and forget the other, and the
326/// result is output that cannot be reproduced — silently, and only noticed
327/// when someone tries to re-run a ledger line.
328///
329/// Byte-stability is the one property every other `FFai` component already
330/// holds. `Mercury` TTS ships it as a competitive claim its reference
331/// structurally cannot match ([`TtsOptions::seed`]); Carmenta gates on
332/// byte-identity; `Diana` matches `PyTorch` detection-for-detection. `Argus` does
333/// not get to be the exception, so the type makes the exception
334/// unrepresentable.
335#[derive(Debug, Clone, PartialEq, Default)]
336pub enum Decoding {
337 /// Always the argmax. Deterministic by construction, and **the default**.
338 ///
339 /// `#[default]` rather than a hand-written `impl Default`: the default
340 /// belongs to the type, and a separate impl is one more place for it to
341 /// drift away from what this doc comment promises.
342 #[default]
343 Greedy,
344 /// Stochastic — and always seeded, because `seed` is not optional here.
345 ///
346 /// Same input + same options + same seed = same bytes.
347 Sampled {
348 /// Logit temperature. `1.0` is the model's own distribution.
349 temperature: f32,
350 /// Nucleus cutoff, `None` = disabled.
351 top_p: Option<f32>,
352 /// Top-k cutoff, `None` = disabled.
353 top_k: Option<usize>,
354 /// Not optional. See the type-level note above.
355 seed: u64,
356 },
357}
358
359/// One piece of a multimodal prompt.
360///
361/// Borrowed rather than owned: an `ImageBuffer` is the decoded raster, and a
362/// tiling VLM will re-encode it into a dozen-plus tiles anyway. Cloning it to
363/// build a prompt would copy megabytes for nothing.
364#[derive(Debug, Clone, PartialEq, Eq)]
365pub enum VlmPart<'a> {
366 Text(&'a str),
367 Image(&'a ImageBuffer),
368}
369
370/// An ordered, interleaved multimodal prompt — `text <img> text <img> text`.
371///
372/// **Order is the payload.** "Compare the first image to the second" is not
373/// expressible as a set of images plus a question, and a model that receives
374/// the images in the wrong order answers the wrong question fluently. So the
375/// prompt is a sequence and the engine splices its image-token blocks at the
376/// positions the sequence gives it.
377#[derive(Debug, Clone, Default, PartialEq, Eq)]
378pub struct VlmPrompt<'a> {
379 pub parts: Vec<VlmPart<'a>>,
380}
381
382impl<'a> VlmPrompt<'a> {
383 /// The single-image case: the image, then the instruction if there is one.
384 #[must_use]
385 pub fn single(image: &'a ImageBuffer, text: Option<&'a str>) -> Self {
386 let mut parts = vec![VlmPart::Image(image)];
387 if let Some(t) = text {
388 parts.push(VlmPart::Text(t));
389 }
390 Self { parts }
391 }
392
393 /// Number of images in the prompt — what an engine checks against the
394 /// image-token placeholders it is about to splice.
395 #[must_use]
396 pub fn image_count(&self) -> usize {
397 self.parts
398 .iter()
399 .filter(|p| matches!(p, VlmPart::Image(_)))
400 .count()
401 }
402}
403
404/// Options for a VLM call (Argus).
405///
406/// # Gate 2 — the v1 surface, and what is deliberately NOT here
407///
408/// Settled once, before implementation, because every field is cheap now and a
409/// breaking change later (`docs/plans/argus-launch-plan.md` §2 Gate 2).
410///
411/// **Excluded from v1, as decisions rather than oversights:**
412///
413/// - **Streaming.** Tokens as produced. It changes the return type of every
414/// method, so it is a trait redesign and not a field; it waits until there
415/// is a consumer that needs it.
416/// - **Grounding.** Region-in ("what is in this box") and grounded-out ("the
417/// dog `[x,y,w,h]`"). Diana already returns boxes; a second, weaker box
418/// source in the toolkit needs a reason beyond "the model can".
419/// - **Structured / JSON output.** Needs constrained decoding to be worth
420/// anything — an unconstrained "please reply in JSON" is a request, not a
421/// guarantee. High value for a tooling product, so it is v2 rather than
422/// never; mistral.rs already provides grammar-constrained decoding, which is
423/// what makes it cheap when it lands.
424/// - **Token-level confidence (logprobs).** Carmenta uses recognition
425/// confidence for its verifier and the analog is genuinely valuable, but it
426/// is only meaningful once an engine exists to produce it honestly.
427/// - **Conversation history / multi-turn.** [`VlmPrompt`] can already express
428/// an interleaved turn sequence; a typed history is a chat-session concern,
429/// and Argus is a media op.
430///
431/// **Not here because it belongs to the ENGINE, not the caller:** the chat
432/// template, the special vision tokens (`<image>`, `<|vision_start|>`), and
433/// the M-RoPE time axis for video. Those are properties of a specific
434/// checkpoint. Exposing them as options would invite a caller to set them
435/// wrong — and the launch plan measured what that costs: **43 of 50 answers
436/// changed** on identical weights purely from prompt formatting, with no error
437/// raised.
438#[derive(Debug, Clone, Default)]
439pub struct VlmOptions {
440 /// Instruction for the model; `None` = plain captioning.
441 ///
442 /// Read by [`VlmEngine::describe_image`] only. [`VlmEngine::describe`]
443 /// takes its text from the prompt's own [`VlmPart::Text`] parts, because
444 /// position matters there and a separate field could not say where the
445 /// text goes.
446 pub prompt: Option<String>,
447 /// Role/behaviour framing, when the checkpoint's template has a slot for
448 /// it. Engines without one must ignore it rather than concatenate it into
449 /// the user turn, which changes the prompt the model was tuned on.
450 pub system_prompt: Option<String>,
451 /// Token-selection strategy. Defaults to [`Decoding::Greedy`].
452 pub decoding: Decoding,
453 /// Token budget. `None` = the engine's own default.
454 pub max_new_tokens: Option<usize>,
455 /// Stop strings. Generation halts as soon as one is produced; the string
456 /// itself is not included in the returned text.
457 ///
458 /// EOS is not here — that is the tokenizer's, and an engine that needed to
459 /// be told its own EOS would be misconfigured.
460 pub stop: Vec<String>,
461 /// Frames shown to the model per video caption. `None` = the engine's own
462 /// default.
463 ///
464 /// This is the video knob that decides what a caption can be ABOUT, and
465 /// the arithmetic is worth stating because it is not obvious. A still
466 /// image is split into tiles so fine print survives — for `SmolVLM` that
467 /// is 17 tiles at 64 tokens, **1088 tokens per image**. A text tower with
468 /// 8192 positions therefore holds seven such frames, which is not a window
469 /// so much as a slideshow.
470 ///
471 /// Video engines turn splitting off, making a frame **one** tile, and the
472 /// same 8192 positions then hold a hundred. So this number trades fine
473 /// detail within a frame against temporal context across frames, and the
474 /// right value depends on the question being asked — which is why it is a
475 /// caller's option and not a constant.
476 ///
477 /// `1` degenerates to per-frame captioning: correct, and blind to motion.
478 pub frames_per_window: Option<usize>,
479 /// Repetition penalty, `None` = disabled.
480 ///
481 /// Sits beside [`Self::decoding`] rather than inside
482 /// [`Decoding::Sampled`] because it is a *logit* transform, not a
483 /// sampling one: it applies to greedy decoding too, and small models loop
484 /// under greedy more than under sampling.
485 pub repetition_penalty: Option<f32>,
486}
487
488/// Speech → text (Mercury).
489pub trait AsrEngine: Send + Sync {
490 fn info(&self) -> EngineInfo;
491 fn transcribe(&self, audio: &AudioBuffer, opts: &AsrOptions) -> Result<Transcript>;
492}
493
494/// Text → speech (Mercury).
495pub trait TtsEngine: Send + Sync {
496 fn info(&self) -> EngineInfo;
497 fn synthesize(&self, text: &str, opts: &TtsOptions) -> Result<AudioBuffer>;
498}
499
500/// Image → text (Carmenta).
501pub trait OcrEngine: Send + Sync {
502 fn info(&self) -> EngineInfo;
503 fn recognize(&self, image: &ImageBuffer, opts: &OcrOptions) -> Result<OcrOutput>;
504}
505
506/// Image → objects (Diana).
507/// What a depth engine returns: a dense map plus what is needed to place it
508/// back on the source image.
509#[derive(Debug, Clone)]
510pub struct DepthOutput {
511 /// Row-major depth, `height * width` values, in **metres**.
512 pub depth: Vec<f32>,
513 pub width: usize,
514 pub height: usize,
515 /// The letterbox that produced this map, when one was applied — the same
516 /// role it plays in [`DetectOutput`], and needed for the same reason: the
517 /// map is in letterboxed space and means nothing without it.
518 pub letterbox: Option<crate::types::Letterbox>,
519}
520
521impl DepthOutput {
522 /// Depth at a pixel of the map. `None` when out of range.
523 #[must_use]
524 pub fn at(&self, x: usize, y: usize) -> Option<f32> {
525 if x >= self.width || y >= self.height {
526 return None;
527 }
528 self.depth.get(y * self.width + x).copied()
529 }
530
531 /// `(min, max)` over the map, for callers normalising it for display.
532 /// Returns `None` for an empty map rather than a nonsense range.
533 #[must_use]
534 pub fn range(&self) -> Option<(f32, f32)> {
535 if self.depth.is_empty() {
536 return None;
537 }
538 let mut lo = f32::MAX;
539 let mut hi = f32::MIN;
540 for &v in &self.depth {
541 if v.is_finite() {
542 lo = lo.min(v);
543 hi = hi.max(v);
544 }
545 }
546 (lo <= hi).then_some((lo, hi))
547 }
548}
549
550/// Options for a depth run.
551#[derive(Debug, Clone, Default)]
552pub struct DepthOptions {
553 /// Resize the map to the SOURCE image's resolution and undo the
554 /// letterbox, instead of returning the raw network output at stride 4.
555 ///
556 /// Off by default: the raw map is what the model computed, and resizing
557 /// is a lossy convenience the caller may want to do differently.
558 pub full_resolution: bool,
559}
560
561/// Monocular depth estimation.
562pub trait DepthEngine: Send + Sync {
563 fn info(&self) -> EngineInfo;
564 fn depth(&self, image: &ImageBuffer, opts: &DepthOptions) -> Result<DepthOutput>;
565}
566
567pub trait DetectEngine: Send + Sync {
568 fn info(&self) -> EngineInfo;
569 fn detect(&self, image: &ImageBuffer, opts: &DetectOptions) -> Result<DetectOutput>;
570
571 /// Detect over many images, using the whole machine.
572 ///
573 /// **This is a first-class path, not a convenience wrapper**, because it
574 /// is where a detector's throughput actually lives. Measured on Diana:
575 /// running images concurrently is worth **3.6-5.3x** over calling
576 /// [`Self::detect`] in a loop, with byte-identical output and no change
577 /// whatever to the per-image path — because intra-image parallelism is
578 /// nearly exhausted (24 cores buy one image only 1.42x) while the images
579 /// themselves are independent.
580 ///
581 /// The trait is `Send + Sync`, so one loaded model serves every thread.
582 /// That is the structural advantage over the Python reference: measured
583 /// on the same machine, `PyTorch` gets *slower* under threading (0.68-0.72x)
584 /// because of the GIL, and its escape hatch — multiprocessing — pays a
585 /// full model copy per worker.
586 ///
587 /// The default implementation is sequential so existing engines keep
588 /// working; an engine that can do better should override it.
589 fn detect_batch(
590 &self,
591 images: &[ImageBuffer],
592 opts: &DetectOptions,
593 ) -> Result<Vec<DetectOutput>> {
594 images.iter().map(|i| self.detect(i, opts)).collect()
595 }
596 /// Class names for the ids in [`DetectOutput`], in id order. They come
597 /// from the weight manifest, so they belong to the engine rather than
598 /// to every output it produces.
599 fn class_names(&self) -> &[String];
600}
601
602/// Image/video → description (Argus).
603///
604/// See [`VlmOptions`] for the Gate-2 surface decision and the written-down v1
605/// exclusions.
606pub trait VlmEngine: Send + Sync {
607 fn info(&self) -> EngineInfo;
608
609 /// The general path: an ordered, interleaved multimodal prompt.
610 ///
611 /// **This is the required method, and `describe_image` is derived from
612 /// it, rather than the other way round.** An engine that implemented only
613 /// the single-image case would still compile against a multi-image
614 /// prompt — and would then silently answer using the first image, or the
615 /// last, or a concatenation. Making the general case the one an
616 /// implementor must write means multi-image support is a compile-time
617 /// obligation instead of a runtime surprise.
618 fn describe(&self, prompt: &VlmPrompt<'_>, opts: &VlmOptions) -> Result<String>;
619
620 /// One image, with [`VlmOptions::prompt`] as the instruction.
621 ///
622 /// Provided: builds a single-image [`VlmPrompt`] and calls
623 /// [`Self::describe`]. Zero-copy — the prompt borrows the image.
624 fn describe_image(&self, image: &ImageBuffer, opts: &VlmOptions) -> Result<String> {
625 self.describe(&VlmPrompt::single(image, opts.prompt.as_deref()), opts)
626 }
627
628 /// Video understanding over sampled frames → a timed caption track.
629 ///
630 /// Frames arrive with their timestamps ([`VideoFrame::timestamp`]) so an
631 /// engine can encode *time* and not merely order — M-RoPE's third axis.
632 /// Whether it does is the engine's business; the trait's job is to make
633 /// sure the information reaches it.
634 fn describe_video(
635 &self,
636 frames: &[VideoFrame],
637 opts: &VlmOptions,
638 ) -> Result<Vec<TimedSegment<String>>>;
639}
640
641#[cfg(test)]
642mod vlm_surface_tests {
643 use super::*;
644 use crate::types::PixelFormat;
645
646 fn img(byte: u8) -> ImageBuffer {
647 ImageBuffer {
648 width: 1,
649 height: 1,
650 format: PixelFormat::Rgb8,
651 data: vec![byte, byte, byte],
652 }
653 }
654
655 /// An engine that implements ONLY the required method and records what it
656 /// was handed — which is how we check `describe_image` really routes
657 /// through `describe` rather than being a second, divergent path.
658 struct Recorder(std::sync::Mutex<Vec<String>>);
659
660 impl VlmEngine for Recorder {
661 fn info(&self) -> EngineInfo {
662 EngineInfo {
663 name: "recorder".into(),
664 task: Task::Vlm,
665 status: EngineStatus::Stub,
666 description: String::new(),
667 }
668 }
669 fn describe(&self, prompt: &VlmPrompt<'_>, _opts: &VlmOptions) -> Result<String> {
670 let shape: Vec<String> = prompt
671 .parts
672 .iter()
673 .map(|p| match p {
674 VlmPart::Text(t) => format!("text:{t}"),
675 VlmPart::Image(i) => format!("image:{}", i.data[0]),
676 })
677 .collect();
678 self.0.lock().unwrap().push(shape.join("|"));
679 Ok(shape.join("|"))
680 }
681 fn describe_video(
682 &self,
683 _frames: &[VideoFrame],
684 _opts: &VlmOptions,
685 ) -> Result<Vec<TimedSegment<String>>> {
686 Ok(Vec::new())
687 }
688 }
689
690 /// Gate 2's determinism requirement, checked rather than asserted in prose:
691 /// a caller who sets nothing gets deterministic decoding.
692 #[test]
693 fn the_default_decoding_is_deterministic() {
694 assert_eq!(VlmOptions::default().decoding, Decoding::Greedy);
695 // ...and the default options carry no stochasticity anywhere else.
696 assert!(VlmOptions::default().stop.is_empty());
697 assert_eq!(VlmOptions::default().repetition_penalty, None);
698 }
699
700 /// There is no way to construct stochastic decoding without a seed. This
701 /// test cannot fail at runtime — the point is that the alternative does
702 /// not COMPILE, and this is where that intent is recorded.
703 #[test]
704 fn sampling_always_carries_a_seed() {
705 let d = Decoding::Sampled {
706 temperature: 0.7,
707 top_p: Some(0.9),
708 top_k: None,
709 seed: 42,
710 };
711 // Every Sampled value has a seed by construction; matching proves it.
712 let Decoding::Sampled { seed, .. } = d else {
713 panic!("expected Sampled")
714 };
715 assert_eq!(seed, 42);
716 }
717
718 #[test]
719 fn describe_image_routes_through_describe() {
720 let e = Recorder(std::sync::Mutex::new(Vec::new()));
721 let opts = VlmOptions {
722 prompt: Some("what is this?".into()),
723 ..VlmOptions::default()
724 };
725 let out = e.describe_image(&img(7), &opts).unwrap();
726 // Image FIRST, then the instruction — the order most VLM chat
727 // templates expect, and the order `VlmPrompt::single` documents.
728 assert_eq!(out, "image:7|text:what is this?");
729 assert_eq!(e.0.lock().unwrap().len(), 1, "describe must have been used");
730 }
731
732 #[test]
733 fn a_prompt_with_no_instruction_is_just_the_image() {
734 let e = Recorder(std::sync::Mutex::new(Vec::new()));
735 let out = e.describe_image(&img(3), &VlmOptions::default()).unwrap();
736 assert_eq!(out, "image:3");
737 }
738
739 /// Order is the payload: "compare the first to the second" is not
740 /// expressible as a set, so the sequence must survive intact.
741 #[test]
742 fn interleaved_order_is_preserved() {
743 let (a, b) = (img(1), img(2));
744 let e = Recorder(std::sync::Mutex::new(Vec::new()));
745 let prompt = VlmPrompt {
746 parts: vec![
747 VlmPart::Text("before"),
748 VlmPart::Image(&a),
749 VlmPart::Text("between"),
750 VlmPart::Image(&b),
751 VlmPart::Text("after"),
752 ],
753 };
754 assert_eq!(prompt.image_count(), 2);
755 let out = e.describe(&prompt, &VlmOptions::default()).unwrap();
756 assert_eq!(
757 out, "text:before|image:1|text:between|image:2|text:after",
758 "the sequence an engine receives must be the sequence it was given"
759 );
760 }
761}