rto_graph/media.rs
1//! Generated media content — a separate artifact store, never a graph fact.
2//!
3//! An ASR transcript (Voxtral) and a VLM description (`SmolVLM`) are **generated**,
4//! not decoded. Asked to transcribe digital silence a model does not return
5//! nothing; it returns fluent, confident prose. That is not a deterministic pure
6//! function of `(path, blob id, bytes)` — change the model, the quantisation or
7//! the sampling parameters and the same blob yields different "facts" — so it is
8//! not a `derived` fact and must not be stored as one (ADR-0015, issue #300).
9//!
10//! It lives here instead: its own table, its own retrieval surface, and never in
11//! `nodes`/`edges`. Two consequences are load-bearing, and both are asserted by
12//! tests rather than assumed:
13//!
14//! - [`crate::Store::export_factset`] — and therefore the published
15//! [`crate::GraphArtifact`] — stays a pure function of the tree **across a
16//! `media build`**, because nothing in this module writes a node or an edge.
17//! - No record acquires the `authored` relevance boost that [`crate::search`]
18//! applies, because generated content is ranked in [a separate
19//! channel](crate::search_channels) by a scorer that has no provenance term at
20//! all.
21//!
22//! Nothing here adds a [`crate::Provenance`] variant.
23//!
24//! # The boundary is generation, not models
25//!
26//! | Content | Nature | Verdict |
27//! |---|---|---|
28//! | Prose, PDF text | deterministic parse | stays `derived` |
29//! | **OCR** (`ocrs-text`) | discriminative; decodes text that is *actually present*; its errors are misreadings, correctable against the image | **stays `derived`** |
30//! | **ASR transcript** (Voxtral) | generative | **lives here** |
31//! | **VLM description** (`SmolVLM`) | generative | **lives here** |
32//!
33//! OCR has ground truth in the artefact. A transcript of silence has no ground
34//! truth to be wrong *against*, and no amount of model improvement changes its
35//! kind.
36//!
37//! # Keying: source blob + producer identity
38//!
39//! A record is keyed by `(blob_id, producer)`, where the producer is the whole
40//! identity of what produced the text — model id and file digest, quantisation,
41//! mmproj digest, prompt, and sampling parameters (see [`Producer`]). So
42//! re-describing the same blob with a better model writes a **new record, not a
43//! mutation**: you can compare the two, and you can discard one producer's output
44//! wholesale when you stop trusting it.
45//!
46//! Records survive [`crate::Store::rebuild`], following the `imports` precedent —
47//! they are expensive to reproduce (a 715 MB projector load per blob, issue #301)
48//! and are not derivable from source alone.
49//!
50//! # Two outcomes, both recorded
51//!
52//! A record holds a [`MediaOutcome`], not a string. Either a model ran and
53//! produced text, or the [pre-generation gate](gate) refused the blob before any
54//! model was loaded — and **the refusal is stored too**, with the value it
55//! measured, so `media status` can distinguish *not generated* from *generated
56//! nothing*. The two cases are variants of one enum rather than a nullable text
57//! column precisely so that a skip cannot carry generated text and a generated
58//! record cannot claim a measurement.
59//!
60//! @rto:0015
61
62use rusqlite::{Connection, OptionalExtension, params};
63use serde::{Deserialize, Serialize};
64
65use crate::store::StoreError;
66
67pub mod gate;
68pub mod producers;
69
70pub use gate::{GateReason, GateThresholds, MediaSkip};
71
72/// The prefix of every rendered [`ProducerId`].
73pub const MEDIA_PRODUCER_PREFIX: &str = "media";
74
75/// Stable schema tag on [`MediaBuildReport`] and [`MediaStatus`], so a
76/// programmatic consumer can depend on the shape.
77pub const MEDIA_SCHEMA: &str = "roteiro.media/v1";
78
79/// Audio files larger than this (compressed bytes) are not transcribed — decode
80/// plus inference time scales with duration, so cap the work one clip imposes.
81///
82/// Unconditional (not behind `audio-transcribe`) because [`build_media`] applies
83/// the cap while *enumerating* candidates, which every build can do.
84pub const MAX_AUDIO_BYTES: usize = 50 * 1024 * 1024;
85
86/// Images larger than this (compressed bytes) are not described. Shared with the
87/// OCR path in `crate::extract`, which applies the same cap.
88pub const MAX_IMAGE_BYTES: usize = 20 * 1024 * 1024;
89
90/// Longest permitted prompt, in bytes. A prompt is part of the producer identity
91/// and is stored on every row; anything longer is a configuration mistake, not a
92/// prompt.
93pub const MAX_PROMPT: usize = 4096;
94
95/// Longest permitted model id, in characters — the same bound, and the same
96/// character set, the analyzer ids in `crate::findings` use, because a model id
97/// is likewise a component of a stored, indexed and printed identity.
98pub const MAX_MODEL_ID: usize = 64;
99
100/// Errors raised when constructing or building generated media content.
101#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
102pub enum MediaError {
103 /// A model id was empty, over-long, or contained a character outside
104 /// lowercase `[a-z0-9._-]`.
105 #[error(
106 "invalid model id {0:?} (expected 1 to {MAX_MODEL_ID} characters of lowercase [a-z0-9._-])"
107 )]
108 InvalidModelId(String),
109 /// A producer field that must be present was empty, or a prompt was longer
110 /// than [`MAX_PROMPT`]. The message names the field.
111 #[error("invalid producer {field}: {reason}")]
112 InvalidProducer {
113 /// The offending field.
114 field: &'static str,
115 /// Why it was refused.
116 reason: String,
117 },
118 /// `media build` was asked for a modality this **binary** cannot produce.
119 /// Names the feature that would provide it, because the fix is a rebuild.
120 #[error(
121 "this build cannot generate {kind} content: rebuild with `--features {feature}` \
122 (generated media content is opt-in, so the default build has no producer)"
123 )]
124 NoProducer {
125 /// The modality asked for.
126 kind: &'static str,
127 /// The cargo feature that provides it.
128 feature: &'static str,
129 },
130 /// The feature is compiled in but the model is not on disk. Names the exact
131 /// command that installs it, rather than degrading to silence.
132 #[error("model `{model}` is not installed: run `roteiro model pull {model}`")]
133 ModelMissing {
134 /// Registry name of the missing model.
135 model: String,
136 },
137 /// The `[models]` key governing this modality names a model that cannot be
138 /// used — an unknown name, the wrong modality, or one that is not installed.
139 ///
140 /// A separate variant from [`MediaError::ModelMissing`] because the fix is
141 /// different in kind: that one is a download, this one is an edit to a config
142 /// file that is currently *appearing* to be honoured. Falling back to the
143 /// default instead would be the worst outcome available — and, given that
144 /// llama.cpp aborts rather than errors when handed a model of the wrong
145 /// architecture, not merely a cosmetic one.
146 #[cfg(feature = "models")]
147 #[error(transparent)]
148 ModelConfig(#[from] crate::model_choice::ModelChoiceError),
149 /// A stored row could not be interpreted (database corruption).
150 #[error("corrupt media record: {0}")]
151 Corrupt(String),
152}
153
154/// Which generative modality produced a record.
155///
156/// Only generative modalities appear here: OCR is discriminative and stays on the
157/// `derived` extraction path, so it has no variant and cannot acquire one by
158/// accident.
159#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
160#[serde(rename_all = "lowercase")]
161pub enum MediaKind {
162 /// Speech transcription of an audio blob.
163 Audio,
164 /// A vision-language model's description of an image blob.
165 Vision,
166}
167
168impl MediaKind {
169 /// Stable string token used in the `SQLite` store and in `--json` output.
170 #[must_use]
171 pub fn as_str(self) -> &'static str {
172 match self {
173 Self::Audio => "audio",
174 Self::Vision => "vision",
175 }
176 }
177
178 /// Parse a modality from its stable token; `None` for an unrecognised value
179 /// (a corrupt row).
180 #[must_use]
181 pub fn from_token(s: &str) -> Option<Self> {
182 match s {
183 "audio" => Some(Self::Audio),
184 "vision" => Some(Self::Vision),
185 _ => None,
186 }
187 }
188
189 /// Whether `path` names a blob this modality can read.
190 #[must_use]
191 pub fn accepts_path(self, path: &str) -> bool {
192 match self {
193 Self::Audio => is_audio(path),
194 Self::Vision => is_image(path),
195 }
196 }
197
198 /// The byte cap this modality applies to a candidate blob.
199 #[must_use]
200 pub fn max_bytes(self) -> usize {
201 match self {
202 Self::Audio => MAX_AUDIO_BYTES,
203 Self::Vision => MAX_IMAGE_BYTES,
204 }
205 }
206
207 /// The cargo feature that compiles this modality's generator in.
208 ///
209 /// Unconditional, so a build *without* the feature can still name it — which
210 /// is the point: telling an operator which flag they lack is only useful from
211 /// the binary that lacks it.
212 #[must_use]
213 pub fn feature(self) -> &'static str {
214 match self {
215 Self::Audio => "audio-transcribe",
216 Self::Vision => "image-vision",
217 }
218 }
219
220 /// Registry name of the model this modality generates with **when nothing
221 /// pins one** — the argument to `roteiro model pull` on a stock setup, and
222 /// the value `ModelTask::default_model` reads for this modality.
223 /// Unconditional for the same reason as [`MediaKind::feature`].
224 ///
225 /// Since Stage 33 a project can pin another with `[models] audio` /
226 /// `[models] vision`, so a caller that needs the model *this repository*
227 /// actually uses must ask `resolve_model` with `MediaKind::task`, not this.
228 /// The two differ exactly when a pin is set, which is why this one is
229 /// documented as the default rather than as "the" model.
230 ///
231 /// Those three are named rather than linked: the resolver is behind
232 /// `models` and this method deliberately is not, so a link would be
233 /// unresolved in precisely the builds where this method *is* the whole
234 /// answer — there is nothing to resolve a pin with.
235 #[must_use]
236 pub const fn model(self) -> &'static str {
237 match self {
238 Self::Audio => "voxtral-mini-3b",
239 Self::Vision => "smolvlm-500m-gguf",
240 }
241 }
242
243 /// The resolver task this modality's generation is (`transcribe` /
244 /// `describe`), so a call site holding a [`MediaKind`] can ask which model
245 /// this repository pinned without restating the mapping.
246 #[cfg(feature = "models")]
247 #[must_use]
248 pub fn task(self) -> crate::model_choice::ModelTask {
249 match self {
250 Self::Audio => crate::model_choice::ModelTask::Transcribe,
251 Self::Vision => crate::model_choice::ModelTask::Describe,
252 }
253 }
254
255 /// Whether *this binary* was compiled with this modality's generator.
256 ///
257 /// Separates the two reasons a modality can be unavailable, which call for
258 /// very different instructions: a rebuild, or a download.
259 #[must_use]
260 pub fn compiled_in(self) -> bool {
261 match self {
262 Self::Audio => cfg!(feature = "audio-transcribe"),
263 Self::Vision => cfg!(feature = "image-vision"),
264 }
265 }
266}
267
268impl std::fmt::Display for MediaKind {
269 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270 f.write_str(self.as_str())
271 }
272}
273
274/// Whether `path` is an audio file the projector's miniaudio decoder can read
275/// (WAV/MP3/FLAC — the formats llama.cpp bundles support for).
276#[must_use]
277pub fn is_audio(path: &str) -> bool {
278 matches!(
279 crate::extract::extension(path).as_deref(),
280 Some("wav" | "mp3" | "flac")
281 )
282}
283
284/// Whether `path` is an image the OCR and vision paths can read.
285#[must_use]
286pub fn is_image(path: &str) -> bool {
287 matches!(
288 crate::extract::extension(path).as_deref(),
289 Some("png" | "jpg" | "jpeg")
290 )
291}
292
293/// Whether a character may appear in a model id.
294fn is_model_id_char(c: char) -> bool {
295 c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-')
296}
297
298/// Whether `id` is a well-formed model id: 1..=[`MAX_MODEL_ID`] characters of
299/// lowercase `[a-z0-9._-]`. A `:` is excluded because a model id is a component
300/// of a rendered [`ProducerId`].
301#[must_use]
302pub fn is_valid_model_id(id: &str) -> bool {
303 !id.is_empty() && id.len() <= MAX_MODEL_ID && id.chars().all(is_model_id_char)
304}
305
306/// Everything about *what produced* a piece of generated text — the evidence
307/// chain graph provenance was never designed to hold.
308///
309/// This whole struct is the identity a record is keyed by, via
310/// [`Producer::id`]: change the model, its digest, the quantisation, the
311/// projector, the prompt or a sampling parameter and you have a different
312/// producer, so the next `media build` writes a **new record** rather than
313/// overwriting the old one.
314///
315/// The **tool version** is deliberately *not* part of the identity — it is
316/// recorded on the row ([`MediaRecord::tool_version`]) for forensics, but folding
317/// it in would invalidate every record on every release without the output having
318/// changed.
319#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
320pub struct Producer {
321 /// Which generative modality this producer covers.
322 pub kind: MediaKind,
323 /// Registry name of the model (`voxtral-mini-3b`, `smolvlm-500m-gguf`).
324 pub model: String,
325 /// Digest of the model file as pinned in the registry — the tie between a
326 /// record and the exact weights that produced it.
327 pub model_digest: String,
328 /// Quantisation of those weights (`Q4_K_M`, `Q8_0`, …), read from the pinned
329 /// file name; `unknown` when it cannot be determined.
330 pub quantisation: String,
331 /// Digest of the multimodal projector (`mmproj.gguf`) the model was run with.
332 pub mmproj_digest: String,
333 /// The prompt the model was given.
334 pub prompt: String,
335 /// Sampling temperature.
336 pub temperature: f64,
337 /// Token budget for the generation.
338 pub max_tokens: u32,
339}
340
341impl Producer {
342 /// Validate a producer, refusing an identity that could not be stored or
343 /// rendered unambiguously.
344 ///
345 /// # Errors
346 /// Returns [`MediaError::InvalidModelId`] for a malformed model id, or
347 /// [`MediaError::InvalidProducer`] naming the field for an empty digest, an
348 /// empty or over-long prompt, or a non-finite temperature.
349 pub fn validate(&self) -> Result<(), MediaError> {
350 if !is_valid_model_id(&self.model) {
351 return Err(MediaError::InvalidModelId(self.model.clone()));
352 }
353 let non_empty = |field: &'static str, value: &str| {
354 if value.is_empty() {
355 Err(MediaError::InvalidProducer {
356 field,
357 reason: "it is empty".to_owned(),
358 })
359 } else {
360 Ok(())
361 }
362 };
363 non_empty("model_digest", &self.model_digest)?;
364 non_empty("quantisation", &self.quantisation)?;
365 non_empty("mmproj_digest", &self.mmproj_digest)?;
366 non_empty("prompt", &self.prompt)?;
367 if self.prompt.len() > MAX_PROMPT {
368 return Err(MediaError::InvalidProducer {
369 field: "prompt",
370 reason: format!(
371 "it is {} bytes, over the {MAX_PROMPT}-byte limit",
372 self.prompt.len()
373 ),
374 });
375 }
376 if !self.temperature.is_finite() {
377 return Err(MediaError::InvalidProducer {
378 field: "temperature",
379 reason: format!("{} is not a finite number", self.temperature),
380 });
381 }
382 Ok(())
383 }
384
385 /// The identity token this producer's records are keyed by:
386 /// `media:<kind>:<model>:<fingerprint>`.
387 ///
388 /// The fingerprint is a 64-bit FNV-1a fold of the canonical rendering of
389 /// *every* identity field, in a fixed order. It is a **handle, not a
390 /// digest**: it makes the identity short enough to type at
391 /// `media clear --producer <id>`, while the row itself carries all the fields
392 /// verbatim, so nothing depends on the fold being collision-free. And because
393 /// the kind and the model name are in the token literally, a collision would
394 /// additionally require the *same* model, differing only in digest, prompt or
395 /// sampling parameters.
396 ///
397 /// No hash crate is involved deliberately: this is not a security boundary,
398 /// and the workspace does not take a dependency for one.
399 #[must_use]
400 pub fn id(&self) -> ProducerId {
401 use std::fmt::Write as _;
402
403 // A length-prefixed, ordered rendering, so two producers cannot fold to
404 // the same bytes by moving a `:` from one field into the next.
405 let mut canonical = String::new();
406 for part in [
407 self.kind.as_str(),
408 self.model.as_str(),
409 self.model_digest.as_str(),
410 self.quantisation.as_str(),
411 self.mmproj_digest.as_str(),
412 self.prompt.as_str(),
413 ] {
414 // Writing to a `String` is infallible.
415 let _ = write!(canonical, "{}:{part}", part.len());
416 }
417 // `{:?}` on an f64 round-trips exactly, so two distinct temperatures can
418 // never render identically.
419 let _ = write!(canonical, "t{:?}m{}", self.temperature, self.max_tokens);
420 ProducerId(format!(
421 "{MEDIA_PRODUCER_PREFIX}:{}:{}:{:016x}",
422 self.kind.as_str(),
423 self.model,
424 fnv1a(canonical.as_bytes())
425 ))
426 }
427}
428
429/// 64-bit FNV-1a. Deterministic, dependency-free, and used only for the
430/// [`ProducerId`] handle — see [`Producer::id`] for why that is sufficient.
431fn fnv1a(bytes: &[u8]) -> u64 {
432 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
433 for b in bytes {
434 hash ^= u64::from(*b);
435 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
436 }
437 hash
438}
439
440/// A rendered [`Producer`] identity — the token `media clear --producer` takes
441/// and `media status` prints.
442#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
443pub struct ProducerId(String);
444
445impl ProducerId {
446 /// The token.
447 #[must_use]
448 pub fn as_str(&self) -> &str {
449 &self.0
450 }
451}
452
453impl std::fmt::Display for ProducerId {
454 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
455 f.write_str(&self.0)
456 }
457}
458
459/// What a [`MediaProducer`] returns for one blob.
460#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
461pub struct GeneratedContent {
462 /// The generated text.
463 pub text: String,
464 /// A confidence signal, when the runtime exposes one. `None` is the honest
465 /// answer for both ASR and VLM today — neither emits a calibrated score — and
466 /// this is *not* the confidence an `inferred` edge carries; it must never be
467 /// read as one.
468 #[serde(default, skip_serializing_if = "Option::is_none")]
469 pub confidence: Option<f64>,
470}
471
472/// What one producer run concluded about one blob: text, or a recorded refusal.
473///
474/// The two cases are a **sum type, not a nullable field**, because the invariant
475/// that matters is mutual exclusion: a gated skip must write no generated text
476/// anywhere, and a generated record must not claim a measurement it never made.
477/// Expressed this way, neither is representable — a `text` column and a
478/// `skip_reason` column would leave both mistakes a `NULL` away. (The store
479/// enforces the same thing again in SQL, because the table outlives this type.)
480///
481/// Serialises internally tagged, so every record's JSON says which it is:
482/// `{"outcome":"generated","text":…}` or
483/// `{"outcome":"skipped","reason":"silence","value":0.0,"threshold":0.0001}`.
484#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
485#[serde(tag = "outcome", rename_all = "lowercase")]
486pub enum MediaOutcome {
487 /// A model ran and returned this text.
488 Generated(GeneratedContent),
489 /// The pre-generation gate refused the blob; **no model was loaded**. See
490 /// [`gate`].
491 Skipped(MediaSkip),
492}
493
494impl MediaOutcome {
495 /// The generated text, or `None` for a gated skip.
496 ///
497 /// The only way to reach a record's text, so every consumer — search,
498 /// the CLI, the explorer — has to acknowledge that a record may have none.
499 #[must_use]
500 pub fn text(&self) -> Option<&str> {
501 match self {
502 Self::Generated(content) => Some(content.text.as_str()),
503 Self::Skipped(_) => None,
504 }
505 }
506
507 /// The recorded refusal, or `None` when a model actually ran.
508 #[must_use]
509 pub fn skip(&self) -> Option<MediaSkip> {
510 match self {
511 Self::Generated(_) => None,
512 Self::Skipped(skip) => Some(*skip),
513 }
514 }
515
516 /// Whether the gate refused this blob.
517 #[must_use]
518 pub fn is_skipped(&self) -> bool {
519 matches!(self, Self::Skipped(_))
520 }
521}
522
523/// One stored record: a blob, the producer that described it, and what it said —
524/// or why nothing was said.
525#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
526pub struct MediaRecord {
527 /// Git blob id of the source media.
528 pub blob_id: String,
529 /// Repository path the blob was seen at. Evidence, not identity — the same
530 /// blob at two paths is one record.
531 pub path: String,
532 /// The rendered producer identity this record is keyed by.
533 pub producer_id: ProducerId,
534 /// The full producer identity, verbatim.
535 pub producer: Producer,
536 /// Version of the tool that wrote the record. Recorded, never part of the
537 /// identity — see [`Producer`].
538 pub tool_version: String,
539 /// Which description of this blob this is, counting from 1 across **all**
540 /// producers. Lets `media status` show that a blob has been re-described
541 /// rather than merely described.
542 ///
543 /// **Strictly increasing per blob while records accumulate**, including
544 /// across a `--force` rebuild: each write takes one more than the highest
545 /// generation currently on record for that blob.
546 ///
547 /// It is derived from the blob's surviving records, so `media clear` does
548 /// reset it — completely, if every record for that blob is discarded. See
549 /// `record` for why that limit is accepted rather than engineered around.
550 pub generation: u32,
551 /// When the record was written, as `SQLite`'s `datetime('now')`. Written for
552 /// humans and for `media status`; no ordering or policy depends on it.
553 pub produced_at: String,
554 /// What the run concluded: generated text, or a recorded gate refusal.
555 #[serde(flatten)]
556 pub outcome: MediaOutcome,
557}
558
559/// A narrowing filter for [`crate::Store::media_records`]. All-`None` means
560/// "every record".
561#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
562pub struct MediaFilter<'a> {
563 /// Only records written by this producer id.
564 pub producer: Option<&'a str>,
565 /// Only records of this modality.
566 pub kind: Option<MediaKind>,
567 /// Only records for this source blob.
568 pub blob_id: Option<&'a str>,
569}
570
571/// The values [`crate::Store::record_media_content`] writes.
572#[derive(Debug, Clone, Copy)]
573pub struct MediaWrite<'a> {
574 /// Git blob id of the source media.
575 pub blob_id: &'a str,
576 /// Repository path the blob was seen at.
577 pub path: &'a str,
578 /// Who produced the text — or who *would* have, on a gated skip: a refusal
579 /// belongs to a producer identity too, so changing the model re-evaluates
580 /// the blob instead of inheriting the old identity's skip.
581 pub producer: &'a Producer,
582 /// Version of the tool doing the writing.
583 pub tool_version: &'a str,
584 /// What the run concluded.
585 pub outcome: &'a MediaOutcome,
586 /// Replace an existing record for this exact `(blob, producer)` instead of
587 /// leaving it alone. Only `media build --force` sets this: a *different*
588 /// producer never mutates, it writes a new record.
589 pub replace: bool,
590}
591
592/// What one producer has in the store, for `media status`.
593#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
594pub struct ProducerSummary {
595 /// The producer identity.
596 pub producer_id: ProducerId,
597 /// Its modality.
598 pub kind: MediaKind,
599 /// The model it ran.
600 pub model: String,
601 /// Its quantisation.
602 pub quantisation: String,
603 /// How many records it owns, skips included.
604 pub records: u64,
605 /// How many of those are gate refusals rather than generated text. A
606 /// producer whose records are *all* skips has been run and has said nothing,
607 /// which is a very different report from having no records at all.
608 pub skipped: u64,
609 /// The most recent `produced_at` among them.
610 pub latest: String,
611}
612
613/// One gate refusal, as `media status` lists it.
614#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
615pub struct SkipEntry {
616 /// Git blob id of the refused source media.
617 pub blob_id: String,
618 /// Repository path it was seen at.
619 pub path: String,
620 /// The modality that would have described it.
621 pub kind: MediaKind,
622 /// The producer identity the refusal was recorded under.
623 pub producer_id: ProducerId,
624 /// Why, and what was measured.
625 #[serde(flatten)]
626 pub skip: MediaSkip,
627}
628
629/// The `media status` report.
630#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
631pub struct MediaStatus {
632 /// Stable schema tag ([`MEDIA_SCHEMA`]).
633 pub schema: &'static str,
634 /// Total stored records.
635 pub records: u64,
636 /// One entry per producer, ordered by producer id.
637 pub producers: Vec<ProducerSummary>,
638 /// Media blobs in the current tree, by modality — the denominator a rebuild
639 /// would work against.
640 pub candidates: Vec<CandidateCount>,
641 /// Producers this **binary** could run right now, ordered by id. Empty in a
642 /// build with no media features, or with no model installed; that is what
643 /// makes "0 records" legible as *cannot generate* rather than *nothing to
644 /// generate*.
645 pub available_producers: Vec<ProducerSummaryAvailable>,
646 /// Every blob the [pre-generation gate](gate) refused, with the value it
647 /// measured, ordered by `(producer, blob)`.
648 ///
649 /// This is the field that keeps a skip from being an invisible hole: an
650 /// operator reading `media status` sees *"assets/silence.wav — below silence
651 /// threshold (rms=0)"*, not a blob that silently failed to appear.
652 pub skipped: Vec<SkipEntry>,
653}
654
655/// How many blobs of one modality the current tree holds, and how many of them
656/// already have a record for *some* producer.
657#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
658pub struct CandidateCount {
659 /// The modality.
660 pub kind: MediaKind,
661 /// Distinct media blobs in the tree (within the size cap).
662 pub blobs: u64,
663 /// How many of those have at least one record carrying **generated text**.
664 pub described: u64,
665 /// How many of those the [`crate::media::gate`] refused, and no producer has since
666 /// described. Counted apart from `described` deliberately: a skipped blob is
667 /// not a described one, and folding the two together would restore exactly
668 /// the ambiguity the recorded skip exists to remove.
669 pub skipped: u64,
670}
671
672/// A producer this binary could run, as reported by `media status`.
673#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
674pub struct ProducerSummaryAvailable {
675 /// The producer identity it would write under.
676 pub producer_id: ProducerId,
677 /// Its modality.
678 pub kind: MediaKind,
679 /// The model it would run.
680 pub model: String,
681 /// Whether the store already holds records for exactly this identity — so a
682 /// caller can see at a glance that a rebuild would produce *new* records
683 /// because the installed model moved.
684 pub current: bool,
685}
686
687// --- Building --------------------------------------------------------------
688
689/// One modality's generator. The seam exists so the orchestration in
690/// [`build_media`] — incrementality, idempotence, `--force`, per-blob dedup — is
691/// testable without a 3 GB model and without a GPU, which is what CI actually
692/// runs.
693pub trait MediaProducer {
694 /// The identity every record this producer writes is keyed by.
695 fn producer(&self) -> &Producer;
696
697 /// Generate content for one blob, or `None` when the model declines to
698 /// produce anything usable. `path` is passed for logging and for producers
699 /// that need the extension; the identity never includes it.
700 fn generate(&self, path: &str, bytes: &[u8]) -> Option<GeneratedContent>;
701}
702
703/// Which modalities a `media build` should run, whether to redo work, and what
704/// the [pre-generation gate](gate) refuses.
705///
706/// Not `Eq`, because the thresholds are floats. Nothing compares two option sets
707/// for equality; they are read, not matched.
708#[derive(Debug, Clone, Copy, PartialEq)]
709pub struct MediaBuildOptions {
710 /// Generate audio transcripts.
711 pub audio: bool,
712 /// Generate image descriptions.
713 pub vision: bool,
714 /// Regenerate even where a record already exists for the current producer,
715 /// replacing it in place. Without this, `build` is incremental: a second run
716 /// with the same producer does no work.
717 ///
718 /// **Also overrides the gate.** Asking explicitly for a silent clip to be
719 /// transcribed is a legitimate request — to see what the model says, or
720 /// because the operator disagrees with a threshold — and a flag named
721 /// `--force` that quietly declined would be worse than no flag at all.
722 pub force: bool,
723 /// What the gate refuses. [`GateThresholds::disabled`] turns it off.
724 pub thresholds: GateThresholds,
725}
726
727impl Default for MediaBuildOptions {
728 /// Both modalities, incremental, gate on at its conservative defaults.
729 fn default() -> Self {
730 Self {
731 audio: true,
732 vision: true,
733 force: false,
734 thresholds: GateThresholds::default(),
735 }
736 }
737}
738
739impl MediaBuildOptions {
740 /// Whether `kind` is requested.
741 #[must_use]
742 pub fn wants(self, kind: MediaKind) -> bool {
743 match kind {
744 MediaKind::Audio => self.audio,
745 MediaKind::Vision => self.vision,
746 }
747 }
748}
749
750/// What one `media build` did.
751#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
752pub struct MediaBuildReport {
753 /// Stable schema tag ([`MEDIA_SCHEMA`]).
754 #[serde(default = "media_schema")]
755 pub schema: &'static str,
756 /// Distinct `(blob, producer)` pairs considered.
757 pub candidates: usize,
758 /// Records written (new, or replaced under `--force`).
759 pub generated: usize,
760 /// Pairs skipped because a record for that exact producer already existed —
761 /// the number that makes incrementality visible.
762 pub skipped_existing: usize,
763 /// Pairs the [pre-generation gate](gate) refused **before loading a model**,
764 /// each of which wrote a skip record naming its measured value.
765 #[serde(default)]
766 pub gated: usize,
767 /// Pairs where the model was invoked and returned nothing usable.
768 ///
769 /// Distinct from `gated`: this one *did* load a model and run it. The two
770 /// counts are what make the projector saving legible in a build report.
771 pub empty: usize,
772 /// Producer ids that ran, ordered.
773 pub producers: Vec<ProducerId>,
774}
775
776/// Default for [`MediaBuildReport::schema`] when deserialising.
777fn media_schema() -> &'static str {
778 MEDIA_SCHEMA
779}
780
781/// One candidate blob: a media file in the tree, within its modality's cap.
782#[derive(Debug, Clone, PartialEq, Eq)]
783pub struct MediaBlob {
784 /// Git blob id.
785 pub blob_id: String,
786 /// Repository path.
787 pub path: String,
788 /// Which modality can read it.
789 pub kind: MediaKind,
790}
791
792/// Every media blob in `repo`'s `HEAD` tree that some modality can read and that
793/// is within that modality's byte cap, de-duplicated by `(blob id, kind)` and
794/// ordered by `(kind, blob id)` so a build is deterministic.
795///
796/// The same blob committed at two paths is **one** candidate: the record is keyed
797/// by blob, so describing it twice would be work for one row. The lexically
798/// first path is the one recorded.
799///
800/// # Errors
801/// Returns [`crate::GitError`] if the tree cannot be walked or a blob cannot be
802/// read.
803pub fn media_blobs(repo: &crate::Repo) -> Result<Vec<MediaBlob>, crate::GitError> {
804 let mut blobs = repo.walk_blobs()?;
805 // Sort by path so "the lexically first path wins" is a fact, not an accident
806 // of the walk order.
807 blobs.sort_by(|a, b| a.path.cmp(&b.path));
808 let mut seen: std::collections::BTreeSet<(MediaKind, String)> =
809 std::collections::BTreeSet::new();
810 let mut out = Vec::new();
811 for blob in blobs {
812 for kind in [MediaKind::Audio, MediaKind::Vision] {
813 if !kind.accepts_path(&blob.path) {
814 continue;
815 }
816 if seen.contains(&(kind, blob.oid.clone())) {
817 continue;
818 }
819 // The size cap is applied here, before any model is loaded: an
820 // oversized clip is refused rather than partially transcribed.
821 let bytes = repo.read_blob(&blob.oid)?;
822 if bytes.len() > kind.max_bytes() {
823 continue;
824 }
825 seen.insert((kind, blob.oid.clone()));
826 out.push(MediaBlob {
827 blob_id: blob.oid.clone(),
828 path: blob.path.clone(),
829 kind,
830 });
831 }
832 }
833 out.sort_by(|a, b| (a.kind, &a.blob_id).cmp(&(b.kind, &b.blob_id)));
834 Ok(out)
835}
836
837/// Generate content for every candidate blob that has no record for the current
838/// producer, writing one record per `(blob, producer)`.
839///
840/// **Incremental by default and idempotent**: a second run with the same
841/// producers does no work at all — every pair lands in
842/// [`MediaBuildReport::skipped_existing`] and no model is invoked. A producer
843/// whose identity changed (a new model, a different quantisation, an edited
844/// prompt) has a different [`Producer::id`], so its pairs are *not* skipped and
845/// its output is a **new record beside the old one**, never an overwrite. Only
846/// [`MediaBuildOptions::force`] replaces, and only for the identical producer.
847///
848/// Before a producer is asked for anything, the [pre-generation gate](gate)
849/// measures the blob. A refusal writes a **skip record** — the reason and the
850/// measured value — and `generate` is never called, which is what keeps a
851/// repository of silent or blank assets from loading a model at all. `--force`
852/// overrides it.
853///
854/// `read` supplies a blob's bytes — [`crate::Repo::read_blob`] in production, a
855/// closure in tests.
856///
857/// Nothing here writes a node or an edge.
858///
859/// # Errors
860/// Returns [`StoreError`] on a store failure; a producer that fails to generate
861/// contributes to [`MediaBuildReport::empty`] rather than aborting the build, so
862/// one bad blob cannot lose the whole run's work.
863pub fn build_media<F>(
864 store: &mut crate::Store,
865 blobs: &[MediaBlob],
866 producers: &[&dyn MediaProducer],
867 opts: MediaBuildOptions,
868 mut read: F,
869) -> Result<MediaBuildReport, StoreError>
870where
871 F: FnMut(&MediaBlob) -> Option<Vec<u8>>,
872{
873 let tool_version = env!("CARGO_PKG_VERSION");
874 let mut report = MediaBuildReport {
875 schema: MEDIA_SCHEMA,
876 ..MediaBuildReport::default()
877 };
878 let mut ids: Vec<ProducerId> = producers.iter().map(|p| p.producer().id()).collect();
879 ids.sort();
880 ids.dedup();
881 report.producers = ids;
882
883 for producer in producers {
884 let identity = producer.producer();
885 let id = identity.id();
886 for blob in blobs {
887 if blob.kind != identity.kind || !opts.wants(blob.kind) {
888 continue;
889 }
890 report.candidates += 1;
891 // The incrementality decision, made *before* the bytes are read and
892 // long before a model is loaded.
893 if !opts.force && store.has_media_record(&blob.blob_id, id.as_str())? {
894 report.skipped_existing += 1;
895 continue;
896 }
897 let Some(bytes) = read(blob) else {
898 report.empty += 1;
899 continue;
900 };
901 // **The pre-generation gate**, evaluated here and not inside the
902 // producer. This is the whole point of its position: `generate` is
903 // never called, and the llama.cpp engines are built lazily *inside*
904 // `generate` (see `producers`), so a repository of silent or blank
905 // assets loads no model at all — no 715 MB projector, no backend
906 // init, nothing (ADR-0015; issue #301).
907 //
908 // `--force` skips the check outright rather than recording and then
909 // overriding: an operator who asked for the model to run wants the
910 // model to run.
911 let gated = if opts.force {
912 None
913 } else {
914 gate::evaluate(blob.kind, &bytes, opts.thresholds)
915 };
916 if let Some(skip) = gated {
917 // Recorded, not silent: the blob gets a record stating why and
918 // what was measured, so `media status` can tell an operator
919 // "skipped: below silence threshold (rms=0)" instead of leaving
920 // an indistinguishable hole.
921 store.record_media_content(&MediaWrite {
922 blob_id: &blob.blob_id,
923 path: &blob.path,
924 producer: identity,
925 tool_version,
926 outcome: &MediaOutcome::Skipped(skip),
927 replace: false,
928 })?;
929 report.gated += 1;
930 continue;
931 }
932 let Some(content) = producer.generate(&blob.path, &bytes) else {
933 report.empty += 1;
934 continue;
935 };
936 if content.text.trim().is_empty() {
937 report.empty += 1;
938 continue;
939 }
940 let written = store.record_media_content(&MediaWrite {
941 blob_id: &blob.blob_id,
942 path: &blob.path,
943 producer: identity,
944 tool_version,
945 outcome: &MediaOutcome::Generated(content),
946 replace: opts.force,
947 })?;
948 if written {
949 report.generated += 1;
950 } else {
951 report.skipped_existing += 1;
952 }
953 }
954 }
955 Ok(report)
956}
957
958/// Assemble the `media status` report: what is stored, by which producer, and
959/// how it compares with the media blobs actually in the tree.
960///
961/// `blobs` is the current candidate set (see [`media_blobs`]); pass an empty
962/// slice to report on the store alone.
963///
964/// # Errors
965/// Returns [`StoreError`] on a store failure.
966pub fn status(store: &crate::Store, blobs: &[MediaBlob]) -> Result<MediaStatus, StoreError> {
967 let mut candidates = Vec::new();
968 for kind in [MediaKind::Audio, MediaKind::Vision] {
969 let described_ids = store.described_media_blobs(kind)?;
970 let gated_ids = store.gated_media_blobs(kind)?;
971 let in_tree: std::collections::BTreeSet<&str> = blobs
972 .iter()
973 .filter(|b| b.kind == kind)
974 .map(|b| b.blob_id.as_str())
975 .collect();
976 let described = in_tree
977 .iter()
978 .filter(|id| described_ids.contains(**id))
979 .count();
980 // A blob one producer refused and another described counts as described,
981 // not skipped: it has content, so the operator has what they came for.
982 let skipped = in_tree
983 .iter()
984 .filter(|id| gated_ids.contains(**id) && !described_ids.contains(**id))
985 .count();
986 candidates.push(CandidateCount {
987 kind,
988 blobs: u64::try_from(in_tree.len()).unwrap_or(u64::MAX),
989 described: u64::try_from(described).unwrap_or(u64::MAX),
990 skipped: u64::try_from(skipped).unwrap_or(u64::MAX),
991 });
992 }
993 let stored = store.media_producer_summaries()?;
994 let mut available_producers: Vec<ProducerSummaryAvailable> = producers::available()
995 .into_iter()
996 .map(|p| {
997 let producer_id = p.id();
998 ProducerSummaryAvailable {
999 current: stored.iter().any(|s| s.producer_id == producer_id),
1000 producer_id,
1001 kind: p.kind,
1002 model: p.model,
1003 }
1004 })
1005 .collect();
1006 available_producers.sort_by(|a, b| a.producer_id.cmp(&b.producer_id));
1007 // Read from the records themselves rather than from a counter, so a skip
1008 // reported here is one that is actually stored.
1009 let skipped = store
1010 .media_records(&MediaFilter::default())?
1011 .into_iter()
1012 .filter_map(|record| {
1013 record.outcome.skip().map(|skip| SkipEntry {
1014 blob_id: record.blob_id,
1015 path: record.path,
1016 kind: record.producer.kind,
1017 producer_id: record.producer_id,
1018 skip,
1019 })
1020 })
1021 .collect();
1022 Ok(MediaStatus {
1023 schema: MEDIA_SCHEMA,
1024 records: store.media_content_count()?,
1025 producers: stored,
1026 candidates,
1027 available_producers,
1028 skipped,
1029 })
1030}
1031
1032// --- Persistence. Free helpers over a `Connection` (a `Transaction` derefs to
1033// one), mirroring the findings store. Every statement here touches
1034// `media_content` and nothing else: nothing in this module reads or writes
1035// `nodes` or `edges`. ---
1036
1037/// Columns of `media_content`, in the order [`record_from_row`] decodes them.
1038const RECORD_COLS: &str = "m.blob_id, m.path, m.kind, m.producer, m.model, m.model_digest, \
1039 m.quantisation, m.mmproj_digest, m.prompt, m.temperature, m.max_tokens, \
1040 m.tool_version, m.generation, m.produced_at, m.text, m.confidence, \
1041 m.skip_reason, m.skip_value, m.skip_threshold";
1042
1043/// Write one record, returning whether a row was written. See [`MediaWrite`].
1044pub(crate) fn record(conn: &Connection, write: &MediaWrite<'_>) -> Result<bool, StoreError> {
1045 let id = write.producer.id();
1046 let existing: Option<i64> = conn
1047 .query_row(
1048 "SELECT id FROM media_content WHERE blob_id = ?1 AND producer = ?2",
1049 params![write.blob_id, id.as_str()],
1050 |r| r.get(0),
1051 )
1052 .optional()?;
1053 // The generation counter is per *blob*, not per producer: it answers "has
1054 // this blob been described before, by anyone?".
1055 //
1056 // Read **before** the `--force` delete below, and from `MAX(generation)`
1057 // rather than `COUNT(*)`. Both details are load-bearing, and getting either
1058 // wrong makes the counter silently non-monotonic:
1059 //
1060 // * *Before the delete*, because a forced rebuild removes the row it is
1061 // about to replace. Counting afterwards made `--force` on a blob with one
1062 // producer write `generation = 1` for ever, so the field could not
1063 // distinguish a first description from a fifth.
1064 // * *`MAX`, not `COUNT`*, because rows are deletable — `media clear
1065 // --producer X` removes some of a blob's records — and a count would then
1066 // hand a later write a number it had already used, putting two different
1067 // descriptions at the same generation.
1068 //
1069 // The counter is therefore derived from the blob's **surviving** records. It
1070 // rises for as long as records accumulate — which is the property that was
1071 // broken — and it falls back when they are discarded, so `media clear` does
1072 // reset it, completely if every record for that blob goes.
1073 //
1074 // That is a deliberate limit, not an oversight. Making the counter survive
1075 // deletion means persisting a per-blob high-water mark that nothing ever
1076 // clears: a second table, maintained on every write, kept for blobs that have
1077 // since left the tree — real complexity, and an unbounded one, for a field
1078 // that exists so `media status` can say "described twice". Deriving it from
1079 // the rows keeps it honest about what is actually stored, which is the more
1080 // defensible thing for a display counter to be.
1081 let previous: i64 = conn.query_row(
1082 "SELECT COALESCE(MAX(generation), 0) FROM media_content WHERE blob_id = ?1",
1083 [write.blob_id],
1084 |r| r.get(0),
1085 )?;
1086
1087 match (existing, write.replace) {
1088 // Already described by this exact producer, and not forced: leave it.
1089 // This is what makes a second `media build` free.
1090 (Some(_), false) => return Ok(false),
1091 (Some(row), true) => {
1092 conn.execute("DELETE FROM media_content WHERE id = ?1", [row])?;
1093 }
1094 (None, _) => {}
1095 }
1096 let generation = u32::try_from(previous + 1).unwrap_or(u32::MAX);
1097 // A generated record carries text and no measurement; a gated skip carries a
1098 // measurement and — literally — no text. The table's `CHECK` refuses any
1099 // other combination, so this is the only shape that can be written.
1100 let (text, confidence, skip) = match write.outcome {
1101 MediaOutcome::Generated(content) => {
1102 (content.text.as_str(), content.confidence, None::<MediaSkip>)
1103 }
1104 MediaOutcome::Skipped(skip) => ("", None, Some(*skip)),
1105 };
1106 conn.execute(
1107 "INSERT INTO media_content (
1108 blob_id, path, kind, producer, model, model_digest, quantisation, mmproj_digest,
1109 prompt, temperature, max_tokens, tool_version, generation, text, confidence,
1110 skip_reason, skip_value, skip_threshold
1111 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
1112 params![
1113 write.blob_id,
1114 write.path,
1115 write.producer.kind.as_str(),
1116 id.as_str(),
1117 write.producer.model,
1118 write.producer.model_digest,
1119 write.producer.quantisation,
1120 write.producer.mmproj_digest,
1121 write.producer.prompt,
1122 write.producer.temperature,
1123 write.producer.max_tokens,
1124 write.tool_version,
1125 generation,
1126 text,
1127 confidence,
1128 // All three from the SAME `Option`, and `MediaSkip`'s fields are not
1129 // themselves optional — so the trio is all-present or all-absent by
1130 // construction, matching the table's outcome `CHECK`. Sourcing any
1131 // one of them from a different `Option` would make a row with a
1132 // measurement but no reason writable, and such a row reads back as
1133 // *generated content that happens to be empty*. The schema is the
1134 // backstop; this is the guard.
1135 skip.map(|s| s.reason.as_str()),
1136 skip.map(|s| s.value),
1137 skip.map(|s| s.threshold),
1138 ],
1139 )?;
1140 Ok(true)
1141}
1142
1143/// Whether a record exists for exactly this `(blob, producer)`.
1144pub(crate) fn exists(conn: &Connection, blob_id: &str, producer: &str) -> Result<bool, StoreError> {
1145 let n: i64 = conn.query_row(
1146 "SELECT COUNT(*) FROM media_content WHERE blob_id = ?1 AND producer = ?2",
1147 params![blob_id, producer],
1148 |r| r.get(0),
1149 )?;
1150 Ok(n > 0)
1151}
1152
1153/// Records matching `filter`, ordered by `(producer, blob_id)` so output is
1154/// deterministic.
1155pub(crate) fn records(
1156 conn: &Connection,
1157 filter: &MediaFilter<'_>,
1158) -> Result<Vec<MediaRecord>, StoreError> {
1159 let mut where_parts: Vec<&str> = Vec::new();
1160 let mut bound: Vec<String> = Vec::new();
1161 if let Some(producer) = filter.producer {
1162 where_parts.push("m.producer = ?");
1163 bound.push(producer.to_owned());
1164 }
1165 if let Some(kind) = filter.kind {
1166 where_parts.push("m.kind = ?");
1167 bound.push(kind.as_str().to_owned());
1168 }
1169 if let Some(blob) = filter.blob_id {
1170 where_parts.push("m.blob_id = ?");
1171 bound.push(blob.to_owned());
1172 }
1173 let clause = if where_parts.is_empty() {
1174 String::new()
1175 } else {
1176 format!(" WHERE {}", where_parts.join(" AND "))
1177 };
1178 let sql =
1179 format!("SELECT {RECORD_COLS} FROM media_content m{clause} ORDER BY m.producer, m.blob_id");
1180 let mut stmt = conn.prepare(&sql)?;
1181 let mut rows = stmt.query(rusqlite::params_from_iter(bound))?;
1182 let mut out = Vec::new();
1183 while let Some(row) = rows.next()? {
1184 out.push(record_from_row(row)?);
1185 }
1186 Ok(out)
1187}
1188
1189/// Delete every record, or only one producer's. Returns how many rows went.
1190pub(crate) fn delete(conn: &Connection, producer: Option<&str>) -> Result<usize, StoreError> {
1191 let removed = match producer {
1192 Some(id) => conn.execute("DELETE FROM media_content WHERE producer = ?1", [id])?,
1193 None => conn.execute("DELETE FROM media_content", [])?,
1194 };
1195 Ok(removed)
1196}
1197
1198/// Total number of stored records.
1199pub(crate) fn count(conn: &Connection) -> Result<u64, StoreError> {
1200 let n: i64 = conn.query_row("SELECT COUNT(*) FROM media_content", [], |r| r.get(0))?;
1201 Ok(u64::try_from(n).unwrap_or(0))
1202}
1203
1204/// One summary row per producer, ordered by producer id.
1205pub(crate) fn producer_summaries(conn: &Connection) -> Result<Vec<ProducerSummary>, StoreError> {
1206 let mut stmt = conn.prepare(
1207 "SELECT producer, kind, model, quantisation, COUNT(*),
1208 SUM(skip_reason IS NOT NULL), MAX(produced_at)
1209 FROM media_content GROUP BY producer, kind, model, quantisation ORDER BY producer",
1210 )?;
1211 let mut rows = stmt.query([])?;
1212 let mut out = Vec::new();
1213 while let Some(row) = rows.next()? {
1214 let kind_token: String = row.get(1)?;
1215 let kind = MediaKind::from_token(&kind_token)
1216 .ok_or_else(|| StoreError::Corrupt(format!("unknown media kind: {kind_token}")))?;
1217 let records: i64 = row.get(4)?;
1218 let skipped: i64 = row.get(5)?;
1219 out.push(ProducerSummary {
1220 producer_id: ProducerId(row.get(0)?),
1221 kind,
1222 model: row.get(2)?,
1223 quantisation: row.get(3)?,
1224 records: u64::try_from(records).unwrap_or(0),
1225 skipped: u64::try_from(skipped).unwrap_or(0),
1226 latest: row.get(6)?,
1227 });
1228 }
1229 Ok(out)
1230}
1231
1232/// The set of blob ids with at least one record carrying **generated text**, for
1233/// a modality. A gated skip is not a description, so it does not appear here.
1234pub(crate) fn described_blobs(
1235 conn: &Connection,
1236 kind: MediaKind,
1237) -> Result<std::collections::BTreeSet<String>, StoreError> {
1238 blob_ids(
1239 conn,
1240 "SELECT DISTINCT blob_id FROM media_content
1241 WHERE kind = ?1 AND skip_reason IS NULL ORDER BY blob_id",
1242 kind,
1243 )
1244}
1245
1246/// The set of blob ids the gate refused, for a modality. The complement of
1247/// [`described_blobs`] over the records that exist.
1248pub(crate) fn gated_blobs(
1249 conn: &Connection,
1250 kind: MediaKind,
1251) -> Result<std::collections::BTreeSet<String>, StoreError> {
1252 blob_ids(
1253 conn,
1254 "SELECT DISTINCT blob_id FROM media_content
1255 WHERE kind = ?1 AND skip_reason IS NOT NULL ORDER BY blob_id",
1256 kind,
1257 )
1258}
1259
1260/// Run a one-column blob-id query for one modality.
1261fn blob_ids(
1262 conn: &Connection,
1263 sql: &str,
1264 kind: MediaKind,
1265) -> Result<std::collections::BTreeSet<String>, StoreError> {
1266 let mut stmt = conn.prepare(sql)?;
1267 let mut rows = stmt.query([kind.as_str()])?;
1268 let mut out = std::collections::BTreeSet::new();
1269 while let Some(row) = rows.next()? {
1270 out.insert(row.get::<_, String>(0)?);
1271 }
1272 Ok(out)
1273}
1274
1275/// Decode a `media_content` row.
1276fn record_from_row(row: &rusqlite::Row<'_>) -> Result<MediaRecord, StoreError> {
1277 let kind_token: String = row.get(2)?;
1278 let kind = MediaKind::from_token(&kind_token)
1279 .ok_or_else(|| StoreError::Corrupt(format!("unknown media kind: {kind_token}")))?;
1280 let generation: i64 = row.get(12)?;
1281 // `skip_reason` is the discriminant; the table's `CHECK` guarantees its two
1282 // companions are present exactly when it is, so a row that disagrees is
1283 // corruption and is reported as such rather than silently read as generated.
1284 let skip_reason: Option<String> = row.get(16)?;
1285 let outcome = match skip_reason {
1286 Some(token) => {
1287 let reason = GateReason::from_token(&token).ok_or_else(|| {
1288 StoreError::Corrupt(format!("unknown media skip reason: {token}"))
1289 })?;
1290 MediaOutcome::Skipped(MediaSkip {
1291 reason,
1292 value: row.get(17)?,
1293 threshold: row.get(18)?,
1294 })
1295 }
1296 None => MediaOutcome::Generated(GeneratedContent {
1297 text: row.get(14)?,
1298 confidence: row.get(15)?,
1299 }),
1300 };
1301 Ok(MediaRecord {
1302 blob_id: row.get(0)?,
1303 path: row.get(1)?,
1304 producer_id: ProducerId(row.get(3)?),
1305 producer: Producer {
1306 kind,
1307 model: row.get(4)?,
1308 model_digest: row.get(5)?,
1309 quantisation: row.get(6)?,
1310 mmproj_digest: row.get(7)?,
1311 prompt: row.get(8)?,
1312 temperature: row.get(9)?,
1313 max_tokens: row.get(10)?,
1314 },
1315 tool_version: row.get(11)?,
1316 generation: u32::try_from(generation).unwrap_or(u32::MAX),
1317 produced_at: row.get(13)?,
1318 outcome,
1319 })
1320}
1321
1322#[cfg(test)]
1323mod tests {
1324 use super::{
1325 GeneratedContent, MAX_MODEL_ID, MAX_PROMPT, MediaError, MediaKind, Producer,
1326 is_valid_model_id,
1327 };
1328
1329 fn producer() -> Producer {
1330 Producer {
1331 kind: MediaKind::Audio,
1332 model: "voxtral-mini-3b".to_owned(),
1333 model_digest: "4705be8e".to_owned(),
1334 quantisation: "Q4_K_M".to_owned(),
1335 mmproj_digest: "4f24c4ef".to_owned(),
1336 prompt: "Transcribe this audio recording.".to_owned(),
1337 temperature: 0.0,
1338 max_tokens: 512,
1339 }
1340 }
1341
1342 #[test]
1343 fn a_producer_id_names_its_modality_and_model() {
1344 let id = producer().id();
1345 assert!(
1346 id.as_str().starts_with("media:audio:voxtral-mini-3b:"),
1347 "got {id}"
1348 );
1349 // Stable across calls — the identity is a pure function of the fields.
1350 assert_eq!(producer().id(), producer().id());
1351 }
1352
1353 /// Every identity field must move the id. This is the property the whole
1354 /// store rests on: if changing the quantisation left the id alone, a
1355 /// re-describe would silently *skip* instead of writing a new record.
1356 #[test]
1357 fn every_identity_field_changes_the_producer_id() {
1358 /// A named single-field edit to a [`Producer`].
1359 type Mutation = (&'static str, fn(&mut Producer));
1360
1361 let base = producer().id();
1362 let mutate: [Mutation; 7] = [
1363 ("kind", |p| p.kind = MediaKind::Vision),
1364 ("model", |p| p.model = "smolvlm-500m-gguf".to_owned()),
1365 ("model_digest", |p| p.model_digest = "deadbeef".to_owned()),
1366 ("quantisation", |p| p.quantisation = "Q8_0".to_owned()),
1367 ("mmproj_digest", |p| p.mmproj_digest = "cafebabe".to_owned()),
1368 ("prompt", |p| p.prompt = "Describe this.".to_owned()),
1369 ("temperature", |p| p.temperature = 0.2),
1370 ];
1371 for (field, apply) in mutate {
1372 let mut p = producer();
1373 apply(&mut p);
1374 assert_ne!(p.id(), base, "changing {field} must change the producer id");
1375 }
1376 // …including `max_tokens`, which the table above cannot express because it
1377 // is not a `String` field.
1378 let mut p = producer();
1379 p.max_tokens = 256;
1380 assert_ne!(
1381 p.id(),
1382 base,
1383 "changing max_tokens must change the producer id"
1384 );
1385 }
1386
1387 /// The canonical rendering is length-prefixed, so no field can borrow a
1388 /// character from its neighbour to impersonate a different identity.
1389 #[test]
1390 fn adjacent_fields_cannot_be_confused() {
1391 let mut a = producer();
1392 a.model_digest = "ab".to_owned();
1393 a.quantisation = "cd".to_owned();
1394 let mut b = producer();
1395 b.model_digest = "abc".to_owned();
1396 b.quantisation = "d".to_owned();
1397 assert_ne!(a.id(), b.id());
1398 }
1399
1400 #[test]
1401 fn model_ids_accept_the_registry_names_and_reject_separators() {
1402 assert!(is_valid_model_id("voxtral-mini-3b"));
1403 assert!(is_valid_model_id("smolvlm-500m-gguf"));
1404 assert!(!is_valid_model_id(""));
1405 // A `:` would make a producer id ambiguous.
1406 assert!(!is_valid_model_id("a:b"));
1407 assert!(!is_valid_model_id("Voxtral"));
1408 assert!(is_valid_model_id(&"a".repeat(MAX_MODEL_ID)));
1409 assert!(!is_valid_model_id(&"a".repeat(MAX_MODEL_ID + 1)));
1410 }
1411
1412 #[test]
1413 fn validation_names_the_field_it_refused() {
1414 assert!(producer().validate().is_ok());
1415
1416 let mut bad = producer();
1417 bad.model = "Voxtral".to_owned();
1418 assert_eq!(
1419 bad.validate(),
1420 Err(MediaError::InvalidModelId("Voxtral".to_owned()))
1421 );
1422
1423 for (field, apply) in [
1424 (
1425 "model_digest",
1426 (|p: &mut Producer| p.model_digest.clear()) as fn(&mut Producer),
1427 ),
1428 ("quantisation", |p: &mut Producer| p.quantisation.clear()),
1429 ("mmproj_digest", |p: &mut Producer| p.mmproj_digest.clear()),
1430 ("prompt", |p: &mut Producer| p.prompt.clear()),
1431 ] {
1432 let mut p = producer();
1433 apply(&mut p);
1434 let err = p.validate().expect_err("empty field must be refused");
1435 assert!(
1436 err.to_string().contains(field),
1437 "the rejection must name {field}: {err}"
1438 );
1439 }
1440
1441 let mut long = producer();
1442 long.prompt = "x".repeat(MAX_PROMPT + 1);
1443 assert!(
1444 long.validate()
1445 .expect_err("over-long prompt")
1446 .to_string()
1447 .contains("over the")
1448 );
1449
1450 let mut nan = producer();
1451 nan.temperature = f64::NAN;
1452 assert!(
1453 nan.validate()
1454 .expect_err("NaN temperature")
1455 .to_string()
1456 .contains("finite")
1457 );
1458 }
1459
1460 #[test]
1461 fn media_kind_tokens_round_trip() {
1462 for kind in [MediaKind::Audio, MediaKind::Vision] {
1463 assert_eq!(MediaKind::from_token(kind.as_str()), Some(kind));
1464 }
1465 // OCR is not a generative modality, so it has no token here — the
1466 // boundary of ADR-0015 expressed as a type.
1467 assert_eq!(MediaKind::from_token("ocr"), None);
1468 assert_eq!(MediaKind::from_token("nope"), None);
1469 }
1470
1471 #[test]
1472 fn modalities_accept_only_their_own_extensions() {
1473 assert!(MediaKind::Audio.accepts_path("a/clip.wav"));
1474 assert!(MediaKind::Audio.accepts_path("a/clip.MP3"));
1475 assert!(MediaKind::Audio.accepts_path("a/clip.flac"));
1476 assert!(!MediaKind::Audio.accepts_path("a/clip.ogg"));
1477 assert!(!MediaKind::Audio.accepts_path("a/clip.wav.bak"));
1478 assert!(MediaKind::Vision.accepts_path("a/x.png"));
1479 assert!(MediaKind::Vision.accepts_path("a/x.jpeg"));
1480 assert!(!MediaKind::Vision.accepts_path("a/x.gif"));
1481 // No modality claims a document.
1482 assert!(!MediaKind::Audio.accepts_path("a/x.md"));
1483 assert!(!MediaKind::Vision.accepts_path("a/x.md"));
1484 }
1485
1486 #[test]
1487 fn generated_content_omits_an_absent_confidence() {
1488 let bare = GeneratedContent {
1489 text: "hello".to_owned(),
1490 confidence: None,
1491 };
1492 assert_eq!(
1493 serde_json::to_string(&bare).expect("serialize"),
1494 r#"{"text":"hello"}"#
1495 );
1496 }
1497}