Skip to main content

aurum_core/tts/
conformance.rs

1//! Adapter/model-pack conformance suite (JOE-1616).
2//!
3//! Fast fixture tests run on every PR. Real pinned-model integration stays
4//! scheduled/release-only (network + large artifacts).
5
6use super::adapter::{
7    list_adapters, preflight_manifest, AdapterDescriptor, ModelPackManifest, TrustMode,
8    ADAPTER_FAKE_SINE_V1, ADAPTER_KITTEN_ONNX_V1, ADAPTER_KOKORO_ONNX_V0, MANIFEST_SCHEMA_VERSION,
9};
10use super::catalogue::{lookup_model, DEFAULT_TTS_MODEL, KOKORO_TTS_MODEL};
11use super::pack::{load_pack_dir, verify_pack_artifacts};
12use serde::{Deserialize, Serialize};
13use std::path::Path;
14
15/// Machine-readable conformance report.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct ConformanceReport {
18    pub schema_version: u32,
19    pub adapter_id: String,
20    pub model_id: String,
21    pub passed: Vec<String>,
22    pub failed: Vec<ConformanceFailure>,
23    pub skipped: Vec<String>,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct ConformanceFailure {
28    pub check: String,
29    pub detail: String,
30}
31
32impl ConformanceReport {
33    pub fn ok(&self) -> bool {
34        self.failed.is_empty()
35    }
36}
37
38/// Run the fast conformance subset against a pack directory.
39pub fn run_pack_conformance(pack_dir: &Path) -> ConformanceReport {
40    let mut report = ConformanceReport {
41        schema_version: 1,
42        adapter_id: String::new(),
43        model_id: String::new(),
44        passed: vec![],
45        failed: vec![],
46        skipped: vec![],
47    };
48
49    let (root, manifest) = match load_pack_dir(pack_dir, true) {
50        Ok(v) => v,
51        Err(e) => {
52            // Retry without unverified opt-in distinction already inside load
53            report.failed.push(ConformanceFailure {
54                check: "load_pack".into(),
55                detail: e.to_string(),
56            });
57            return report;
58        }
59    };
60    report.adapter_id = manifest.adapter_id.clone();
61    report.model_id = manifest.model_id.clone();
62    report.passed.push("load_pack".into());
63
64    check(
65        &mut report,
66        "manifest_schema",
67        manifest.validate_schema().map_err(|e| e.to_string()),
68    );
69    check(
70        &mut report,
71        "adapter_preflight",
72        preflight_manifest(&manifest)
73            .map(|_| ())
74            .map_err(|e| e.to_string()),
75    );
76    check(
77        &mut report,
78        "artifact_verify",
79        verify_pack_artifacts(&root, &manifest).map_err(|e| e.to_string()),
80    );
81    check(
82        &mut report,
83        "languages_nonempty",
84        if manifest.languages.is_empty() {
85            Err("languages empty".into())
86        } else {
87            Ok(())
88        },
89    );
90    check(
91        &mut report,
92        "license_present",
93        if manifest.license.trim().is_empty() {
94            Err("license empty".into())
95        } else {
96            Ok(())
97        },
98    );
99    check(
100        &mut report,
101        "sample_rate",
102        if manifest.sample_rate_hz == 0 {
103            Err("sample_rate_hz is 0".into())
104        } else {
105            Ok(())
106        },
107    );
108
109    // Fake adapter can synthesize a short tone without ONNX.
110    if manifest.adapter_id == ADAPTER_FAKE_SINE_V1 {
111        match synthesize_fake_sine_ms(50) {
112            Ok(pcm) if !pcm.is_empty() && pcm.iter().all(|s| s.is_finite()) => {
113                report.passed.push("fake_synth_finite_pcm".into());
114            }
115            Ok(_) => report.failed.push(ConformanceFailure {
116                check: "fake_synth_finite_pcm".into(),
117                detail: "empty or non-finite".into(),
118            }),
119            Err(e) => report.failed.push(ConformanceFailure {
120                check: "fake_synth_finite_pcm".into(),
121                detail: e,
122            }),
123        }
124    } else {
125        report
126            .skipped
127            .push("real_onnx_synthesis (scheduled/integration only)".into());
128    }
129
130    report
131}
132
133/// Conformance for built-in Kitten catalogue metadata (no network).
134pub fn run_kitten_catalogue_conformance() -> ConformanceReport {
135    let mut report = ConformanceReport {
136        schema_version: 1,
137        adapter_id: ADAPTER_KITTEN_ONNX_V1.into(),
138        model_id: DEFAULT_TTS_MODEL.into(),
139        passed: vec![],
140        failed: vec![],
141        skipped: vec![],
142    };
143    match lookup_model(DEFAULT_TTS_MODEL) {
144        Ok(info) => {
145            report.passed.push("catalogue_lookup".into());
146            if info.adapter != ADAPTER_KITTEN_ONNX_V1 {
147                report.failed.push(ConformanceFailure {
148                    check: "adapter_id".into(),
149                    detail: format!("expected {ADAPTER_KITTEN_ONNX_V1}, got {}", info.adapter),
150                });
151            } else {
152                report.passed.push("adapter_id".into());
153            }
154            if !info.shipped {
155                report.failed.push(ConformanceFailure {
156                    check: "shipped".into(),
157                    detail: "default model must be shipped".into(),
158                });
159            } else {
160                report.passed.push("shipped".into());
161            }
162            if info.sample_rate_hz == 0 {
163                report.failed.push(ConformanceFailure {
164                    check: "sample_rate".into(),
165                    detail: "zero".into(),
166                });
167            } else {
168                report.passed.push("sample_rate".into());
169            }
170            // Pins present
171            if info.onnx.sha256.len() == 64 && info.voices.sha256.len() == 64 {
172                report.passed.push("pins".into());
173            } else {
174                report.failed.push(ConformanceFailure {
175                    check: "pins".into(),
176                    detail: "sha256 length".into(),
177                });
178            }
179            report
180                .skipped
181                .push("warm_cache_synth (integration; requires model on disk)".into());
182        }
183        Err(e) => report.failed.push(ConformanceFailure {
184            check: "catalogue_lookup".into(),
185            detail: e.to_string(),
186        }),
187    }
188    report
189}
190
191/// Ensure every registered adapter appears in the suite policy.
192pub fn adapter_registry_complete() -> Result<(), String> {
193    let ids: Vec<_> = list_adapters().into_iter().map(|a| a.id).collect();
194    for need in [
195        ADAPTER_KITTEN_ONNX_V1,
196        ADAPTER_FAKE_SINE_V1,
197        ADAPTER_KOKORO_ONNX_V0,
198    ] {
199        if !ids.contains(&need) {
200            return Err(format!("missing adapter {need}"));
201        }
202    }
203    let kokoro = list_adapters()
204        .into_iter()
205        .find(|a| a.id == ADAPTER_KOKORO_ONNX_V0)
206        .ok_or_else(|| "kokoro adapter missing".to_string())?;
207    if !kokoro.synthesis_supported {
208        return Err("kokoro-onnx-v0 must support synthesis after JOE-1618".into());
209    }
210    let info = lookup_model(KOKORO_TTS_MODEL).map_err(|e| e.to_string())?;
211    if !info.shipped || info.adapter != ADAPTER_KOKORO_ONNX_V0 {
212        return Err("kokoro-82m-int8 catalogue entry must be shipped with kokoro-onnx-v0".into());
213    }
214    Ok(())
215}
216
217fn check(report: &mut ConformanceReport, name: &str, res: std::result::Result<(), String>) {
218    match res {
219        Ok(()) => report.passed.push(name.into()),
220        Err(detail) => report.failed.push(ConformanceFailure {
221            check: name.into(),
222            detail,
223        }),
224    }
225}
226
227/// Deterministic short mono tone for the fake adapter.
228pub fn synthesize_fake_sine_ms(duration_ms: u64) -> std::result::Result<Vec<f32>, String> {
229    let sr = 24_000u32;
230    let n = (sr as u64 * duration_ms.max(1) / 1000) as usize;
231    if n == 0 {
232        return Err("zero samples".into());
233    }
234    let mut pcm = Vec::with_capacity(n);
235    let freq = 440.0f32;
236    for i in 0..n {
237        let t = i as f32 / sr as f32;
238        let s = (2.0 * std::f32::consts::PI * freq * t).sin() * 0.2;
239        if !s.is_finite() {
240            return Err("non-finite".into());
241        }
242        pcm.push(s);
243    }
244    Ok(pcm)
245}
246
247/// Build a Kitten-shaped manifest from catalogue pins (for inspect/export).
248pub fn kitten_builtin_manifest() -> ModelPackManifest {
249    let info = lookup_model(DEFAULT_TTS_MODEL).expect("default model");
250    ModelPackManifest {
251        schema_version: MANIFEST_SCHEMA_VERSION,
252        adapter_id: ADAPTER_KITTEN_ONNX_V1.into(),
253        adapter_version: 1,
254        model_id: info.id.into(),
255        sample_rate_hz: info.sample_rate_hz,
256        channels: 1,
257        max_phoneme_tokens: info.max_phoneme_tokens,
258        languages: info.languages.iter().map(|s| (*s).to_string()).collect(),
259        license: info.license.into(),
260        trust: TrustMode::Builtin,
261        artifacts: vec![
262            super::adapter::ManifestArtifact {
263                role: "onnx".into(),
264                filename: info.onnx.filename.into(),
265                sha256: Some(info.onnx.sha256.into()),
266                size_bytes: Some(info.onnx.approx_bytes),
267            },
268            super::adapter::ManifestArtifact {
269                role: "voices".into(),
270                filename: info.voices.filename.into(),
271                sha256: Some(info.voices.sha256.into()),
272                size_bytes: Some(info.voices.approx_bytes),
273            },
274            super::adapter::ManifestArtifact {
275                role: "config".into(),
276                filename: info.config.filename.into(),
277                sha256: Some(info.config.sha256.into()),
278                size_bytes: Some(info.config.approx_bytes),
279            },
280        ],
281        voices: super::catalogue::VOICES
282            .iter()
283            .filter(|v| v.model == info.id)
284            .map(|v| super::adapter::ManifestVoice {
285                id: v.id.into(),
286                internal_key: v.internal_key.into(),
287                language: v.language.into(),
288                notes: v.notes.into(),
289            })
290            .collect(),
291        source: Some(format!("huggingface:{}", info.hf_repo)),
292        notes: Some(info.notes.into()),
293    }
294}
295
296pub fn describe_adapter(a: &AdapterDescriptor) -> String {
297    format!(
298        "{} v{} — {} (synth={}, roles={:?})",
299        a.id, a.version, a.description, a.synthesis_supported, a.required_artifact_roles
300    )
301}
302
303#[cfg(test)]
304mod tests {
305    use super::super::pack::write_fake_sine_pack;
306    use super::*;
307    use tempfile::tempdir;
308
309    #[test]
310    fn fake_pack_conformance_passes() {
311        let dir = tempdir().unwrap();
312        let pack = dir.path().join("p");
313        write_fake_sine_pack(&pack, "fake-1").unwrap();
314        let r = run_pack_conformance(&pack);
315        assert!(r.ok(), "{:?}", r.failed);
316        assert!(r.passed.iter().any(|p| p == "fake_synth_finite_pcm"));
317    }
318
319    #[test]
320    fn kitten_catalogue_conformance() {
321        let r = run_kitten_catalogue_conformance();
322        assert!(r.ok(), "{:?}", r.failed);
323    }
324
325    #[test]
326    fn registry_has_two_synth_adapters() {
327        adapter_registry_complete().unwrap();
328        let synth: Vec<_> = list_adapters()
329            .into_iter()
330            .filter(|a| a.synthesis_supported)
331            .collect();
332        assert!(synth.len() >= 2);
333    }
334
335    #[test]
336    fn broken_digest_fails_suite() {
337        let dir = tempdir().unwrap();
338        let pack = dir.path().join("p");
339        let mut m = write_fake_sine_pack(&pack, "x").unwrap();
340        m.artifacts[0].sha256 = Some("00".repeat(32));
341        super::super::pack::write_manifest(&pack, &m).unwrap();
342        let r = run_pack_conformance(&pack);
343        assert!(!r.ok());
344    }
345}