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