1use 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#[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
38pub 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 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 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
133pub 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 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
191pub 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
227pub 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
247pub 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}