1use crate::error::{Result, UserError};
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11use std::fs;
12use std::path::Path;
13
14pub const OBSERVATORY_SCHEMA_VERSION: u32 = 1;
16
17pub const STT_OBSERVATORY_EVIDENCE_VERSION: &str = "0.0.22-observatory-v1";
19
20pub const NORMALIZATION_POLICY_VERSION: &str = "normalize_v1_lower_alnum_ws";
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum AssetResolution {
31 Redistributable,
33 ExternalFetch,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
39pub struct ObservatoryFixture {
40 pub id: String,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub audio: Option<String>,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub audio_sha256: Option<String>,
47 #[serde(default)]
49 pub duration_secs: f64,
50 pub language: String,
51 pub reference: String,
53 #[serde(default = "default_norm_policy")]
55 pub normalization_policy: String,
56 #[serde(default)]
58 pub tags: Vec<String>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub speaker_id: Option<String>,
62 pub license: String,
64 #[serde(default)]
66 pub provenance: String,
67 pub asset_resolution: AssetResolution,
68 #[serde(default)]
70 pub redistributable: bool,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub use_restrictions: Option<String>,
74 #[serde(default = "default_true")]
75 pub timestamps_expected_reliable: bool,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub timing_reference: Option<String>,
79}
80
81fn default_norm_policy() -> String {
82 NORMALIZATION_POLICY_VERSION.into()
83}
84
85fn default_true() -> bool {
86 true
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
91pub struct ObservatoryCorpus {
92 pub schema_version: u32,
93 pub name: String,
94 pub corpus_version: String,
96 #[serde(default)]
97 pub description: String,
98 pub fixtures: Vec<ObservatoryFixture>,
99}
100
101impl ObservatoryCorpus {
102 pub fn validate(&self, enforce_production_coverage: bool) -> Result<CorpusCoverage> {
108 if self.schema_version != OBSERVATORY_SCHEMA_VERSION {
109 return Err(UserError::Other {
110 message: format!(
111 "unsupported observatory corpus schema_version {} (expected {OBSERVATORY_SCHEMA_VERSION})",
112 self.schema_version
113 ),
114 }
115 .into());
116 }
117 if self.name.trim().is_empty() {
118 return Err(UserError::Other {
119 message: "observatory corpus name must be non-empty".into(),
120 }
121 .into());
122 }
123 if self.fixtures.is_empty() {
124 return Err(UserError::Other {
125 message: "observatory corpus has no fixtures".into(),
126 }
127 .into());
128 }
129
130 let mut ids = std::collections::BTreeSet::new();
131 let mut speakers = std::collections::BTreeSet::new();
132 let mut total_secs = 0.0f64;
133 let mut tags_seen = std::collections::BTreeSet::new();
134 let mut long_form = 0u32;
135 let mut accents = std::collections::BTreeSet::new();
136
137 for f in &self.fixtures {
138 if f.id.trim().is_empty() {
139 return Err(UserError::Other {
140 message: "fixture id must be non-empty".into(),
141 }
142 .into());
143 }
144 if !ids.insert(f.id.clone()) {
145 return Err(UserError::Other {
146 message: format!("duplicate fixture id '{}'", f.id),
147 }
148 .into());
149 }
150 if f.license.trim().is_empty() {
151 return Err(UserError::Other {
152 message: format!("fixture '{}' missing license/provenance", f.id),
153 }
154 .into());
155 }
156 if f.duration_secs < 0.0 || !f.duration_secs.is_finite() {
157 return Err(UserError::Other {
158 message: format!("fixture '{}' has invalid duration_secs", f.id),
159 }
160 .into());
161 }
162 if f.reference.len() > 2_000_000 {
164 return Err(UserError::Other {
165 message: format!("fixture '{}' reference exceeds size bound", f.id),
166 }
167 .into());
168 }
169 total_secs += f.duration_secs;
170 if let Some(ref sp) = f.speaker_id {
171 if !sp.is_empty() {
172 speakers.insert(sp.clone());
173 }
174 }
175 for t in &f.tags {
176 tags_seen.insert(t.to_ascii_lowercase());
177 if t.to_ascii_lowercase().starts_with("accent_") {
178 accents.insert(t.to_ascii_lowercase());
179 }
180 }
181 if f.tags.iter().any(|t| {
182 let l = t.to_ascii_lowercase();
183 l == "long_form" || l == "long-form" || l == "longform"
184 }) || f.duration_secs > 600.0
185 {
186 long_form += 1;
187 }
188 }
189
190 let coverage = CorpusCoverage {
191 fixture_count: self.fixtures.len(),
192 total_duration_secs: total_secs,
193 speaker_count: speakers.len(),
194 accent_tag_count: accents.len(),
195 long_form_count: long_form,
196 has_silence: tags_seen.iter().any(|t| t == "silence"),
197 has_noisy: tags_seen
198 .iter()
199 .any(|t| t == "noisy" || t == "noise" || t == "reverberant"),
200 has_lecture: tags_seen
201 .iter()
202 .any(|t| t == "lecture" || t == "presentation"),
203 has_conversational: tags_seen
204 .iter()
205 .any(|t| t == "conversational" || t == "clean" || t == "conversation"),
206 has_numbers: tags_seen
207 .iter()
208 .any(|t| t == "numbers" || t == "dates" || t == "acronyms"),
209 has_multilingual: tags_seen
210 .iter()
211 .any(|t| t == "multilingual" || t == "code_switch" || t == "code-switching"),
212 has_low_volume: tags_seen
213 .iter()
214 .any(|t| t == "low_volume" || t == "low-volume" || t == "pause"),
215 tags: tags_seen.into_iter().collect(),
216 };
217
218 if enforce_production_coverage {
219 coverage.require_production_minima()?;
220 }
221
222 Ok(coverage)
223 }
224
225 pub fn load(path: &Path) -> Result<Self> {
226 let data = fs::read_to_string(path).map_err(|e| UserError::Other {
227 message: format!("read observatory corpus {}: {e}", path.display()),
228 })?;
229 if data.len() > 32 * 1024 * 1024 {
231 return Err(UserError::Other {
232 message: format!(
233 "observatory corpus {} exceeds 32 MiB size bound",
234 path.display()
235 ),
236 }
237 .into());
238 }
239 let corpus: Self = serde_json::from_str(&data).map_err(|e| UserError::Other {
240 message: format!("parse observatory corpus: {e}"),
241 })?;
242 Ok(corpus)
243 }
244
245 pub fn to_json_pretty(&self) -> Result<String> {
246 serde_json::to_string_pretty(self).map_err(|e| {
247 UserError::Other {
248 message: format!("serialize observatory corpus: {e}"),
249 }
250 .into()
251 })
252 }
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
257pub struct CorpusCoverage {
258 pub fixture_count: usize,
259 pub total_duration_secs: f64,
260 pub speaker_count: usize,
261 pub accent_tag_count: usize,
262 pub long_form_count: u32,
263 pub has_silence: bool,
264 pub has_noisy: bool,
265 pub has_lecture: bool,
266 pub has_conversational: bool,
267 pub has_numbers: bool,
268 pub has_multilingual: bool,
269 pub has_low_volume: bool,
270 pub tags: Vec<String>,
271}
272
273impl CorpusCoverage {
274 pub fn require_production_minima(&self) -> Result<()> {
276 let mut missing = Vec::new();
277 if self.total_duration_secs < 60.0 * 60.0 {
278 missing.push(format!(
279 "duration {:.1}s < 3600s (60 minutes)",
280 self.total_duration_secs
281 ));
282 }
283 if self.speaker_count < 20 {
284 missing.push(format!("speakers {} < 20", self.speaker_count));
285 }
286 if self.accent_tag_count < 4 {
287 missing.push(format!("accent tags {} < 4", self.accent_tag_count));
288 }
289 if self.long_form_count < 3 {
290 missing.push(format!("long-form fixtures {} < 3", self.long_form_count));
291 }
292 if !self.has_silence {
293 missing.push("silence control".into());
294 }
295 if !self.has_noisy {
296 missing.push("noisy/reverberant".into());
297 }
298 if !self.has_lecture {
299 missing.push("lecture/presentation".into());
300 }
301 if !self.has_conversational {
302 missing.push("conversational/clean".into());
303 }
304 if !self.has_numbers {
305 missing.push("numbers/dates/acronyms".into());
306 }
307 if !self.has_multilingual {
308 missing.push("multilingual/code-switching".into());
309 }
310 if !self.has_low_volume {
311 missing.push("low_volume/pause".into());
312 }
313 if missing.is_empty() {
314 Ok(())
315 } else {
316 Err(UserError::Other {
317 message: format!(
318 "production corpus coverage incomplete: {}",
319 missing.join("; ")
320 ),
321 }
322 .into())
323 }
324 }
325}
326
327#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
333pub struct ObservatoryFixtureScore {
334 pub fixture_id: String,
335 pub wer: f64,
336 pub cer: f64,
337 pub empty_hypothesis: bool,
338 pub silence_false_positive: bool,
339 pub repetition_ratio: f64,
340 pub length_ratio: f64,
342 pub ref_words: usize,
343 pub hyp_words: usize,
344 #[serde(default)]
346 pub tags: Vec<String>,
347 #[serde(default, skip_serializing_if = "Option::is_none")]
349 pub timestamp_mae_secs: Option<f64>,
350 #[serde(default, skip_serializing_if = "Option::is_none")]
352 pub boundary_error: Option<f64>,
353 #[serde(default, skip_serializing_if = "Option::is_none")]
355 pub processing_secs: Option<f64>,
356 #[serde(default, skip_serializing_if = "Option::is_none")]
358 pub rtf: Option<f64>,
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
363pub struct RunIdentity {
364 #[serde(default, skip_serializing_if = "Option::is_none")]
365 pub aurum_version: Option<String>,
366 #[serde(default, skip_serializing_if = "Option::is_none")]
367 pub commit: Option<String>,
368 #[serde(default, skip_serializing_if = "Option::is_none")]
369 pub provider: Option<String>,
370 #[serde(default, skip_serializing_if = "Option::is_none")]
371 pub backend_class: Option<String>,
372 #[serde(default, skip_serializing_if = "Option::is_none")]
373 pub model_id: Option<String>,
374 #[serde(default, skip_serializing_if = "Option::is_none")]
375 pub model_digest: Option<String>,
376 #[serde(default, skip_serializing_if = "Option::is_none")]
377 pub support_tier: Option<String>,
378 #[serde(default, skip_serializing_if = "Option::is_none")]
379 pub language: Option<String>,
380 #[serde(default)]
381 pub timestamps: bool,
382 #[serde(default, skip_serializing_if = "Option::is_none")]
383 pub hardware_profile: Option<String>,
384 #[serde(default, skip_serializing_if = "Option::is_none")]
385 pub os: Option<String>,
386 #[serde(default, skip_serializing_if = "Option::is_none")]
387 pub cold_warm: Option<String>,
388 #[serde(default, skip_serializing_if = "Option::is_none")]
389 pub corpus_version: Option<String>,
390 #[serde(default, skip_serializing_if = "Option::is_none")]
391 pub normalization_policy: Option<String>,
392}
393
394#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
396pub struct ObservatoryReport {
397 pub schema_version: u32,
398 pub evidence_version: String,
399 pub corpus_name: String,
400 pub corpus_version: String,
401 pub model: String,
402 pub backend_kind: String,
403 #[serde(default)]
404 pub identity: RunIdentity,
405 pub scores: Vec<ObservatoryFixtureScore>,
406 pub mean_wer: f64,
407 pub mean_cer: f64,
408 pub silence_false_positives: u32,
409 pub mean_repetition_ratio: f64,
410 pub mean_length_ratio: f64,
411 #[serde(default)]
413 pub scenario_mean_wer: BTreeMap<String, f64>,
414 #[serde(default, skip_serializing_if = "Option::is_none")]
415 pub notes: Option<String>,
416}
417
418impl ObservatoryReport {
419 pub fn from_scores(
420 corpus: &ObservatoryCorpus,
421 model: &str,
422 backend_kind: &str,
423 mut scores: Vec<ObservatoryFixtureScore>,
424 identity: RunIdentity,
425 ) -> Self {
426 scores.sort_by(|a, b| a.fixture_id.cmp(&b.fixture_id));
428 let n = scores.len().max(1) as f64;
429 let mean_wer = scores.iter().map(|s| s.wer).sum::<f64>() / n;
430 let mean_cer = scores.iter().map(|s| s.cer).sum::<f64>() / n;
431 let silence_false_positives =
432 scores.iter().filter(|s| s.silence_false_positive).count() as u32;
433 let rep: Vec<f64> = scores
434 .iter()
435 .filter(|s| s.hyp_words > 0)
436 .map(|s| s.repetition_ratio)
437 .collect();
438 let mean_repetition_ratio = if rep.is_empty() {
439 0.0
440 } else {
441 rep.iter().sum::<f64>() / rep.len() as f64
442 };
443 let mean_length_ratio = scores.iter().map(|s| s.length_ratio).sum::<f64>() / n;
444 let scenario_mean_wer = scenario_group_means(&scores);
445
446 let mut identity = identity;
447 if identity.corpus_version.is_none() {
448 identity.corpus_version = Some(corpus.corpus_version.clone());
449 }
450 if identity.normalization_policy.is_none() {
451 identity.normalization_policy = Some(NORMALIZATION_POLICY_VERSION.into());
452 }
453 if identity.model_id.is_none() {
454 identity.model_id = Some(model.into());
455 }
456
457 Self {
458 schema_version: OBSERVATORY_SCHEMA_VERSION,
459 evidence_version: STT_OBSERVATORY_EVIDENCE_VERSION.into(),
460 corpus_name: corpus.name.clone(),
461 corpus_version: corpus.corpus_version.clone(),
462 model: model.into(),
463 backend_kind: backend_kind.into(),
464 identity,
465 scores,
466 mean_wer,
467 mean_cer,
468 silence_false_positives,
469 mean_repetition_ratio,
470 mean_length_ratio,
471 scenario_mean_wer,
472 notes: None,
473 }
474 }
475
476 pub fn load(path: &Path) -> Result<Self> {
477 let data = fs::read_to_string(path).map_err(|e| UserError::Other {
478 message: format!("read observatory report {}: {e}", path.display()),
479 })?;
480 serde_json::from_str(&data).map_err(|e| {
481 UserError::Other {
482 message: format!("parse observatory report: {e}"),
483 }
484 .into()
485 })
486 }
487
488 pub fn to_json_pretty(&self) -> Result<String> {
489 serde_json::to_string_pretty(self).map_err(|e| {
490 UserError::Other {
491 message: format!("serialize observatory report: {e}"),
492 }
493 .into()
494 })
495 }
496
497 pub fn to_markdown_scorecard(&self) -> String {
499 let mut out = String::new();
500 out.push_str("# STT quality scorecard\n\n");
501 out.push_str(&format!(
502 "- **Evidence version:** {}\n- **Corpus:** {} ({})\n- **Model:** `{}`\n- **Backend:** {}\n",
503 self.evidence_version, self.corpus_name, self.corpus_version, self.model, self.backend_kind
504 ));
505 if let Some(ref hw) = self.identity.hardware_profile {
506 out.push_str(&format!("- **Hardware profile:** `{hw}`\n"));
507 }
508 if let Some(ref commit) = self.identity.commit {
509 out.push_str(&format!("- **Commit:** `{commit}`\n"));
510 }
511 out.push_str(&format!(
512 "\n## Aggregate\n\n| Metric | Value |\n|--------|-------|\n| mean WER | {:.4} |\n| mean CER | {:.4} |\n| silence FP | {} |\n| mean repetition | {:.4} |\n| mean length ratio | {:.4} |\n",
513 self.mean_wer,
514 self.mean_cer,
515 self.silence_false_positives,
516 self.mean_repetition_ratio,
517 self.mean_length_ratio
518 ));
519 out.push_str(
520 "\n## Scenario mean WER\n\n| Scenario | mean WER |\n|----------|----------|\n",
521 );
522 for (k, v) in &self.scenario_mean_wer {
523 out.push_str(&format!("| {k} | {v:.4} |\n"));
524 }
525 out.push_str("\n## Per-fixture\n\n| Fixture | WER | CER | silence FP | rep |\n|---------|-----|-----|------------|-----|\n");
526 for s in &self.scores {
527 out.push_str(&format!(
528 "| {} | {:.4} | {:.4} | {} | {:.3} |\n",
529 s.fixture_id, s.wer, s.cer, s.silence_false_positive, s.repetition_ratio
530 ));
531 }
532 out.push('\n');
533 out
534 }
535}
536
537fn scenario_group_means(scores: &[ObservatoryFixtureScore]) -> BTreeMap<String, f64> {
538 let mut sums: BTreeMap<String, (f64, u32)> = BTreeMap::new();
539 for s in scores {
540 let scenario = s
542 .tags
543 .iter()
544 .find(|t| {
545 let l = t.to_ascii_lowercase();
546 !matches!(l.as_str(), "synthetic" | "placeholder" | "redistributable")
547 })
548 .cloned()
549 .unwrap_or_else(|| "untagged".into());
550 let e = sums.entry(scenario).or_insert((0.0, 0));
551 e.0 += s.wer;
552 e.1 += 1;
553 for t in &s.tags {
555 let l = t.to_ascii_lowercase();
556 if l.starts_with("accent_")
557 || l == "silence"
558 || l == "long_form"
559 || l == "noisy"
560 || l == "lecture"
561 {
562 let e = sums.entry(l).or_insert((0.0, 0));
563 e.0 += s.wer;
564 e.1 += 1;
565 }
566 }
567 }
568 sums.into_iter()
569 .map(|(k, (sum, n))| (k, sum / n.max(1) as f64))
570 .collect()
571}
572
573pub fn score_observatory_fixture(
575 fixture: &ObservatoryFixture,
576 hypothesis: &str,
577 extras: ObservatoryScoreExtras,
578) -> ObservatoryFixtureScore {
579 use super::{
580 char_error_rate, normalize_transcript, repetition_ratio, silence_false_positive,
581 word_error_rate,
582 };
583
584 let wer = word_error_rate(&fixture.reference, hypothesis);
585 let cer = char_error_rate(&fixture.reference, hypothesis);
586 let ref_n = normalize_transcript(&fixture.reference)
587 .split_whitespace()
588 .filter(|w| !w.is_empty())
589 .count();
590 let hyp_n = normalize_transcript(hypothesis)
591 .split_whitespace()
592 .filter(|w| !w.is_empty())
593 .count();
594 let length_ratio = hyp_n as f64 / ref_n.max(1) as f64;
595
596 ObservatoryFixtureScore {
597 fixture_id: fixture.id.clone(),
598 wer,
599 cer,
600 empty_hypothesis: hypothesis.trim().is_empty(),
601 silence_false_positive: silence_false_positive(&fixture.reference, hypothesis),
602 repetition_ratio: repetition_ratio(hypothesis),
603 length_ratio,
604 ref_words: ref_n,
605 hyp_words: hyp_n,
606 tags: fixture.tags.clone(),
607 timestamp_mae_secs: extras.timestamp_mae_secs,
608 boundary_error: extras.boundary_error,
609 processing_secs: extras.processing_secs,
610 rtf: extras.rtf,
611 }
612}
613
614#[derive(Debug, Clone, Default)]
616pub struct ObservatoryScoreExtras {
617 pub timestamp_mae_secs: Option<f64>,
618 pub boundary_error: Option<f64>,
619 pub processing_secs: Option<f64>,
620 pub rtf: Option<f64>,
621}
622
623#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
629pub struct SttBudget {
630 pub schema_version: u32,
631 pub evidence_version: String,
632 pub model: String,
633 pub backend_kind: String,
634 pub baseline_mean_wer: f64,
636 #[serde(default = "default_abs_wer_points")]
638 pub max_absolute_wer_points: f64,
639 #[serde(default = "default_rel_wer")]
641 pub max_relative_wer: f64,
642 #[serde(default)]
644 pub scenario_baseline_wer: BTreeMap<String, f64>,
645 #[serde(default = "default_scenario_rel")]
647 pub max_scenario_relative_wer: f64,
648 #[serde(default)]
650 pub max_silence_false_positives: u32,
651 #[serde(default = "default_max_rep")]
653 pub max_mean_repetition_ratio: f64,
654 #[serde(default, skip_serializing_if = "Option::is_none")]
656 pub max_timestamp_mae_secs: Option<f64>,
657 #[serde(default, skip_serializing_if = "Option::is_none")]
658 pub notes: Option<String>,
659}
660
661fn default_abs_wer_points() -> f64 {
662 1.0
663}
664fn default_rel_wer() -> f64 {
665 0.10
666}
667fn default_scenario_rel() -> f64 {
668 0.15
669}
670fn default_max_rep() -> f64 {
671 0.35
672}
673
674impl SttBudget {
675 pub fn load(path: &Path) -> Result<Self> {
676 let data = fs::read_to_string(path).map_err(|e| UserError::Other {
677 message: format!("read STT budget {}: {e}", path.display()),
678 })?;
679 serde_json::from_str(&data).map_err(|e| {
680 UserError::Other {
681 message: format!("parse STT budget: {e}"),
682 }
683 .into()
684 })
685 }
686}
687
688#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
690pub struct BudgetFinding {
691 pub severity: BudgetSeverity,
692 pub code: String,
693 pub message: String,
694}
695
696#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
697#[serde(rename_all = "snake_case")]
698pub enum BudgetSeverity {
699 Pass,
700 Warn,
701 Fail,
702}
703
704#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
706pub struct BudgetComparison {
707 pub model: String,
708 pub passed: bool,
709 pub findings: Vec<BudgetFinding>,
710 pub candidate_mean_wer: f64,
711 pub baseline_mean_wer: f64,
712 pub allowed_mean_wer: f64,
713}
714
715pub fn allowed_mean_wer(baseline: f64, max_relative: f64, max_absolute_points: f64) -> f64 {
717 let rel = baseline * (1.0 + max_relative);
718 let abs = baseline + max_absolute_points;
719 rel.max(abs)
720}
721
722pub fn compare_stt_budget(report: &ObservatoryReport, budget: &SttBudget) -> BudgetComparison {
724 let mut findings = Vec::new();
725 let allowed = allowed_mean_wer(
726 budget.baseline_mean_wer,
727 budget.max_relative_wer,
728 budget.max_absolute_wer_points,
729 );
730
731 if report.model != budget.model {
732 findings.push(BudgetFinding {
733 severity: BudgetSeverity::Fail,
734 code: "model_mismatch".into(),
735 message: format!(
736 "report model '{}' != budget model '{}'",
737 report.model, budget.model
738 ),
739 });
740 }
741
742 if report.mean_wer > allowed + f64::EPSILON {
743 findings.push(BudgetFinding {
744 severity: BudgetSeverity::Fail,
745 code: "aggregate_wer_regression".into(),
746 message: format!(
747 "mean WER {:.4} exceeds allowed {:.4} (baseline {:.4}, max(rel {:.0}%, abs +{:.1}))",
748 report.mean_wer,
749 allowed,
750 budget.baseline_mean_wer,
751 budget.max_relative_wer * 100.0,
752 budget.max_absolute_wer_points
753 ),
754 });
755 }
756
757 if report.silence_false_positives > budget.max_silence_false_positives {
758 findings.push(BudgetFinding {
759 severity: BudgetSeverity::Fail,
760 code: "silence_false_positive".into(),
761 message: format!(
762 "silence FP {} exceeds max {}",
763 report.silence_false_positives, budget.max_silence_false_positives
764 ),
765 });
766 }
767
768 if report.mean_repetition_ratio > budget.max_mean_repetition_ratio + f64::EPSILON {
769 findings.push(BudgetFinding {
770 severity: BudgetSeverity::Fail,
771 code: "repetition_degeneration".into(),
772 message: format!(
773 "mean repetition {:.4} exceeds max {:.4}",
774 report.mean_repetition_ratio, budget.max_mean_repetition_ratio
775 ),
776 });
777 }
778
779 for (scenario, baseline) in &budget.scenario_baseline_wer {
780 if let Some(&cand) = report.scenario_mean_wer.get(scenario) {
781 let scen_allowed = baseline * (1.0 + budget.max_scenario_relative_wer);
782 if cand > scen_allowed + f64::EPSILON {
783 findings.push(BudgetFinding {
784 severity: BudgetSeverity::Fail,
785 code: "scenario_wer_regression".into(),
786 message: format!(
787 "scenario '{scenario}' mean WER {cand:.4} exceeds allowed {scen_allowed:.4} (baseline {baseline:.4})"
788 ),
789 });
790 }
791 }
792 }
793
794 if let Some(max_mae) = budget.max_timestamp_mae_secs {
795 for s in &report.scores {
796 if let Some(mae) = s.timestamp_mae_secs {
797 if mae > max_mae + f64::EPSILON {
798 findings.push(BudgetFinding {
799 severity: BudgetSeverity::Fail,
800 code: "timestamp_alignment".into(),
801 message: format!(
802 "fixture '{}' timestamp MAE {:.4}s exceeds budget {max_mae:.4}s",
803 s.fixture_id, mae
804 ),
805 });
806 }
807 }
808 }
809 }
810
811 if findings.is_empty() {
812 findings.push(BudgetFinding {
813 severity: BudgetSeverity::Pass,
814 code: "ok".into(),
815 message: "all budget checks passed".into(),
816 });
817 }
818
819 let passed = findings.iter().all(|f| f.severity != BudgetSeverity::Fail);
820 BudgetComparison {
821 model: report.model.clone(),
822 passed,
823 findings,
824 candidate_mean_wer: report.mean_wer,
825 baseline_mean_wer: budget.baseline_mean_wer,
826 allowed_mean_wer: allowed,
827 }
828}
829
830pub fn budget_exit_code(cmp: &BudgetComparison) -> i32 {
832 if cmp.passed {
833 0
834 } else {
835 1
836 }
837}
838
839pub fn observatory_core_corpus() -> ObservatoryCorpus {
845 ObservatoryCorpus {
846 schema_version: OBSERVATORY_SCHEMA_VERSION,
847 name: "aurum-observatory-core-v1".into(),
848 corpus_version: "observatory-core-v1".into(),
849 description: "Redistributable synthetic/control core for schema and budget CI. Full production coverage is the external-fetch pack (see evals/observatory/README.md).".into(),
850 fixtures: vec![
851 ObservatoryFixture {
852 id: "core_clean_en".into(),
853 audio: None,
854 audio_sha256: None,
855 duration_secs: 3.0,
856 language: "en".into(),
857 reference: "hello world from aurum".into(),
858 normalization_policy: NORMALIZATION_POLICY_VERSION.into(),
859 tags: vec!["clean".into(), "conversational".into(), "short".into()],
860 speaker_id: Some("spk_core_01".into()),
861 license: "synthetic CC0".into(),
862 provenance: "aurum synthetic text".into(),
863 asset_resolution: AssetResolution::Redistributable,
864 redistributable: true,
865 use_restrictions: None,
866 timestamps_expected_reliable: true,
867 timing_reference: None,
868 },
869 ObservatoryFixture {
870 id: "core_numbers_en".into(),
871 audio: None,
872 audio_sha256: None,
873 duration_secs: 4.0,
874 language: "en".into(),
875 reference: "the meeting is at 3 30 pm on 12 january 2026".into(),
876 normalization_policy: NORMALIZATION_POLICY_VERSION.into(),
877 tags: vec!["numbers".into(), "dates".into(), "clean".into()],
878 speaker_id: Some("spk_core_02".into()),
879 license: "synthetic CC0".into(),
880 provenance: "aurum synthetic text".into(),
881 asset_resolution: AssetResolution::Redistributable,
882 redistributable: true,
883 use_restrictions: None,
884 timestamps_expected_reliable: true,
885 timing_reference: None,
886 },
887 ObservatoryFixture {
888 id: "core_silence".into(),
889 audio: Some("audio/silence_1s.wav".into()),
890 audio_sha256: None,
891 duration_secs: 1.0,
892 language: "en".into(),
893 reference: "".into(),
894 normalization_policy: NORMALIZATION_POLICY_VERSION.into(),
895 tags: vec!["silence".into()],
896 speaker_id: None,
897 license: "synthetic CC0".into(),
898 provenance: "aurum generate_eval_audio".into(),
899 asset_resolution: AssetResolution::Redistributable,
900 redistributable: true,
901 use_restrictions: None,
902 timestamps_expected_reliable: true,
903 timing_reference: None,
904 },
905 ObservatoryFixture {
906 id: "core_non_speech_tone".into(),
907 audio: Some("audio/tone_440_1s.wav".into()),
908 audio_sha256: None,
909 duration_secs: 1.0,
910 language: "en".into(),
911 reference: "".into(),
912 normalization_policy: NORMALIZATION_POLICY_VERSION.into(),
913 tags: vec!["noise".into(), "non_speech".into()],
914 speaker_id: None,
915 license: "synthetic CC0".into(),
916 provenance: "aurum generate_eval_audio".into(),
917 asset_resolution: AssetResolution::Redistributable,
918 redistributable: true,
919 use_restrictions: None,
920 timestamps_expected_reliable: true,
921 timing_reference: None,
922 },
923 ObservatoryFixture {
924 id: "core_accent_us".into(),
925 audio: None,
926 audio_sha256: None,
927 duration_secs: 5.0,
928 language: "en".into(),
929 reference: "schedule the call for tomorrow morning".into(),
930 normalization_policy: NORMALIZATION_POLICY_VERSION.into(),
931 tags: vec!["accent_us".into(), "clean".into()],
932 speaker_id: Some("spk_core_03".into()),
933 license: "synthetic CC0".into(),
934 provenance: "aurum synthetic text".into(),
935 asset_resolution: AssetResolution::Redistributable,
936 redistributable: true,
937 use_restrictions: None,
938 timestamps_expected_reliable: true,
939 timing_reference: None,
940 },
941 ],
942 }
943}
944
945pub fn observatory_core_budget_tiny() -> SttBudget {
947 SttBudget {
948 schema_version: OBSERVATORY_SCHEMA_VERSION,
949 evidence_version: STT_OBSERVATORY_EVIDENCE_VERSION.into(),
950 model: "tiny-q5_1".into(),
951 backend_kind: "asr".into(),
952 baseline_mean_wer: 0.0,
953 max_absolute_wer_points: 1.0,
954 max_relative_wer: 0.10,
955 scenario_baseline_wer: BTreeMap::from([("silence".into(), 0.0), ("clean".into(), 0.0)]),
956 max_scenario_relative_wer: 0.15,
957 max_silence_false_positives: 0,
958 max_mean_repetition_ratio: 0.35,
959 max_timestamp_mae_secs: None,
960 notes: Some("Core CI budget for perfect-match synthetic scoring".into()),
961 }
962}
963
964#[cfg(test)]
965mod tests {
966 use super::*;
967
968 #[test]
969 fn core_corpus_validates_without_production() {
970 let c = observatory_core_corpus();
971 let cov = c.validate(false).unwrap();
972 assert!(cov.fixture_count >= 5);
973 assert!(cov.has_silence);
974 assert!(c.validate(true).is_err());
975 }
976
977 #[test]
978 fn production_coverage_requires_minima() {
979 let mut c = observatory_core_corpus();
980 c.fixtures.clear();
982 for i in 0..25 {
983 c.fixtures.push(ObservatoryFixture {
984 id: format!("prod_{i:02}"),
985 audio: None,
986 audio_sha256: None,
987 duration_secs: 150.0,
988 language: "en".into(),
989 reference: "hello".into(),
990 normalization_policy: NORMALIZATION_POLICY_VERSION.into(),
991 tags: vec![
992 "clean".into(),
993 "conversational".into(),
994 "lecture".into(),
995 "noisy".into(),
996 "numbers".into(),
997 "multilingual".into(),
998 "low_volume".into(),
999 format!("accent_{}", ["us", "gb", "au", "in"][i % 4]),
1000 if i < 3 {
1001 "long_form".into()
1002 } else {
1003 "short".into()
1004 },
1005 if i == 0 {
1006 "silence".into()
1007 } else {
1008 "speech".into()
1009 },
1010 ],
1011 speaker_id: Some(format!("spk_{i:02}")),
1012 license: "test".into(),
1013 provenance: "unit".into(),
1014 asset_resolution: AssetResolution::ExternalFetch,
1015 redistributable: false,
1016 use_restrictions: Some("test only".into()),
1017 timestamps_expected_reliable: true,
1018 timing_reference: None,
1019 });
1020 }
1021 c.fixtures[0].reference = String::new();
1023 let cov = c.validate(true).unwrap();
1024 assert!(cov.total_duration_secs >= 3600.0);
1025 assert!(cov.speaker_count >= 20);
1026 }
1027
1028 #[test]
1029 fn budget_allows_within_tolerance() {
1030 let corpus = observatory_core_corpus();
1031 let scores: Vec<_> = corpus
1032 .fixtures
1033 .iter()
1034 .map(|f| score_observatory_fixture(f, &f.reference, Default::default()))
1035 .collect();
1036 let report = ObservatoryReport::from_scores(
1037 &corpus,
1038 "tiny-q5_1",
1039 "asr",
1040 scores,
1041 RunIdentity::default(),
1042 );
1043 let budget = observatory_core_budget_tiny();
1044 let cmp = compare_stt_budget(&report, &budget);
1045 assert!(cmp.passed, "{:?}", cmp.findings);
1046 assert_eq!(budget_exit_code(&cmp), 0);
1047 }
1048
1049 #[test]
1050 fn budget_fails_on_wer_regression() {
1051 let corpus = observatory_core_corpus();
1052 let scores: Vec<_> = corpus
1053 .fixtures
1054 .iter()
1055 .map(|f| {
1056 score_observatory_fixture(f, "zzz yyy xxx www vvv", Default::default())
1058 })
1059 .collect();
1060 let report = ObservatoryReport::from_scores(
1061 &corpus,
1062 "tiny-q5_1",
1063 "asr",
1064 scores,
1065 RunIdentity::default(),
1066 );
1067 let budget = observatory_core_budget_tiny();
1068 let cmp = compare_stt_budget(&report, &budget);
1069 assert!(!cmp.passed);
1070 assert_eq!(budget_exit_code(&cmp), 1);
1071 assert!(cmp
1072 .findings
1073 .iter()
1074 .any(|f| f.code == "aggregate_wer_regression"));
1075 }
1076
1077 #[test]
1078 fn budget_fails_on_new_silence_fp() {
1079 let corpus = observatory_core_corpus();
1080 let scores: Vec<_> = corpus
1081 .fixtures
1082 .iter()
1083 .map(|f| {
1084 let hyp = if f.reference.is_empty() {
1085 "hallucinated text"
1086 } else {
1087 f.reference.as_str()
1088 };
1089 score_observatory_fixture(f, hyp, Default::default())
1090 })
1091 .collect();
1092 let report = ObservatoryReport::from_scores(
1093 &corpus,
1094 "tiny-q5_1",
1095 "asr",
1096 scores,
1097 RunIdentity::default(),
1098 );
1099 let budget = observatory_core_budget_tiny();
1100 let cmp = compare_stt_budget(&report, &budget);
1101 assert!(!cmp.passed);
1102 assert!(cmp
1103 .findings
1104 .iter()
1105 .any(|f| f.code == "silence_false_positive"));
1106 }
1107
1108 #[test]
1109 fn allowed_wer_uses_larger_of_rel_and_abs() {
1110 assert!((allowed_mean_wer(0.05, 0.10, 1.0) - 1.05).abs() < 1e-9);
1112 assert!((allowed_mean_wer(0.50, 0.10, 1.0) - 1.50).abs() < 1e-9);
1114 assert!((allowed_mean_wer(0.40, 0.10, 0.05) - 0.45).abs() < 1e-9);
1116 }
1117
1118 #[test]
1119 fn scorecard_and_json_deterministic() {
1120 let corpus = observatory_core_corpus();
1121 let scores: Vec<_> = corpus
1122 .fixtures
1123 .iter()
1124 .map(|f| score_observatory_fixture(f, &f.reference, Default::default()))
1125 .collect();
1126 let r1 = ObservatoryReport::from_scores(
1127 &corpus,
1128 "tiny-q5_1",
1129 "asr",
1130 scores.clone(),
1131 RunIdentity::default(),
1132 );
1133 let r2 = ObservatoryReport::from_scores(
1134 &corpus,
1135 "tiny-q5_1",
1136 "asr",
1137 scores,
1138 RunIdentity::default(),
1139 );
1140 assert_eq!(r1.to_json_pretty().unwrap(), r2.to_json_pretty().unwrap());
1141 let md = r1.to_markdown_scorecard();
1142 assert!(md.contains("mean WER"));
1143 assert!(md.contains("core_clean_en"));
1144 }
1145
1146 #[test]
1147 fn duplicate_fixture_id_rejected() {
1148 let mut c = observatory_core_corpus();
1149 c.fixtures.push(c.fixtures[0].clone());
1150 assert!(c.validate(false).is_err());
1151 }
1152}