Skip to main content

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}