1use std::path::Path;
19use std::time::Duration;
20
21use sha2::{Digest as _, Sha256};
22
23use crate::{Config, Error, SAMPLE_RATE, Stages};
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum Tier {
29 Consensus,
33 ReadAloud,
35 Public,
38}
39
40#[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
49pub struct Clip {
51 pub source: ClipSource,
52 pub audio: Vec<f32>,
53 pub truth: Option<String>,
57 pub recorded: Option<String>,
60}
61
62#[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
70pub struct Corpus {
72 pub clips: Vec<Clip>,
73 pub id: CorpusId,
74}
75
76pub 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
95pub fn install_public_corpus(models_dir: &Path) -> Result<(), Error> {
103 assets::install(&models_dir.join("public-corpus"))
104}
105
106#[derive(serde::Deserialize)]
109struct ManifestClip {
110 file: String,
111 sha256: String,
112 transcript: String,
113}
114
115#[derive(serde::Deserialize)]
120struct Manifest {
121 clips: Vec<ManifestClip>,
122}
123
124pub 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 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
181pub 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
188pub fn takes(dir: &Path) -> Result<Vec<Clip>, Error> {
194 let Ok(entries) = std::fs::read_dir(dir) else {
195 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 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
229pub 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
249pub fn store_read_aloud(dir: &Path, samples: &[f32]) -> Result<(), Error> {
259 store_read_aloud_at(dir, samples, crate::diagnostics::take_stamp())
260}
261
262fn 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 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 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
301fn 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
320const 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
337pub 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
348pub 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
360pub 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
375fn 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
384pub 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
399fn samples_seconds(samples: usize) -> f64 {
401 f64::from(u32::try_from(samples).unwrap_or(u32::MAX)) / f64::from(SAMPLE_RATE)
402}
403
404fn 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
419pub struct ClipRun {
421 pub index: usize,
422 pub text: String,
423 pub stages: Stages,
424 pub wall: Duration,
425 pub matched: Option<bool>,
430}
431
432#[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#[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 pub xrt_wall: f64,
452 pub term_accuracy: Option<f64>,
456 pub wer: Option<f64>,
460 pub matched: Option<(usize, usize)>,
464 pub stages_ms: StageTotals,
465}
466
467struct ClipOutcome {
469 text: String,
470 stages: Stages,
471}
472
473fn 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 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
501pub 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 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 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
596fn 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 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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}