1pub mod observatory;
10pub mod perf;
11pub mod tts_listening;
12
13pub use observatory::{
14 allowed_mean_wer, budget_exit_code, compare_stt_budget, observatory_core_budget_tiny,
15 observatory_core_corpus, score_observatory_fixture, AssetResolution, BudgetComparison,
16 BudgetFinding, BudgetSeverity, CorpusCoverage, ObservatoryCorpus, ObservatoryFixture,
17 ObservatoryFixtureScore, ObservatoryReport, ObservatoryScoreExtras, RunIdentity, SttBudget,
18 NORMALIZATION_POLICY_VERSION, OBSERVATORY_SCHEMA_VERSION, STT_OBSERVATORY_EVIDENCE_VERSION,
19};
20pub use perf::{
21 compare_perf_budget, percentile_sorted, perf_budget_exit_code, perf_scenario_catalogue,
22 tier_a_profile_templates, HardwareTier, NamedHardwareProfile, PerfBudget, PerfComparison,
23 PerfFinding, PerfReport, PerfScenario, PerfScenarioBudget, PerfScenarioResult, PerfSeverity,
24 PERF_EVIDENCE_VERSION, PERF_SCHEMA_VERSION,
25};
26pub use tts_listening::{
27 aggregate_listening, evaluate_support_tier, join_discontinuity_score, score_tts_pcm,
28 tts_local_matrix, tts_production_pack, ListeningAggregate, ListeningRating, ListeningReport,
29 SupportTierDecision, TtsEvalFixture, TtsEvalPack, TtsEvalParticipation, TtsObjectiveReport,
30 TtsObjectiveScore, TtsObjectiveThresholds, TtsRunIdentity, TTS_EVAL_SCHEMA_VERSION,
31 TTS_EVIDENCE_VERSION,
32};
33
34use serde::{Deserialize, Serialize};
35
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
38pub struct SttFixture {
39 pub id: String,
40 pub language: String,
41 pub reference: String,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub audio: Option<String>,
46 #[serde(default)]
48 pub tags: Vec<String>,
49 #[serde(default = "default_true")]
51 pub timestamps_expected_reliable: bool,
52 #[serde(default)]
54 pub license: String,
55}
56
57fn default_true() -> bool {
58 true
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
63pub struct TtsFixture {
64 pub id: String,
65 pub text: String,
66 #[serde(default)]
67 pub tags: Vec<String>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub duration_ms_min: Option<u64>,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub duration_ms_max: Option<u64>,
73 #[serde(default)]
74 pub license: String,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
79pub struct EvalCorpus {
80 pub version: u32,
81 pub name: String,
82 #[serde(default)]
83 pub stt: Vec<SttFixture>,
84 #[serde(default)]
85 pub tts: Vec<TtsFixture>,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
90pub struct SttScore {
91 pub fixture_id: String,
92 pub wer: f64,
93 pub cer: f64,
94 pub empty_hypothesis: bool,
95 pub ref_words: usize,
96 pub hyp_words: usize,
97 pub timestamps_reliable: bool,
99 #[serde(default)]
101 pub silence_false_positive: bool,
102 #[serde(default)]
104 pub repetition_ratio: f64,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
109pub struct EvalReport {
110 pub corpus_version: u32,
111 pub corpus_name: String,
112 pub model: String,
113 pub backend_kind: String,
114 pub stt_scores: Vec<SttScore>,
115 pub mean_wer: f64,
116 pub mean_cer: f64,
117 #[serde(default)]
119 pub silence_false_positives: u32,
120 #[serde(default)]
122 pub mean_repetition_ratio: f64,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub hardware_profile: Option<String>,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub notes: Option<String>,
128}
129
130pub fn normalize_transcript(s: &str) -> String {
132 let mut out = String::with_capacity(s.len());
133 let mut last_space = true;
134 for ch in s.chars() {
135 if ch.is_alphanumeric() {
136 for c in ch.to_lowercase() {
137 out.push(c);
138 }
139 last_space = false;
140 } else if ch.is_whitespace() && !last_space {
141 out.push(' ');
142 last_space = true;
143 }
144 }
146 out.trim().to_string()
147}
148
149fn words(s: &str) -> Vec<&str> {
150 s.split_whitespace().filter(|w| !w.is_empty()).collect()
151}
152
153pub fn word_error_rate(reference: &str, hypothesis: &str) -> f64 {
155 let r = normalize_transcript(reference);
156 let h = normalize_transcript(hypothesis);
157 let rw = words(&r);
158 let hw = words(&h);
159 if rw.is_empty() {
160 return if hw.is_empty() { 0.0 } else { 1.0 };
161 }
162 let dist = levenshtein(&rw, &hw);
163 dist as f64 / rw.len() as f64
164}
165
166pub fn char_error_rate(reference: &str, hypothesis: &str) -> f64 {
168 let r: Vec<char> = normalize_transcript(reference).chars().collect();
169 let h: Vec<char> = normalize_transcript(hypothesis).chars().collect();
170 if r.is_empty() {
171 return if h.is_empty() { 0.0 } else { 1.0 };
172 }
173 let dist = levenshtein(&r, &h);
174 dist as f64 / r.len() as f64
175}
176
177fn levenshtein<T: PartialEq>(a: &[T], b: &[T]) -> usize {
178 let n = a.len();
179 let m = b.len();
180 if n == 0 {
181 return m;
182 }
183 if m == 0 {
184 return n;
185 }
186 let mut prev: Vec<usize> = (0..=m).collect();
187 let mut curr = vec![0; m + 1];
188 for i in 1..=n {
189 curr[0] = i;
190 for j in 1..=m {
191 let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
192 curr[j] = (prev[j] + 1) .min(curr[j - 1] + 1) .min(prev[j - 1] + cost); }
196 std::mem::swap(&mut prev, &mut curr);
197 }
198 prev[m]
199}
200
201pub fn silence_false_positive(reference: &str, hypothesis: &str) -> bool {
203 normalize_transcript(reference).is_empty() && !normalize_transcript(hypothesis).is_empty()
204}
205
206pub fn repetition_ratio(hypothesis: &str) -> f64 {
208 let normalized = normalize_transcript(hypothesis);
209 let hw = words(&normalized);
210 if hw.is_empty() {
211 return 0.0;
212 }
213 let mut best = 1usize;
214 let mut run = 1usize;
215 for w in hw.windows(2) {
216 if w[0] == w[1] {
217 run += 1;
218 best = best.max(run);
219 } else {
220 run = 1;
221 }
222 }
223 best as f64 / hw.len() as f64
224}
225
226pub fn score_stt(fixture: &SttFixture, hypothesis: &str, timestamps_reliable: bool) -> SttScore {
228 let wer = word_error_rate(&fixture.reference, hypothesis);
229 let cer = char_error_rate(&fixture.reference, hypothesis);
230 let hyp_n = words(&normalize_transcript(hypothesis)).len();
231 let ref_n = words(&normalize_transcript(&fixture.reference)).len();
232 SttScore {
233 fixture_id: fixture.id.clone(),
234 wer,
235 cer,
236 empty_hypothesis: hypothesis.trim().is_empty(),
237 ref_words: ref_n,
238 hyp_words: hyp_n,
239 timestamps_reliable,
240 silence_false_positive: silence_false_positive(&fixture.reference, hypothesis),
241 repetition_ratio: repetition_ratio(hypothesis),
242 }
243}
244
245pub fn build_report(
247 corpus: &EvalCorpus,
248 model: &str,
249 backend_kind: &str,
250 scores: Vec<SttScore>,
251) -> EvalReport {
252 let n = scores.len().max(1) as f64;
253 let mean_wer = scores.iter().map(|s| s.wer).sum::<f64>() / n;
254 let mean_cer = scores.iter().map(|s| s.cer).sum::<f64>() / n;
255 let silence_false_positives = scores.iter().filter(|s| s.silence_false_positive).count() as u32;
256 let rep_scores: Vec<f64> = scores
257 .iter()
258 .filter(|s| s.hyp_words > 0)
259 .map(|s| s.repetition_ratio)
260 .collect();
261 let mean_repetition_ratio = if rep_scores.is_empty() {
262 0.0
263 } else {
264 rep_scores.iter().sum::<f64>() / rep_scores.len() as f64
265 };
266 EvalReport {
267 corpus_version: corpus.version,
268 corpus_name: corpus.name.clone(),
269 model: model.into(),
270 backend_kind: backend_kind.into(),
271 stt_scores: scores,
272 mean_wer,
273 mean_cer,
274 silence_false_positives,
275 mean_repetition_ratio,
276 hardware_profile: None,
277 notes: None,
278 }
279}
280
281pub fn smoke_corpus() -> EvalCorpus {
286 EvalCorpus {
287 version: 2,
288 name: "aurum-smoke-v2".into(),
289 stt: vec![
290 SttFixture {
291 id: "clean_short_en".into(),
292 language: "en".into(),
293 reference: "hello world".into(),
294 audio: None,
295 tags: vec!["clean".into(), "short".into()],
296 timestamps_expected_reliable: true,
297 license: "synthetic CC0".into(),
298 },
299 SttFixture {
300 id: "numbers_en".into(),
301 language: "en".into(),
302 reference: "the meeting is at 3 30 pm".into(),
303 audio: None,
304 tags: vec!["numbers".into(), "punctuation".into()],
305 timestamps_expected_reliable: true,
306 license: "synthetic CC0".into(),
307 },
308 SttFixture {
309 id: "silence_empty".into(),
310 language: "en".into(),
311 reference: "".into(),
312 audio: Some("audio/silence_1s.wav".into()),
313 tags: vec!["silence".into()],
314 timestamps_expected_reliable: true,
315 license: "synthetic CC0".into(),
316 },
317 SttFixture {
318 id: "tone_non_speech".into(),
319 language: "en".into(),
320 reference: "".into(),
321 audio: Some("audio/tone_440_1s.wav".into()),
322 tags: vec!["noise".into(), "music".into(), "non_speech".into()],
323 timestamps_expected_reliable: true,
324 license: "synthetic CC0".into(),
325 },
326 SttFixture {
327 id: "long_phrase_en".into(),
328 language: "en".into(),
329 reference: "the quick brown fox jumps over the lazy dog near the river bank"
330 .into(),
331 audio: None,
332 tags: vec!["clean".into(), "long".into()],
333 timestamps_expected_reliable: true,
334 license: "synthetic CC0".into(),
335 },
336 SttFixture {
337 id: "accent_placeholder_en".into(),
338 language: "en".into(),
339 reference: "schedule the call for tomorrow morning".into(),
340 audio: None,
341 tags: vec!["accent".into(), "placeholder".into()],
342 timestamps_expected_reliable: true,
343 license: "synthetic CC0 — replace with licensed multi-accent speech".into(),
344 },
345 ],
346 tts: vec![
347 TtsFixture {
348 id: "tts_short".into(),
349 text: "Hello from Aurum.".into(),
350 tags: vec!["short".into()],
351 duration_ms_min: Some(200),
352 duration_ms_max: Some(5_000),
353 license: "synthetic CC0".into(),
354 },
355 TtsFixture {
356 id: "tts_numbers".into(),
357 text: "Call me at extension 42.".into(),
358 tags: vec!["numbers".into(), "abbreviations".into()],
359 duration_ms_min: Some(300),
360 duration_ms_max: Some(8_000),
361 license: "synthetic CC0".into(),
362 },
363 TtsFixture {
364 id: "tts_long_join".into(),
365 text: "First sentence ends here. Second sentence starts now and continues for a bit longer.".into(),
366 tags: vec!["long".into(), "join".into()],
367 duration_ms_min: Some(500),
368 duration_ms_max: Some(20_000),
369 license: "synthetic CC0".into(),
370 },
371 ],
372 }
373}
374
375pub fn tts_duration_in_range(fixture: &TtsFixture, duration_ms: u64) -> bool {
377 if let Some(min) = fixture.duration_ms_min {
378 if duration_ms < min {
379 return false;
380 }
381 }
382 if let Some(max) = fixture.duration_ms_max {
383 if duration_ms > max {
384 return false;
385 }
386 }
387 true
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393
394 #[test]
395 fn perfect_match_zero_wer() {
396 assert_eq!(word_error_rate("Hello, world!", "hello world"), 0.0);
397 }
398
399 #[test]
400 fn one_sub_wer() {
401 let wer = word_error_rate("a b c", "a x c");
402 assert!((wer - 1.0 / 3.0).abs() < 1e-9);
403 }
404
405 #[test]
406 fn cer_basic() {
407 let cer = char_error_rate("abc", "axc");
408 assert!((cer - 1.0 / 3.0).abs() < 1e-9);
409 }
410
411 #[test]
412 fn smoke_corpus_scores() {
413 let c = smoke_corpus();
414 let scores: Vec<_> = c
415 .stt
416 .iter()
417 .map(|f| score_stt(f, &f.reference, true))
418 .collect();
419 let report = build_report(&c, "tiny-q5_1", "asr", scores);
420 assert_eq!(report.mean_wer, 0.0);
421 assert_eq!(report.corpus_version, 2);
422 assert_eq!(report.silence_false_positives, 0);
423 let json = serde_json::to_string_pretty(&report).unwrap();
424 assert!(json.contains("mean_wer"));
425 assert!(json.contains("silence_false_positives"));
426 }
427
428 #[test]
429 fn silence_fp_and_repetition() {
430 assert!(silence_false_positive("", "hello hello hello"));
431 assert!(!silence_false_positive("", ""));
432 let r = repetition_ratio("yes yes yes yes no");
433 assert!(r >= 0.5);
434 }
435
436 #[test]
437 fn empty_ref_empty_hyp() {
438 assert_eq!(word_error_rate("", ""), 0.0);
439 assert_eq!(word_error_rate("", "hi"), 1.0);
440 }
441}