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 generation;
13pub mod model_store;
14pub mod readiness;
15pub mod trust;
16
17pub use download_plan::{
18    DEFAULT_MODEL_DOWNLOAD_CONCURRENCY, DownloadAction, DownloadPlan, DownloadPlanError,
19    FileDownloadProgress, FileDownloadStatus, FriendlyDownloadProblem, ModelFilePlan,
20    OverallDownloadProgress, build_download_plan,
21};
22pub use generation::{
23    GenerationTransitionError, GenerationValueError, ManagedGenerationId, ManagedGenerationRecord,
24    ManagedGenerationSnapshot, ManagedGenerationState, ManagedProfileState, ModelStoreProfileId,
25    StartupEpoch, StartupValidationEvidence,
26};
27pub use model_store::{
28    ExclusiveAccess, MODEL_STORE_LOCK_FILE, ManagedModelStore, ModelStoreLockError,
29    ModelStoreMutationGuard, SharedAccess,
30};
31pub use readiness::{
32    FileReadiness, LocalFileIntegrity, LocalFileStatus, ModelProvenance, ModelReadiness,
33    ModelReadinessReport, check_app_managed_model_readiness, check_model_readiness,
34    check_user_supplied_model_readiness,
35};
36pub use trust::{
37    DEFAULT_TRUSTED_MODEL, HeaderDisposition, HttpClientPolicy, PRODUCTION_HTTP_CLIENT_POLICY,
38    TrustPolicyError, TrustedModelFile, TrustedModelIdentity, TrustedModelManifest,
39    TrustedTransportPolicy, redirect_header_disposition, validate_initial_url,
40    validate_redirect_url,
41};
42
43use serde::{Deserialize, Serialize};
44
45/// Model roles (catalog `models.role`).
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum ModelRole {
49    Embedding,
50    Reranker,
51}
52
53impl ModelRole {
54    pub fn as_str(&self) -> &'static str {
55        match self {
56            ModelRole::Embedding => "embedding",
57            ModelRole::Reranker => "reranker",
58        }
59    }
60}
61
62/// Model availability (catalog `models.status`).
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case")]
65pub enum ModelStatus {
66    Available,
67    Missing,
68    Invalid,
69    Installing,
70    Disabled,
71}
72
73impl ModelStatus {
74    pub fn as_str(&self) -> &'static str {
75        match self {
76            ModelStatus::Available => "available",
77            ModelStatus::Missing => "missing",
78            ModelStatus::Invalid => "invalid",
79            ModelStatus::Installing => "installing",
80            ModelStatus::Disabled => "disabled",
81        }
82    }
83}
84
85/// Search capability derived from model availability. Keyword search
86/// never depends on models (RFC-007: works with zero models installed).
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum SearchCapability {
89    /// Keyword only: no embedding model available.
90    KeywordOnly,
91    /// Keyword + semantic: embedding model available.
92    Hybrid,
93    /// Keyword + semantic + rerank refinement.
94    HybridWithRerank,
95}
96
97/// Derive the capability shown in the UI from model statuses.
98pub fn search_capability(
99    embedding: Option<ModelStatus>,
100    reranker: Option<ModelStatus>,
101) -> SearchCapability {
102    match (embedding, reranker) {
103        (Some(ModelStatus::Available), Some(ModelStatus::Available)) => {
104            SearchCapability::HybridWithRerank
105        }
106        (Some(ModelStatus::Available), _) => SearchCapability::Hybrid,
107        _ => SearchCapability::KeywordOnly,
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    // RFC-007/RFC-010: search degrades gracefully without models.
116    #[test]
117    fn capability_degrades_gracefully() {
118        assert_eq!(search_capability(None, None), SearchCapability::KeywordOnly);
119        assert_eq!(
120            search_capability(Some(ModelStatus::Missing), None),
121            SearchCapability::KeywordOnly
122        );
123        assert_eq!(
124            search_capability(Some(ModelStatus::Available), None),
125            SearchCapability::Hybrid
126        );
127        assert_eq!(
128            search_capability(Some(ModelStatus::Available), Some(ModelStatus::Missing)),
129            SearchCapability::Hybrid
130        );
131        assert_eq!(
132            search_capability(Some(ModelStatus::Available), Some(ModelStatus::Available)),
133            SearchCapability::HybridWithRerank
134        );
135    }
136}
137
138/// A vector search candidate (RFC-008 §13).
139#[derive(Debug, Clone)]
140pub struct VectorCandidate {
141    pub chunk_id: orbok_core::ChunkId,
142    pub file_id: orbok_core::FileId,
143    pub rank: u32,
144    pub score: f32,
145}
146
147/// Per-call batch statistics for measurement only (RFC-048 Task 011) —
148/// never consulted by embedding logic itself. `padded_seq_len` is what
149/// the backend's padding strategy actually resolved to for this call;
150/// `real_token_positions` is the sum, across the whole batch, of each
151/// item's non-padding token count (from its attention mask, where the
152/// backend has one). `batch_size * padded_seq_len` is the padded
153/// token-position count the call actually paid for.
154#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
155pub struct EmbeddingBatchStats {
156    pub batch_size: usize,
157    pub padded_seq_len: usize,
158    pub real_token_positions: u64,
159}
160
161/// Local embedding model abstraction (RFC-008 §6).
162///
163/// Implementations must not transmit text externally (NFR-001).
164pub trait EmbeddingModel: Send + Sync {
165    /// Stable name stored in `models.model_name`.
166    fn name(&self) -> &str;
167    /// Version string stored in `models.model_version`.
168    fn version(&self) -> &str;
169    /// Output dimension — must match stored embeddings (RFC-008 §11).
170    fn dimension(&self) -> u32;
171    /// Embed a batch of normalized texts. Returns one vector per input,
172    /// each L2-normalized.
173    fn embed_batch(&self, texts: &[&str]) -> orbok_core::OrbokResult<Vec<Vec<f32>>>;
174
175    /// `embed_batch`, plus this call's batch statistics (RFC-048 Task 011)
176    /// — `None` when a backend doesn't track them. The default
177    /// implementation delegates to `embed_batch` and reports no stats, so
178    /// every existing backend and call site is unaffected; a backend that
179    /// can report real figures overrides this instead, as a byproduct of
180    /// work it already performs, adding no cost to plain `embed_batch`.
181    fn embed_batch_with_stats(
182        &self,
183        texts: &[&str],
184    ) -> orbok_core::OrbokResult<(Vec<Vec<f32>>, Option<EmbeddingBatchStats>)> {
185        Ok((self.embed_batch(texts)?, None))
186    }
187}
188
189/// Compute cosine similarity between two L2-normalized vectors.
190pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
191    a.iter().zip(b).map(|(x, y)| x * y).sum()
192}
193
194/// L2-normalize a vector in-place. No-op for the zero vector.
195pub fn l2_normalize(v: &mut [f32]) {
196    let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
197    if norm > 1e-10 {
198        for x in v.iter_mut() {
199            *x /= norm;
200        }
201    }
202}
203
204/// Serialize a vector to little-endian bytes for BLOB storage (RFC-008
205/// §12.1 "sqlite_blob with FP32").
206pub fn vec_to_blob(v: &[f32]) -> Vec<u8> {
207    v.iter().flat_map(|x| x.to_le_bytes()).collect()
208}
209
210/// Deserialize from BLOB bytes; returns `None` on length mismatch.
211pub fn blob_to_vec(blob: &[u8], expected_dim: u32) -> Option<Vec<f32>> {
212    let dim = expected_dim as usize;
213    if blob.len() != dim * 4 {
214        return None;
215    }
216    Some(
217        blob.as_chunks::<4>()
218            .0
219            .iter()
220            .map(|b| f32::from_le_bytes(*b))
221            .collect(),
222    )
223}
224
225// ── Mock model ──────────────────────────────────────────────────────
226
227/// Deterministic 8-dimensional mock embedding model.
228///
229/// Uses the SHA-256 of the input text as a pseudo-random source for 8
230/// f32 components, then L2-normalizes the result.  **Never use for
231/// semantic search** — the outputs are semantically meaningless.
232/// Suitable for pipeline correctness tests (RFC-008 §24 tests 1–10).
233pub struct MockEmbeddingModel;
234
235impl EmbeddingModel for MockEmbeddingModel {
236    fn name(&self) -> &str {
237        "mock"
238    }
239    fn version(&self) -> &str {
240        "v1"
241    }
242    fn dimension(&self) -> u32 {
243        8
244    }
245    fn embed_batch(&self, texts: &[&str]) -> orbok_core::OrbokResult<Vec<Vec<f32>>> {
246        use sha2::{Digest, Sha256};
247        texts
248            .iter()
249            .map(|text| {
250                let digest = Sha256::digest(text.as_bytes());
251                let mut v: Vec<f32> = digest[..8].iter().map(|&b| b as f32 / 255.0).collect();
252                l2_normalize(&mut v);
253                Ok(v)
254            })
255            .collect()
256    }
257}
258
259#[cfg(test)]
260mod embedding_tests {
261    use super::*;
262
263    // RFC-008 §24 test 2: embedding generation succeeds for sample chunks.
264    #[test]
265    fn mock_embed_batch() {
266        let model = MockEmbeddingModel;
267        let vecs = model.embed_batch(&["hello world", "foo bar"]).unwrap();
268        assert_eq!(vecs.len(), 2);
269        for v in &vecs {
270            assert_eq!(v.len(), model.dimension() as usize);
271        }
272    }
273
274    // RFC-008 §24 test 3: dimension mismatch can be detected by caller.
275    #[test]
276    fn blob_roundtrip_and_dim_mismatch() {
277        let v = vec![0.1_f32, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8];
278        let blob = vec_to_blob(&v);
279        assert_eq!(blob.len(), 32);
280        let back = blob_to_vec(&blob, 8).unwrap();
281        for (a, b) in v.iter().zip(&back) {
282            assert!((a - b).abs() < 1e-6);
283        }
284        assert!(
285            blob_to_vec(&blob, 16).is_none(),
286            "dim mismatch must return None"
287        );
288    }
289
290    // L2 normalization: unit-length vectors.
291    #[test]
292    fn normalize_produces_unit_vector() {
293        let mut v = vec![3.0_f32, 4.0];
294        l2_normalize(&mut v);
295        let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
296        assert!((norm - 1.0).abs() < 1e-6);
297    }
298
299    // RFC-008 §24 test 9: cosine sim of identical vectors = 1.0.
300    #[test]
301    fn cosine_sim_identical_vectors() {
302        let mut v = vec![1.0_f32, 2.0, 3.0];
303        l2_normalize(&mut v);
304        let sim = cosine_similarity(&v, &v);
305        assert!((sim - 1.0).abs() < 1e-6);
306    }
307}
308
309// ── Reranker (RFC-010) ───────────────────────────────────────────────
310
311/// A candidate document passed to the reranker.
312#[derive(Debug, Clone)]
313pub struct RerankCandidate {
314    pub chunk_id: orbok_core::ChunkId,
315    /// Best available text for the passage — typically the loaded snippet.
316    pub passage_text: String,
317}
318
319/// Per-candidate rerank score (higher = more relevant).
320#[derive(Debug, Clone)]
321pub struct RerankScore {
322    pub chunk_id: orbok_core::ChunkId,
323    pub score: f32,
324}
325
326/// Cross-encoder reranker contract (RFC-010 §5).
327///
328/// Designed in RFC-010, not implemented: the only prior implementation
329/// (`MockReranker`, below) scored by passage *length*, which is not
330/// reranking, and had no production caller. RFC-060 §8 decided against
331/// building a real cross-encoder for now — a second model is a second
332/// download and a second forward pass per query, and orbok's own p99
333/// search-latency gate is unmet by roughly 4x with *one* model already
334/// (RFC-060 §8, Task 040) — so `orbok-search`'s `HybridSearchService`
335/// wires nothing to this trait (its `with_reranker` method and the dead
336/// call path behind it were deleted in Task 040, along with the known bug
337/// that path carried: `enrich_many(&fused, limit)` truncated to `limit`
338/// *before* reranking, so even a real reranker could never have reached
339/// fusion ranks 21-50 — the exact headroom
340/// `crates/search/engine/src/hybrid.rs`'s `Limits::fusion_n = 50` reserves
341/// for it).
342///
343/// This trait is kept anyway, as the documented insertion point if a real
344/// cross-encoder is ever built: RFC-060 §8's reopen condition is orbok's
345/// p99 search-latency gate being met with one model. Implementors:
346///
347/// - Reranking must always be optional; a missing model must not break
348///   search.
349/// - Must not log `passage_text` (NFR-014).
350pub trait CrossEncoderReranker: Send + Sync {
351    fn name(&self) -> &str;
352    fn version(&self) -> &str;
353    /// Maximum candidates to rerank (RFC-010 §9 top-N limit).
354    fn max_candidates(&self) -> u32;
355    fn rerank(
356        &self,
357        query: &str,
358        candidates: &[RerankCandidate],
359    ) -> orbok_core::OrbokResult<Vec<RerankScore>>;
360}
361
362/// Deterministic mock reranker: scores by passage length (longer = more
363/// informative) — this is what makes `Status: Implemented` on RFC-010
364/// false rather than merely incomplete (Task 040): it is not a cross-
365/// encoder, and it never had a production caller. Test-only now that Task
366/// 040 deleted `HybridSearchService::with_reranker`, its only integration
367/// point — nothing outside this crate's own test module (below) uses it
368/// any more, so `#[cfg(test)]` is sufficient (it was `pub` and consumed
369/// across the crate boundary by `orbok-workers`'s tests before that
370/// deletion, which is why it wasn't already gated this way).
371#[cfg(test)]
372struct MockReranker;
373
374#[cfg(test)]
375impl CrossEncoderReranker for MockReranker {
376    fn name(&self) -> &str {
377        "mock-reranker"
378    }
379    fn version(&self) -> &str {
380        "v1"
381    }
382    fn max_candidates(&self) -> u32 {
383        20
384    }
385    fn rerank(
386        &self,
387        _query: &str,
388        candidates: &[RerankCandidate],
389    ) -> orbok_core::OrbokResult<Vec<RerankScore>> {
390        let mut scores: Vec<RerankScore> = candidates
391            .iter()
392            .map(|c| RerankScore {
393                chunk_id: c.chunk_id.clone(),
394                score: c.passage_text.len() as f32,
395            })
396            .collect();
397        scores.sort_by(|a, b| {
398            b.score
399                .partial_cmp(&a.score)
400                .unwrap_or(std::cmp::Ordering::Equal)
401        });
402        Ok(scores)
403    }
404}
405
406#[cfg(test)]
407mod reranker_tests {
408    use super::*;
409    use orbok_core::ChunkId;
410
411    // RFC-010 §19 test 4: reranker changes final order when scores differ.
412    #[test]
413    fn mock_reranker_orders_by_length() {
414        let r = MockReranker;
415        let candidates = vec![
416            RerankCandidate {
417                chunk_id: ChunkId::from_string("c1".to_string()),
418                passage_text: "short".into(),
419            },
420            RerankCandidate {
421                chunk_id: ChunkId::from_string("c2".to_string()),
422                passage_text: "a much longer passage".into(),
423            },
424        ];
425        let scores = r.rerank("query", &candidates).unwrap();
426        assert_eq!(
427            scores[0].chunk_id.as_str(),
428            "c2",
429            "longer passage should rank first"
430        );
431    }
432
433    // RFC-010 §20: missing reranker does not break search.
434    #[test]
435    fn rerank_max_candidates_limit() {
436        assert!(MockReranker.max_candidates() > 0);
437    }
438}
439
440// ── Inference backend (M12) ──────────────────────────────────────────
441
442/// The compute backend used for local inference.
443///
444/// **Note (RFC-046):** `CandleCpu` and `CandleCuda` are **not currently
445/// supported**. The `orbok-embed` factory routes them to a not-supported
446/// error; no Candle backend is implemented. These variants are retained for
447/// model-layer/API stability. A future backend-API RFC may revisit them
448/// (e.g. reintroduce a Candle backend, or remove the variants under a
449/// deprecation policy). They are intentionally **not** `#[deprecated]` yet.
450#[derive(Debug, Clone, PartialEq, Eq)]
451pub enum InferenceBackend {
452    /// CPU-only inference via candle. Not currently supported (RFC-046).
453    CandleCpu,
454    /// GPU inference via candle + CUDA. Not currently supported (RFC-046).
455    CandleCuda,
456    /// ONNX Runtime (CPU or GPU via execution provider).
457    OnnxRuntime,
458    /// Mock backend for tests — deterministic, no model files.
459    Mock,
460}
461
462impl InferenceBackend {
463    pub fn as_str(&self) -> &'static str {
464        match self {
465            InferenceBackend::CandleCpu => "candle-cpu",
466            InferenceBackend::CandleCuda => "candle-cuda",
467            InferenceBackend::OnnxRuntime => "onnx-runtime",
468            InferenceBackend::Mock => "mock",
469        }
470    }
471}
472
473/// Configuration for loading a real embedding model from disk.
474///
475/// This is the configuration type callers populate to construct a real
476/// `EmbeddingModel` implementation via a future `BackendLoader`. The
477/// `MockEmbeddingModel` ignores this; it is used only when testing the
478/// pipeline without model files.
479///
480/// Once an `onnx-runtime` (tract) integration is fully wired (M12
481/// full implementation), it will consume this config and return a
482/// `Box<dyn EmbeddingModel>`.
483#[derive(Debug, Clone)]
484pub struct EmbeddingModelConfig {
485    /// Path to the model weights file (ONNX `.onnx` or safetensors).
486    pub weights_path: String,
487    /// Tokenizer config path (tokenizer.json for HuggingFace tokenizers).
488    pub tokenizer_path: Option<String>,
489    /// Expected embedding dimension.
490    pub dimension: u32,
491    /// Maximum input token length (truncation limit).
492    pub max_seq_len: u32,
493    /// Compute backend selection.
494    pub backend: InferenceBackend,
495    /// Model name for registry (e.g. "nomic-embed-text-v1.5").
496    pub model_name: String,
497    /// Model version string.
498    pub model_version: String,
499}
500
501impl EmbeddingModelConfig {
502    /// Check that the model weights file exists on disk.
503    pub fn weights_exist(&self) -> bool {
504        std::path::Path::new(&self.weights_path).exists()
505    }
506}
507
508/// Configuration for a cross-encoder reranker model.
509#[derive(Debug, Clone)]
510pub struct RerankerConfig {
511    pub weights_path: String,
512    pub tokenizer_path: Option<String>,
513    pub max_seq_len: u32,
514    pub backend: InferenceBackend,
515    pub model_name: String,
516    pub model_version: String,
517}
518
519// ── Vector quantization (RFC-024) ───────────────────────────────────
520
521/// Quantize an L2-normalized FP32 vector to INT8.
522///
523/// Maps `[-1.0, +1.0]` → `[-127, +127]` (values outside clip to ±127).
524/// Storage cost: 4× smaller than FP32 (1 byte vs 4 bytes per component).
525/// Quality impact: typically < 2% recall degradation for 384-dim models.
526pub fn quantize_to_i8(v: &[f32]) -> Vec<i8> {
527    v.iter()
528        .map(|&x| (x * 127.0).round().clamp(-127.0, 127.0) as i8)
529        .collect()
530}
531
532/// Dequantize INT8 back to FP32 for similarity computation.
533pub fn dequantize_from_i8(v: &[i8]) -> Vec<f32> {
534    v.iter().map(|&x| x as f32 / 127.0).collect()
535}
536
537/// Serialize INT8 vector to bytes for BLOB storage.
538pub fn i8_vec_to_blob(v: &[i8]) -> Vec<u8> {
539    // i8 values stored as raw bytes (same as u8 cast).
540    v.iter().map(|&x| x as u8).collect()
541}
542
543/// Deserialize INT8 vector from BLOB bytes.
544pub fn i8_blob_to_vec(blob: &[u8], expected_dim: u32) -> Option<Vec<i8>> {
545    if blob.len() != expected_dim as usize {
546        return None;
547    }
548    Some(blob.iter().map(|&b| b as i8).collect())
549}
550
551/// Compute approximate cosine similarity from INT8 vectors via FP32 conversion.
552/// For exact INT8 dot-product, a SIMD-optimised path would be preferable;
553/// this provides correct results at lower compute cost than full FP32.
554pub fn cosine_similarity_i8(a: &[i8], b: &[i8]) -> f32 {
555    cosine_similarity(&dequantize_from_i8(a), &dequantize_from_i8(b))
556}
557
558#[cfg(test)]
559mod quantization_tests {
560    use super::*;
561
562    // RFC-024 AC: FP32 baseline exists — quantization is optional.
563    #[test]
564    fn fp32_and_i8_both_available() {
565        let v = vec![0.6f32, 0.8, 0.0, -0.5];
566        let blob_fp32 = vec_to_blob(&v);
567        let i8_vec = quantize_to_i8(&v);
568        let blob_i8 = i8_vec_to_blob(&i8_vec);
569        // INT8 is 4× smaller.
570        assert_eq!(blob_i8.len() * 4, blob_fp32.len());
571    }
572
573    // RFC-024 AC: Storage savings measured (4× with INT8).
574    #[test]
575    fn int8_is_4x_smaller_than_fp32() {
576        let v: Vec<f32> = (0..384).map(|i| (i as f32 / 384.0) - 0.5).collect();
577        let mut vn = v.clone();
578        l2_normalize(&mut vn);
579        let fp32_bytes = vec_to_blob(&vn).len();
580        let int8_bytes = i8_vec_to_blob(&quantize_to_i8(&vn)).len();
581        assert_eq!(fp32_bytes, 384 * 4);
582        assert_eq!(int8_bytes, 384);
583        assert_eq!(fp32_bytes / int8_bytes, 4);
584    }
585
586    // RFC-024 AC: Quality loss measured (cosine sim error < 0.02 for normalised vectors).
587    #[test]
588    fn quantization_quality_loss_is_small() {
589        let mut v: Vec<f32> = (0..384).map(|i| (i as f32 * 0.017).sin()).collect();
590        l2_normalize(&mut v);
591        let q = quantize_to_i8(&v);
592        let original_self_sim = cosine_similarity(&v, &v);
593        let quantized_self_sim = cosine_similarity_i8(&q, &q);
594        // After dequantize, self-sim should still be ~1.0.
595        assert!(
596            (quantized_self_sim - original_self_sim).abs() < 0.02,
597            "quantization quality loss too high: {:.4}",
598            (quantized_self_sim - original_self_sim).abs()
599        );
600    }
601
602    // RFC-024 AC: Vector format migration defined (FP32 ↔ INT8 round-trip).
603    #[test]
604    fn fp32_int8_roundtrip_within_tolerance() {
605        let mut v: Vec<f32> = vec![0.3, -0.7, 0.5, 0.1, -0.2, 0.8, -0.4, 0.6];
606        l2_normalize(&mut v);
607        let quantized = quantize_to_i8(&v);
608        let dequantized = dequantize_from_i8(&quantized);
609        for (orig, deq) in v.iter().zip(&dequantized) {
610            assert!(
611                (orig - deq).abs() < 0.01,
612                "round-trip error too large: {orig:.4} → {deq:.4}"
613            );
614        }
615    }
616}
617
618#[cfg(test)]
619mod rfc043_tests;