Skip to main content

aurum_core/eval/
observatory.rs

1//! Versioned STT quality observatory (JOE-2216).
2//!
3//! Production-grade corpus schema, machine-readable reports, Markdown scorecards,
4//! and fail-closed baseline budget comparison. CI uses the redistributable core;
5//! larger licensed speech is fetched by a documented recipe and never required
6//! as private Plaud material.
7
8use crate::error::{Result, UserError};
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11use std::fs;
12use std::path::Path;
13
14/// Observatory report / corpus schema version for the production programme.
15pub const OBSERVATORY_SCHEMA_VERSION: u32 = 1;
16
17/// Evidence pack identifier written into profile recommendations after review.
18pub const STT_OBSERVATORY_EVIDENCE_VERSION: &str = "0.0.22-observatory-v1";
19
20/// Normalization policy identifier (must match scoring path).
21pub const NORMALIZATION_POLICY_VERSION: &str = "normalize_v1_lower_alnum_ws";
22
23// ---------------------------------------------------------------------------
24// Corpus
25// ---------------------------------------------------------------------------
26
27/// How a fixture asset is obtained.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum AssetResolution {
31    /// Checked into the repository under a relative path.
32    Redistributable,
33    /// Obtained via the documented fetch/prepare script; not in CI by default.
34    ExternalFetch,
35}
36
37/// One real-speech (or control) fixture in the observatory corpus.
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
39pub struct ObservatoryFixture {
40    pub id: String,
41    /// Relative path under corpus root, or external asset key.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub audio: Option<String>,
44    /// Optional expected SHA-256 of the audio bytes (when present locally).
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub audio_sha256: Option<String>,
47    /// Approximate duration in seconds (for coverage accounting).
48    #[serde(default)]
49    pub duration_secs: f64,
50    pub language: String,
51    /// Reference transcript (empty for silence / non-speech controls).
52    pub reference: String,
53    /// Normalization policy id applied before WER/CER.
54    #[serde(default = "default_norm_policy")]
55    pub normalization_policy: String,
56    /// Scenario tags: clean, lecture, noisy, accent_*, numbers, silence, long_form, multilingual, …
57    #[serde(default)]
58    pub tags: Vec<String>,
59    /// Distinct speaker id within the corpus (opaque token).
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub speaker_id: Option<String>,
62    /// Licensing / provenance note (required for production fixtures).
63    pub license: String,
64    /// Provenance source (dataset name, URL family, synthetic generator).
65    #[serde(default)]
66    pub provenance: String,
67    pub asset_resolution: AssetResolution,
68    /// Whether the fixture may be redistributed with the repo.
69    #[serde(default)]
70    pub redistributable: bool,
71    /// Use restrictions for operators (e.g. "research only", "no commercial retrain").
72    #[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    /// Optional word-level timing reference path (relative).
77    #[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/// Versioned observatory corpus manifest.
90#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
91pub struct ObservatoryCorpus {
92    pub schema_version: u32,
93    pub name: String,
94    /// Human/product corpus version string (e.g. `observatory-core-v1`).
95    pub corpus_version: String,
96    #[serde(default)]
97    pub description: String,
98    pub fixtures: Vec<ObservatoryFixture>,
99}
100
101impl ObservatoryCorpus {
102    /// Validate structural and coverage contracts for the production programme.
103    ///
104    /// Coverage minima apply when `enforce_production_coverage` is true (full
105    /// production corpus). Redistributable core may pass with lower coverage
106    /// when `enforce_production_coverage` is false.
107    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            // Bound pathological manifests.
163            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        // Bound parse size (~32 MiB JSON).
230        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/// Aggregate coverage metrics for documentation and gates.
256#[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    /// Production minima from JOE-2216 corpus contract.
275    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// ---------------------------------------------------------------------------
328// Per-fixture metrics & report
329// ---------------------------------------------------------------------------
330
331/// Extended per-fixture STT metrics for the observatory.
332#[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    /// hyp_words / max(ref_words, 1)
341    pub length_ratio: f64,
342    pub ref_words: usize,
343    pub hyp_words: usize,
344    /// Scenario tags copied from the fixture for grouping.
345    #[serde(default)]
346    pub tags: Vec<String>,
347    /// Mean absolute timestamp alignment error in seconds when reference timing exists.
348    #[serde(default, skip_serializing_if = "Option::is_none")]
349    pub timestamp_mae_secs: Option<f64>,
350    /// Long-form boundary deletion/duplication score in [0, 1] (0 = clean).
351    #[serde(default, skip_serializing_if = "Option::is_none")]
352    pub boundary_error: Option<f64>,
353    /// Wall-clock processing seconds (for performance cross-link).
354    #[serde(default, skip_serializing_if = "Option::is_none")]
355    pub processing_secs: Option<f64>,
356    /// Real-time factor when audio duration is known.
357    #[serde(default, skip_serializing_if = "Option::is_none")]
358    pub rtf: Option<f64>,
359}
360
361/// Machine identity for a retained report (no serial numbers / usernames).
362#[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/// Versioned observatory JSON report (no raw hypotheses or private paths).
395#[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    /// Scenario → mean WER.
412    #[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        // Deterministic ordering.
427        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    /// Human-readable Markdown scorecard (deterministic section order).
498    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        // Primary scenario tag = first non-meta tag, or "untagged".
541        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        // Also bucket by each accent_* / silence / long_form tag.
554        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
573/// Build a fixture score from reference/hypothesis using shared metric helpers.
574pub 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/// Optional metric fields supplied by the runner.
615#[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// ---------------------------------------------------------------------------
624// Budget comparison
625// ---------------------------------------------------------------------------
626
627/// Committed baseline budget for one model (or remote lane).
628#[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    /// Aggregate mean WER baseline.
635    pub baseline_mean_wer: f64,
636    /// Absolute WER points allowed beyond relative rule (default 1.0).
637    #[serde(default = "default_abs_wer_points")]
638    pub max_absolute_wer_points: f64,
639    /// Relative WER regression fraction (default 0.10 = 10%).
640    #[serde(default = "default_rel_wer")]
641    pub max_relative_wer: f64,
642    /// Scenario group → baseline mean WER.
643    #[serde(default)]
644    pub scenario_baseline_wer: BTreeMap<String, f64>,
645    /// Relative scenario regression fraction (default 0.15).
646    #[serde(default = "default_scenario_rel")]
647    pub max_scenario_relative_wer: f64,
648    /// Maximum allowed silence false positives on the protected silence set.
649    #[serde(default)]
650    pub max_silence_false_positives: u32,
651    /// Maximum mean repetition ratio.
652    #[serde(default = "default_max_rep")]
653    pub max_mean_repetition_ratio: f64,
654    /// Maximum timestamp MAE (seconds) when backend is marked reliable.
655    #[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/// One comparison finding.
689#[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/// Result of comparing a candidate report to a committed budget.
705#[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
715/// Allowed aggregate mean WER: max(baseline * (1+rel), baseline + abs_points).
716pub 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
722/// Compare candidate report against budget. Fail-closed on violations.
723pub 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
830/// Exit code helper: 0 pass, 1 fail.
831pub fn budget_exit_code(cmp: &BudgetComparison) -> i32 {
832    if cmp.passed {
833        0
834    } else {
835        1
836    }
837}
838
839// ---------------------------------------------------------------------------
840// Built-in redistributable core (CI-safe)
841// ---------------------------------------------------------------------------
842
843/// Small redistributable core corpus for unit tests and CI (not production coverage).
844pub 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
945/// Baseline budget for the core corpus perfect-match path (CI negative tests mutate reports).
946pub 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        // Expand to satisfy production minima with synthetic placeholders.
981        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        // First fixture is silence control with empty reference.
1022        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                // Deliberately garbage hypothesis.
1057                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        // baseline 0.05: rel 10% → 0.055; abs +1.0 → 1.05 → allowed 1.05
1111        assert!((allowed_mean_wer(0.05, 0.10, 1.0) - 1.05).abs() < 1e-9);
1112        // baseline 0.50: rel 10% → 0.55; abs +1.0 → 1.50 → allowed 1.50
1113        assert!((allowed_mean_wer(0.50, 0.10, 1.0) - 1.50).abs() < 1e-9);
1114        // baseline 0.40 with abs 0.05: rel → 0.44; abs → 0.45 → 0.45
1115        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}