Skip to main content

voxora_traits/
source.rs

1//! The [`ModelSource`] trait and the value types that describe where a
2//! model lives on disk and how it was acquired.
3
4use std::path::PathBuf;
5
6use async_trait::async_trait;
7
8use crate::engine::ModelCapabilities;
9use crate::error::AsrError;
10
11/// A descriptor for a model that can be enumerated without downloading it.
12#[derive(Debug, Clone, PartialEq, Eq)]
13#[non_exhaustive]
14pub struct ModelDescriptor {
15    /// Identifier as the source would accept it back
16    /// (e.g. `Qwen/Qwen3-ASR-0.6B`).
17    pub id: String,
18
19    /// Human-readable name, if the source provides one.
20    pub display_name: Option<String>,
21
22    /// Reported capabilities, if the source can determine them without
23    /// downloading the weights.
24    pub capabilities: Option<ModelCapabilities>,
25}
26
27impl ModelDescriptor {
28    /// Build a descriptor with just an id (no display name, no
29    /// capabilities).
30    pub fn new(id: impl Into<String>) -> Self {
31        Self {
32            id: id.into(),
33            display_name: None,
34            capabilities: None,
35        }
36    }
37
38    /// Build a descriptor with id, display name, and capabilities.
39    pub fn with_details(
40        id: impl Into<String>,
41        display_name: Option<String>,
42        capabilities: Option<ModelCapabilities>,
43    ) -> Self {
44        Self {
45            id: id.into(),
46            display_name,
47            capabilities,
48        }
49    }
50}
51
52/// Where a resolved model lives on disk.
53#[derive(Debug, Clone, PartialEq, Eq)]
54#[non_exhaustive]
55pub struct ModelDir {
56    /// Root directory of the model on disk.
57    pub path: PathBuf,
58
59    /// Specific file inside `path` (e.g. `ggml-large-v3.bin` for
60    /// single-file HF requests). `None` for whole-repo directories
61    /// where the engine picks the right file from the directory
62    /// listing. Populated by `voxora-hf` for 3-segment model ids
63    /// (`org/repo/file`) starting in 0.1.2.
64    pub entry: Option<PathBuf>,
65
66    /// Which source provided this model.
67    pub kind: ModelSourceKind,
68
69    /// Concrete quantization this model was serialized in.
70    pub quantization: Quantization,
71}
72
73impl ModelDir {
74    /// Build a `ModelDir` from its four fields, with `entry` left as
75    /// `None`. Whole-repo resolvers should keep using this constructor
76    /// so the engine falls back to `locate_model_file`-style
77    /// directory scanning.
78    pub fn new(path: PathBuf, kind: ModelSourceKind, quantization: Quantization) -> Self {
79        Self {
80            path,
81            entry: None,
82            kind,
83            quantization,
84        }
85    }
86
87    /// Build a `ModelDir` with an explicit `entry` naming the specific
88    /// file inside `path`. Used by `voxora-hf` for 3-segment model ids
89    /// (`org/repo/file`) so the engine does not have to lex-sort a
90    /// multi-file directory and accidentally pick the wrong file.
91    pub fn with_entry(
92        path: PathBuf,
93        entry: PathBuf,
94        kind: ModelSourceKind,
95        quantization: Quantization,
96    ) -> Self {
97        Self {
98            path,
99            entry: Some(entry),
100            kind,
101            quantization,
102        }
103    }
104}
105
106/// Class of model provider.
107///
108/// `#[non_exhaustive]` so new sources can be added without breaking
109/// downstream `match` arms.
110#[derive(Debug, Clone, PartialEq, Eq)]
111#[non_exhaustive]
112pub enum ModelSourceKind {
113    /// A directory already on disk; no download required.
114    Local,
115    /// Hugging Face Hub.
116    HuggingFace,
117}
118
119impl ModelSourceKind {
120    /// Stable string tag (`"local"`, `"huggingface"`, …) for logging.
121    pub fn tag(&self) -> &'static str {
122        match self {
123            ModelSourceKind::Local => "local",
124            ModelSourceKind::HuggingFace => "huggingface",
125        }
126    }
127}
128
129/// Concrete quantization variants a model was serialized in.
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131#[non_exhaustive]
132pub enum Quantization {
133    /// 32-bit IEEE float.
134    F32,
135    /// 16-bit brain float.
136    Bf16,
137    /// 16-bit IEEE float.
138    F16,
139    /// GGUF `Q4_K` (whisper.cpp).
140    Q4K,
141    /// GGUF `Q8_0` (whisper.cpp).
142    Q8_0,
143}
144
145/// Caller's preferred quantization when one is not otherwise specified.
146#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
147#[non_exhaustive]
148pub enum QuantizationPreference {
149    /// Let the source pick a sensible default.
150    #[default]
151    Auto,
152    /// Prefer 32-bit float if available.
153    F32,
154    /// Prefer 16-bit brain float if available.
155    Bf16,
156    /// Prefer 16-bit IEEE float if available.
157    F16,
158    /// Prefer GGUF `Q4_K` if available.
159    Q4K,
160    /// Prefer GGUF `Q8_0` if available.
161    Q8_0,
162}
163
164/// Options controlling how [`ModelSource::resolve`] acquires a model.
165#[derive(Debug, Clone, Default, PartialEq, Eq)]
166#[non_exhaustive]
167pub struct ResolveOptions {
168    /// Preferred quantization; the source may pick a different one if
169    /// the preferred is not available for this model.
170    pub quantization: QuantizationPreference,
171
172    /// Auth token, if the source requires one. For Hugging Face this
173    /// overrides the `HF_TOKEN` environment variable.
174    pub token: Option<String>,
175
176    /// Specific git revision (branch, tag, or SHA) to pin the model to.
177    pub revision: Option<String>,
178}
179
180impl ResolveOptions {
181    /// Construct a [`ResolveOptions`] with the given revision;
182    /// everything else is `Auto` / `None`.
183    pub fn with_revision(revision: impl Into<String>) -> Self {
184        Self {
185            revision: Some(revision.into()),
186            ..Self::default()
187        }
188    }
189
190    /// Construct a [`ResolveOptions`] with the given token;
191    /// everything else is `Auto` / `None`.
192    pub fn with_token(token: impl Into<String>) -> Self {
193        Self {
194            token: Some(token.into()),
195            ..Self::default()
196        }
197    }
198}
199
200/// A source of models (Hugging Face, a local directory, future registries).
201///
202/// Acquisition is inherently asynchronous (network downloads), so this
203/// trait uses `async_trait` even though [`crate::AsrEngine`] is
204/// sync. The trait requires `Send + Sync` so a `Box<dyn ModelSource>`
205/// can move across thread boundaries inside an HTTP server or CLI.
206#[async_trait]
207pub trait ModelSource: Send + Sync {
208    /// Short, stable identifier for this source
209    /// (`"huggingface"`, `"local"`, …).
210    fn name(&self) -> &'static str;
211
212    /// Resolve a model id (e.g. `Qwen/Qwen3-ASR-0.6B`) to a concrete
213    /// [`ModelDir`] on disk, downloading if necessary.
214    async fn resolve(&self, model_id: &str, opts: &ResolveOptions) -> Result<ModelDir, AsrError>;
215
216    /// Query a model's capabilities without downloading the weights.
217    async fn capabilities_for(&self, model_id: &str) -> Result<ModelCapabilities, AsrError>;
218
219    /// List models known to this source. Defaults to
220    /// [`AsrError::Unsupported`] because not every source can enumerate.
221    async fn list_available(&self) -> Result<Vec<ModelDescriptor>, AsrError> {
222        Err(AsrError::Unsupported("list_available"))
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    /// In-memory `ModelSource` used to verify the trait is usable
231    /// without network access. Exercises the default `list_available`.
232    ///
233    /// The async methods are intentionally not called from these tests
234    /// so we do not need an executor in the test suite — that keeps
235    /// `voxora-traits` free of `tokio` and `futures` per the Phase 1
236    /// "build offline" rule. Behaviour of `resolve` /
237    /// `capabilities_for` is covered in `voxora-hf` (Phase 2).
238    struct FakeSource;
239
240    #[async_trait]
241    impl ModelSource for FakeSource {
242        fn name(&self) -> &'static str {
243            "fake"
244        }
245
246        async fn resolve(
247            &self,
248            model_id: &str,
249            _opts: &ResolveOptions,
250        ) -> Result<ModelDir, AsrError> {
251            Ok(ModelDir {
252                path: PathBuf::from(format!("/cache/{model_id}")),
253                entry: None,
254                kind: ModelSourceKind::Local,
255                quantization: Quantization::F16,
256            })
257        }
258
259        async fn capabilities_for(&self, _model_id: &str) -> Result<ModelCapabilities, AsrError> {
260            Ok(ModelCapabilities::UNKNOWN)
261        }
262    }
263
264    #[test]
265    fn default_list_available_returns_unsupported_via_dispatch() {
266        // We exercise the default-method dispatch without awaiting: a
267        // `&dyn ModelSource` knows the vtable, and calling any async
268        // method through it returns a future. We only assert that the
269        // trait is constructible and `name()` works synchronously.
270        let src: &dyn ModelSource = &FakeSource;
271        assert_eq!(src.name(), "fake");
272    }
273
274    #[test]
275    fn quantization_preference_default_is_auto() {
276        assert_eq!(
277            QuantizationPreference::default(),
278            QuantizationPreference::Auto
279        );
280    }
281
282    #[test]
283    fn resolve_options_default_is_auto_no_token_no_revision() {
284        let opts = ResolveOptions::default();
285        assert_eq!(opts.quantization, QuantizationPreference::Auto);
286        assert!(opts.token.is_none());
287        assert!(opts.revision.is_none());
288    }
289
290    #[test]
291    fn resolve_options_implements_eq() {
292        let a = ResolveOptions {
293            quantization: QuantizationPreference::F16,
294            token: Some("tok".into()),
295            revision: Some("main".into()),
296        };
297        let b = ResolveOptions {
298            quantization: QuantizationPreference::F16,
299            token: Some("tok".into()),
300            revision: Some("main".into()),
301        };
302        let c = ResolveOptions {
303            quantization: QuantizationPreference::Q4K,
304            token: Some("tok".into()),
305            revision: Some("main".into()),
306        };
307        assert_eq!(a, b);
308        assert_ne!(a, c);
309    }
310
311    #[test]
312    fn quantization_is_copy_and_eq() {
313        let a = Quantization::F16;
314        let b = a;
315        assert_eq!(a, b);
316        assert_ne!(a, Quantization::Q4K);
317    }
318
319    #[test]
320    fn model_source_kind_implements_eq() {
321        assert_eq!(ModelSourceKind::Local, ModelSourceKind::Local);
322        assert_ne!(ModelSourceKind::Local, ModelSourceKind::HuggingFace);
323    }
324
325    #[test]
326    fn model_dir_new_has_no_entry() {
327        let dir = ModelDir::new(
328            PathBuf::from("/cache/foo"),
329            ModelSourceKind::Local,
330            Quantization::F16,
331        );
332        assert!(
333            dir.entry.is_none(),
334            "ModelDir::new must default entry to None"
335        );
336    }
337
338    #[test]
339    fn model_dir_with_entry_records_specific_file() {
340        let entry = PathBuf::from("/cache/foo/ggml-large-v3.bin");
341        let dir = ModelDir::with_entry(
342            PathBuf::from("/cache/foo"),
343            entry.clone(),
344            ModelSourceKind::HuggingFace,
345            Quantization::F16,
346        );
347        assert_eq!(dir.entry.as_ref(), Some(&entry));
348    }
349}