Skip to main content

polyvoice/models/
mod.rs

1//! Model registry — manifest-driven downloads with SHA-256 verification,
2//! adapter selection by config string, and self-describing model metadata.
3
4pub mod adapter;
5pub mod download;
6pub mod manifest;
7pub mod metadata;
8pub mod verify;
9pub use adapter::{AdapterError, AdapterFactory, AdapterRegistry, AdapterStage, BuiltinAdapter};
10pub use download::{
11    DownloadError, download_with_checksum, download_with_checksum_and_signature, verify_sha256,
12};
13pub use manifest::{
14    Manifest, ManifestError, ModelEntry, ProfileEntry, SCHEMA_V1, SCHEMA_V2, is_supported_schema,
15};
16pub use metadata::{MetaSource, ModelConfigMeta, load_model_config, read_onnx_metadata_props};
17
18use crate::types::Profile;
19use std::path::{Path, PathBuf};
20use std::sync::OnceLock;
21
22/// The default manifest shipped with the crate. Embedded at compile time.
23pub const DEFAULT_MANIFEST_TOML: &str = include_str!("manifest.toml");
24
25/// One VBx PLDA artifact: manifest id, on-disk filename, sha256, byte size.
26///
27/// Not profile-resolved — only pulled when the `vbx` clusterer is selected
28/// without a local PLDA dir. SHA-256 only until minisign signatures land
29/// (same optional-model pattern as `sortformer_v2`).
30#[derive(Clone, Copy, Debug)]
31pub struct VbxPldaArtifact {
32    pub id: &'static str,
33    pub filename: &'static str,
34    pub sha256: &'static str,
35    pub size: u64,
36}
37
38/// Manifest model ids of the six precomputed VBx PLDA `.npy` files, in
39/// `PldaModel::from_dir` order. This is the only hardcoded part of the
40/// artifact table; filenames, hashes and sizes come from the embedded
41/// manifest via [`vbx_plda_artifacts`].
42pub const VBX_PLDA_MODEL_IDS: &[&str] = &[
43    "vbx_plda_transform",
44    "vbx_plda_phi_computed",
45    "vbx_plda_mean1",
46    "vbx_plda_mean2",
47    "vbx_plda_lda",
48    "vbx_plda_mu",
49];
50
51/// The six precomputed VBx PLDA artifacts, in [`VBX_PLDA_MODEL_IDS`] order.
52///
53/// Built from [`default_manifest`] on first call so `manifest.toml` stays the
54/// single source of truth for ids + integrity checks (previously the sha256 /
55/// size / filename values were hardcoded here and kept consistent with the
56/// manifest by a test). Entry strings are leaked once so the table keeps
57/// handing out `&'static str`; bounded to six short manifest strings per
58/// process.
59#[allow(clippy::panic)] // missing VBx PLDA entry = embedded static-asset bug, same
60// rationale as `default_manifest`'s `expect`; covered by unit tests on every build.
61pub fn vbx_plda_artifacts() -> &'static [VbxPldaArtifact; 6] {
62    static TABLE: OnceLock<[VbxPldaArtifact; 6]> = OnceLock::new();
63    TABLE.get_or_init(|| {
64        let manifest = default_manifest();
65        std::array::from_fn(|i| {
66            let id = VBX_PLDA_MODEL_IDS[i];
67            // A missing entry is a static-asset bug, same class as a malformed
68            // embedded manifest (see `default_manifest`); the
69            // `optional_vbx_plda_entries_present_but_not_in_profiles` test
70            // covers this on every build.
71            let entry = manifest.model(id).unwrap_or_else(|| {
72                panic!("embedded manifest is missing VBx PLDA entry '{id}' — static-asset bug")
73            });
74            VbxPldaArtifact {
75                id,
76                filename: leak_manifest_string(&entry.filename),
77                sha256: leak_manifest_string(&entry.sha256),
78                size: entry.size.unwrap_or_else(|| {
79                    panic!("manifest VBx PLDA entry '{id}' has no size — static-asset bug")
80                }),
81            }
82        })
83    })
84}
85
86/// Leak a manifest string so [`vbx_plda_artifacts`] can keep the `&'static str`
87/// shape callers already use. Runs once per entry per process.
88fn leak_manifest_string(s: &str) -> &'static str {
89    Box::leak(s.to_owned().into_boxed_str())
90}
91
92/// Backwards-compatible iterable over [`vbx_plda_artifacts`]: keeps
93/// `for art in VBX_PLDA_ARTIFACTS` call sites working now that the table is
94/// manifest-derived instead of a const slice.
95#[derive(Clone, Copy, Debug)]
96pub struct VbxPldaArtifacts;
97
98/// The six precomputed VBx PLDA `.npy` files, in `PldaModel::from_dir` order.
99pub const VBX_PLDA_ARTIFACTS: VbxPldaArtifacts = VbxPldaArtifacts;
100
101impl IntoIterator for VbxPldaArtifacts {
102    type Item = &'static VbxPldaArtifact;
103    type IntoIter = std::slice::Iter<'static, VbxPldaArtifact>;
104
105    fn into_iter(self) -> Self::IntoIter {
106        vbx_plda_artifacts().iter()
107    }
108}
109
110/// { true }
111/// pub fn default_manifest() -> Manifest
112/// { true }
113/// Parse the bundled default manifest. Panics in debug if the embedded TOML is
114/// malformed — that's a static asset bug caught by `cargo test`.
115///
116/// This and [`vbx_plda_artifacts`] are the only places the project allows
117/// panics on the embedded manifest: the asset is shipped with the crate, and
118/// the `embedded_manifest_parses` / VBx PLDA entry tests verify it on every
119/// build.
120#[allow(clippy::expect_used)]
121pub fn default_manifest() -> Manifest {
122    // SAFETY: embedded manifest.toml is a compile-time static asset;
123    // test `embedded_manifest_parses` verifies it on every build.
124    Manifest::from_toml_str(DEFAULT_MANIFEST_TOML)
125        .expect("embedded manifest.toml must parse — this is a static-asset bug")
126}
127
128/// Errors from `ModelRegistry` operations.
129#[derive(Debug, thiserror::Error)]
130pub enum RegistryError {
131    #[error("model '{model_id}' not found in manifest")]
132    ModelNotFound { model_id: String },
133    #[error(
134        "model '{model_id}' has no signature in the manifest — release builds require a \
135         minisign signature for every profile-resolved model (a manifest that drops the \
136         signature would otherwise silently downgrade authenticity to a self-consistent hash)"
137    )]
138    UnsignedModel { model_id: String },
139    #[error("profile '{profile}' not found in manifest")]
140    ProfileNotFound { profile: String },
141    #[error("custom profile cannot be resolved by registry — caller must supply models")]
142    CustomProfileUnresolvable,
143    #[error("cache directory {path} is not writable")]
144    CacheNotWritable { path: PathBuf },
145    #[error("model '{model_id}' is not present in cache and offline mode is requested")]
146    OfflineMissing { model_id: String },
147    #[error("manifest error: {0}")]
148    Manifest(#[from] ManifestError),
149    #[error("download error: {0}")]
150    Download(#[from] DownloadError),
151    #[error("io error on {path}: {source}")]
152    Io {
153        path: PathBuf,
154        #[source]
155        source: std::io::Error,
156    },
157}
158
159/// Resolved file paths for the segmenter and embedder of a profile.
160#[derive(Debug, Clone)]
161pub struct ProfileModels {
162    pub segmenter_path: PathBuf,
163    pub embedder_path: PathBuf,
164}
165
166/// A model registry: holds a manifest + a cache directory, and downloads/verifies
167/// models on demand.
168#[derive(Debug, Clone)]
169pub struct ModelRegistry {
170    manifest: Manifest,
171    cache_dir: PathBuf,
172    /// When true (the default in release builds), profile resolution refuses
173    /// manifest entries without a minisign signature (`UnsignedModel`). Debug
174    /// builds stay lenient so local fixtures don't need signatures.
175    require_signatures: bool,
176}
177
178/// Signature presence is enforced for profile-resolved models in release
179/// builds; debug builds keep the lenient transition behavior.
180const REQUIRE_SIGNATURES_DEFAULT: bool = cfg!(not(debug_assertions));
181
182impl ModelRegistry {
183    /// { true }
184    /// `pub fn default() -> Result<Self, RegistryError>`
185    /// { ret.as_ref().map_or(true, |r| r.cache_dir().exists()) }
186    /// Build a registry rooted at the user's cache directory (`~/.cache/polyvoice/models`
187    /// on Linux, `~/Library/Caches/polyvoice/models` on macOS, `%LOCALAPPDATA%\polyvoice\models`
188    /// on Windows) using the embedded default manifest.
189    #[allow(clippy::should_implement_trait)]
190    pub fn default() -> Result<Self, RegistryError> {
191        let cache = dirs::cache_dir()
192            .ok_or_else(|| RegistryError::CacheNotWritable {
193                path: PathBuf::from("(unresolved-cache-dir)"),
194            })?
195            .join("polyvoice")
196            .join("models");
197        Self::with_cache_dir(cache)
198    }
199
200    /// { true }
201    /// `pub fn with_cache_dir(path: impl AsRef<Path>) -> Result<Self, RegistryError>`
202    /// { ret.as_ref().map_or(true, |r| r.cache_dir().exists()) }
203    /// Build a registry with a caller-specified cache directory and the embedded
204    /// default manifest. Creates the directory if it doesn't exist.
205    pub fn with_cache_dir(path: impl AsRef<Path>) -> Result<Self, RegistryError> {
206        let path = path.as_ref().to_path_buf();
207        std::fs::create_dir_all(&path).map_err(|e| RegistryError::Io {
208            path: path.clone(),
209            source: e,
210        })?;
211        Ok(Self {
212            manifest: default_manifest(),
213            cache_dir: path,
214            require_signatures: REQUIRE_SIGNATURES_DEFAULT,
215        })
216    }
217
218    /// { true }
219    /// pub fn with_manifest_override(mut self, manifest: Manifest) -> Self
220    /// { true }
221    /// Override the manifest. Useful for tests that need a fixture manifest
222    /// without hitting the network.
223    #[cfg(test)]
224    pub fn with_manifest_override(mut self, manifest: Manifest) -> Self {
225        self.manifest = manifest;
226        self
227    }
228
229    /// Test-only: force the signature-presence strictness regardless of build
230    /// profile, so both the strict and lenient paths are testable in debug.
231    #[cfg(test)]
232    pub fn with_require_signatures(mut self, require: bool) -> Self {
233        self.require_signatures = require;
234        self
235    }
236
237    /// { true }
238    /// `pub fn with_manifest( manifest: Manifest, cache_dir: impl AsRef<Path>, ) -> Result<Self, RegistryError>`
239    /// { ret.as_ref().map_or(true, |r| r.cache_dir().exists()) }
240    /// Build a registry with a custom manifest and cache directory.
241    #[cfg(test)]
242    pub fn with_manifest(
243        manifest: Manifest,
244        cache_dir: impl AsRef<Path>,
245    ) -> Result<Self, RegistryError> {
246        let path = cache_dir.as_ref().to_path_buf();
247        std::fs::create_dir_all(&path).map_err(|e| RegistryError::Io {
248            path: path.clone(),
249            source: e,
250        })?;
251        Ok(Self {
252            manifest,
253            cache_dir: path,
254            require_signatures: REQUIRE_SIGNATURES_DEFAULT,
255        })
256    }
257
258    /// { true }
259    /// pub fn cache_dir(&self) -> &Path
260    /// { ret == self.cache_dir }
261    pub fn cache_dir(&self) -> &Path {
262        &self.cache_dir
263    }
264
265    /// { true }
266    /// pub fn manifest(&self) -> &Manifest
267    /// { ret == self.manifest }
268    pub fn manifest(&self) -> &Manifest {
269        &self.manifest
270    }
271
272    /// { !model_id.is_empty() }
273    /// `pub fn ensure(&self, model_id: &str) -> Result<PathBuf, RegistryError>`
274    /// { ret.as_ref().map_or(true, |p| p.exists()) }
275    /// Ensure the model with id `model_id` is present in cache and SHA-256-verified.
276    /// Downloads if missing. Idempotent: returns immediately when the cached file
277    /// already matches the expected hash.
278    pub fn ensure(&self, model_id: &str) -> Result<PathBuf, RegistryError> {
279        let entry = self
280            .manifest
281            .model(model_id)
282            .ok_or_else(|| RegistryError::ModelNotFound {
283                model_id: model_id.to_owned(),
284            })?;
285        let dest = self.cache_dir.join(&entry.filename);
286        download_with_checksum_and_signature(
287            &entry.url,
288            &entry.sha256,
289            entry.signature.as_deref(),
290            &dest,
291        )?;
292        Ok(dest)
293    }
294
295    /// Ensure all six VBx PLDA `.npy` files are present in the cache (SHA-256
296    /// verified, optionally minisign-verified when signatures land). Returns the
297    /// directory that holds them — pass it to
298    /// [`crate::clusterer::vbx::VbxClusterer::from_dir`].
299    ///
300    /// Files land next to other cached models under [`Self::cache_dir`]; their
301    /// filenames match the `PldaModel::from_dir` set (`plda_transform.npy`, …).
302    pub fn ensure_vbx_plda_dir(&self) -> Result<PathBuf, RegistryError> {
303        for id in VBX_PLDA_MODEL_IDS {
304            self.ensure(id)?;
305        }
306        Ok(self.cache_dir.clone())
307    }
308
309    /// { !model_id.is_empty() }
310    /// `pub fn ensure_in_cache_only(&self, model_id: &str) -> Result<PathBuf, RegistryError>`
311    /// { ret.as_ref().map_or(true, |p| p.exists()) }
312    /// Test-only helper that bypasses SHA-256 verification.
313    #[doc(hidden)]
314    /// Same as `ensure` but never makes a network call. Returns `OfflineMissing`
315    /// if the file is not in cache or has a wrong hash.
316    #[cfg(test)] // test-only: bypasses SHA-256/signature verification — never reachable in release
317    pub fn ensure_in_cache_only(&self, model_id: &str) -> Result<PathBuf, RegistryError> {
318        let entry = self
319            .manifest
320            .model(model_id)
321            .ok_or_else(|| RegistryError::ModelNotFound {
322                model_id: model_id.to_owned(),
323            })?;
324        let dest = self.cache_dir.join(&entry.filename);
325        if !dest.exists() {
326            return Err(RegistryError::OfflineMissing {
327                model_id: model_id.to_owned(),
328            });
329        }
330        // Skip hash check in cache-only path; it's expensive and tests pre-place
331        // exact-content files. Production callers should use `ensure` not this.
332        Ok(dest)
333    }
334
335    /// Enforce signature presence for a profile-resolved model when strict mode
336    /// is on. Runs BEFORE any network access, so a tampered manifest that drops
337    /// a signature fails fast instead of downloading. Ad-hoc single-model
338    /// `ensure` stays lenient by design (dev/test convenience); only profile
339    /// resolution is strict.
340    fn require_signature_for(&self, model_id: &str) -> Result<(), RegistryError> {
341        if !self.require_signatures {
342            return Ok(());
343        }
344        let entry = self
345            .manifest
346            .model(model_id)
347            .ok_or_else(|| RegistryError::ModelNotFound {
348                model_id: model_id.to_owned(),
349            })?;
350        if entry.signature.is_none() {
351            return Err(RegistryError::UnsignedModel {
352                model_id: model_id.to_owned(),
353            });
354        }
355        Ok(())
356    }
357
358    /// { true }
359    /// `pub fn ensure_for_profile(&self, profile: Profile) -> Result<ProfileModels, RegistryError>`
360    /// { ret.as_ref().map_or(true, |p| p.segmenter_path.exists() && p.embedder_path.exists()) }
361    /// Resolve all models for a profile, downloading any that are missing.
362    /// In release builds every profile-resolved model must carry a manifest
363    /// signature (`UnsignedModel` otherwise); all bundled models are signed.
364    pub fn ensure_for_profile(&self, profile: Profile) -> Result<ProfileModels, RegistryError> {
365        self.ensure_for_profile_with(profile, Self::ensure)
366    }
367
368    /// { true }
369    /// `pub fn ensure_in_cache_only_for_profile( &self, profile: Profile, ) -> Result<ProfileModels, RegistryError>`
370    /// { ret.as_ref().map_or(true, |p| p.segmenter_path.exists() && p.embedder_path.exists()) }
371    /// Same as `ensure_for_profile` but never touches the network.
372    #[cfg(test)]
373    pub fn ensure_in_cache_only_for_profile(
374        &self,
375        profile: Profile,
376    ) -> Result<ProfileModels, RegistryError> {
377        self.ensure_for_profile_with(profile, Self::ensure_in_cache_only)
378    }
379
380    /// Shared body of `ensure_for_profile` and `ensure_in_cache_only_for_profile`:
381    /// resolves the profile's segmenter/embedder ids, enforces signature
382    /// presence, then delegates each model to `ensure_fn` (`Self::ensure`
383    /// online, `Self::ensure_in_cache_only` offline — mirroring the same
384    /// strictness so the offline test path can exercise both modes).
385    fn ensure_for_profile_with(
386        &self,
387        profile: Profile,
388        ensure_fn: impl Fn(&Self, &str) -> Result<PathBuf, RegistryError>,
389    ) -> Result<ProfileModels, RegistryError> {
390        if profile == Profile::Custom {
391            return Err(RegistryError::CustomProfileUnresolvable);
392        }
393        let prof = self
394            .manifest
395            .profile(profile.manifest_id())
396            .ok_or_else(|| RegistryError::ProfileNotFound {
397                profile: profile.manifest_id().to_owned(),
398            })?;
399        self.require_signature_for(&prof.segmenter)?;
400        self.require_signature_for(&prof.embedder)?;
401        let segmenter_path = ensure_fn(self, &prof.segmenter)?;
402        let embedder_path = ensure_fn(self, &prof.embedder)?;
403        Ok(ProfileModels {
404            segmenter_path,
405            embedder_path,
406        })
407    }
408}
409
410#[allow(clippy::unwrap_used)]
411#[cfg(test)]
412pub(crate) mod tests_helpers {
413    /// Minimal manifest used by registry unit tests. SHA-256 below is hash of "hello":
414    /// 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
415    pub const TINY_MANIFEST: &str = r#"
416        schema = "polyvoice-models-v1"
417        [profiles.mobile]
418        segmenter = "hello_model"
419        embedder  = "hello_model"
420        [profiles.balanced]
421        segmenter = "hello_model"
422        embedder  = "hello_model"
423        [models.hello_model]
424        url      = "file:///dev/null"
425        sha256   = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
426        size     = 5
427        filename = "hello.bin"
428    "#;
429}
430
431#[allow(clippy::unwrap_used)]
432#[cfg(test)]
433mod tests {
434    use super::*;
435    use crate::types::Profile;
436    use std::path::Path;
437    use tempfile::TempDir;
438
439    #[test]
440    fn embedded_manifest_parses() {
441        // This will panic if the bundled manifest.toml is malformed.
442        let m = default_manifest();
443        assert!(
444            is_supported_schema(&m.schema),
445            "embedded schema must be v1 or v2, got {}",
446            m.schema
447        );
448        assert!(m.profiles.contains_key("mobile"));
449        assert!(m.profiles.contains_key("balanced"));
450    }
451
452    #[test]
453    fn embedded_manifest_is_v2_with_adapter_metadata() {
454        let m = default_manifest();
455        assert_eq!(m.schema, SCHEMA_V2);
456        // Every shipped model carries adapter_type + license (schema v2).
457        for (id, entry) in &m.models {
458            assert!(
459                entry.adapter_type.is_some(),
460                "model '{id}' missing adapter_type"
461            );
462            assert!(entry.license.is_some(), "model '{id}' missing license");
463            assert!(entry.version.is_some(), "model '{id}' missing version");
464        }
465        // latest aliases resolve to pinned model ids.
466        assert_eq!(
467            m.resolve_model_ref("segmenter", "latest"),
468            Some("powerset_fp32")
469        );
470        assert_eq!(
471            m.resolve_model_ref("embedder", "latest"),
472            Some("wespeaker_resnet34")
473        );
474        assert_eq!(m.resolve_model_ref("vad", "latest"), Some("silero_vad"));
475    }
476
477    #[test]
478    fn embedded_manifest_lists_legacy_models() {
479        let m = default_manifest();
480        assert!(m.models.contains_key("silero_vad"));
481        assert!(m.models.contains_key("wespeaker_resnet34"));
482    }
483
484    #[test]
485    fn profiles_share_segmenter_and_embedder_in_v2_hotfix() {
486        // V2 hotfix (2026-05-18): both Mobile and Balanced use ResNet34 + AHC
487        // because CAM++ ONNX produces near-identical embeddings (cosine sim ~0.85
488        // between different speakers). NME-SC also falls back to AHC on small n.
489        // Revert this test once CAM++ is re-converted and NME-SC is fixed.
490        let m = default_manifest();
491        let mob = m.profile("mobile").unwrap();
492        let bal = m.profile("balanced").unwrap();
493        assert_eq!(mob.segmenter, bal.segmenter, "both use powerset");
494        assert_eq!(
495            mob.embedder, bal.embedder,
496            "both use resnet34 (CAM++ broken)"
497        );
498    }
499
500    #[test]
501    fn registry_default_uses_user_cache() {
502        let r = ModelRegistry::default().expect("default cache dir resolvable");
503        let path = r.cache_dir().to_path_buf();
504        assert!(path.ends_with("polyvoice/models") || path.ends_with("polyvoice\\models"));
505    }
506
507    #[test]
508    fn registry_with_cache_dir_creates_dir() {
509        let tmp = TempDir::new().unwrap();
510        let path = tmp.path().join("nested/models");
511        let r = ModelRegistry::with_cache_dir(&path).unwrap();
512        assert!(path.exists());
513        assert_eq!(r.cache_dir(), path.as_path());
514    }
515
516    #[test]
517    fn ensure_returns_err_for_unknown_model_id() {
518        let tmp = TempDir::new().unwrap();
519        let r = ModelRegistry::with_cache_dir(tmp.path()).unwrap();
520        let err = r
521            .ensure_in_cache_only("ghost")
522            .expect_err("must be missing");
523        assert!(matches!(err, RegistryError::ModelNotFound { .. }));
524    }
525
526    #[test]
527    fn ensure_in_cache_only_succeeds_when_file_present() {
528        let tmp = TempDir::new().unwrap();
529        let manifest =
530            Manifest::from_toml_str(crate::models::tests_helpers::TINY_MANIFEST).unwrap();
531        let r = ModelRegistry::with_cache_dir(tmp.path())
532            .unwrap()
533            .with_manifest_override(manifest);
534
535        let cached = tmp.path().join("hello.bin");
536        std::fs::write(&cached, b"hello").unwrap();
537        let path = r.ensure_in_cache_only("hello_model").unwrap();
538        assert_eq!(path, cached);
539    }
540
541    #[test]
542    fn ensure_for_profile_uses_manifest_lookup() {
543        let tmp = TempDir::new().unwrap();
544        let manifest =
545            Manifest::from_toml_str(crate::models::tests_helpers::TINY_MANIFEST).unwrap();
546        let r = ModelRegistry::with_cache_dir(tmp.path())
547            .unwrap()
548            .with_manifest_override(manifest)
549            // TINY_MANIFEST is unsigned; pin the lenient mode so this lookup
550            // test also passes under `cargo test --release`.
551            .with_require_signatures(false);
552
553        std::fs::write(tmp.path().join("hello.bin"), b"hello").unwrap();
554
555        let bundle = r.ensure_in_cache_only_for_profile(Profile::Mobile).unwrap();
556        assert_eq!(bundle.segmenter_path, tmp.path().join("hello.bin"));
557        assert_eq!(bundle.embedder_path, tmp.path().join("hello.bin"));
558    }
559
560    /// Signed variant of TINY_MANIFEST — the signature value only needs to be
561    /// present for the strictness check (cryptographic verification happens on
562    /// the download path, not here).
563    const TINY_MANIFEST_SIGNED: &str = r#"
564        schema = "polyvoice-models-v1"
565        [profiles.mobile]
566        segmenter = "hello_model"
567        embedder  = "hello_model"
568        [profiles.balanced]
569        segmenter = "hello_model"
570        embedder  = "hello_model"
571        [models.hello_model]
572        url      = "file:///dev/null"
573        sha256   = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
574        size     = 5
575        filename = "hello.bin"
576        signature = "untrusted comment: fixture\nRWQfixturesignature"
577    "#;
578
579    #[test]
580    fn strict_profile_resolution_rejects_unsigned_model() {
581        let tmp = TempDir::new().unwrap();
582        let manifest =
583            Manifest::from_toml_str(crate::models::tests_helpers::TINY_MANIFEST).unwrap();
584        let r = ModelRegistry::with_cache_dir(tmp.path())
585            .unwrap()
586            .with_manifest_override(manifest)
587            .with_require_signatures(true);
588
589        // Fails before any network/cache access — both profile paths agree.
590        let err = r.ensure_for_profile(Profile::Mobile).expect_err("unsigned");
591        assert!(
592            matches!(err, RegistryError::UnsignedModel { ref model_id } if model_id == "hello_model")
593        );
594        let err = r
595            .ensure_in_cache_only_for_profile(Profile::Mobile)
596            .expect_err("unsigned");
597        assert!(matches!(err, RegistryError::UnsignedModel { .. }));
598    }
599
600    #[test]
601    fn strict_profile_resolution_accepts_signed_model() {
602        let tmp = TempDir::new().unwrap();
603        let manifest = Manifest::from_toml_str(TINY_MANIFEST_SIGNED).unwrap();
604        let r = ModelRegistry::with_cache_dir(tmp.path())
605            .unwrap()
606            .with_manifest_override(manifest)
607            .with_require_signatures(true);
608
609        std::fs::write(tmp.path().join("hello.bin"), b"hello").unwrap();
610        let bundle = r.ensure_in_cache_only_for_profile(Profile::Mobile).unwrap();
611        assert_eq!(bundle.segmenter_path, tmp.path().join("hello.bin"));
612    }
613
614    #[test]
615    fn every_profile_model_is_signed() {
616        // Profile resolution in release builds requires signatures. Optional
617        // models (e.g. sortformer_v2) are not profile-resolved and may ship
618        // with SHA-256-only integrity until a signed release artifact exists.
619        let m = default_manifest();
620        for (profile_id, prof) in &m.profiles {
621            for model_id in [&prof.segmenter, &prof.embedder] {
622                let entry = m.models.get(model_id).unwrap_or_else(|| {
623                    panic!("profile '{profile_id}' references missing model '{model_id}'")
624                });
625                assert!(
626                    entry.signature.is_some(),
627                    "profile model '{model_id}' (via '{profile_id}') has no signature — \
628                     release profile resolution would fail"
629                );
630            }
631        }
632    }
633
634    #[test]
635    fn optional_sortformer_entry_present_but_not_in_profiles() {
636        let m = default_manifest();
637        let entry = m.model("sortformer_v2").expect("sortformer_v2 in manifest");
638        assert_eq!(entry.adapter_type.as_deref(), Some("sortformer-v2"));
639        assert_eq!(entry.license.as_deref(), Some("CC-BY-4.0"));
640        assert_eq!(entry.num_speakers, Some(4));
641        // Must never be a default profile target (opt-in download only).
642        for (pid, prof) in &m.profiles {
643            assert_ne!(
644                prof.segmenter, "sortformer_v2",
645                "profile {pid} must not pull sortformer as segmenter"
646            );
647            assert_ne!(
648                prof.embedder, "sortformer_v2",
649                "profile {pid} must not pull sortformer as embedder"
650            );
651        }
652    }
653
654    #[test]
655    fn optional_vbx_plda_entries_present_but_not_in_profiles() {
656        let m = default_manifest();
657        let artifacts = vbx_plda_artifacts();
658        assert_eq!(VBX_PLDA_MODEL_IDS.len(), artifacts.len());
659        for (art, listed_id) in artifacts.iter().zip(VBX_PLDA_MODEL_IDS.iter()) {
660            assert_eq!(art.id, *listed_id);
661            let entry = m
662                .model(art.id)
663                .unwrap_or_else(|| panic!("missing manifest entry {}", art.id));
664            // The table is manifest-derived, so these hold by construction;
665            // keep the explicit checks to pin the derivation itself.
666            assert_eq!(entry.sha256, art.sha256, "{} sha256 mismatch", art.id);
667            assert_eq!(entry.size, Some(art.size), "{} size mismatch", art.id);
668            assert_eq!(entry.filename, art.filename, "{} filename mismatch", art.id);
669            assert_eq!(entry.adapter_type.as_deref(), Some("vbx-plda"));
670            assert_eq!(entry.license.as_deref(), Some("CC-BY-4.0"));
671            // Optional path: no minisign until a human signs the release assets.
672            assert!(
673                entry.signature.is_none(),
674                "{} must stay unsigned until a release engineer signs it \
675                 (profile resolution never pulls these)",
676                art.id
677            );
678            assert!(
679                entry.url.starts_with("https://"),
680                "{} url must be https",
681                art.id
682            );
683            for (pid, prof) in &m.profiles {
684                assert_ne!(
685                    prof.segmenter.as_str(),
686                    art.id,
687                    "profile {pid} must not pull PLDA as segmenter"
688                );
689                assert_ne!(
690                    prof.embedder.as_str(),
691                    art.id,
692                    "profile {pid} must not pull PLDA as embedder"
693                );
694            }
695        }
696    }
697
698    #[test]
699    fn ensure_vbx_plda_dir_uses_local_cache_without_network() {
700        // Pre-place the six fixture files under a temp cache; ensure must
701        // treat them as cache hits (hash match) and never touch the network.
702        let tmp = TempDir::new().unwrap();
703        let fixture_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/vbx-plda");
704        for art in VBX_PLDA_ARTIFACTS {
705            let src = fixture_dir.join(art.filename);
706            assert!(
707                src.is_file(),
708                "fixture missing: {} (run scripts/build-vbx-plda.py)",
709                src.display()
710            );
711            std::fs::copy(&src, tmp.path().join(art.filename)).unwrap();
712        }
713        let r = ModelRegistry::with_cache_dir(tmp.path()).unwrap();
714        let dir = r
715            .ensure_vbx_plda_dir()
716            .expect("cache-hit ensure must succeed offline");
717        assert_eq!(dir, tmp.path());
718        for art in VBX_PLDA_ARTIFACTS {
719            assert!(dir.join(art.filename).is_file());
720        }
721    }
722}