Skip to main content

forge_dictate/
bench.rs

1//! The bench's corpus: the audio a run scores against, taken from this
2//! machine's own material and from the slice the binary carries.
3//!
4//! **Three sources, and what each can say is different.** A saved take is
5//! this machine's own speech with the in-use model's words beside it -
6//! latency on real material, but its `text.txt` is a model's output, not
7//! truth: the consensus tier reads how far the candidate sits from the
8//! model in use, which is a signal and not an error. The read-aloud set is
9//! the one source whose words are known, because they are a passage
10//! somebody read aloud on purpose - only a clip from it can be scored for
11//! term accuracy, and everything here keeps that distinction in the types.
12//!
13//! **The public slice is the exception to "nothing is embedded".** It is a
14//! fixed LibriSpeech test-clean slice carried in the binary and installed
15//! beside the models on first bench, so a machine that never recorded
16//! anything still has known words to score against.
17
18use std::path::Path;
19use std::time::Duration;
20
21use sha2::{Digest as _, Sha256};
22
23use crate::{Config, Error, SAMPLE_RATE, Stages};
24
25/// Which clips a run scores against, in the names the page uses.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum Tier {
29    /// The saved takes: latency on this machine's own dictation, and how
30    /// far the candidate's words sit from the in-use model's on the same
31    /// clips.
32    Consensus,
33    /// The read-aloud set alone: the one tier whose words are known.
34    ReadAloud,
35    /// The bundled public slice: words known like the read-aloud set, and
36    /// material a machine that never recorded anything still has.
37    Public,
38}
39
40/// Where one clip came from.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum ClipSource {
44    Take,
45    ReadAloud,
46    Public,
47}
48
49/// One clip of audio, and whatever is known about it.
50pub struct Clip {
51    pub source: ClipSource,
52    pub audio: Vec<f32>,
53    /// The words a run is scored against where they are KNOWN: the
54    /// read-aloud passage, and nothing else. `None` for a saved take,
55    /// whose `text.txt` is a model's output rather than truth.
56    pub truth: Option<String>,
57    /// The in-use model's own words for this audio, where a take saved
58    /// them.
59    pub recorded: Option<String>,
60}
61
62/// What two runs are comparable by: the corpus's own shape and bytes.
63#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
64pub struct CorpusId {
65    pub clips: usize,
66    pub audio_seconds: u64,
67    pub sha256: String,
68}
69
70/// The audio a run scores against, and its identity.
71pub struct Corpus {
72    pub clips: Vec<Clip>,
73    pub id: CorpusId,
74}
75
76/// Build one tier's corpus from the machine's own material, and the
77/// installed public slice where the tier asks for it.
78pub fn corpus(
79    tier: Tier,
80    takes_dir: &Path,
81    read_aloud_dir: &Path,
82    public_dir: &Path,
83) -> Result<Corpus, Error> {
84    let clips = match tier {
85        Tier::Consensus => takes(takes_dir)?,
86        Tier::ReadAloud => read_aloud(read_aloud_dir)?,
87        Tier::Public => public_corpus(public_dir)?,
88    };
89    let id = corpus_id(&clips);
90    Ok(Corpus { clips, id })
91}
92
93mod assets;
94
95/// Install the bundled public slice into `models_dir`'s `public-corpus`
96/// directory. Idempotent: a slice this binary wrote is left alone, and a
97/// manifest it did not write is replaced by the bundled one.
98///
99/// **First use installs it.** A bench run over the public tier calls this
100/// before reading, so the corpus a machine scores is the one this binary
101/// carries rather than something it has to find.
102pub fn install_public_corpus(models_dir: &Path) -> Result<(), Error> {
103    assets::install(&models_dir.join("public-corpus"))
104}
105
106/// One clip the public manifest names: a wav, its digest, and the words it
107/// was read from.
108#[derive(serde::Deserialize)]
109struct ManifestClip {
110    file: String,
111    sha256: String,
112    transcript: String,
113}
114
115/// The manifest the installed public slice carries beside its clips.
116///
117/// The licence and attribution live in the ATTRIBUTION file the installer
118/// writes beside it, so a reader of the files has them where the clips are.
119#[derive(serde::Deserialize)]
120struct Manifest {
121    clips: Vec<ManifestClip>,
122}
123
124/// The installed public slice: every clip the manifest names, each verified
125/// against its own digest before anything scores it.
126///
127/// A missing manifest is an empty tier; a mismatched digest is a refusal
128/// that names the clip.
129pub fn public_corpus(dir: &Path) -> Result<Vec<Clip>, Error> {
130    let manifest_path = dir.join("manifest.json");
131    let bytes = match std::fs::read(&manifest_path) {
132        Ok(bytes) => bytes,
133        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
134        Err(source) => return Err(Error::Io { path: manifest_path, source }),
135    };
136    let manifest: Manifest = serde_json::from_slice(&bytes)
137        .map_err(|error| Error::Bench { message: format!("public corpus manifest: {error}") })?;
138
139    let mut clips = Vec::with_capacity(manifest.clips.len());
140    for entry in manifest.clips {
141        // A manifest is data, and a path in it must stay inside the corpus:
142        // an absolute or `..` entry would read a file the digest check
143        // cannot vouch for, because the digest comes from the same manifest.
144        let relative = Path::new(&entry.file);
145        if relative.is_absolute()
146            || relative.components().any(|part| matches!(part, std::path::Component::ParentDir))
147        {
148            return Err(Error::Bench {
149                message: format!("{}: the manifest names a path outside the corpus", entry.file),
150            });
151        }
152        let path = dir.join(relative);
153        let audio_bytes = std::fs::read(&path).map_err(|error| Error::Bench {
154            message: format!(
155                "{}: the manifest names a clip that is not there: {error}",
156                entry.file
157            ),
158        })?;
159        let mut hasher = Sha256::new();
160        hasher.update(&audio_bytes);
161        let actual = hex::encode(hasher.finalize());
162        if !actual.eq_ignore_ascii_case(&entry.sha256) {
163            return Err(Error::Bench {
164                message: format!(
165                    "{}: the manifest names sha256 {}, and the file is {actual}",
166                    entry.file, entry.sha256,
167                ),
168            });
169        }
170        let audio = decode_wav(&path)?;
171        clips.push(Clip {
172            source: ClipSource::Public,
173            audio,
174            truth: Some(entry.transcript),
175            recorded: None,
176        });
177    }
178    Ok(clips)
179}
180
181/// Word error rate of `text` against `truth`: edits over the truth's own
182/// words, and no error when the truth has none.
183pub fn wer(text: &str, truth: &str) -> f64 {
184    let truth_words = words(truth);
185    ratio(edits(&words(text), &truth_words), truth_words.len())
186}
187
188/// Every complete take in `dir`, oldest first.
189///
190/// A take whose `meta.json` is absent is one the store was still writing -
191/// that file lands last, which is what makes a directory complete - and it
192/// is skipped rather than half-read.
193pub fn takes(dir: &Path) -> Result<Vec<Clip>, Error> {
194    let Ok(entries) = std::fs::read_dir(dir) else {
195        // A machine that has never recorded has no store, and that is an
196        // empty corpus rather than a failure: the run says so with its
197        // own zero.
198        return Ok(Vec::new());
199    };
200    let mut names: Vec<String> = entries
201        .flatten()
202        .map(|entry| entry.file_name().to_string_lossy().into_owned())
203        .filter(|name| {
204            name.strip_prefix("take-").is_some_and(|rest| {
205                !rest.is_empty() && rest.bytes().all(|byte| byte.is_ascii_digit())
206            })
207        })
208        .collect();
209    // The stamp is zero-padded to 13 digits, so the names sort by recency.
210    names.sort();
211
212    let mut clips = Vec::new();
213    for name in names {
214        let take = dir.join(&name);
215        if !take.join("meta.json").is_file() {
216            continue;
217        }
218        let wav = take.join("output.wav");
219        if !wav.is_file() {
220            continue;
221        }
222        let audio = decode_wav(&wav)?;
223        let recorded = std::fs::read_to_string(take.join("text.txt")).ok();
224        clips.push(Clip { source: ClipSource::Take, audio, truth: None, recorded });
225    }
226    Ok(clips)
227}
228
229/// The read-aloud set: the passage, and every recording of it this machine
230/// has kept - each scored against the passage as its truth.
231///
232/// Empty when nothing is recorded, and empty rather than an error when the
233/// passage stands without a finished recording under it.
234pub fn read_aloud(dir: &Path) -> Result<Vec<Clip>, Error> {
235    let passage = dir.join("passage.txt");
236    if !passage.is_file() {
237        return Ok(Vec::new());
238    }
239    let truth = std::fs::read_to_string(&passage)
240        .map_err(|source| Error::Io { path: passage.clone(), source })?;
241
242    let framed: Vec<Clip> = takes(dir)?;
243    Ok(framed
244        .into_iter()
245        .map(|clip| Clip { source: ClipSource::ReadAloud, truth: Some(truth.clone()), ..clip })
246        .collect())
247}
248
249/// Write one recording into the read-aloud set: the passage read, the take
250/// the words were recorded in, and a manifest naming the wav's own sha256.
251///
252/// Every recording is KEPT: the set is a list, a later recording adds to it
253/// rather than replacing it, and the page drops one by name when its reader
254/// asks. The take's `meta.json` lands last, which is what makes it complete
255/// to [`read_aloud`] - an interrupted write reads as no recording rather
256/// than as a partial one. The outcome is the caller's to report, because the
257/// recording is a press somebody is watching.
258pub fn store_read_aloud(dir: &Path, samples: &[f32]) -> Result<(), Error> {
259    store_read_aloud_at(dir, samples, crate::diagnostics::take_stamp())
260}
261
262/// [`store_read_aloud`] for a caller that brings its own stamp, which is how
263/// two recordings in one millisecond are testable at all.
264fn store_read_aloud_at(dir: &Path, samples: &[f32], stamp: u128) -> Result<(), Error> {
265    std::fs::create_dir_all(dir).map_err(|source| Error::Io { path: dir.to_path_buf(), source })?;
266
267    // Two recordings in one millisecond are two takes, not one: the claim
268    // advances a millisecond per name already taken. See
269    // [`crate::diagnostics::claim_take_dir`] for why the claim is a
270    // `create_dir` and not a probe before one.
271    let (take, take_dir) = crate::diagnostics::claim_take_dir(dir, stamp)
272        .map_err(|source| Error::Io { path: dir.to_path_buf(), source })?;
273    let wav = take_dir.join("output.wav");
274    crate::diagnostics::write_wav(&wav, samples)
275        .map_err(|message| Error::Bench { message: format!("{}: {message}", wav.display()) })?;
276    // The digest is written here, once, rather than hashed again on every
277    // read: a recording's identity is worth a row, not a per-read cost.
278    let meta = serde_json::json!({
279        "duration_ms": u64::try_from(samples.len()).unwrap_or(u64::MAX) / 16,
280        "sha256": crate::diagnostics::sha256_file(&wav),
281    });
282    let bytes = serde_json::to_vec_pretty(&meta)
283        .map_err(|error| Error::Bench { message: format!("the set's meta: {error}") })?;
284    let meta_path = take_dir.join("meta.json");
285    std::fs::write(&meta_path, bytes).map_err(|source| Error::Io { path: meta_path, source })?;
286
287    let passage = dir.join("passage.txt");
288    std::fs::write(&passage, READ_ALOUD_PASSAGE)
289        .map_err(|source| Error::Io { path: passage, source })?;
290    let manifest = serde_json::json!({
291        "sha256": crate::diagnostics::sha256_file(&wav),
292        "take": take,
293    });
294    let bytes = serde_json::to_vec_pretty(&manifest)
295        .map_err(|error| Error::Bench { message: format!("the set's manifest: {error}") })?;
296    let manifest_path = dir.join("manifest.json");
297    std::fs::write(&manifest_path, bytes)
298        .map_err(|source| Error::Io { path: manifest_path, source })
299}
300
301/// The identity two runs are comparable by: the clip count, the seconds of
302/// audio, and a hash over every clip's own samples, so two corpora that
303/// differ by one take cannot share an id.
304fn corpus_id(clips: &[Clip]) -> CorpusId {
305    let mut hasher = Sha256::new();
306    for clip in clips {
307        hasher.update((clip.audio.len() as u64).to_le_bytes());
308        for sample in &clip.audio {
309            hasher.update(sample.to_le_bytes());
310        }
311    }
312    let samples: u64 = clips.iter().map(|clip| clip.audio.len() as u64).sum();
313    CorpusId {
314        clips: clips.len(),
315        audio_seconds: samples / u64::from(SAMPLE_RATE),
316        sha256: hex::encode(hasher.finalize()),
317    }
318}
319
320/// The words a mishearing shows up in, across the whole corpus. A term
321/// survives when it comes out whole and spelled right, case-insensitively -
322/// which is the figure a bench exists to move.
323const KNOWN_TERMS: [&str; 11] = [
324    "playwright",
325    "tauri",
326    "forge",
327    "redb",
328    "mcp",
329    "gguf",
330    "fleurs",
331    "parakeet",
332    "cohere",
333    "granite",
334    "whisper",
335];
336
337/// The passage the read-aloud set is recorded from: the one document whose
338/// words are known, so a run over it can be scored rather than compared.
339/// Read aloud once, kept like a model artifact, and re-used for every
340/// future candidate.
341///
342/// It carries several of the known terms - the words a dictation model
343/// actually gets wrong - so the figure it yields moves for the reason it
344/// exists.
345pub const READ_ALOUD_PASSAGE: &str = "\
346I want the forge session to pick up where it left off, so open the rail and check which agents are still running. The Tauri client talks to the socket on port 8790, and the transcript is folded into turns before the page draws it. Push the Playwright suite after the redb migration lands, then run the gate once more against main. The MCP server hands the tool list to the session at startup, and the GGUF weights are checked by size before any load. If the gateway cannot bind, the accounts card says so and the spawn is refused.";
347
348/// The passage's own terms: every word in it the vocabulary knows, whole
349/// words only, deduplicated, in the passage's order.
350pub fn terms(passage: &str) -> Vec<String> {
351    let mut found: Vec<String> = Vec::new();
352    for word in words(passage) {
353        if KNOWN_TERMS.contains(&word.as_str()) && !found.contains(&word) {
354            found.push(word);
355        }
356    }
357    found
358}
359
360/// Term accuracy and word error rate of `text` against `truth`.
361///
362/// **Term accuracy is the share of `terms` that survived** as whole words -
363/// `playwright` misspelled as `playright` is a miss, in the middle of an
364/// otherwise perfect sentence. WER is Levenshtein edits over words over
365/// the truth's word count; a truth with no words reads as no error rather
366/// than dividing by zero. Neither figure is meaningful for a take, whose
367/// truth is a model's own output; only the read-aloud passage carries
368/// words somebody knows were said.
369pub fn score(text: &str, truth: &str, terms: &[String]) -> (f64, f64) {
370    let survived = terms.iter().filter(|term| words(text).contains(term)).count();
371    let accuracy = ratio(survived, terms.len());
372    (accuracy, wer(text, truth))
373}
374
375/// A string's words: lowercased, split on anything that is not a letter or
376/// a digit, blanks dropped. The one spelling both axes compare on.
377fn words(text: &str) -> Vec<String> {
378    text.split(|c: char| !c.is_alphanumeric())
379        .filter(|word| !word.is_empty())
380        .map(str::to_lowercase)
381        .collect()
382}
383
384/// One count's share of another, `0.0` when there is nothing to divide by.
385///
386/// The conversions go through `u32` because [`f64::from`] only takes
387/// those: every count this module forms is words, terms or clips, and
388/// four billion of any is far past what a corpus or a sentence holds, so
389/// the saturation arm is unreachable in fact and only keeps the function
390/// total.
391pub fn ratio(part: usize, whole: usize) -> f64 {
392    if whole == 0 {
393        return 0.0;
394    }
395    f64::from(u32::try_from(part).unwrap_or(u32::MAX))
396        / f64::from(u32::try_from(whole).unwrap_or(u32::MAX))
397}
398
399/// A count of samples as seconds at the model's own rate.
400fn samples_seconds(samples: usize) -> f64 {
401    f64::from(u32::try_from(samples).unwrap_or(u32::MAX)) / f64::from(SAMPLE_RATE)
402}
403
404/// Word-level Levenshtein distance: the edits between two word sequences.
405fn edits(text: &[String], truth: &[String]) -> usize {
406    let mut previous: Vec<usize> = (0..=truth.len()).collect();
407    let mut current = vec![0_usize; truth.len() + 1];
408    for (row, word) in text.iter().enumerate() {
409        current[0] = row + 1;
410        for (column, against) in truth.iter().enumerate() {
411            let substitute = previous[column] + usize::from(word != against);
412            current[column + 1] = substitute.min(previous[column + 1] + 1).min(current[column] + 1);
413        }
414        std::mem::swap(&mut previous, &mut current);
415    }
416    previous[truth.len()]
417}
418
419/// One clip's outcome, as the callback sees it while a run is out.
420pub struct ClipRun {
421    pub index: usize,
422    pub text: String,
423    pub stages: Stages,
424    pub wall: Duration,
425    /// Whether the run's words are the same as what this clip already
426    /// carries - another model's output, which is the in-use model's own
427    /// text for a saved take. `None` on a read-aloud clip, whose truth is
428    /// a passage rather than a model, and on a take that saved none.
429    pub matched: Option<bool>,
430}
431
432/// Where one run's time went, summed over its clips.
433#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
434pub struct StageTotals {
435    pub model_load_ms: u64,
436    pub resample_ms: u64,
437    pub mel_ms: u64,
438    pub encode_ms: u64,
439    pub decode_ms: u64,
440    pub normalize_ms: u64,
441}
442
443/// What one run measured.
444#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
445pub struct Metrics {
446    pub clips: usize,
447    pub audio_seconds: f64,
448    pub wall_seconds: f64,
449    /// The headline speed: audio seconds per wall second, the feed's own
450    /// axis.
451    pub xrt_wall: f64,
452    /// The share of the passage's known terms that survived. `None`
453    /// without a read-aloud clip, because nothing else has words anybody
454    /// knows were said.
455    pub term_accuracy: Option<f64>,
456    /// Mean word error rate against every clip that carries a truth,
457    /// which the read-aloud and public tiers do: `None` on a tier without
458    /// one.
459    pub wer: Option<f64>,
460    /// How many clips the run agreed with word-for-word, of the clips
461    /// that had something to compare against. The consensus signal;
462    /// `None` when no clip did.
463    pub matched: Option<(usize, usize)>,
464    pub stages_ms: StageTotals,
465}
466
467/// One clip's transcription as the loop needs it.
468struct ClipOutcome {
469    text: String,
470    stages: Stages,
471}
472
473/// Walk the corpus, handing each clip's outcome to `on_clip`; a `Break`
474/// stops the run where it stands.
475fn walk(
476    corpus: &Corpus,
477    mut transcribe: impl FnMut(usize, &Clip) -> Result<ClipOutcome, Error>,
478    mut on_clip: impl FnMut(&ClipRun) -> std::ops::ControlFlow<()>,
479) -> Result<Vec<ClipRun>, Error> {
480    let mut runs = Vec::new();
481    for (index, clip) in corpus.clips.iter().enumerate() {
482        let started = std::time::Instant::now();
483        let outcome = transcribe(index, clip)?;
484        let wall = started.elapsed();
485        let against = match clip.source {
486            // Both carry words somebody knows, not another model's output.
487            ClipSource::ReadAloud | ClipSource::Public => None,
488            ClipSource::Take => clip.recorded.as_ref(),
489        };
490        let matched = against.map(|other| words(&outcome.text) == words(other));
491        let run = ClipRun { index, text: outcome.text, stages: outcome.stages, wall, matched };
492        let stopped = on_clip(&run) == std::ops::ControlFlow::Break(());
493        runs.push(run);
494        if stopped {
495            return Err(Error::Cancelled);
496        }
497    }
498    Ok(runs)
499}
500
501/// Run one corpus through one config, reporting each clip as it lands.
502///
503/// The engine is built and made ready first, so a model that will not load
504/// fails here rather than on the first clip. A `Break` from `on_clip`
505/// returns [`Error::Cancelled`]: what ran is not a result, and the caller
506/// saves nothing.
507pub fn run(
508    cfg: &Config,
509    corpus: &Corpus,
510    mut on_clip: impl FnMut(&ClipRun) -> std::ops::ControlFlow<()>,
511) -> Result<Metrics, Error> {
512    let engine = crate::Engine::new(cfg.clone())?;
513    engine.wait_ready()?;
514
515    let runs = walk(
516        corpus,
517        |index, clip| {
518            let outcome = engine.transcribe(crate::Samples::mono(clip.audio.clone()))?.recv()?;
519            match outcome {
520                crate::Outcome::Transcript(transcript) => {
521                    Ok(ClipOutcome { text: transcript.text, stages: transcript.stages })
522                }
523                // A clip with no audio is the corpus's problem rather than
524                // recognition's: the material is supposed to be speech.
525                other @ crate::Outcome::NoAudio { .. } => Err(Error::Bench {
526                    message: format!("clip {index} produced no transcript: {other:?}"),
527                }),
528            }
529        },
530        &mut on_clip,
531    )?;
532    if runs.is_empty() {
533        return Err(Error::Bench { message: "the corpus has no clips to run".to_owned() });
534    }
535
536    // The read-aloud passage is the one corpus scored by term accuracy; a
537    // take's truth is another model's output.
538    let term_accuracy =
539        corpus.clips.iter().position(|clip| clip.source == ClipSource::ReadAloud).and_then(
540            |index| {
541                let truth = corpus.clips.get(index)?.truth.clone()?;
542                let passage_terms = terms(&truth);
543                runs.iter()
544                    .find(|run| run.index == index)
545                    .map(|run| score(&run.text, &truth, &passage_terms).0)
546            },
547        );
548
549    let audio_samples: usize = corpus.clips.iter().map(|clip| clip.audio.len()).sum();
550    let audio_seconds = samples_seconds(audio_samples);
551    let wall_seconds: f64 = runs.iter().map(|run| run.wall.as_secs_f64()).sum();
552
553    let ms = |d: Duration| u64::try_from(d.as_millis()).unwrap_or(u64::MAX);
554    let mut stages_ms = StageTotals::default();
555    for run in &runs {
556        stages_ms.model_load_ms += ms(run.stages.model_load);
557        stages_ms.resample_ms += ms(run.stages.resample);
558        stages_ms.mel_ms += ms(run.stages.mel);
559        stages_ms.encode_ms += ms(run.stages.encode);
560        stages_ms.decode_ms += ms(run.stages.decode);
561        stages_ms.normalize_ms += run.stages.normalize.map_or(0, ms);
562    }
563
564    let mut edits_total = 0_usize;
565    let mut truth_total = 0_usize;
566    for run in &runs {
567        let Some(clip) = corpus.clips.get(run.index) else { continue };
568        let Some(truth) = clip.truth.as_deref() else { continue };
569        let truth_words = words(truth);
570        if truth_words.is_empty() {
571            continue;
572        }
573        edits_total += edits(&words(&run.text), &truth_words);
574        truth_total += truth_words.len();
575    }
576    let wer = (truth_total > 0).then(|| ratio(edits_total, truth_total));
577
578    let scored: Vec<&ClipRun> = runs.iter().filter(|run| run.matched.is_some()).collect();
579    let matched = (!scored.is_empty()).then(|| {
580        let agreed = scored.iter().filter(|run| run.matched == Some(true)).count();
581        (agreed, scored.len())
582    });
583
584    Ok(Metrics {
585        clips: runs.len(),
586        audio_seconds,
587        wall_seconds,
588        xrt_wall: if wall_seconds > 0.0 { audio_seconds / wall_seconds } else { 0.0 },
589        term_accuracy,
590        wer,
591        matched,
592        stages_ms,
593    })
594}
595
596/// Decode one 16-bit mono wav at the model's own rate, refusing anything
597/// else by name rather than resampling it.
598fn decode_wav(path: &Path) -> Result<Vec<f32>, Error> {
599    let reader = hound::WavReader::open(path)
600        .map_err(|error| Error::Bench { message: format!("{}: {error}", path.display()) })?;
601    decode_reader(reader, &path.display().to_string())
602}
603
604fn decode_reader<R: std::io::Read>(
605    mut reader: hound::WavReader<R>,
606    name: &str,
607) -> Result<Vec<f32>, Error> {
608    let spec = reader.spec();
609    if spec.sample_rate != SAMPLE_RATE {
610        return Err(Error::SampleRate { expected: SAMPLE_RATE, actual: spec.sample_rate });
611    }
612    if spec.channels != 1 {
613        return Err(Error::Channels { actual: spec.channels });
614    }
615    let samples: Result<Vec<f32>, _> = reader
616        .samples::<i16>()
617        .map(|sample| sample.map(|value| f32::from(value) / 32768.0))
618        .collect();
619    samples.map_err(|error| Error::Bench { message: format!("{name}: {error}") })
620}
621
622#[cfg(test)]
623mod tests {
624    use super::*;
625
626    /// One complete take, the way the store writes one: the wav, the text,
627    /// and the meta that lands last.
628    fn write_take(dir: &Path, stamp: u128, samples: usize, text: &str) {
629        let take = dir.join(format!("take-{stamp:013}"));
630        std::fs::create_dir_all(&take).unwrap();
631        write_wav(&take.join("output.wav"), samples);
632        std::fs::write(take.join("text.txt"), text).unwrap();
633        std::fs::write(take.join("meta.json"), "{}").unwrap();
634    }
635
636    fn write_wav(path: &Path, samples: usize) {
637        let spec = hound::WavSpec {
638            channels: 1,
639            sample_rate: SAMPLE_RATE,
640            bits_per_sample: 16,
641            sample_format: hound::SampleFormat::Int,
642        };
643        let mut writer = hound::WavWriter::create(path, spec).unwrap();
644        for n in 0..samples {
645            writer.write_sample(i16::try_from(n % 100).unwrap()).unwrap();
646        }
647        writer.finalize().unwrap();
648    }
649
650    /// A take is complete when its meta landed; anything else is a store
651    /// mid-write and is skipped rather than half-read. Order is the
652    /// stamp's, oldest first.
653    #[test]
654    fn reads_every_complete_take_oldest_first_and_skips_incomplete_ones() {
655        let dir = tempfile::tempdir().unwrap();
656        write_take(dir.path(), 2, 4, "second");
657        write_take(dir.path(), 1, 4, "first");
658        // Incomplete: the wav and text are there, the meta is not.
659        let half = dir.path().join("take-0000000000003");
660        std::fs::create_dir_all(&half).unwrap();
661        write_wav(&half.join("output.wav"), 4);
662        std::fs::write(half.join("text.txt"), "half").unwrap();
663        // Foreign: a directory sharing the prefix that is not a take.
664        std::fs::create_dir_all(dir.path().join("take-2026-photos")).unwrap();
665
666        let clips = takes(dir.path()).unwrap();
667
668        assert_eq!(clips.len(), 2, "one clip per complete take");
669        assert_eq!(clips[0].recorded.as_deref(), Some("first"), "oldest first");
670        assert_eq!(clips[1].recorded.as_deref(), Some("second"));
671        assert_eq!(clips[0].audio.len(), 4, "the wav decodes whole");
672        assert!(clips.iter().all(|clip| clip.source == ClipSource::Take));
673        assert!(
674            clips.iter().all(|clip| clip.truth.is_none()),
675            "a take's own text is a model's output, not truth"
676        );
677    }
678
679    /// A machine that never recorded has no store, and that is an empty
680    /// corpus rather than a failure.
681    #[test]
682    fn a_store_that_is_not_there_is_empty_rather_than_an_error() {
683        let dir = tempfile::tempdir().unwrap();
684        let none = dir.path().join("never-created");
685
686        assert!(takes(&none).unwrap().is_empty());
687    }
688
689    /// The corpus's identity moves with its clips: the count, the seconds,
690    /// and a hash over the samples, so two takes of different audio cannot
691    /// share one.
692    #[test]
693    fn a_corpus_id_follows_its_clips() {
694        let dir = tempfile::tempdir().unwrap();
695        write_take(dir.path(), 1, 16_000, "one");
696        let empty = tempfile::tempdir().unwrap();
697
698        let one = corpus(Tier::Consensus, dir.path(), empty.path(), empty.path()).unwrap();
699        let again = corpus(Tier::Consensus, dir.path(), empty.path(), empty.path()).unwrap();
700        assert_eq!(one.id, again.id, "the same clips are the same corpus");
701        assert_eq!(one.id.clips, 1);
702        assert_eq!(one.id.audio_seconds, 1, "16k samples is one second");
703
704        write_take(dir.path(), 2, 8_000, "two");
705        let two = corpus(Tier::Consensus, dir.path(), empty.path(), empty.path()).unwrap();
706        assert_ne!(one.id.sha256, two.id.sha256, "one more take is a different corpus");
707        assert_eq!(two.id.clips, 2, "the tier is the machine's own takes and nothing else");
708
709        let gold = corpus(Tier::ReadAloud, empty.path(), empty.path(), empty.path()).unwrap();
710        assert!(gold.clips.is_empty(), "no passage recorded, no read-aloud tier");
711    }
712
713    /// **A recording joins the set, and a later one adds to it.** The take
714    /// carries the wav and its meta - what the reader needs to see it as
715    /// complete - and the manifest names the wav's own sha.
716    #[test]
717    fn recordings_join_the_set_and_a_later_one_adds_to_it() {
718        let dir = tempfile::tempdir().unwrap();
719        let audio = vec![0.25_f32; 1_600];
720
721        store_read_aloud(dir.path(), &audio).expect("the set writes");
722        let clips = read_aloud(dir.path()).expect("the set reads");
723        assert_eq!(clips.len(), 1, "the first recording is the whole set");
724        assert_eq!(clips[0].source, ClipSource::ReadAloud);
725        assert_eq!(clips[0].truth.as_deref(), Some(READ_ALOUD_PASSAGE));
726        assert_eq!(clips[0].audio.len(), audio.len(), "the samples the recording carried");
727        let first = std::fs::read_dir(dir.path())
728            .unwrap()
729            .flatten()
730            .map(|entry| entry.file_name().to_string_lossy().into_owned())
731            .find(|name| name.starts_with("take-"))
732            .expect("the take is beside the passage");
733        let manifest: serde_json::Value = serde_json::from_str(
734            &std::fs::read_to_string(dir.path().join("manifest.json")).unwrap(),
735        )
736        .unwrap();
737        assert!(
738            manifest["sha256"].as_str().is_some_and(|sha| sha.len() == 64),
739            "the manifest names the wav's own sha, got {manifest}"
740        );
741
742        store_read_aloud(dir.path(), &audio[..800]).expect("a second recording writes");
743        let clips = read_aloud(dir.path()).unwrap();
744        assert_eq!(clips.len(), 2, "a later recording adds to the set");
745        assert!(dir.path().join(&first).exists(), "the first recording stays");
746        assert_eq!(clips[0].audio.len(), audio.len(), "oldest first");
747        assert_eq!(clips[1].audio.len(), 800, "the newest recording is the last clip");
748        assert!(
749            clips.iter().all(|clip| clip.truth.as_deref() == Some(READ_ALOUD_PASSAGE)),
750            "every recording is scored against the one passage"
751        );
752    }
753
754    /// **Two recordings in one millisecond are two takes.** The name is the
755    /// clock's, so a set that stored both under one name kept only the later -
756    /// the flake #1920 was filed for, pinned by handing both the same stamp.
757    #[test]
758    fn two_recordings_in_one_millisecond_are_two_takes() {
759        let dir = tempfile::tempdir().unwrap();
760        let audio = vec![0.25_f32; 1_600];
761
762        store_read_aloud_at(dir.path(), &audio, 42).expect("the first writes");
763        store_read_aloud_at(dir.path(), &audio[..800], 42).expect("a second writes");
764
765        let clips = read_aloud(dir.path()).expect("the set reads");
766        assert_eq!(clips.len(), 2, "the same-millisecond take did not replace the first");
767        assert_eq!(clips[0].audio.len(), audio.len(), "oldest first");
768        assert_eq!(clips[1].audio.len(), 800, "and the newest is the last clip");
769    }
770
771    /// **Term accuracy counts the passage's terms that survived.** A miss
772    /// lands in the middle of an otherwise perfect sentence - the shape a
773    /// misheard proper noun actually has - and moves the figure; nothing
774    /// else does.
775    /// The passage carries terms, and enough of them: a gold set whose
776    /// text has nothing a model can get wrong would measure nothing.
777    #[test]
778    fn the_read_aloud_passage_carries_terms_worth_scoring() {
779        let found = terms(READ_ALOUD_PASSAGE);
780
781        assert!(found.len() >= 5, "the passage must carry several known terms, found: {found:?}");
782        assert!(found.contains(&"playwright".to_owned()));
783        assert!(found.contains(&"gguf".to_owned()));
784    }
785
786    #[test]
787    fn term_accuracy_counts_the_passages_terms_that_survived() {
788        let truth = "Push the Playwright suite and then check redb.";
789        let found = terms(truth);
790        assert_eq!(found.len(), 2, "the passage names two known terms: {found:?}");
791
792        let (accuracy, _) = score("Push the Playright suite and then check redb.", truth, &found);
793        assert!((accuracy - 0.5).abs() < f64::EPSILON, "one of two survived, got {accuracy}");
794
795        let (perfect, _) = score(truth, truth, &found);
796        assert!((perfect - 1.0).abs() < f64::EPSILON);
797    }
798
799    /// WER is edits over the truth's words, by word.
800    #[test]
801    fn wer_is_edits_over_the_passages_words() {
802        let (_, wer) = score("one two three", "one two four three", &[]);
803
804        assert!((wer - 0.25).abs() < 1e-9, "one insertion in four words, got {wer}");
805    }
806
807    /// A `Break` from the callback stops the walk where it stands, and the
808    /// caller hears a cancellation rather than a truncated result.
809    #[test]
810    fn a_break_from_the_callback_stops_the_loop_early() {
811        let dir = tempfile::tempdir().unwrap();
812        write_take(dir.path(), 1, 8, "one");
813        write_take(dir.path(), 2, 8, "two");
814        write_take(dir.path(), 3, 8, "three");
815        let empty = tempfile::tempdir().unwrap();
816        let built = corpus(Tier::Consensus, dir.path(), empty.path(), empty.path()).unwrap();
817
818        let mut called = 0;
819        let mut transcribed = 0;
820        let outcome = walk(
821            &built,
822            |_index, _clip| {
823                transcribed += 1;
824                Ok(ClipOutcome { text: "word".to_owned(), stages: Stages::default() })
825            },
826            |run| {
827                called += 1;
828                if run.index == 1 {
829                    std::ops::ControlFlow::Break(())
830                } else {
831                    std::ops::ControlFlow::Continue(())
832                }
833            },
834        );
835
836        assert!(matches!(outcome, Err(Error::Cancelled)), "a stop is a cancellation");
837        assert_eq!(called, 2, "the callback saw the clip that broke and no later one");
838        assert_eq!(transcribed, 2, "and nothing was transcribed past the break");
839    }
840
841    /// The agreement axis: a take is compared against the in-use model's
842    /// own recorded words, word-for-word once case and punctuation are
843    /// out of the way; a read-aloud clip carries no comparison at all.
844    #[test]
845    fn a_clip_is_matched_against_the_other_models_words_not_the_passage() {
846        let dir = tempfile::tempdir().unwrap();
847        write_take(dir.path(), 1, 8, "Hello, world!");
848        write_take(dir.path(), 2, 8, "Something else entirely.");
849        let empty = tempfile::tempdir().unwrap();
850        let built = corpus(Tier::Consensus, dir.path(), empty.path(), empty.path()).unwrap();
851
852        let runs = walk(
853            &built,
854            |_index, clip| {
855                let text = if clip.recorded.as_deref() == Some("Hello, world!") {
856                    "hello world".to_owned()
857                } else {
858                    "different words".to_owned()
859                };
860                Ok(ClipOutcome { text, stages: Stages::default() })
861            },
862            |_| std::ops::ControlFlow::Continue(()),
863        )
864        .unwrap();
865
866        assert_eq!(runs[0].matched, Some(true), "case and punctuation are out of the way");
867        assert_eq!(runs[1].matched, Some(false));
868
869        let passage = Clip {
870            source: ClipSource::ReadAloud,
871            audio: vec![0.0; 8],
872            truth: Some("a passage".to_owned()),
873            recorded: None,
874        };
875        let gold = Corpus { id: corpus_id(std::slice::from_ref(&passage)), clips: vec![passage] };
876        let runs = walk(
877            &gold,
878            |_index, _clip| {
879                Ok(ClipOutcome { text: "a passage".to_owned(), stages: Stages::default() })
880            },
881            |_| std::ops::ControlFlow::Continue(()),
882        )
883        .unwrap();
884
885        assert_eq!(runs[0].matched, None, "a passage is scored by term accuracy, not by agreement");
886    }
887
888    /// One public corpus as the installer leaves it: the manifest, and the
889    /// wav files it names with their digests.
890    fn write_public(dir: &Path, clips: &[(&str, &str)]) {
891        std::fs::create_dir_all(dir).unwrap();
892        let mut entries = Vec::new();
893        for (name, transcript) in clips {
894            write_wav(&dir.join(name), 8);
895            let bytes = std::fs::read(dir.join(name)).unwrap();
896            let mut hasher = Sha256::new();
897            hasher.update(&bytes);
898            let digest = hex::encode(hasher.finalize());
899            entries.push(serde_json::json!({
900                "file": name,
901                "sha256": digest,
902                "transcript": transcript,
903            }));
904        }
905        let manifest = serde_json::json!({
906            "license": "CC BY 4.0",
907            "attribution": "LibriSpeech test-clean, OpenSLR",
908            "source": "https://www.openslr.org/12",
909            "clips": entries,
910        });
911        std::fs::write(dir.join("manifest.json"), manifest.to_string()).unwrap();
912    }
913
914    /// A digest is the whole guard on an installed clip: a file whose bytes
915    /// are not the ones the manifest names is refused by the clip's own name,
916    /// not scored.
917    #[test]
918    fn a_corpus_clip_with_a_bad_digest_is_refused_by_name() {
919        let dir = tempfile::tempdir().unwrap();
920        write_public(dir.path(), &[("clip-0001.wav", "the quick brown fox")]);
921        let built = corpus(Tier::Public, dir.path(), dir.path(), dir.path()).unwrap();
922        assert_eq!(built.clips.len(), 1);
923
924        // Rewrite the audio under the same name: the manifest still names
925        // the digest of the bytes that were there.
926        write_wav(&dir.path().join("clip-0001.wav"), 12);
927        let Err(error) = corpus(Tier::Public, dir.path(), dir.path(), dir.path()) else {
928            panic!("a clip whose bytes are not the manifest's must be refused");
929        };
930        assert!(
931            error.to_string().contains("clip-0001.wav"),
932            "the refusal names the clip it could not trust: {error}",
933        );
934    }
935
936    /// A manifest that names a clip the directory does not hold is a refusal,
937    /// not a clip silently skipped: a partial install would otherwise score
938    /// as if it were whole.
939    #[test]
940    fn a_manifest_that_names_a_missing_clip_is_refused_by_name() {
941        let dir = tempfile::tempdir().unwrap();
942        write_public(dir.path(), &[("clip-0001.wav", "the quick brown fox")]);
943        std::fs::remove_file(dir.path().join("clip-0001.wav")).unwrap();
944
945        let Err(error) = corpus(Tier::Public, dir.path(), dir.path(), dir.path()) else {
946            panic!("a manifest naming a missing clip must be refused");
947        };
948        assert!(error.to_string().contains("clip-0001.wav"), "got: {error}");
949    }
950
951    /// A manifest is data, and a path in it must stay inside the corpus.
952    #[test]
953    fn a_manifest_that_names_a_path_outside_the_corpus_is_refused() {
954        let dir = tempfile::tempdir().unwrap();
955        let manifest = serde_json::json!({
956            "clips": [{ "file": "../escape.wav", "sha256": "00", "transcript": "x" }],
957        });
958        std::fs::write(dir.path().join("manifest.json"), manifest.to_string()).unwrap();
959
960        let Err(error) = corpus(Tier::Public, dir.path(), dir.path(), dir.path()) else {
961            panic!("a path outside the corpus must be refused");
962        };
963        assert!(error.to_string().contains("outside the corpus"), "got: {error}");
964    }
965
966    /// A public clip carries words somebody wrote down, so the tier is the
967    /// one a machine with no recordings still has something to score.
968    #[test]
969    fn the_public_tier_scores_a_known_transcript() {
970        let dir = tempfile::tempdir().unwrap();
971        write_public(dir.path(), &[("clip-0001.wav", "the quick brown fox")]);
972        let built = corpus(Tier::Public, dir.path(), dir.path(), dir.path()).unwrap();
973        let clip = &built.clips[0];
974        assert_eq!(clip.source, ClipSource::Public);
975        assert_eq!(clip.truth.as_deref(), Some("the quick brown fox"));
976        assert!(clip.recorded.is_none(), "a public clip has no model output beside it");
977
978        let truth = clip.truth.as_deref().unwrap_or_default();
979        assert_eq!(wer("the quick brown fox", truth), 0.0, "the exact words are no error");
980        assert!(wer("the quick brown fix", truth) > 0.0, "a changed word is an edit");
981    }
982
983    /// A machine where the slice was never installed has an empty tier
984    /// rather than a failure: the run says so with its own zero.
985    #[test]
986    fn an_absent_public_corpus_is_empty() {
987        let dir = tempfile::tempdir().unwrap();
988        let built = corpus(Tier::Public, dir.path(), dir.path(), dir.path()).unwrap();
989        assert!(built.clips.is_empty());
990    }
991
992    /// **The slice ships in the binary.** A machine with no corpus gets it
993    /// installed beside the models on first use, and the installed corpus
994    /// reads back whole - from the binary, not from the repo tree.
995    #[test]
996    fn the_bundled_public_corpus_installs_beside_the_models() {
997        let dir = tempfile::tempdir().unwrap();
998        let models = dir.path().join("models");
999
1000        install_public_corpus(&models).expect("the bundled slice installs");
1001
1002        let corpus =
1003            public_corpus(&models.join("public-corpus")).expect("the installed slice reads");
1004        assert_eq!(corpus.len(), 32, "the whole slice lands");
1005        assert!(
1006            corpus.iter().all(|clip| clip.truth.as_deref().is_some_and(|text| !text.is_empty())),
1007            "every clip carries the words it was read from"
1008        );
1009        let attribution =
1010            std::fs::read_to_string(models.join("public-corpus").join("ATTRIBUTION.md"))
1011                .expect("the attribution lands with the clips");
1012        assert!(attribution.contains("LibriSpeech test-clean"));
1013        assert!(attribution.contains("CC BY 4.0"));
1014    }
1015
1016    /// A second install over the slice it wrote is a no-op: the corpus is
1017    /// the asset's, not a per-run copy.
1018    #[test]
1019    fn a_second_install_leaves_the_slice_alone() {
1020        let dir = tempfile::tempdir().unwrap();
1021        let models = dir.path().join("models");
1022        install_public_corpus(&models).unwrap();
1023        let clip = models.join("public-corpus").join("clip-0000.wav");
1024        let before = std::fs::read(&clip).unwrap();
1025
1026        install_public_corpus(&models).unwrap();
1027
1028        assert_eq!(std::fs::read(&clip).unwrap(), before);
1029    }
1030
1031    /// **A manifest the bundled slice did not write is replaced.** An older
1032    /// slice, or one a person placed, is not this binary's corpus; installing
1033    /// again leaves the clips the binary carries.
1034    #[test]
1035    fn a_foreign_manifest_is_replaced_by_the_bundled_slice() {
1036        let dir = tempfile::tempdir().unwrap();
1037        install_public_corpus(dir.path()).unwrap();
1038        std::fs::write(dir.path().join("public-corpus").join("manifest.json"), "{}").unwrap();
1039
1040        install_public_corpus(dir.path()).unwrap();
1041
1042        let corpus = public_corpus(&dir.path().join("public-corpus")).unwrap();
1043        assert_eq!(corpus.len(), 32, "the bundled manifest came back");
1044    }
1045
1046    /// The installed clips stay digest-guarded: a clip whose bytes changed
1047    /// under the manifest is refused by its own name.
1048    #[test]
1049    fn a_tampered_installed_clip_is_refused_by_name() {
1050        let dir = tempfile::tempdir().unwrap();
1051        install_public_corpus(dir.path()).unwrap();
1052        std::fs::write(dir.path().join("public-corpus").join("clip-0003.wav"), b"not audio")
1053            .unwrap();
1054
1055        let Err(error) = public_corpus(&dir.path().join("public-corpus")) else {
1056            panic!("a tampered clip must be refused");
1057        };
1058        assert!(error.to_string().contains("clip-0003.wav"), "got: {error}");
1059    }
1060}