Skip to main content

orbok_models/
lib.rs

1//! # orbok-models
2//!
3//! Local AI model vocabulary (RFC-012). Milestone M1–M6 only needs the
4//! shared types and the "what is available" summary the UI shows; the
5//! install/locate/validate workflow lands in M12.
6//!
7//! Privacy rule carried from the requirements: model *download* is the
8//! only network operation orbok may ever perform, it is explicit, and
9//! it never involves document contents.
10
11pub mod download_plan;
12pub mod readiness;
13
14pub use download_plan::{
15    DEFAULT_MODEL_DOWNLOAD_CONCURRENCY, DownloadAction, DownloadPlan, FileDownloadProgress,
16    FileDownloadStatus, FriendlyDownloadProblem, ModelFilePlan, OverallDownloadProgress,
17    build_download_plan,
18};
19pub use readiness::{
20    FileReadiness, LocalFileStatus, ModelReadiness, ModelReadinessReport, check_model_readiness,
21};
22
23use serde::{Deserialize, Serialize};
24
25/// Model roles (catalog `models.role`).
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum ModelRole {
29    Embedding,
30    Reranker,
31}
32
33impl ModelRole {
34    pub fn as_str(&self) -> &'static str {
35        match self {
36            ModelRole::Embedding => "embedding",
37            ModelRole::Reranker => "reranker",
38        }
39    }
40}
41
42/// Model availability (catalog `models.status`).
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "snake_case")]
45pub enum ModelStatus {
46    Available,
47    Missing,
48    Invalid,
49    Installing,
50    Disabled,
51}
52
53impl ModelStatus {
54    pub fn as_str(&self) -> &'static str {
55        match self {
56            ModelStatus::Available => "available",
57            ModelStatus::Missing => "missing",
58            ModelStatus::Invalid => "invalid",
59            ModelStatus::Installing => "installing",
60            ModelStatus::Disabled => "disabled",
61        }
62    }
63}
64
65/// Search capability derived from model availability. Keyword search
66/// never depends on models (RFC-007: works with zero models installed).
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum SearchCapability {
69    /// Keyword only: no embedding model available.
70    KeywordOnly,
71    /// Keyword + semantic: embedding model available.
72    Hybrid,
73    /// Keyword + semantic + rerank refinement.
74    HybridWithRerank,
75}
76
77/// Derive the capability shown in the UI from model statuses.
78pub fn search_capability(
79    embedding: Option<ModelStatus>,
80    reranker: Option<ModelStatus>,
81) -> SearchCapability {
82    match (embedding, reranker) {
83        (Some(ModelStatus::Available), Some(ModelStatus::Available)) => {
84            SearchCapability::HybridWithRerank
85        }
86        (Some(ModelStatus::Available), _) => SearchCapability::Hybrid,
87        _ => SearchCapability::KeywordOnly,
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    // RFC-007/RFC-010: search degrades gracefully without models.
96    #[test]
97    fn capability_degrades_gracefully() {
98        assert_eq!(search_capability(None, None), SearchCapability::KeywordOnly);
99        assert_eq!(
100            search_capability(Some(ModelStatus::Missing), None),
101            SearchCapability::KeywordOnly
102        );
103        assert_eq!(
104            search_capability(Some(ModelStatus::Available), None),
105            SearchCapability::Hybrid
106        );
107        assert_eq!(
108            search_capability(Some(ModelStatus::Available), Some(ModelStatus::Missing)),
109            SearchCapability::Hybrid
110        );
111        assert_eq!(
112            search_capability(Some(ModelStatus::Available), Some(ModelStatus::Available)),
113            SearchCapability::HybridWithRerank
114        );
115    }
116}
117
118/// A vector search candidate (RFC-008 §13).
119#[derive(Debug, Clone)]
120pub struct VectorCandidate {
121    pub chunk_id: orbok_core::ChunkId,
122    pub file_id: orbok_core::FileId,
123    pub rank: u32,
124    pub score: f32,
125}
126
127/// Local embedding model abstraction (RFC-008 §6).
128///
129/// Implementations must not transmit text externally (NFR-001).
130pub trait EmbeddingModel: Send + Sync {
131    /// Stable name stored in `models.model_name`.
132    fn name(&self) -> &str;
133    /// Version string stored in `models.model_version`.
134    fn version(&self) -> &str;
135    /// Output dimension — must match stored embeddings (RFC-008 §11).
136    fn dimension(&self) -> u32;
137    /// Embed a batch of normalized texts. Returns one vector per input,
138    /// each L2-normalized.
139    fn embed_batch(&self, texts: &[&str]) -> orbok_core::OrbokResult<Vec<Vec<f32>>>;
140}
141
142/// Compute cosine similarity between two L2-normalized vectors.
143pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
144    a.iter().zip(b).map(|(x, y)| x * y).sum()
145}
146
147/// L2-normalize a vector in-place. No-op for the zero vector.
148pub fn l2_normalize(v: &mut Vec<f32>) {
149    let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
150    if norm > 1e-10 {
151        for x in v.iter_mut() {
152            *x /= norm;
153        }
154    }
155}
156
157/// Serialize a vector to little-endian bytes for BLOB storage (RFC-008
158/// §12.1 "sqlite_blob with FP32").
159pub fn vec_to_blob(v: &[f32]) -> Vec<u8> {
160    v.iter().flat_map(|x| x.to_le_bytes()).collect()
161}
162
163/// Deserialize from BLOB bytes; returns `None` on length mismatch.
164pub fn blob_to_vec(blob: &[u8], expected_dim: u32) -> Option<Vec<f32>> {
165    let dim = expected_dim as usize;
166    if blob.len() != dim * 4 {
167        return None;
168    }
169    Some(
170        blob.chunks_exact(4)
171            .map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
172            .collect(),
173    )
174}
175
176// ── Mock model ──────────────────────────────────────────────────────
177
178/// Deterministic 8-dimensional mock embedding model.
179///
180/// Uses the SHA-256 of the input text as a pseudo-random source for 8
181/// f32 components, then L2-normalizes the result.  **Never use for
182/// semantic search** — the outputs are semantically meaningless.
183/// Suitable for pipeline correctness tests (RFC-008 §24 tests 1–10).
184pub struct MockEmbeddingModel;
185
186impl EmbeddingModel for MockEmbeddingModel {
187    fn name(&self) -> &str {
188        "mock"
189    }
190    fn version(&self) -> &str {
191        "v1"
192    }
193    fn dimension(&self) -> u32 {
194        8
195    }
196    fn embed_batch(&self, texts: &[&str]) -> orbok_core::OrbokResult<Vec<Vec<f32>>> {
197        use sha2::{Digest, Sha256};
198        texts
199            .iter()
200            .map(|text| {
201                let digest = Sha256::digest(text.as_bytes());
202                let mut v: Vec<f32> = digest[..8].iter().map(|&b| b as f32 / 255.0).collect();
203                l2_normalize(&mut v);
204                Ok(v)
205            })
206            .collect()
207    }
208}
209
210#[cfg(test)]
211mod embedding_tests {
212    use super::*;
213
214    // RFC-008 §24 test 2: embedding generation succeeds for sample chunks.
215    #[test]
216    fn mock_embed_batch() {
217        let model = MockEmbeddingModel;
218        let vecs = model.embed_batch(&["hello world", "foo bar"]).unwrap();
219        assert_eq!(vecs.len(), 2);
220        for v in &vecs {
221            assert_eq!(v.len(), model.dimension() as usize);
222        }
223    }
224
225    // RFC-008 §24 test 3: dimension mismatch can be detected by caller.
226    #[test]
227    fn blob_roundtrip_and_dim_mismatch() {
228        let v = vec![0.1_f32, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8];
229        let blob = vec_to_blob(&v);
230        assert_eq!(blob.len(), 32);
231        let back = blob_to_vec(&blob, 8).unwrap();
232        for (a, b) in v.iter().zip(&back) {
233            assert!((a - b).abs() < 1e-6);
234        }
235        assert!(
236            blob_to_vec(&blob, 16).is_none(),
237            "dim mismatch must return None"
238        );
239    }
240
241    // L2 normalization: unit-length vectors.
242    #[test]
243    fn normalize_produces_unit_vector() {
244        let mut v = vec![3.0_f32, 4.0];
245        l2_normalize(&mut v);
246        let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
247        assert!((norm - 1.0).abs() < 1e-6);
248    }
249
250    // RFC-008 §24 test 9: cosine sim of identical vectors = 1.0.
251    #[test]
252    fn cosine_sim_identical_vectors() {
253        let mut v = vec![1.0_f32, 2.0, 3.0];
254        l2_normalize(&mut v);
255        let sim = cosine_similarity(&v, &v);
256        assert!((sim - 1.0).abs() < 1e-6);
257    }
258}
259
260// ── Reranker (RFC-010) ───────────────────────────────────────────────
261
262/// A candidate document passed to the reranker.
263#[derive(Debug, Clone)]
264pub struct RerankCandidate {
265    pub chunk_id: orbok_core::ChunkId,
266    /// Best available text for the passage — typically the loaded snippet.
267    pub passage_text: String,
268}
269
270/// Per-candidate rerank score (higher = more relevant).
271#[derive(Debug, Clone)]
272pub struct RerankScore {
273    pub chunk_id: orbok_core::ChunkId,
274    pub score: f32,
275}
276
277/// Optional local cross-encoder reranker (RFC-010 §5).
278///
279/// - Reranking is always optional; missing model must not break search.
280/// - Implementors must not log `passage_text` (NFR-014).
281pub trait CrossEncoderReranker: Send + Sync {
282    fn name(&self) -> &str;
283    fn version(&self) -> &str;
284    /// Maximum candidates to rerank (RFC-010 §9 top-N limit).
285    fn max_candidates(&self) -> u32;
286    fn rerank(
287        &self,
288        query: &str,
289        candidates: &[RerankCandidate],
290    ) -> orbok_core::OrbokResult<Vec<RerankScore>>;
291}
292
293/// Deterministic mock reranker: scores by passage length (longer = more
294/// informative). Useful for pipeline testing without an ML model.
295pub struct MockReranker;
296
297impl CrossEncoderReranker for MockReranker {
298    fn name(&self) -> &str {
299        "mock-reranker"
300    }
301    fn version(&self) -> &str {
302        "v1"
303    }
304    fn max_candidates(&self) -> u32 {
305        20
306    }
307    fn rerank(
308        &self,
309        _query: &str,
310        candidates: &[RerankCandidate],
311    ) -> orbok_core::OrbokResult<Vec<RerankScore>> {
312        let mut scores: Vec<RerankScore> = candidates
313            .iter()
314            .map(|c| RerankScore {
315                chunk_id: c.chunk_id.clone(),
316                score: c.passage_text.len() as f32,
317            })
318            .collect();
319        scores.sort_by(|a, b| {
320            b.score
321                .partial_cmp(&a.score)
322                .unwrap_or(std::cmp::Ordering::Equal)
323        });
324        Ok(scores)
325    }
326}
327
328#[cfg(test)]
329mod reranker_tests {
330    use super::*;
331    use orbok_core::ChunkId;
332
333    // RFC-010 §19 test 4: reranker changes final order when scores differ.
334    #[test]
335    fn mock_reranker_orders_by_length() {
336        let r = MockReranker;
337        let candidates = vec![
338            RerankCandidate {
339                chunk_id: ChunkId::from_string("c1".to_string()),
340                passage_text: "short".into(),
341            },
342            RerankCandidate {
343                chunk_id: ChunkId::from_string("c2".to_string()),
344                passage_text: "a much longer passage".into(),
345            },
346        ];
347        let scores = r.rerank("query", &candidates).unwrap();
348        assert_eq!(
349            scores[0].chunk_id.as_str(),
350            "c2",
351            "longer passage should rank first"
352        );
353    }
354
355    // RFC-010 §20: missing reranker does not break search.
356    #[test]
357    fn rerank_max_candidates_limit() {
358        assert!(MockReranker.max_candidates() > 0);
359    }
360}
361
362// ── Inference backend (M12) ──────────────────────────────────────────
363
364/// The compute backend used for local inference.
365///
366/// **Note (RFC-046):** `CandleCpu` and `CandleCuda` are **not currently
367/// supported**. The `orbok-embed` factory routes them to a not-supported
368/// error; no Candle backend is implemented. These variants are retained for
369/// model-layer/API stability. A future backend-API RFC may revisit them
370/// (e.g. reintroduce a Candle backend, or remove the variants under a
371/// deprecation policy). They are intentionally **not** `#[deprecated]` yet.
372#[derive(Debug, Clone, PartialEq, Eq)]
373pub enum InferenceBackend {
374    /// CPU-only inference via candle. Not currently supported (RFC-046).
375    CandleCpu,
376    /// GPU inference via candle + CUDA. Not currently supported (RFC-046).
377    CandleCuda,
378    /// ONNX Runtime (CPU or GPU via execution provider).
379    OnnxRuntime,
380    /// Mock backend for tests — deterministic, no model files.
381    Mock,
382}
383
384impl InferenceBackend {
385    pub fn as_str(&self) -> &'static str {
386        match self {
387            InferenceBackend::CandleCpu => "candle-cpu",
388            InferenceBackend::CandleCuda => "candle-cuda",
389            InferenceBackend::OnnxRuntime => "onnx-runtime",
390            InferenceBackend::Mock => "mock",
391        }
392    }
393}
394
395/// Configuration for loading a real embedding model from disk.
396///
397/// This is the configuration type callers populate to construct a real
398/// `EmbeddingModel` implementation via a future `BackendLoader`. The
399/// `MockEmbeddingModel` ignores this; it is used only when testing the
400/// pipeline without model files.
401///
402/// Once an `onnx-runtime` (tract) integration is fully wired (M12
403/// full implementation), it will consume this config and return a
404/// `Box<dyn EmbeddingModel>`.
405#[derive(Debug, Clone)]
406pub struct EmbeddingModelConfig {
407    /// Path to the model weights file (ONNX `.onnx` or safetensors).
408    pub weights_path: String,
409    /// Tokenizer config path (tokenizer.json for HuggingFace tokenizers).
410    pub tokenizer_path: Option<String>,
411    /// Expected embedding dimension.
412    pub dimension: u32,
413    /// Maximum input token length (truncation limit).
414    pub max_seq_len: u32,
415    /// Compute backend selection.
416    pub backend: InferenceBackend,
417    /// Model name for registry (e.g. "nomic-embed-text-v1.5").
418    pub model_name: String,
419    /// Model version string.
420    pub model_version: String,
421}
422
423impl EmbeddingModelConfig {
424    /// Check that the model weights file exists on disk.
425    pub fn weights_exist(&self) -> bool {
426        std::path::Path::new(&self.weights_path).exists()
427    }
428}
429
430/// Configuration for a cross-encoder reranker model.
431#[derive(Debug, Clone)]
432pub struct RerankerConfig {
433    pub weights_path: String,
434    pub tokenizer_path: Option<String>,
435    pub max_seq_len: u32,
436    pub backend: InferenceBackend,
437    pub model_name: String,
438    pub model_version: String,
439}
440
441// ── Vector quantization (RFC-024) ───────────────────────────────────
442
443/// Quantize an L2-normalized FP32 vector to INT8.
444///
445/// Maps `[-1.0, +1.0]` → `[-127, +127]` (values outside clip to ±127).
446/// Storage cost: 4× smaller than FP32 (1 byte vs 4 bytes per component).
447/// Quality impact: typically < 2% recall degradation for 384-dim models.
448pub fn quantize_to_i8(v: &[f32]) -> Vec<i8> {
449    v.iter()
450        .map(|&x| (x * 127.0).round().clamp(-127.0, 127.0) as i8)
451        .collect()
452}
453
454/// Dequantize INT8 back to FP32 for similarity computation.
455pub fn dequantize_from_i8(v: &[i8]) -> Vec<f32> {
456    v.iter().map(|&x| x as f32 / 127.0).collect()
457}
458
459/// Serialize INT8 vector to bytes for BLOB storage.
460pub fn i8_vec_to_blob(v: &[i8]) -> Vec<u8> {
461    // i8 values stored as raw bytes (same as u8 cast).
462    v.iter().map(|&x| x as u8).collect()
463}
464
465/// Deserialize INT8 vector from BLOB bytes.
466pub fn i8_blob_to_vec(blob: &[u8], expected_dim: u32) -> Option<Vec<i8>> {
467    if blob.len() != expected_dim as usize {
468        return None;
469    }
470    Some(blob.iter().map(|&b| b as i8).collect())
471}
472
473/// Compute approximate cosine similarity from INT8 vectors via FP32 conversion.
474/// For exact INT8 dot-product, a SIMD-optimised path would be preferable;
475/// this provides correct results at lower compute cost than full FP32.
476pub fn cosine_similarity_i8(a: &[i8], b: &[i8]) -> f32 {
477    cosine_similarity(&dequantize_from_i8(a), &dequantize_from_i8(b))
478}
479
480#[cfg(test)]
481mod quantization_tests {
482    use super::*;
483
484    // RFC-024 AC: FP32 baseline exists — quantization is optional.
485    #[test]
486    fn fp32_and_i8_both_available() {
487        let v = vec![0.6f32, 0.8, 0.0, -0.5];
488        let blob_fp32 = vec_to_blob(&v);
489        let i8_vec = quantize_to_i8(&v);
490        let blob_i8 = i8_vec_to_blob(&i8_vec);
491        // INT8 is 4× smaller.
492        assert_eq!(blob_i8.len() * 4, blob_fp32.len());
493    }
494
495    // RFC-024 AC: Storage savings measured (4× with INT8).
496    #[test]
497    fn int8_is_4x_smaller_than_fp32() {
498        let v: Vec<f32> = (0..384).map(|i| (i as f32 / 384.0) - 0.5).collect();
499        let mut vn = v.clone();
500        l2_normalize(&mut vn);
501        let fp32_bytes = vec_to_blob(&vn).len();
502        let int8_bytes = i8_vec_to_blob(&quantize_to_i8(&vn)).len();
503        assert_eq!(fp32_bytes, 384 * 4);
504        assert_eq!(int8_bytes, 384);
505        assert_eq!(fp32_bytes / int8_bytes, 4);
506    }
507
508    // RFC-024 AC: Quality loss measured (cosine sim error < 0.02 for normalised vectors).
509    #[test]
510    fn quantization_quality_loss_is_small() {
511        let mut v: Vec<f32> = (0..384).map(|i| (i as f32 * 0.017).sin()).collect();
512        l2_normalize(&mut v);
513        let q = quantize_to_i8(&v);
514        let original_self_sim = cosine_similarity(&v, &v);
515        let quantized_self_sim = cosine_similarity_i8(&q, &q);
516        // After dequantize, self-sim should still be ~1.0.
517        assert!(
518            (quantized_self_sim - original_self_sim).abs() < 0.02,
519            "quantization quality loss too high: {:.4}",
520            (quantized_self_sim - original_self_sim).abs()
521        );
522    }
523
524    // RFC-024 AC: Vector format migration defined (FP32 ↔ INT8 round-trip).
525    #[test]
526    fn fp32_int8_roundtrip_within_tolerance() {
527        let mut v: Vec<f32> = vec![0.3, -0.7, 0.5, 0.1, -0.2, 0.8, -0.4, 0.6];
528        l2_normalize(&mut v);
529        let quantized = quantize_to_i8(&v);
530        let dequantized = dequantize_from_i8(&quantized);
531        for (orig, deq) in v.iter().zip(&dequantized) {
532            assert!(
533                (orig - deq).abs() < 0.01,
534                "round-trip error too large: {orig:.4} → {deq:.4}"
535            );
536        }
537    }
538}
539
540#[cfg(test)]
541mod rfc043_tests;