1pub 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#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum SearchCapability {
89 KeywordOnly,
91 Hybrid,
93 HybridWithRerank,
95}
96
97pub 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 #[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#[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#[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
161pub trait EmbeddingModel: Send + Sync {
165 fn name(&self) -> &str;
167 fn version(&self) -> &str;
169 fn dimension(&self) -> u32;
171 fn embed_batch(&self, texts: &[&str]) -> orbok_core::OrbokResult<Vec<Vec<f32>>>;
174
175 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
189pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
191 a.iter().zip(b).map(|(x, y)| x * y).sum()
192}
193
194pub 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
204pub fn vec_to_blob(v: &[f32]) -> Vec<u8> {
207 v.iter().flat_map(|x| x.to_le_bytes()).collect()
208}
209
210pub 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
225pub 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 #[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 #[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 #[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 #[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#[derive(Debug, Clone)]
313pub struct RerankCandidate {
314 pub chunk_id: orbok_core::ChunkId,
315 pub passage_text: String,
317}
318
319#[derive(Debug, Clone)]
321pub struct RerankScore {
322 pub chunk_id: orbok_core::ChunkId,
323 pub score: f32,
324}
325
326pub trait CrossEncoderReranker: Send + Sync {
351 fn name(&self) -> &str;
352 fn version(&self) -> &str;
353 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#[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 #[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 #[test]
435 fn rerank_max_candidates_limit() {
436 assert!(MockReranker.max_candidates() > 0);
437 }
438}
439
440#[derive(Debug, Clone, PartialEq, Eq)]
451pub enum InferenceBackend {
452 CandleCpu,
454 CandleCuda,
456 OnnxRuntime,
458 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#[derive(Debug, Clone)]
484pub struct EmbeddingModelConfig {
485 pub weights_path: String,
487 pub tokenizer_path: Option<String>,
489 pub dimension: u32,
491 pub max_seq_len: u32,
493 pub backend: InferenceBackend,
495 pub model_name: String,
497 pub model_version: String,
499}
500
501impl EmbeddingModelConfig {
502 pub fn weights_exist(&self) -> bool {
504 std::path::Path::new(&self.weights_path).exists()
505 }
506}
507
508#[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
519pub 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
532pub fn dequantize_from_i8(v: &[i8]) -> Vec<f32> {
534 v.iter().map(|&x| x as f32 / 127.0).collect()
535}
536
537pub fn i8_vec_to_blob(v: &[i8]) -> Vec<u8> {
539 v.iter().map(|&x| x as u8).collect()
541}
542
543pub 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
551pub 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 #[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 assert_eq!(blob_i8.len() * 4, blob_fp32.len());
571 }
572
573 #[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 #[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 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 #[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;