1pub 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#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum SearchCapability {
69 KeywordOnly,
71 Hybrid,
73 HybridWithRerank,
75}
76
77pub 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 #[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#[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
127pub trait EmbeddingModel: Send + Sync {
131 fn name(&self) -> &str;
133 fn version(&self) -> &str;
135 fn dimension(&self) -> u32;
137 fn embed_batch(&self, texts: &[&str]) -> orbok_core::OrbokResult<Vec<Vec<f32>>>;
140}
141
142pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
144 a.iter().zip(b).map(|(x, y)| x * y).sum()
145}
146
147pub 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
157pub fn vec_to_blob(v: &[f32]) -> Vec<u8> {
160 v.iter().flat_map(|x| x.to_le_bytes()).collect()
161}
162
163pub 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
176pub 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 #[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 #[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 #[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 #[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#[derive(Debug, Clone)]
264pub struct RerankCandidate {
265 pub chunk_id: orbok_core::ChunkId,
266 pub passage_text: String,
268}
269
270#[derive(Debug, Clone)]
272pub struct RerankScore {
273 pub chunk_id: orbok_core::ChunkId,
274 pub score: f32,
275}
276
277pub trait CrossEncoderReranker: Send + Sync {
282 fn name(&self) -> &str;
283 fn version(&self) -> &str;
284 fn max_candidates(&self) -> u32;
286 fn rerank(
287 &self,
288 query: &str,
289 candidates: &[RerankCandidate],
290 ) -> orbok_core::OrbokResult<Vec<RerankScore>>;
291}
292
293pub 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 #[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 #[test]
357 fn rerank_max_candidates_limit() {
358 assert!(MockReranker.max_candidates() > 0);
359 }
360}
361
362#[derive(Debug, Clone, PartialEq, Eq)]
366pub enum InferenceBackend {
367 CandleCpu,
369 CandleCuda,
371 OnnxRuntime,
373 Mock,
375}
376
377impl InferenceBackend {
378 pub fn as_str(&self) -> &'static str {
379 match self {
380 InferenceBackend::CandleCpu => "candle-cpu",
381 InferenceBackend::CandleCuda => "candle-cuda",
382 InferenceBackend::OnnxRuntime => "onnx-runtime",
383 InferenceBackend::Mock => "mock",
384 }
385 }
386}
387
388#[derive(Debug, Clone)]
399pub struct EmbeddingModelConfig {
400 pub weights_path: String,
402 pub tokenizer_path: Option<String>,
404 pub dimension: u32,
406 pub max_seq_len: u32,
408 pub backend: InferenceBackend,
410 pub model_name: String,
412 pub model_version: String,
414}
415
416impl EmbeddingModelConfig {
417 pub fn weights_exist(&self) -> bool {
419 std::path::Path::new(&self.weights_path).exists()
420 }
421}
422
423#[derive(Debug, Clone)]
425pub struct RerankerConfig {
426 pub weights_path: String,
427 pub tokenizer_path: Option<String>,
428 pub max_seq_len: u32,
429 pub backend: InferenceBackend,
430 pub model_name: String,
431 pub model_version: String,
432}
433
434pub fn quantize_to_i8(v: &[f32]) -> Vec<i8> {
442 v.iter()
443 .map(|&x| (x * 127.0).round().clamp(-127.0, 127.0) as i8)
444 .collect()
445}
446
447pub fn dequantize_from_i8(v: &[i8]) -> Vec<f32> {
449 v.iter().map(|&x| x as f32 / 127.0).collect()
450}
451
452pub fn i8_vec_to_blob(v: &[i8]) -> Vec<u8> {
454 v.iter().map(|&x| x as u8).collect()
456}
457
458pub fn i8_blob_to_vec(blob: &[u8], expected_dim: u32) -> Option<Vec<i8>> {
460 if blob.len() != expected_dim as usize {
461 return None;
462 }
463 Some(blob.iter().map(|&b| b as i8).collect())
464}
465
466pub fn cosine_similarity_i8(a: &[i8], b: &[i8]) -> f32 {
470 cosine_similarity(&dequantize_from_i8(a), &dequantize_from_i8(b))
471}
472
473#[cfg(test)]
474mod quantization_tests {
475 use super::*;
476
477 #[test]
479 fn fp32_and_i8_both_available() {
480 let v = vec![0.6f32, 0.8, 0.0, -0.5];
481 let blob_fp32 = vec_to_blob(&v);
482 let i8_vec = quantize_to_i8(&v);
483 let blob_i8 = i8_vec_to_blob(&i8_vec);
484 assert_eq!(blob_i8.len() * 4, blob_fp32.len());
486 }
487
488 #[test]
490 fn int8_is_4x_smaller_than_fp32() {
491 let v: Vec<f32> = (0..384).map(|i| (i as f32 / 384.0) - 0.5).collect();
492 let mut vn = v.clone();
493 l2_normalize(&mut vn);
494 let fp32_bytes = vec_to_blob(&vn).len();
495 let int8_bytes = i8_vec_to_blob(&quantize_to_i8(&vn)).len();
496 assert_eq!(fp32_bytes, 384 * 4);
497 assert_eq!(int8_bytes, 384);
498 assert_eq!(fp32_bytes / int8_bytes, 4);
499 }
500
501 #[test]
503 fn quantization_quality_loss_is_small() {
504 let mut v: Vec<f32> = (0..384).map(|i| (i as f32 * 0.017).sin()).collect();
505 l2_normalize(&mut v);
506 let q = quantize_to_i8(&v);
507 let original_self_sim = cosine_similarity(&v, &v);
508 let quantized_self_sim = cosine_similarity_i8(&q, &q);
509 assert!(
511 (quantized_self_sim - original_self_sim).abs() < 0.02,
512 "quantization quality loss too high: {:.4}",
513 (quantized_self_sim - original_self_sim).abs()
514 );
515 }
516
517 #[test]
519 fn fp32_int8_roundtrip_within_tolerance() {
520 let mut v: Vec<f32> = vec![0.3, -0.7, 0.5, 0.1, -0.2, 0.8, -0.4, 0.6];
521 l2_normalize(&mut v);
522 let quantized = quantize_to_i8(&v);
523 let dequantized = dequantize_from_i8(&quantized);
524 for (orig, deq) in v.iter().zip(&dequantized) {
525 assert!(
526 (orig - deq).abs() < 0.01,
527 "round-trip error too large: {orig:.4} → {deq:.4}"
528 );
529 }
530 }
531}
532
533#[cfg(test)]
534mod rfc043_tests;