1#[cfg(any(feature = "model2vec", feature = "fastembed"))]
4use std::collections::BTreeSet;
5use std::fmt::{self, Write as _};
6#[cfg(all(
7 feature = "download",
8 any(feature = "model2vec", feature = "fastembed")
9))]
10use std::io::{self, IsTerminal, Write};
11#[cfg(not(any(feature = "model2vec", feature = "fastembed")))]
12use std::path::Path;
13#[cfg(any(feature = "model2vec", feature = "fastembed"))]
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16#[cfg(all(
17 feature = "download",
18 any(feature = "model2vec", feature = "fastembed")
19))]
20use std::sync::atomic::{AtomicU8, Ordering};
21#[cfg(all(
22 feature = "download",
23 any(feature = "model2vec", feature = "fastembed")
24))]
25use std::time::Instant;
26
27use asupersync::Cx;
28#[cfg(all(
29 feature = "download",
30 any(feature = "model2vec", feature = "fastembed")
31))]
32use asupersync::sync::OnceCell;
33#[cfg(not(any(feature = "model2vec", feature = "fastembed", feature = "api")))]
34use tracing::info;
35#[cfg(any(feature = "model2vec", feature = "fastembed", feature = "api"))]
36use tracing::{info, warn};
37
38use frankensearch_core::error::{SearchError, SearchResult};
39use frankensearch_core::traits::{Embedder, SearchFuture};
40#[cfg(all(
41 feature = "download",
42 any(feature = "model2vec", feature = "fastembed")
43))]
44use frankensearch_core::traits::{ModelCategory, ModelTier};
45
46#[cfg(feature = "bundled-default-models")]
47use crate::bundled_default_models::ensure_default_semantic_models;
48#[cfg(all(feature = "download", feature = "fastembed"))]
49use crate::fastembed_embedder::DEFAULT_DIMENSION as MINILM_DIMENSION;
50#[cfg(feature = "fastembed")]
51use crate::fastembed_embedder::{
52 DEFAULT_HF_ID as MINILM_HF_ID, DEFAULT_MODEL_NAME as MINILM_MODEL_NAME, FastEmbedEmbedder,
53 find_model_dir_with_hf_id as find_fastembed_model_dir,
54};
55#[cfg(feature = "hash")]
56use crate::hash_embedder::HashEmbedder;
57#[cfg(all(
58 feature = "download",
59 any(feature = "model2vec", feature = "fastembed")
60))]
61use crate::model_download::{DownloadProgress, ModelDownloader};
62#[cfg(any(feature = "model2vec", feature = "fastembed"))]
63use crate::model_manifest::ModelManifest;
64#[cfg(all(
65 feature = "download",
66 any(feature = "model2vec", feature = "fastembed")
67))]
68use crate::model_manifest::{
69 ConsentSource, DownloadConsent, ModelLifecycle, resolve_download_consent,
70};
71#[cfg(all(
72 feature = "download",
73 any(feature = "model2vec", feature = "fastembed")
74))]
75use crate::model_registry::ensure_model_storage_layout_checked;
76#[cfg(feature = "model2vec")]
77use crate::model2vec_embedder::{
78 Model2VecEmbedder, find_model_dir_with_hf_id as find_model2vec_model_dir,
79};
80
81#[cfg(feature = "model2vec")]
82const POTION_MODEL_NAME: &str = "potion-multilingual-128M";
83#[cfg(feature = "model2vec")]
84const POTION_HF_ID: &str = "minishlab/potion-multilingual-128M";
85#[cfg(all(feature = "download", feature = "model2vec"))]
86const POTION_DIMENSION: usize = 256;
87#[cfg(all(
88 feature = "download",
89 any(feature = "model2vec", feature = "fastembed")
90))]
91const OFFLINE_ENV: &str = "FRANKENSEARCH_OFFLINE";
92#[cfg(all(
93 feature = "download",
94 any(feature = "model2vec", feature = "fastembed")
95))]
96const PROGRESS_BAR_WIDTH: usize = 30;
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum TwoTierAvailability {
101 Full,
103 FastOnly,
105 HashOnly,
107}
108
109impl TwoTierAvailability {
110 #[must_use]
112 pub const fn is_degraded(self) -> bool {
113 matches!(self, Self::FastOnly | Self::HashOnly)
114 }
115
116 #[must_use]
118 pub const fn degradation_summary(self) -> Option<&'static str> {
119 match self {
120 Self::Full => None,
121 Self::FastOnly => Some(
122 "Quality model unavailable: search will return fast-tier results only (no refinement phase).",
123 ),
124 Self::HashOnly => Some(
125 "No semantic models available: search uses hash-based embedding only (reduced relevance).",
126 ),
127 }
128 }
129}
130
131impl fmt::Display for TwoTierAvailability {
132 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133 match self {
134 Self::Full => write!(f, "full (fast + quality)"),
135 Self::FastOnly => write!(f, "degraded (fast-only, no quality refinement)"),
136 Self::HashOnly => write!(f, "minimal (hash-only, no semantic search)"),
137 }
138 }
139}
140
141#[derive(Debug, Clone)]
146pub struct ModelAvailabilityDiagnostic {
147 pub availability: TwoTierAvailability,
149 pub cache_dir: std::path::PathBuf,
151 pub offline: bool,
153 pub fast_status: ModelStatus,
155 pub quality_status: ModelStatus,
157 pub suggestions: Vec<String>,
159}
160
161#[derive(Debug, Clone)]
163pub enum ModelStatus {
164 Ready {
166 id: String,
168 },
169 NotFound {
171 model_name: String,
173 hf_repo_url: String,
175 searched_paths: Vec<std::path::PathBuf>,
177 },
178 DownloadBlocked {
180 model_name: String,
182 reason: String,
184 },
185 FeatureDisabled {
187 feature_flag: String,
189 },
190 HashFallback,
192}
193
194impl fmt::Display for ModelAvailabilityDiagnostic {
195 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
196 writeln!(f, "Model availability: {}", self.availability)?;
197 writeln!(f, "Cache directory: {}", self.cache_dir.display())?;
198 if self.offline {
199 writeln!(f, "Mode: OFFLINE (FRANKENSEARCH_OFFLINE=1)")?;
200 }
201 writeln!(f)?;
202 writeln!(f, "Fast tier: {}", self.fast_status)?;
203 writeln!(f, "Quality tier: {}", self.quality_status)?;
204 if !self.suggestions.is_empty() {
205 writeln!(f)?;
206 writeln!(f, "To resolve:")?;
207 for suggestion in &self.suggestions {
208 writeln!(f, " - {suggestion}")?;
209 }
210 }
211 Ok(())
212 }
213}
214
215impl fmt::Display for ModelStatus {
216 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
217 match self {
218 Self::Ready { id } => write!(f, "ready ({id})"),
219 Self::NotFound {
220 model_name,
221 hf_repo_url,
222 ..
223 } => {
224 write!(f, "NOT FOUND ({model_name}) — download from {hf_repo_url}")
225 }
226 Self::DownloadBlocked { model_name, reason } => {
227 write!(f, "BLOCKED ({model_name}): {reason}")
228 }
229 Self::FeatureDisabled { feature_flag } => {
230 write!(f, "DISABLED (compile with --features {feature_flag})")
231 }
232 Self::HashFallback => write!(f, "hash fallback (no semantic model)"),
233 }
234 }
235}
236
237#[derive(Clone)]
239pub struct EmbedderStack {
240 fast: Arc<dyn Embedder>,
241 quality: Option<Arc<dyn Embedder>>,
242 availability: TwoTierAvailability,
243}
244
245impl fmt::Debug for EmbedderStack {
246 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
247 f.debug_struct("EmbedderStack")
248 .field("availability", &self.availability)
249 .field("fast_id", &self.fast.id())
250 .field("fast_dim", &self.fast.dimension())
251 .field(
252 "quality_id",
253 &self.quality.as_ref().map(|embedder| embedder.id()),
254 )
255 .finish()
256 }
257}
258
259impl EmbedderStack {
260 #[must_use]
262 pub fn from_parts(fast: Arc<dyn Embedder>, quality: Option<Arc<dyn Embedder>>) -> Self {
263 let availability = if quality.is_some() {
264 TwoTierAvailability::Full
265 } else if fast.is_semantic() {
266 TwoTierAvailability::FastOnly
267 } else {
268 TwoTierAvailability::HashOnly
269 };
270 Self {
271 fast,
272 quality,
273 availability,
274 }
275 }
276
277 pub fn auto_detect() -> SearchResult<Self> {
283 Self::auto_detect_with(None)
284 }
285
286 pub fn auto_detect_with(model_root: Option<&Path>) -> SearchResult<Self> {
292 #[cfg(all(
293 feature = "download",
294 any(feature = "model2vec", feature = "fastembed")
295 ))]
296 {
297 Self::auto_detect_with_policy(model_root, download_policy_from_environment())
298 }
299 #[cfg(not(all(
300 feature = "download",
301 any(feature = "model2vec", feature = "fastembed")
302 )))]
303 {
304 Self::auto_detect_with_policy(model_root)
305 }
306 }
307
308 #[cfg(all(
309 feature = "download",
310 any(feature = "model2vec", feature = "fastembed")
311 ))]
312 fn auto_detect_with_policy(
313 model_root: Option<&Path>,
314 policy: DownloadPolicy,
315 ) -> SearchResult<Self> {
316 materialize_bundled_default_models(model_root);
317 let quality = detect_quality_embedder(model_root)
318 .or_else(|| maybe_lazy_quality_embedder(model_root, policy))
319 .or_else(detect_api_embedder);
320 let fast = detect_fast_embedder(model_root)
321 .or_else(|| maybe_lazy_fast_embedder(model_root, policy))
322 .or_else(hash_fallback_embedder)
323 .ok_or_else(|| SearchError::EmbedderUnavailable {
324 model: "fast-tier".to_owned(),
325 reason: "no model2vec/hash embedder available in this build".to_owned(),
326 })?;
327
328 let stack = Self::from_parts(fast, quality);
329 info!(
330 availability = ?stack.availability,
331 fast = stack.fast.id(),
332 quality = stack.quality.as_ref().map(|embedder| embedder.id()),
333 "embedder stack ready"
334 );
335 Ok(stack)
336 }
337
338 #[cfg(not(all(
339 feature = "download",
340 any(feature = "model2vec", feature = "fastembed")
341 )))]
342 fn auto_detect_with_policy(model_root: Option<&Path>) -> SearchResult<Self> {
343 materialize_bundled_default_models(model_root);
344 let quality = detect_quality_embedder(model_root).or_else(detect_api_embedder);
345 let fast = detect_fast_embedder(model_root)
346 .or_else(hash_fallback_embedder)
347 .ok_or_else(|| SearchError::EmbedderUnavailable {
348 model: "fast-tier".to_owned(),
349 reason: "no model2vec/hash embedder available in this build".to_owned(),
350 })?;
351
352 let stack = Self::from_parts(fast, quality);
353 info!(
354 availability = ?stack.availability,
355 fast = stack.fast.id(),
356 quality = stack.quality.as_ref().map(|embedder| embedder.id()),
357 "embedder stack ready"
358 );
359 Ok(stack)
360 }
361
362 pub fn with_mrl_target_dim(mut self, target_dim: usize) -> SearchResult<Self> {
368 if target_dim == 0 {
369 return Err(SearchError::InvalidConfig {
370 field: "target_dim".to_owned(),
371 value: "0".to_owned(),
372 reason: "target dimension must be at least 1".to_owned(),
373 });
374 }
375
376 self.fast = maybe_wrap_mrl(self.fast.clone(), target_dim)?;
377 self.quality = self
378 .quality
379 .clone()
380 .map(|embedder| maybe_wrap_mrl(embedder, target_dim))
381 .transpose()?;
382 self.availability = if self.quality.is_some() {
383 TwoTierAvailability::Full
384 } else if self.fast.is_semantic() {
385 TwoTierAvailability::FastOnly
386 } else {
387 TwoTierAvailability::HashOnly
388 };
389 Ok(self)
390 }
391
392 #[must_use]
394 pub fn fast(&self) -> &dyn Embedder {
395 self.fast.as_ref()
396 }
397
398 #[must_use]
400 pub fn fast_embedder(&self) -> &dyn Embedder {
401 self.fast()
402 }
403
404 #[must_use]
406 pub fn fast_arc(&self) -> Arc<dyn Embedder> {
407 self.fast.clone()
408 }
409
410 #[must_use]
412 pub fn quality(&self) -> Option<&dyn Embedder> {
413 self.quality.as_deref()
414 }
415
416 #[must_use]
418 pub fn quality_embedder(&self) -> Option<&dyn Embedder> {
419 self.quality()
420 }
421
422 #[must_use]
424 pub fn quality_arc(&self) -> Option<Arc<dyn Embedder>> {
425 self.quality.clone()
426 }
427
428 #[must_use]
430 pub const fn availability(&self) -> TwoTierAvailability {
431 self.availability
432 }
433
434 #[allow(clippy::too_many_lines)]
439 #[must_use]
440 pub fn diagnose(&self) -> ModelAvailabilityDiagnostic {
441 let cache_dir = crate::model_cache::resolve_cache_root();
442 let offline = std::env::var("FRANKENSEARCH_OFFLINE")
443 .ok()
444 .as_deref()
445 .is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true"));
446
447 let fast_status = if self.fast.is_semantic() {
448 ModelStatus::Ready {
449 id: self.fast.id().to_owned(),
450 }
451 } else {
452 ModelStatus::HashFallback
453 };
454
455 #[allow(clippy::option_if_let_else)] let quality_status = if let Some(ref quality) = self.quality {
457 ModelStatus::Ready {
458 id: quality.id().to_owned(),
459 }
460 } else {
461 #[cfg(feature = "fastembed")]
462 {
463 if offline {
464 ModelStatus::DownloadBlocked {
465 model_name: "all-MiniLM-L6-v2".to_owned(),
466 reason: "FRANKENSEARCH_OFFLINE=1 disables auto-download".to_owned(),
467 }
468 } else {
469 ModelStatus::NotFound {
470 model_name: "all-MiniLM-L6-v2".to_owned(),
471 hf_repo_url:
472 "https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2"
473 .to_owned(),
474 searched_paths: vec![cache_dir.join("all-MiniLM-L6-v2")],
475 }
476 }
477 }
478 #[cfg(not(feature = "fastembed"))]
479 {
480 ModelStatus::FeatureDisabled {
481 feature_flag: "fastembed".to_owned(),
482 }
483 }
484 };
485
486 let mut suggestions = Vec::new();
487 if self.availability.is_degraded() {
488 #[cfg(not(feature = "bundled-default-models"))]
489 if offline {
490 suggestions.push(
491 "Unset FRANKENSEARCH_OFFLINE to allow automatic model downloads.".to_owned(),
492 );
493 }
494 suggestions.push(format!(
495 "Set FRANKENSEARCH_MODEL_DIR to point to a pre-populated model cache (current: {}).",
496 cache_dir.display()
497 ));
498
499 if matches!(self.availability, TwoTierAvailability::HashOnly) {
500 #[cfg(all(feature = "bundled-default-models", feature = "model2vec"))]
501 suggestions.push(
502 "Default semantic models are bundled in fsfs. If still unavailable, ensure the model cache path is writable and run `fsfs status`."
503 .to_owned(),
504 );
505 #[cfg(feature = "model2vec")]
506 #[cfg(not(feature = "bundled-default-models"))]
507 suggestions.push(
508 "Download potion-multilingual-128M from https://huggingface.co/minishlab/potion-multilingual-128M and place in cache dir."
509 .to_owned(),
510 );
511 #[cfg(not(feature = "model2vec"))]
512 suggestions.push(
513 "Compile with --features model2vec to enable the fast semantic tier."
514 .to_owned(),
515 );
516 }
517 if self.quality.is_none() {
518 #[cfg(all(feature = "bundled-default-models", feature = "fastembed"))]
519 suggestions.push(
520 "Quality model should be bundled by default. Check cache permissions and verify `all-MiniLM-L6-v2` exists under the model directory."
521 .to_owned(),
522 );
523 #[cfg(feature = "fastembed")]
524 #[cfg(not(feature = "bundled-default-models"))]
525 suggestions.push(
526 "Download all-MiniLM-L6-v2 from https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2 and place in cache dir."
527 .to_owned(),
528 );
529 #[cfg(not(feature = "fastembed"))]
530 suggestions.push(
531 "Compile with --features fastembed to enable the quality semantic tier."
532 .to_owned(),
533 );
534 }
535 #[cfg(not(feature = "bundled-default-models"))]
536 suggestions.push(
537 "For air-gapped environments: run `fsfs download-models --output ./models/` on a networked machine, then copy to target."
538 .to_owned(),
539 );
540 #[cfg(feature = "bundled-default-models")]
541 suggestions.push(
542 "Optional: use `fsfs download-models` only when you want alternate semantic models beyond the bundled defaults."
543 .to_owned(),
544 );
545 }
546
547 ModelAvailabilityDiagnostic {
548 availability: self.availability,
549 cache_dir,
550 offline,
551 fast_status,
552 quality_status,
553 suggestions,
554 }
555 }
556
557 #[must_use]
559 pub fn degradation_message(&self) -> Option<String> {
560 if !self.availability.is_degraded() {
561 return None;
562 }
563
564 let diag = self.diagnose();
565 let mut msg = String::new();
566 if let Some(summary) = self.availability.degradation_summary() {
567 msg.push_str(summary);
568 msg.push('\n');
569 }
570 let _ = writeln!(msg, "Model cache: {}", diag.cache_dir.display());
571 if diag.offline {
572 msg.push_str("Offline mode: enabled (FRANKENSEARCH_OFFLINE=1)\n");
573 }
574 if !diag.suggestions.is_empty() {
575 msg.push_str("\nTo improve search quality:\n");
576 for suggestion in &diag.suggestions {
577 let _ = writeln!(msg, " - {suggestion}");
578 }
579 }
580 Some(msg)
581 }
582}
583
584pub struct DimReduceEmbedder {
586 inner: Arc<dyn Embedder>,
587 target_dim: usize,
588 id: String,
589 model_name: String,
590}
591
592impl fmt::Debug for DimReduceEmbedder {
593 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
594 f.debug_struct("DimReduceEmbedder")
595 .field("inner", &self.inner.id())
596 .field("target_dim", &self.target_dim)
597 .finish_non_exhaustive()
598 }
599}
600
601impl DimReduceEmbedder {
602 pub fn new(inner: Arc<dyn Embedder>, target_dim: usize) -> SearchResult<Self> {
608 if target_dim == 0 {
609 return Err(SearchError::InvalidConfig {
610 field: "target_dim".to_owned(),
611 value: "0".to_owned(),
612 reason: "target dimension must be at least 1".to_owned(),
613 });
614 }
615 if target_dim > inner.dimension() {
616 return Err(SearchError::InvalidConfig {
617 field: "target_dim".to_owned(),
618 value: target_dim.to_string(),
619 reason: format!(
620 "target dimension cannot exceed embedder dimension {}",
621 inner.dimension()
622 ),
623 });
624 }
625 if !inner.supports_mrl() {
626 return Err(SearchError::InvalidConfig {
627 field: "embedder.supports_mrl".to_owned(),
628 value: inner.id().to_owned(),
629 reason: "embedder does not support MRL truncation".to_owned(),
630 });
631 }
632
633 Ok(Self {
634 id: format!("{}-mrl-{target_dim}", inner.id()),
635 model_name: format!("{} (MRL {target_dim})", inner.model_name()),
636 inner,
637 target_dim,
638 })
639 }
640}
641
642impl Embedder for DimReduceEmbedder {
643 fn embed<'a>(&'a self, cx: &'a Cx, text: &'a str) -> SearchFuture<'a, Vec<f32>> {
644 Box::pin(async move {
645 let full = self.inner.embed(cx, text).await?;
646 self.inner.truncate_embedding(&full, self.target_dim)
647 })
648 }
649
650 fn embed_batch<'a>(
651 &'a self,
652 cx: &'a Cx,
653 texts: &'a [&'a str],
654 ) -> SearchFuture<'a, Vec<Vec<f32>>> {
655 Box::pin(async move {
656 let full_batch = self.inner.embed_batch(cx, texts).await?;
657 full_batch
658 .iter()
659 .map(|embedding| self.inner.truncate_embedding(embedding, self.target_dim))
660 .collect()
661 })
662 }
663
664 fn dimension(&self) -> usize {
665 self.target_dim
666 }
667
668 fn id(&self) -> &str {
669 &self.id
670 }
671
672 fn model_name(&self) -> &str {
673 &self.model_name
674 }
675
676 fn is_ready(&self) -> bool {
677 self.inner.is_ready()
678 }
679
680 fn is_semantic(&self) -> bool {
681 self.inner.is_semantic()
682 }
683
684 fn category(&self) -> frankensearch_core::traits::ModelCategory {
685 self.inner.category()
686 }
687
688 fn tier(&self) -> frankensearch_core::traits::ModelTier {
689 self.inner.tier()
690 }
691
692 fn supports_mrl(&self) -> bool {
693 true
694 }
695}
696
697fn maybe_wrap_mrl(
698 embedder: Arc<dyn Embedder>,
699 target_dim: usize,
700) -> SearchResult<Arc<dyn Embedder>> {
701 if target_dim >= embedder.dimension() || !embedder.supports_mrl() {
702 return Ok(embedder);
703 }
704 Ok(Arc::new(DimReduceEmbedder::new(embedder, target_dim)?))
705}
706
707#[cfg(all(
708 feature = "download",
709 any(feature = "model2vec", feature = "fastembed")
710))]
711#[derive(Debug, Clone, Copy)]
712struct DownloadPolicy {
713 consent: DownloadConsent,
714 offline: bool,
715 stderr_is_tty: bool,
716}
717
718#[cfg(all(
719 feature = "download",
720 any(feature = "model2vec", feature = "fastembed")
721))]
722impl DownloadPolicy {
723 const fn can_download(self) -> bool {
724 self.consent.granted && !self.offline
725 }
726
727 fn blocked_reason(self) -> String {
728 if self.offline {
729 return format!("{OFFLINE_ENV}=1 disables model auto-download");
730 }
731 if !self.consent.granted {
732 let source = self
733 .consent
734 .source
735 .map_or_else(|| "unset".to_owned(), |s| format!("{s:?}"));
736 return format!("download consent denied (source={source})");
737 }
738 "download policy blocked".to_owned()
739 }
740
741 #[cfg(test)]
742 const fn for_tests(consent: DownloadConsent, offline: bool, stderr_is_tty: bool) -> Self {
743 Self {
744 consent,
745 offline,
746 stderr_is_tty,
747 }
748 }
749}
750
751#[cfg(all(
752 feature = "download",
753 any(feature = "model2vec", feature = "fastembed")
754))]
755fn download_policy_from_environment() -> DownloadPolicy {
756 let offline = std::env::var(OFFLINE_ENV)
757 .ok()
758 .as_deref()
759 .and_then(parse_bool_flag)
760 .unwrap_or(false);
761 let consent = if offline {
762 DownloadConsent::denied(Some(ConsentSource::Environment))
763 } else {
764 resolve_download_consent(None, None, Some(false))
767 };
768 DownloadPolicy {
769 consent,
770 offline,
771 stderr_is_tty: io::stderr().is_terminal(),
772 }
773}
774
775#[cfg(all(
776 feature = "download",
777 any(feature = "model2vec", feature = "fastembed")
778))]
779fn parse_bool_flag(raw: &str) -> Option<bool> {
780 let value = raw.trim();
781 if value == "1"
782 || value.eq_ignore_ascii_case("true")
783 || value.eq_ignore_ascii_case("yes")
784 || value.eq_ignore_ascii_case("on")
785 {
786 return Some(true);
787 }
788 if value == "0"
789 || value.eq_ignore_ascii_case("false")
790 || value.eq_ignore_ascii_case("no")
791 || value.eq_ignore_ascii_case("off")
792 {
793 return Some(false);
794 }
795 None
796}
797
798#[cfg(all(
799 feature = "download",
800 any(feature = "model2vec", feature = "fastembed"),
801 feature = "model2vec"
802))]
803fn maybe_lazy_fast_embedder(
804 model_root: Option<&Path>,
805 policy: DownloadPolicy,
806) -> Option<Arc<dyn Embedder>> {
807 if !policy.can_download() {
808 info!(
809 model = POTION_MODEL_NAME,
810 tier = "fast",
811 reason = %policy.blocked_reason(),
812 "auto-download disabled; fast tier falling back"
813 );
814 return None;
815 }
816
817 info!(
818 model = POTION_MODEL_NAME,
819 tier = "fast",
820 "model not found locally; deferring download to first embed call"
821 );
822 Some(Arc::new(LazyModel2VecEmbedder::new(
823 model_root.map(Path::to_path_buf),
824 policy,
825 )))
826}
827
828#[cfg(all(
829 feature = "download",
830 any(feature = "model2vec", feature = "fastembed"),
831 not(feature = "model2vec")
832))]
833fn maybe_lazy_fast_embedder(
834 _model_root: Option<&Path>,
835 _policy: DownloadPolicy,
836) -> Option<Arc<dyn Embedder>> {
837 None
838}
839
840#[cfg(all(
841 feature = "download",
842 any(feature = "model2vec", feature = "fastembed"),
843 feature = "fastembed"
844))]
845fn maybe_lazy_quality_embedder(
846 model_root: Option<&Path>,
847 policy: DownloadPolicy,
848) -> Option<Arc<dyn Embedder>> {
849 if !policy.can_download() {
850 info!(
851 model = MINILM_MODEL_NAME,
852 tier = "quality",
853 reason = %policy.blocked_reason(),
854 "auto-download disabled; quality tier unavailable"
855 );
856 return None;
857 }
858
859 info!(
860 model = MINILM_MODEL_NAME,
861 tier = "quality",
862 "model not found locally; deferring download to first embed call"
863 );
864 Some(Arc::new(LazyFastEmbedEmbedder::new(
865 model_root.map(Path::to_path_buf),
866 policy,
867 )))
868}
869
870#[cfg(all(
871 feature = "download",
872 any(feature = "model2vec", feature = "fastembed"),
873 not(feature = "fastembed")
874))]
875fn maybe_lazy_quality_embedder(
876 _model_root: Option<&Path>,
877 _policy: DownloadPolicy,
878) -> Option<Arc<dyn Embedder>> {
879 None
880}
881
882#[cfg(all(
883 feature = "download",
884 any(feature = "model2vec", feature = "fastembed")
885))]
886fn install_destination_dir(model_root: Option<&Path>, model_name: &str) -> SearchResult<PathBuf> {
887 if let Some(root) = model_root {
888 if root.ends_with(model_name) {
889 return Ok(root.to_path_buf());
890 }
891 return Ok(root.join(model_name));
892 }
893 Ok(ensure_model_storage_layout_checked()?.join(model_name))
894}
895
896#[cfg(all(
897 feature = "download",
898 any(feature = "model2vec", feature = "fastembed")
899))]
900async fn download_and_install_manifest(
901 cx: &Cx,
902 manifest: &ModelManifest,
903 destination_dir: &Path,
904 policy: DownloadPolicy,
905) -> SearchResult<()> {
906 let start = Instant::now();
907 let downloader = ModelDownloader::with_defaults();
908 let mut lifecycle = ModelLifecycle::new(manifest.clone(), policy.consent);
909 let staging_root = destination_dir
910 .parent()
911 .map_or_else(|| destination_dir.to_path_buf(), Path::to_path_buf);
912 std::fs::create_dir_all(&staging_root)?;
913
914 let reporter = Arc::new(DownloadProgressReporter::new(
915 manifest.id.clone(),
916 policy.stderr_is_tty,
917 ));
918 let reporter_for_cb = Arc::clone(&reporter);
919
920 info!(
921 model = %manifest.id,
922 destination = %destination_dir.display(),
923 bytes = manifest.total_size_bytes(),
924 "starting automatic model download"
925 );
926
927 let staged = match downloader
928 .download_model(
929 cx,
930 manifest,
931 &staging_root,
932 &mut lifecycle,
933 move |progress| {
934 reporter_for_cb.report(progress);
935 },
936 )
937 .await
938 {
939 Ok(staged) => staged,
940 Err(error) => {
941 reporter.finish_failed(start.elapsed(), &error);
942 warn!(
943 model = %manifest.id,
944 duration_ms = start.elapsed().as_millis(),
945 error = %error,
946 "automatic model download failed"
947 );
948 return Err(error);
949 }
950 };
951
952 match manifest.promote_verified_installation(&staged, destination_dir) {
953 Ok(backup) => {
954 let backup_path = backup
955 .as_ref()
956 .map_or_else(|| "none".to_owned(), |path| path.display().to_string());
957 reporter.finish_ok(start.elapsed(), manifest.total_size_bytes());
958 info!(
959 model = %manifest.id,
960 destination = %destination_dir.display(),
961 backup = %backup_path,
962 duration_ms = start.elapsed().as_millis(),
963 bytes = manifest.total_size_bytes(),
964 "automatic model download completed"
965 );
966 Ok(())
967 }
968 Err(error) => {
969 reporter.finish_failed(start.elapsed(), &error);
970 warn!(
971 model = %manifest.id,
972 destination = %destination_dir.display(),
973 duration_ms = start.elapsed().as_millis(),
974 error = %error,
975 "automatic model promotion failed"
976 );
977 Err(error)
978 }
979 }
980}
981
982#[cfg(all(feature = "download", feature = "model2vec"))]
983struct LazyModel2VecEmbedder {
984 model_root: Option<PathBuf>,
985 policy: DownloadPolicy,
986 inner: OnceCell<Arc<dyn Embedder>>,
987}
988
989#[cfg(all(feature = "download", feature = "model2vec"))]
990impl LazyModel2VecEmbedder {
991 fn new(model_root: Option<PathBuf>, policy: DownloadPolicy) -> Self {
992 Self {
993 model_root,
994 policy,
995 inner: OnceCell::new(),
996 }
997 }
998
999 async fn ensure_loaded(&self, cx: &Cx) -> SearchResult<Arc<dyn Embedder>> {
1000 let embedder = self
1001 .inner
1002 .get_or_try_init(|| async { self.initialize(cx).await })
1003 .await?;
1004 Ok(Arc::clone(embedder))
1005 }
1006
1007 async fn initialize(&self, cx: &Cx) -> SearchResult<Arc<dyn Embedder>> {
1008 if let Some(existing) = detect_fast_embedder(self.model_root.as_deref()) {
1009 return Ok(existing);
1010 }
1011 if !self.policy.can_download() {
1012 return Err(SearchError::EmbedderUnavailable {
1013 model: POTION_MODEL_NAME.to_owned(),
1014 reason: self.policy.blocked_reason(),
1015 });
1016 }
1017
1018 let manifest = ModelManifest::potion_128m();
1019 let destination = install_destination_dir(self.model_root.as_deref(), POTION_MODEL_NAME)?;
1020 download_and_install_manifest(cx, &manifest, &destination, self.policy).await?;
1021 Model2VecEmbedder::load_with_name(&destination, POTION_MODEL_NAME)
1022 .map(|embedder| Arc::new(embedder) as Arc<dyn Embedder>)
1023 }
1024}
1025
1026#[cfg(all(feature = "download", feature = "model2vec"))]
1027impl Embedder for LazyModel2VecEmbedder {
1028 fn embed<'a>(&'a self, cx: &'a Cx, text: &'a str) -> SearchFuture<'a, Vec<f32>> {
1029 Box::pin(async move {
1030 let embedder = self.ensure_loaded(cx).await?;
1031 embedder.embed(cx, text).await
1032 })
1033 }
1034
1035 fn embed_batch<'a>(
1036 &'a self,
1037 cx: &'a Cx,
1038 texts: &'a [&'a str],
1039 ) -> SearchFuture<'a, Vec<Vec<f32>>> {
1040 Box::pin(async move {
1041 let embedder = self.ensure_loaded(cx).await?;
1042 embedder.embed_batch(cx, texts).await
1043 })
1044 }
1045
1046 fn dimension(&self) -> usize {
1047 POTION_DIMENSION
1048 }
1049
1050 fn id(&self) -> &str {
1051 POTION_MODEL_NAME
1052 }
1053
1054 fn model_name(&self) -> &str {
1055 POTION_MODEL_NAME
1056 }
1057
1058 fn is_ready(&self) -> bool {
1059 self.inner.get().is_some_and(|embedder| embedder.is_ready())
1060 }
1061
1062 fn is_semantic(&self) -> bool {
1063 true
1064 }
1065
1066 fn category(&self) -> ModelCategory {
1067 ModelCategory::StaticEmbedder
1068 }
1069
1070 fn tier(&self) -> ModelTier {
1071 ModelTier::Fast
1072 }
1073
1074 fn supports_mrl(&self) -> bool {
1075 true
1076 }
1077}
1078
1079#[cfg(all(feature = "download", feature = "fastembed"))]
1080struct LazyFastEmbedEmbedder {
1081 model_root: Option<PathBuf>,
1082 policy: DownloadPolicy,
1083 inner: OnceCell<Arc<dyn Embedder>>,
1084}
1085
1086#[cfg(all(feature = "download", feature = "fastembed"))]
1087impl LazyFastEmbedEmbedder {
1088 fn new(model_root: Option<PathBuf>, policy: DownloadPolicy) -> Self {
1089 Self {
1090 model_root,
1091 policy,
1092 inner: OnceCell::new(),
1093 }
1094 }
1095
1096 async fn ensure_loaded(&self, cx: &Cx) -> SearchResult<Arc<dyn Embedder>> {
1097 let embedder = self
1098 .inner
1099 .get_or_try_init(|| async { self.initialize(cx).await })
1100 .await?;
1101 Ok(Arc::clone(embedder))
1102 }
1103
1104 async fn initialize(&self, cx: &Cx) -> SearchResult<Arc<dyn Embedder>> {
1105 if let Some(existing) = detect_quality_embedder(self.model_root.as_deref()) {
1106 return Ok(existing);
1107 }
1108 if !self.policy.can_download() {
1109 return Err(SearchError::EmbedderUnavailable {
1110 model: MINILM_MODEL_NAME.to_owned(),
1111 reason: self.policy.blocked_reason(),
1112 });
1113 }
1114
1115 let manifest = ModelManifest::minilm_v2();
1116 let destination = install_destination_dir(self.model_root.as_deref(), MINILM_MODEL_NAME)?;
1117 download_and_install_manifest(cx, &manifest, &destination, self.policy).await?;
1118 FastEmbedEmbedder::load_with_name(&destination, MINILM_MODEL_NAME)
1119 .map(|embedder| Arc::new(embedder) as Arc<dyn Embedder>)
1120 }
1121}
1122
1123#[cfg(all(feature = "download", feature = "fastembed"))]
1124impl Embedder for LazyFastEmbedEmbedder {
1125 fn embed<'a>(&'a self, cx: &'a Cx, text: &'a str) -> SearchFuture<'a, Vec<f32>> {
1126 Box::pin(async move {
1127 let embedder = self.ensure_loaded(cx).await?;
1128 embedder.embed(cx, text).await
1129 })
1130 }
1131
1132 fn embed_batch<'a>(
1133 &'a self,
1134 cx: &'a Cx,
1135 texts: &'a [&'a str],
1136 ) -> SearchFuture<'a, Vec<Vec<f32>>> {
1137 Box::pin(async move {
1138 let embedder = self.ensure_loaded(cx).await?;
1139 embedder.embed_batch(cx, texts).await
1140 })
1141 }
1142
1143 fn dimension(&self) -> usize {
1144 MINILM_DIMENSION
1145 }
1146
1147 fn id(&self) -> &str {
1148 MINILM_MODEL_NAME
1149 }
1150
1151 fn model_name(&self) -> &str {
1152 MINILM_MODEL_NAME
1153 }
1154
1155 fn is_ready(&self) -> bool {
1156 self.inner.get().is_some_and(|embedder| embedder.is_ready())
1157 }
1158
1159 fn is_semantic(&self) -> bool {
1160 true
1161 }
1162
1163 fn category(&self) -> ModelCategory {
1164 ModelCategory::TransformerEmbedder
1165 }
1166
1167 fn tier(&self) -> ModelTier {
1168 ModelTier::Quality
1169 }
1170}
1171
1172#[cfg(all(
1173 feature = "download",
1174 any(feature = "model2vec", feature = "fastembed")
1175))]
1176#[derive(Debug)]
1177struct DownloadProgressReporter {
1178 model_id: String,
1179 stderr_is_tty: bool,
1180 last_bucket: AtomicU8,
1181}
1182
1183#[cfg(all(
1184 feature = "download",
1185 any(feature = "model2vec", feature = "fastembed")
1186))]
1187impl DownloadProgressReporter {
1188 const fn new(model_id: String, stderr_is_tty: bool) -> Self {
1189 Self {
1190 model_id,
1191 stderr_is_tty,
1192 last_bucket: AtomicU8::new(0),
1193 }
1194 }
1195
1196 fn report(&self, progress: &DownloadProgress) {
1197 let progress_x100 = progress_percent_x100(progress);
1198 if self.stderr_is_tty {
1199 self.report_tty(progress, progress_x100);
1200 } else {
1201 self.report_non_tty(progress, progress_x100);
1202 }
1203 }
1204
1205 fn finish_ok(&self, elapsed: std::time::Duration, total_bytes: u64) {
1206 if self.stderr_is_tty {
1207 eprintln!(
1208 "\rDownloaded {} in {:.1}s ({})",
1209 self.model_id,
1210 elapsed.as_secs_f64(),
1211 format_bytes(total_bytes),
1212 );
1213 } else {
1214 eprintln!(
1215 "Downloaded {} in {:.1}s ({})",
1216 self.model_id,
1217 elapsed.as_secs_f64(),
1218 format_bytes(total_bytes),
1219 );
1220 }
1221 }
1222
1223 fn finish_failed(&self, elapsed: std::time::Duration, error: &SearchError) {
1224 if self.stderr_is_tty {
1225 eprintln!(
1226 "\rDownload failed for {} after {:.1}s: {}",
1227 self.model_id,
1228 elapsed.as_secs_f64(),
1229 error
1230 );
1231 } else {
1232 eprintln!(
1233 "Download failed for {} after {:.1}s: {}",
1234 self.model_id,
1235 elapsed.as_secs_f64(),
1236 error
1237 );
1238 }
1239 }
1240
1241 fn report_tty(&self, progress: &DownloadProgress, progress_x100: u64) {
1242 let pct_whole = progress_x100 / 100;
1243 let pct_frac = progress_x100 % 100;
1244 let bar = render_progress_bar(progress_x100);
1245 let total = progress
1246 .total_bytes
1247 .map_or_else(|| "?".to_owned(), format_bytes);
1248 eprint!(
1249 "\rDownloading {} [{}] {:>3}.{pct_frac:02}% {}/{} {} ETA {} ({}/{})",
1250 self.model_id,
1251 bar,
1252 pct_whole,
1253 format_bytes(progress.bytes_downloaded),
1254 total,
1255 format_speed(progress.speed_bytes_per_sec),
1256 format_eta(progress.eta_seconds),
1257 progress.files_completed + 1,
1258 progress.files_total.max(1),
1259 );
1260 let _ = io::stderr().flush();
1261 }
1262
1263 fn report_non_tty(&self, progress: &DownloadProgress, progress_x100: u64) {
1264 let bucket = u8::try_from((progress_x100 / 1000).min(10)).unwrap_or(10);
1265 let previous = self.last_bucket.load(Ordering::Relaxed);
1266 if bucket <= previous {
1267 return;
1268 }
1269 if self
1270 .last_bucket
1271 .compare_exchange(previous, bucket, Ordering::Relaxed, Ordering::Relaxed)
1272 .is_ok()
1273 {
1274 eprintln!(
1275 "Downloading {}... {}% ({}/{} {} ETA {})",
1276 self.model_id,
1277 bucket.saturating_mul(10),
1278 format_bytes(progress.bytes_downloaded),
1279 progress
1280 .total_bytes
1281 .map_or_else(|| "?".to_owned(), format_bytes),
1282 format_speed(progress.speed_bytes_per_sec),
1283 format_eta(progress.eta_seconds),
1284 );
1285 }
1286 }
1287}
1288
1289#[cfg(all(
1290 feature = "download",
1291 any(feature = "model2vec", feature = "fastembed")
1292))]
1293fn progress_percent_x100(progress: &DownloadProgress) -> u64 {
1294 let files_total = u64::try_from(progress.files_total).unwrap_or(1).max(1);
1295 let files_completed = u64::try_from(progress.files_completed)
1296 .unwrap_or(files_total)
1297 .min(files_total);
1298 let current_file_percent_x100 = progress
1299 .total_bytes
1300 .filter(|&total| total > 0)
1301 .map_or(0, |total| {
1302 progress.bytes_downloaded.min(total).saturating_mul(10_000) / total
1303 });
1304 files_completed
1305 .saturating_mul(10_000)
1306 .saturating_add(current_file_percent_x100)
1307 / files_total
1308}
1309
1310#[cfg(all(
1311 feature = "download",
1312 any(feature = "model2vec", feature = "fastembed")
1313))]
1314fn render_progress_bar(progress_x100: u64) -> String {
1315 let width = u64::try_from(PROGRESS_BAR_WIDTH).unwrap_or(30);
1316 let filled = usize::try_from(progress_x100.saturating_mul(width) / 10_000)
1317 .unwrap_or(PROGRESS_BAR_WIDTH)
1318 .min(PROGRESS_BAR_WIDTH);
1319 let mut bar = String::with_capacity(PROGRESS_BAR_WIDTH);
1320 bar.push_str(&"=".repeat(filled));
1321 bar.push_str(&" ".repeat(PROGRESS_BAR_WIDTH.saturating_sub(filled)));
1322 bar
1323}
1324
1325#[cfg(all(
1326 feature = "download",
1327 any(feature = "model2vec", feature = "fastembed")
1328))]
1329fn format_eta(seconds: Option<f64>) -> String {
1330 match seconds {
1331 Some(value) if value.is_finite() && value >= 0.0 => format!("{value:.1}s"),
1332 _ => "?".to_owned(),
1333 }
1334}
1335
1336#[cfg(all(
1337 feature = "download",
1338 any(feature = "model2vec", feature = "fastembed")
1339))]
1340fn format_speed(bytes_per_sec: f64) -> String {
1341 const KB: f64 = 1024.0;
1342 const MB: f64 = 1024.0 * 1024.0;
1343 const GB: f64 = 1024.0 * 1024.0 * 1024.0;
1344 if !bytes_per_sec.is_finite() || bytes_per_sec <= 0.0 {
1345 return "0 B/s".to_owned();
1346 }
1347 if bytes_per_sec >= GB {
1348 format!("{:.1} GB/s", bytes_per_sec / GB)
1349 } else if bytes_per_sec >= MB {
1350 format!("{:.1} MB/s", bytes_per_sec / MB)
1351 } else if bytes_per_sec >= KB {
1352 format!("{:.1} KB/s", bytes_per_sec / KB)
1353 } else {
1354 format!("{bytes_per_sec:.0} B/s")
1355 }
1356}
1357
1358#[cfg(all(
1359 feature = "download",
1360 any(feature = "model2vec", feature = "fastembed")
1361))]
1362fn format_bytes(bytes: u64) -> String {
1363 const KB: u64 = 1024;
1364 const MB: u64 = 1024 * 1024;
1365 const GB: u64 = 1024 * 1024 * 1024;
1366
1367 if bytes >= GB {
1368 let whole = bytes / GB;
1369 let frac = bytes % GB * 10 / GB;
1370 format!("{whole}.{frac} GB")
1371 } else if bytes >= MB {
1372 let whole = bytes / MB;
1373 let frac = bytes % MB * 10 / MB;
1374 format!("{whole}.{frac} MB")
1375 } else if bytes >= KB {
1376 let whole = bytes / KB;
1377 let frac = bytes % KB * 10 / KB;
1378 format!("{whole}.{frac} KB")
1379 } else {
1380 format!("{bytes} B")
1381 }
1382}
1383
1384#[cfg(feature = "model2vec")]
1385fn detect_fast_embedder(model_root: Option<&Path>) -> Option<Arc<dyn Embedder>> {
1386 let manifest = ModelManifest::potion_128m();
1387 let discovered = if model_root.is_some() {
1390 None
1391 } else {
1392 find_model2vec_model_dir(POTION_MODEL_NAME, POTION_HF_ID)
1393 };
1394 let candidates = candidate_directories(model_root, POTION_MODEL_NAME, discovered.as_deref());
1395 let checked_paths: Vec<String> = candidates
1396 .iter()
1397 .map(|path| path.display().to_string())
1398 .collect();
1399
1400 for candidate in candidates {
1401 if !manifest_files_exist(&manifest, &candidate) {
1402 continue;
1403 }
1404 if let Err(error) = crate::model_manifest::verify_dir_cached(&manifest, &candidate) {
1405 warn!(
1406 model = POTION_MODEL_NAME,
1407 path = %candidate.display(),
1408 error = %error,
1409 "model2vec manifest verification failed, skipping candidate"
1410 );
1411 continue;
1412 }
1413
1414 match Model2VecEmbedder::load_with_name(&candidate, POTION_MODEL_NAME) {
1415 Ok(embedder) => {
1416 info!(
1417 model = POTION_MODEL_NAME,
1418 tier = "fast",
1419 path = %candidate.display(),
1420 dimension = embedder.dimension(),
1421 "embedder detected"
1422 );
1423 return Some(Arc::new(embedder));
1424 }
1425 Err(error) => {
1426 warn!(
1427 model = POTION_MODEL_NAME,
1428 tier = "fast",
1429 path = %candidate.display(),
1430 error = %error,
1431 "embedder unavailable"
1432 );
1433 }
1434 }
1435 }
1436
1437 info!(
1438 model = POTION_MODEL_NAME,
1439 tier = "fast",
1440 checked_paths = ?checked_paths,
1441 "embedder unavailable"
1442 );
1443 None
1444}
1445
1446#[cfg(not(feature = "model2vec"))]
1447fn detect_fast_embedder(_model_root: Option<&Path>) -> Option<Arc<dyn Embedder>> {
1448 None
1449}
1450
1451#[cfg(feature = "fastembed")]
1452fn detect_quality_embedder(model_root: Option<&Path>) -> Option<Arc<dyn Embedder>> {
1453 let manifest = ModelManifest::minilm_v2();
1454 let discovered = if model_root.is_some() {
1457 None
1458 } else {
1459 find_fastembed_model_dir(MINILM_MODEL_NAME, MINILM_HF_ID)
1460 };
1461 let candidates = candidate_directories(model_root, MINILM_MODEL_NAME, discovered.as_deref());
1462 let checked_paths: Vec<String> = candidates
1463 .iter()
1464 .map(|path| path.display().to_string())
1465 .collect();
1466
1467 for candidate in candidates {
1468 if !manifest_files_exist(&manifest, &candidate) {
1469 continue;
1470 }
1471 if let Err(error) = crate::model_manifest::verify_dir_cached(&manifest, &candidate) {
1472 warn!(
1473 model = MINILM_MODEL_NAME,
1474 path = %candidate.display(),
1475 error = %error,
1476 "quality manifest verification failed, skipping candidate"
1477 );
1478 continue;
1479 }
1480
1481 match FastEmbedEmbedder::load_with_name(&candidate, MINILM_MODEL_NAME) {
1482 Ok(embedder) => {
1483 info!(
1484 model = MINILM_MODEL_NAME,
1485 tier = "quality",
1486 path = %candidate.display(),
1487 dimension = embedder.dimension(),
1488 "embedder detected"
1489 );
1490 return Some(Arc::new(embedder));
1491 }
1492 Err(error) => {
1493 warn!(
1494 model = MINILM_MODEL_NAME,
1495 tier = "quality",
1496 path = %candidate.display(),
1497 error = %error,
1498 "embedder unavailable"
1499 );
1500 }
1501 }
1502 }
1503
1504 info!(
1505 model = MINILM_MODEL_NAME,
1506 tier = "quality",
1507 checked_paths = ?checked_paths,
1508 "embedder unavailable"
1509 );
1510 None
1511}
1512
1513#[cfg(not(feature = "fastembed"))]
1514fn detect_quality_embedder(_model_root: Option<&Path>) -> Option<Arc<dyn Embedder>> {
1515 None
1516}
1517
1518#[cfg(feature = "hash")]
1519#[allow(clippy::unnecessary_wraps)]
1520fn hash_fallback_embedder() -> Option<Arc<dyn Embedder>> {
1521 Some(Arc::new(HashEmbedder::default_256()))
1522}
1523
1524#[cfg(not(feature = "hash"))]
1525fn hash_fallback_embedder() -> Option<Arc<dyn Embedder>> {
1526 None
1527}
1528
1529#[cfg(feature = "api")]
1537fn detect_api_embedder() -> Option<Arc<dyn Embedder>> {
1538 use crate::api_embedder::ApiEmbedder;
1539 use crate::api_provider::{GeminiProvider, OpenAiProvider};
1540
1541 let explicit_provider = std::env::var("FRANKENSEARCH_API_PROVIDER").ok();
1542 let explicit_model = std::env::var("FRANKENSEARCH_API_MODEL").ok();
1543 let explicit_dim: Option<usize> = std::env::var("FRANKENSEARCH_API_DIMENSION")
1544 .ok()
1545 .and_then(|s| s.parse().ok());
1546
1547 let provider: Box<dyn crate::api_provider::ApiProvider> = match explicit_provider.as_deref() {
1548 Some("gemini") => {
1549 let key = std::env::var("GEMINI_API_KEY").ok()?;
1550 match explicit_model.as_deref() {
1551 Some("embedding-001") => Box::new(GeminiProvider::embedding_001(key)),
1552 _ => Box::new(GeminiProvider::text_embedding_004(key)),
1553 }
1554 }
1555 Some("openai") => {
1556 let key = std::env::var("OPENAI_API_KEY").ok()?;
1558 match explicit_model.as_deref() {
1559 Some("text-embedding-3-large") => {
1560 Box::new(OpenAiProvider::text_embedding_3_large(key, explicit_dim))
1561 }
1562 _ => Box::new(OpenAiProvider::text_embedding_3_small(key, explicit_dim)),
1563 }
1564 }
1565 None => {
1566 if let Ok(key) = std::env::var("OPENAI_API_KEY") {
1569 match explicit_model.as_deref() {
1570 Some("text-embedding-3-large") => {
1571 Box::new(OpenAiProvider::text_embedding_3_large(key, explicit_dim))
1572 }
1573 _ => Box::new(OpenAiProvider::text_embedding_3_small(key, explicit_dim)),
1574 }
1575 } else if let Ok(key) = std::env::var("GEMINI_API_KEY") {
1576 match explicit_model.as_deref() {
1577 Some("embedding-001") => Box::new(GeminiProvider::embedding_001(key)),
1578 _ => Box::new(GeminiProvider::text_embedding_004(key)),
1579 }
1580 } else {
1581 return None;
1582 }
1583 }
1584 Some(other) => {
1585 warn!(provider = other, "unknown FRANKENSEARCH_API_PROVIDER value");
1586 return None;
1587 }
1588 };
1589
1590 info!(
1591 provider = provider.provider_name(),
1592 model = provider.api_model_id(),
1593 dimension = provider.dimension(),
1594 "detected API embedder from environment"
1595 );
1596
1597 let embedder = ApiEmbedder::with_defaults(provider);
1598 Some(Arc::new(embedder.cached_default()))
1599}
1600
1601#[cfg(not(feature = "api"))]
1602fn detect_api_embedder() -> Option<Arc<dyn Embedder>> {
1603 None
1604}
1605
1606#[cfg(feature = "bundled-default-models")]
1607fn materialize_bundled_default_models(model_root: Option<&Path>) {
1608 match ensure_default_semantic_models(model_root) {
1609 Ok(summary) => {
1610 if summary.models_written > 0 {
1611 info!(
1612 model_root = %summary.model_root.display(),
1613 models_written = summary.models_written,
1614 bytes_written = summary.bytes_written,
1615 "materialized bundled default semantic models"
1616 );
1617 }
1618 }
1619 Err(error) => {
1620 warn!(
1621 error = %error,
1622 "failed to materialize bundled default semantic models; continuing with normal detection"
1623 );
1624 }
1625 }
1626}
1627
1628#[cfg(not(feature = "bundled-default-models"))]
1629const fn materialize_bundled_default_models(_model_root: Option<&Path>) {}
1630
1631#[cfg(any(feature = "model2vec", feature = "fastembed"))]
1632fn manifest_files_exist(manifest: &ModelManifest, model_dir: &Path) -> bool {
1633 manifest
1634 .files
1635 .iter()
1636 .all(|file| model_dir.join(&file.name).is_file())
1637}
1638
1639#[cfg(any(feature = "model2vec", feature = "fastembed"))]
1640fn candidate_directories(
1641 model_root: Option<&Path>,
1642 model_name: &str,
1643 discovered: Option<&Path>,
1644) -> Vec<PathBuf> {
1645 let mut paths = Vec::new();
1646 if let Some(root) = model_root {
1647 paths.push(root.join(model_name));
1648 paths.push(root.to_path_buf());
1649 }
1650 if let Some(path) = discovered {
1651 paths.push(path.to_path_buf());
1652 }
1653
1654 let mut seen = BTreeSet::new();
1655 paths
1656 .into_iter()
1657 .filter(|path| seen.insert(path.clone()))
1658 .collect()
1659}
1660
1661#[cfg(test)]
1662mod tests {
1663 #[cfg(all(
1664 feature = "model2vec",
1665 feature = "hash",
1666 not(feature = "bundled-default-models")
1667 ))]
1668 use std::fs;
1669
1670 #[cfg(all(feature = "download", feature = "model2vec"))]
1671 use asupersync::test_utils::run_test_with_cx;
1672
1673 use super::*;
1674 use frankensearch_core::traits::ModelCategory;
1675
1676 #[cfg(all(feature = "hash", not(feature = "bundled-default-models")))]
1677 #[test]
1678 fn auto_detect_hash_only_when_no_models_present() {
1679 let temp = tempfile::tempdir().unwrap();
1680 #[cfg(all(
1681 feature = "download",
1682 any(feature = "model2vec", feature = "fastembed")
1683 ))]
1684 let stack = EmbedderStack::auto_detect_with_policy(
1685 Some(temp.path()),
1686 DownloadPolicy::for_tests(
1687 DownloadConsent::denied(Some(ConsentSource::Programmatic)),
1688 false,
1689 false,
1690 ),
1691 )
1692 .unwrap();
1693 #[cfg(not(all(
1694 feature = "download",
1695 any(feature = "model2vec", feature = "fastembed")
1696 )))]
1697 let stack = EmbedderStack::auto_detect_with(Some(temp.path())).unwrap();
1698 assert_eq!(stack.availability(), TwoTierAvailability::HashOnly);
1699 assert_eq!(stack.fast().category(), ModelCategory::HashEmbedder);
1700 assert!(stack.quality().is_none());
1701 }
1702
1703 #[cfg(all(
1704 feature = "model2vec",
1705 feature = "hash",
1706 not(feature = "bundled-default-models")
1707 ))]
1708 #[test]
1709 fn auto_detect_fast_only_when_model2vec_is_available() {
1710 let temp = tempfile::tempdir().unwrap();
1711 let model_dir = temp.path().join(POTION_MODEL_NAME);
1712 fs::create_dir_all(&model_dir).unwrap();
1713 create_test_model2vec_layout(&model_dir, 16, 8);
1714 crate::model_manifest::write_verification_marker(
1717 &crate::model_manifest::ModelManifest::potion_128m(),
1718 &model_dir,
1719 );
1720
1721 #[cfg(all(
1722 feature = "download",
1723 any(feature = "model2vec", feature = "fastembed")
1724 ))]
1725 let stack = EmbedderStack::auto_detect_with_policy(
1726 Some(temp.path()),
1727 DownloadPolicy::for_tests(
1728 DownloadConsent::denied(Some(ConsentSource::Programmatic)),
1729 false,
1730 false,
1731 ),
1732 )
1733 .unwrap();
1734 #[cfg(not(all(
1735 feature = "download",
1736 any(feature = "model2vec", feature = "fastembed")
1737 )))]
1738 let stack = EmbedderStack::auto_detect_with(Some(temp.path())).unwrap();
1739 assert_eq!(stack.availability(), TwoTierAvailability::FastOnly);
1740 assert_eq!(stack.fast().id(), POTION_MODEL_NAME);
1741 }
1742
1743 #[cfg(all(
1744 feature = "model2vec",
1745 feature = "hash",
1746 not(feature = "bundled-default-models")
1747 ))]
1748 #[test]
1749 fn corrupted_model2vec_falls_back_to_hash() {
1750 let temp = tempfile::tempdir().unwrap();
1751 let model_dir = temp.path().join(POTION_MODEL_NAME);
1752 fs::create_dir_all(&model_dir).unwrap();
1753 fs::write(model_dir.join("tokenizer.json"), "{}").unwrap();
1754 fs::write(model_dir.join("model.safetensors"), b"not-safetensors").unwrap();
1755
1756 #[cfg(all(
1757 feature = "download",
1758 any(feature = "model2vec", feature = "fastembed")
1759 ))]
1760 let stack = EmbedderStack::auto_detect_with_policy(
1761 Some(temp.path()),
1762 DownloadPolicy::for_tests(
1763 DownloadConsent::denied(Some(ConsentSource::Programmatic)),
1764 false,
1765 false,
1766 ),
1767 )
1768 .unwrap();
1769 #[cfg(not(all(
1770 feature = "download",
1771 any(feature = "model2vec", feature = "fastembed")
1772 )))]
1773 let stack = EmbedderStack::auto_detect_with(Some(temp.path())).unwrap();
1774 assert_eq!(stack.availability(), TwoTierAvailability::HashOnly);
1775 assert_eq!(stack.fast().category(), ModelCategory::HashEmbedder);
1776 }
1777
1778 #[cfg(all(feature = "download", feature = "model2vec", feature = "hash"))]
1779 #[test]
1780 fn auto_detect_prefers_lazy_fast_embedder_when_download_enabled() {
1781 let temp = tempfile::tempdir().unwrap();
1782 let stack = EmbedderStack::auto_detect_with_policy(
1783 Some(temp.path()),
1784 DownloadPolicy::for_tests(
1785 DownloadConsent::granted(ConsentSource::Programmatic),
1786 false,
1787 false,
1788 ),
1789 )
1790 .unwrap();
1791 assert_eq!(stack.fast().id(), POTION_MODEL_NAME);
1792 assert_eq!(stack.fast().category(), ModelCategory::StaticEmbedder);
1793 }
1794
1795 #[cfg(all(feature = "download", feature = "model2vec"))]
1796 #[test]
1797 fn lazy_model2vec_returns_unavailable_when_download_is_denied() {
1798 let temp = tempfile::tempdir().unwrap();
1799 let lazy = LazyModel2VecEmbedder::new(
1800 Some(temp.path().to_path_buf()),
1801 DownloadPolicy::for_tests(
1802 DownloadConsent::denied(Some(ConsentSource::Programmatic)),
1803 false,
1804 false,
1805 ),
1806 );
1807
1808 run_test_with_cx(|cx| async move {
1809 let err = lazy
1810 .embed(&cx, "hello world")
1811 .await
1812 .expect_err("download-denied lazy model should error");
1813 assert!(matches!(err, SearchError::EmbedderUnavailable { .. }));
1814 });
1815 }
1816
1817 #[cfg(all(
1818 feature = "download",
1819 any(feature = "model2vec", feature = "fastembed")
1820 ))]
1821 #[test]
1822 fn parse_bool_flag_supports_common_values() {
1823 assert_eq!(parse_bool_flag("1"), Some(true));
1824 assert_eq!(parse_bool_flag("true"), Some(true));
1825 assert_eq!(parse_bool_flag("YES"), Some(true));
1826 assert_eq!(parse_bool_flag("0"), Some(false));
1827 assert_eq!(parse_bool_flag("false"), Some(false));
1828 assert_eq!(parse_bool_flag("off"), Some(false));
1829 assert_eq!(parse_bool_flag("invalid"), None);
1830 }
1831
1832 #[cfg(all(
1833 feature = "download",
1834 any(feature = "model2vec", feature = "fastembed")
1835 ))]
1836 #[test]
1837 fn progress_percent_accounts_for_current_file_fraction() {
1838 let progress = DownloadProgress {
1839 file_name: "model.onnx".to_owned(),
1840 bytes_downloaded: 50,
1841 total_bytes: Some(100),
1842 files_completed: 1,
1843 files_total: 4,
1844 speed_bytes_per_sec: 1.0,
1845 eta_seconds: Some(2.0),
1846 };
1847 assert_eq!(progress_percent_x100(&progress), 3_750);
1849 }
1850
1851 #[cfg(all(
1852 feature = "model2vec",
1853 feature = "hash",
1854 not(feature = "bundled-default-models")
1855 ))]
1856 fn create_test_model2vec_layout(dir: &Path, vocab_size: usize, dimensions: usize) {
1857 let tokenizer_json = serde_json::json!({
1858 "version": "1.0",
1859 "truncation": null,
1860 "padding": null,
1861 "added_tokens": [{
1862 "id": 0,
1863 "content": "[UNK]",
1864 "single_word": false,
1865 "lstrip": false,
1866 "rstrip": false,
1867 "normalized": false,
1868 "special": true
1869 }],
1870 "normalizer": { "type": "Lowercase" },
1871 "pre_tokenizer": { "type": "Whitespace" },
1872 "post_processor": null,
1873 "decoder": null,
1874 "model": {
1875 "type": "WordLevel",
1876 "vocab": create_test_vocab(vocab_size),
1877 "unk_token": "[UNK]"
1878 }
1879 });
1880 fs::write(
1881 dir.join("tokenizer.json"),
1882 serde_json::to_string_pretty(&tokenizer_json).unwrap(),
1883 )
1884 .unwrap();
1885 create_test_safetensors(dir, vocab_size, dimensions);
1886 }
1887
1888 #[cfg(all(
1889 feature = "model2vec",
1890 feature = "hash",
1891 not(feature = "bundled-default-models")
1892 ))]
1893 fn create_test_vocab(vocab_size: usize) -> serde_json::Value {
1894 let mut vocab = serde_json::Map::new();
1895 vocab.insert("[UNK]".to_owned(), serde_json::Value::from(0));
1896 for idx in 1..vocab_size {
1897 vocab.insert(format!("token{idx}"), serde_json::Value::from(idx));
1898 }
1899 serde_json::Value::Object(vocab)
1900 }
1901
1902 #[cfg(feature = "hash")]
1903 #[test]
1904 fn from_parts_hash_only_availability() {
1905 let hash = Arc::new(crate::hash_embedder::HashEmbedder::default_256());
1906 let stack = EmbedderStack::from_parts(hash, None);
1907 assert_eq!(stack.availability(), TwoTierAvailability::HashOnly);
1908 assert!(stack.quality().is_none());
1909 assert!(stack.quality_arc().is_none());
1910 assert_eq!(stack.fast().category(), ModelCategory::HashEmbedder);
1911 assert_eq!(stack.fast_embedder().id(), stack.fast().id());
1912 }
1913
1914 #[cfg(feature = "hash")]
1915 #[test]
1916 fn from_parts_with_quality_is_full() {
1917 let fast: Arc<dyn Embedder> = Arc::new(crate::hash_embedder::HashEmbedder::default_256());
1918 let quality: Arc<dyn Embedder> =
1919 Arc::new(crate::hash_embedder::HashEmbedder::default_384());
1920 let stack = EmbedderStack::from_parts(fast, Some(quality));
1921 assert_eq!(stack.availability(), TwoTierAvailability::Full);
1922 assert!(stack.quality().is_some());
1923 assert!(stack.quality_arc().is_some());
1924 assert_eq!(
1925 stack.quality_embedder().unwrap().id(),
1926 stack.quality().unwrap().id()
1927 );
1928 }
1929
1930 #[cfg(feature = "hash")]
1931 #[test]
1932 fn dim_reduce_rejects_zero_target_dim() {
1933 let inner: Arc<dyn Embedder> = Arc::new(crate::hash_embedder::HashEmbedder::default_256());
1934 let err = DimReduceEmbedder::new(inner, 0).expect_err("should reject target_dim=0");
1935 assert!(matches!(err, SearchError::InvalidConfig { .. }));
1936 }
1937
1938 #[cfg(feature = "hash")]
1939 #[test]
1940 fn dim_reduce_rejects_target_exceeding_inner_dim() {
1941 let inner: Arc<dyn Embedder> = Arc::new(crate::hash_embedder::HashEmbedder::default_256());
1942 let err =
1943 DimReduceEmbedder::new(inner, 512).expect_err("should reject target_dim > inner dim");
1944 assert!(matches!(err, SearchError::InvalidConfig { .. }));
1945 }
1946
1947 #[cfg(feature = "hash")]
1948 #[test]
1949 fn dim_reduce_rejects_non_mrl_embedder() {
1950 let inner: Arc<dyn Embedder> = Arc::new(crate::hash_embedder::HashEmbedder::default_256());
1951 assert!(!inner.supports_mrl());
1952 let err = DimReduceEmbedder::new(inner, 64).expect_err("should reject non-MRL embedder");
1953 assert!(matches!(err, SearchError::InvalidConfig { .. }));
1954 }
1955
1956 #[cfg(feature = "hash")]
1957 #[test]
1958 fn with_mrl_target_dim_zero_is_rejected() {
1959 let fast: Arc<dyn Embedder> = Arc::new(crate::hash_embedder::HashEmbedder::default_256());
1960 let stack = EmbedderStack::from_parts(fast, None);
1961 let err = stack
1962 .with_mrl_target_dim(0)
1963 .expect_err("should reject target_dim=0");
1964 assert!(matches!(err, SearchError::InvalidConfig { .. }));
1965 }
1966
1967 #[cfg(feature = "hash")]
1968 #[test]
1969 fn with_mrl_passthrough_when_non_mrl() {
1970 let fast: Arc<dyn Embedder> = Arc::new(crate::hash_embedder::HashEmbedder::default_256());
1971 let stack = EmbedderStack::from_parts(fast, None);
1972 let stack = stack.with_mrl_target_dim(64).unwrap();
1975 assert_eq!(stack.availability(), TwoTierAvailability::HashOnly);
1976 assert_eq!(stack.fast().dimension(), 256);
1978 }
1979
1980 #[cfg(feature = "hash")]
1981 #[test]
1982 fn embedder_stack_debug_format() {
1983 let fast: Arc<dyn Embedder> = Arc::new(crate::hash_embedder::HashEmbedder::default_256());
1984 let stack = EmbedderStack::from_parts(fast, None);
1985 let debug = format!("{stack:?}");
1986 assert!(debug.contains("EmbedderStack"));
1987 assert!(debug.contains("HashOnly"));
1988 }
1989
1990 #[cfg(feature = "hash")]
1993 #[test]
1994 fn availability_is_degraded_for_hash_only() {
1995 assert!(TwoTierAvailability::HashOnly.is_degraded());
1996 assert!(TwoTierAvailability::FastOnly.is_degraded());
1997 assert!(!TwoTierAvailability::Full.is_degraded());
1998 }
1999
2000 #[cfg(feature = "hash")]
2001 #[test]
2002 fn availability_display_format() {
2003 let full = format!("{}", TwoTierAvailability::Full);
2004 assert!(full.contains("full"));
2005 let fast = format!("{}", TwoTierAvailability::FastOnly);
2006 assert!(fast.contains("degraded"));
2007 let hash = format!("{}", TwoTierAvailability::HashOnly);
2008 assert!(hash.contains("minimal"));
2009 }
2010
2011 #[cfg(feature = "hash")]
2012 #[test]
2013 fn degradation_summary_none_for_full() {
2014 assert!(TwoTierAvailability::Full.degradation_summary().is_none());
2015 }
2016
2017 #[cfg(feature = "hash")]
2018 #[test]
2019 fn degradation_summary_present_for_degraded() {
2020 assert!(
2021 TwoTierAvailability::FastOnly
2022 .degradation_summary()
2023 .unwrap()
2024 .contains("Quality model")
2025 );
2026 assert!(
2027 TwoTierAvailability::HashOnly
2028 .degradation_summary()
2029 .unwrap()
2030 .contains("semantic")
2031 );
2032 }
2033
2034 #[cfg(feature = "hash")]
2035 #[test]
2036 fn diagnose_hash_only_provides_suggestions() {
2037 let fast: Arc<dyn Embedder> = Arc::new(crate::hash_embedder::HashEmbedder::default_256());
2038 let stack = EmbedderStack::from_parts(fast, None);
2039 let diag = stack.diagnose();
2040 assert_eq!(diag.availability, TwoTierAvailability::HashOnly);
2041 assert!(matches!(diag.fast_status, ModelStatus::HashFallback));
2042 assert!(!diag.suggestions.is_empty());
2043 assert!(
2044 diag.suggestions
2045 .iter()
2046 .any(|s| s.contains("FRANKENSEARCH_MODEL_DIR"))
2047 );
2048 #[cfg(not(feature = "bundled-default-models"))]
2049 assert!(diag.suggestions.iter().any(|s| s.contains("air-gapped")));
2050 #[cfg(feature = "bundled-default-models")]
2051 assert!(
2052 diag.suggestions
2053 .iter()
2054 .any(|s| s.contains("download-models"))
2055 );
2056 }
2057
2058 #[cfg(feature = "hash")]
2059 #[test]
2060 fn diagnose_full_has_no_suggestions() {
2061 let fast: Arc<dyn Embedder> = Arc::new(crate::hash_embedder::HashEmbedder::default_256());
2062 let quality: Arc<dyn Embedder> =
2063 Arc::new(crate::hash_embedder::HashEmbedder::default_384());
2064 let stack = EmbedderStack::from_parts(fast, Some(quality));
2065 let diag = stack.diagnose();
2066 assert_eq!(diag.availability, TwoTierAvailability::Full);
2067 assert!(diag.suggestions.is_empty());
2068 }
2069
2070 #[cfg(feature = "hash")]
2071 #[test]
2072 fn degradation_message_none_when_full() {
2073 let fast: Arc<dyn Embedder> = Arc::new(crate::hash_embedder::HashEmbedder::default_256());
2074 let quality: Arc<dyn Embedder> =
2075 Arc::new(crate::hash_embedder::HashEmbedder::default_384());
2076 let stack = EmbedderStack::from_parts(fast, Some(quality));
2077 assert!(stack.degradation_message().is_none());
2078 }
2079
2080 #[cfg(feature = "hash")]
2081 #[test]
2082 fn degradation_message_present_when_degraded() {
2083 let fast: Arc<dyn Embedder> = Arc::new(crate::hash_embedder::HashEmbedder::default_256());
2084 let stack = EmbedderStack::from_parts(fast, None);
2085 let msg = stack
2086 .degradation_message()
2087 .expect("should be present for hash-only");
2088 assert!(msg.contains("Model cache:"));
2089 assert!(msg.contains("FRANKENSEARCH_MODEL_DIR"));
2090 }
2091
2092 #[cfg(feature = "hash")]
2093 #[test]
2094 fn model_status_display_ready() {
2095 let status = ModelStatus::Ready {
2096 id: "test-model".to_owned(),
2097 };
2098 let display = format!("{status}");
2099 assert!(display.contains("ready"));
2100 assert!(display.contains("test-model"));
2101 }
2102
2103 #[cfg(feature = "hash")]
2104 #[test]
2105 fn model_status_display_not_found() {
2106 let status = ModelStatus::NotFound {
2107 model_name: "test-model".to_owned(),
2108 hf_repo_url: "https://huggingface.co/test/model".to_owned(),
2109 searched_paths: vec![],
2110 };
2111 let display = format!("{status}");
2112 assert!(display.contains("NOT FOUND"));
2113 assert!(display.contains("https://huggingface.co/test/model"));
2114 }
2115
2116 #[cfg(feature = "hash")]
2117 #[test]
2118 fn model_status_display_download_blocked() {
2119 let status = ModelStatus::DownloadBlocked {
2120 model_name: "test-model".to_owned(),
2121 reason: "offline mode".to_owned(),
2122 };
2123 let display = format!("{status}");
2124 assert!(display.contains("BLOCKED"));
2125 assert!(display.contains("offline mode"));
2126 }
2127
2128 #[cfg(feature = "hash")]
2129 #[test]
2130 fn model_status_display_feature_disabled() {
2131 let status = ModelStatus::FeatureDisabled {
2132 feature_flag: "fastembed".to_owned(),
2133 };
2134 let display = format!("{status}");
2135 assert!(display.contains("DISABLED"));
2136 assert!(display.contains("fastembed"));
2137 }
2138
2139 #[cfg(feature = "hash")]
2140 #[test]
2141 fn diagnostic_display_includes_all_sections() {
2142 let diag = ModelAvailabilityDiagnostic {
2143 availability: TwoTierAvailability::HashOnly,
2144 cache_dir: std::path::PathBuf::from("/tmp/test-cache"),
2145 offline: false,
2146 fast_status: ModelStatus::HashFallback,
2147 quality_status: ModelStatus::FeatureDisabled {
2148 feature_flag: "fastembed".to_owned(),
2149 },
2150 suggestions: vec!["Fix something".to_owned()],
2151 };
2152 let display = format!("{diag}");
2153 assert!(display.contains("minimal"));
2154 assert!(display.contains("/tmp/test-cache"));
2155 assert!(display.contains("hash fallback"));
2156 assert!(display.contains("Fix something"));
2157 }
2158
2159 #[cfg(feature = "hash")]
2160 #[test]
2161 fn diagnostic_display_offline_mode_indicator() {
2162 let diag = ModelAvailabilityDiagnostic {
2163 availability: TwoTierAvailability::HashOnly,
2164 cache_dir: std::path::PathBuf::from("/tmp/test-cache"),
2165 offline: true,
2166 fast_status: ModelStatus::HashFallback,
2167 quality_status: ModelStatus::DownloadBlocked {
2168 model_name: "test".to_owned(),
2169 reason: "offline".to_owned(),
2170 },
2171 suggestions: vec![],
2172 };
2173 let display = format!("{diag}");
2174 assert!(display.contains("OFFLINE"));
2175 }
2176
2177 #[test]
2180 fn two_tier_availability_clone_copy_eq() {
2181 let a = TwoTierAvailability::Full;
2182 let b = a;
2183 assert_eq!(a, b);
2184 #[allow(clippy::clone_on_copy)]
2185 let c = a.clone();
2186 assert_eq!(a, c);
2187 assert_ne!(TwoTierAvailability::Full, TwoTierAvailability::HashOnly);
2188 assert_ne!(TwoTierAvailability::FastOnly, TwoTierAvailability::HashOnly);
2189 }
2190
2191 #[test]
2192 fn two_tier_availability_debug() {
2193 let debug = format!("{:?}", TwoTierAvailability::Full);
2194 assert_eq!(debug, "Full");
2195 let debug = format!("{:?}", TwoTierAvailability::FastOnly);
2196 assert_eq!(debug, "FastOnly");
2197 let debug = format!("{:?}", TwoTierAvailability::HashOnly);
2198 assert_eq!(debug, "HashOnly");
2199 }
2200
2201 #[test]
2202 fn model_status_debug_all_variants() {
2203 let ready = ModelStatus::Ready {
2204 id: "test".to_owned(),
2205 };
2206 let debug = format!("{ready:?}");
2207 assert!(debug.contains("Ready"));
2208 assert!(debug.contains("test"));
2209
2210 let not_found = ModelStatus::NotFound {
2211 model_name: "m".to_owned(),
2212 hf_repo_url: "url".to_owned(),
2213 searched_paths: vec![],
2214 };
2215 let debug = format!("{not_found:?}");
2216 assert!(debug.contains("NotFound"));
2217
2218 let blocked = ModelStatus::DownloadBlocked {
2219 model_name: "m".to_owned(),
2220 reason: "r".to_owned(),
2221 };
2222 let debug = format!("{blocked:?}");
2223 assert!(debug.contains("DownloadBlocked"));
2224
2225 let disabled = ModelStatus::FeatureDisabled {
2226 feature_flag: "f".to_owned(),
2227 };
2228 let debug = format!("{disabled:?}");
2229 assert!(debug.contains("FeatureDisabled"));
2230
2231 let hash = ModelStatus::HashFallback;
2232 let debug = format!("{hash:?}");
2233 assert!(debug.contains("HashFallback"));
2234 }
2235
2236 #[test]
2237 fn model_status_clone_all_variants() {
2238 fn clone_and_format(status: &ModelStatus) -> String {
2240 format!("{}", status.clone())
2241 }
2242
2243 assert!(
2244 clone_and_format(&ModelStatus::Ready {
2245 id: "test".to_owned(),
2246 })
2247 .contains("test")
2248 );
2249
2250 assert!(
2251 clone_and_format(&ModelStatus::NotFound {
2252 model_name: "m".to_owned(),
2253 hf_repo_url: "u".to_owned(),
2254 searched_paths: vec![std::path::PathBuf::from("/tmp/p")],
2255 })
2256 .contains("NOT FOUND")
2257 );
2258
2259 assert!(
2260 clone_and_format(&ModelStatus::DownloadBlocked {
2261 model_name: "m".to_owned(),
2262 reason: "r".to_owned(),
2263 })
2264 .contains("BLOCKED")
2265 );
2266
2267 assert!(
2268 clone_and_format(&ModelStatus::FeatureDisabled {
2269 feature_flag: "f".to_owned(),
2270 })
2271 .contains("DISABLED")
2272 );
2273
2274 assert!(clone_and_format(&ModelStatus::HashFallback).contains("hash fallback"));
2275 }
2276
2277 #[test]
2278 fn model_status_hash_fallback_display() {
2279 let status = ModelStatus::HashFallback;
2280 let display = format!("{status}");
2281 assert_eq!(display, "hash fallback (no semantic model)");
2282 }
2283
2284 #[test]
2285 fn model_availability_diagnostic_clone_debug() {
2286 let diag = ModelAvailabilityDiagnostic {
2287 availability: TwoTierAvailability::Full,
2288 cache_dir: std::path::PathBuf::from("/tmp/cache"),
2289 offline: false,
2290 fast_status: ModelStatus::Ready {
2291 id: "fast".to_owned(),
2292 },
2293 quality_status: ModelStatus::Ready {
2294 id: "quality".to_owned(),
2295 },
2296 suggestions: vec![],
2297 };
2298 let cloned = diag.clone();
2299 assert_eq!(cloned.availability, TwoTierAvailability::Full);
2300 assert!(!cloned.offline);
2301 assert!(cloned.suggestions.is_empty());
2302
2303 let debug = format!("{diag:?}");
2304 assert!(debug.contains("ModelAvailabilityDiagnostic"));
2305 assert!(debug.contains("Full"));
2306 }
2307
2308 #[test]
2309 fn diagnostic_display_no_suggestions_skips_resolve_section() {
2310 let diag = ModelAvailabilityDiagnostic {
2311 availability: TwoTierAvailability::Full,
2312 cache_dir: std::path::PathBuf::from("/tmp/cache"),
2313 offline: false,
2314 fast_status: ModelStatus::Ready {
2315 id: "fast".to_owned(),
2316 },
2317 quality_status: ModelStatus::Ready {
2318 id: "quality".to_owned(),
2319 },
2320 suggestions: vec![],
2321 };
2322 let display = format!("{diag}");
2323 assert!(!display.contains("To resolve:"));
2324 }
2325
2326 #[cfg(feature = "hash")]
2327 #[test]
2328 fn embedder_stack_clone() {
2329 let fast: Arc<dyn Embedder> = Arc::new(crate::hash_embedder::HashEmbedder::default_256());
2330 let stack = EmbedderStack::from_parts(fast, None);
2331 let cloned = stack.clone();
2332 assert_eq!(cloned.availability(), stack.availability());
2333 assert_eq!(cloned.fast().id(), stack.fast().id());
2334 }
2335
2336 #[cfg(feature = "hash")]
2337 #[test]
2338 fn embedder_stack_fast_arc_returns_same_id() {
2339 let fast: Arc<dyn Embedder> = Arc::new(crate::hash_embedder::HashEmbedder::default_256());
2340 let stack = EmbedderStack::from_parts(fast, None);
2341 let arc = stack.fast_arc();
2342 assert_eq!(arc.id(), stack.fast().id());
2343 }
2344
2345 #[cfg(all(
2346 feature = "download",
2347 any(feature = "model2vec", feature = "fastembed")
2348 ))]
2349 #[test]
2350 fn format_bytes_edge_cases() {
2351 assert_eq!(format_bytes(0), "0 B");
2352 assert_eq!(format_bytes(1), "1 B");
2353 assert_eq!(format_bytes(1023), "1023 B");
2354 assert_eq!(format_bytes(1024), "1.0 KB");
2355 assert_eq!(format_bytes(1536), "1.5 KB");
2356 assert_eq!(format_bytes(1024 * 1024), "1.0 MB");
2357 assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB");
2358 assert_eq!(
2359 format_bytes(1024 * 1024 * 1024 + 512 * 1024 * 1024),
2360 "1.5 GB"
2361 );
2362 }
2363
2364 #[cfg(all(
2365 feature = "download",
2366 any(feature = "model2vec", feature = "fastembed")
2367 ))]
2368 #[test]
2369 fn format_speed_edge_cases() {
2370 assert_eq!(format_speed(0.0), "0 B/s");
2371 assert_eq!(format_speed(-1.0), "0 B/s");
2372 assert_eq!(format_speed(f64::NAN), "0 B/s");
2373 assert_eq!(format_speed(f64::INFINITY), "0 B/s");
2374 assert_eq!(format_speed(f64::NEG_INFINITY), "0 B/s");
2375 assert_eq!(format_speed(500.0), "500 B/s");
2376 assert!(format_speed(2048.0).contains("KB/s"));
2377 assert!(format_speed(2.0 * 1024.0 * 1024.0).contains("MB/s"));
2378 assert!(format_speed(2.0 * 1024.0 * 1024.0 * 1024.0).contains("GB/s"));
2379 }
2380
2381 #[cfg(all(
2382 feature = "download",
2383 any(feature = "model2vec", feature = "fastembed")
2384 ))]
2385 #[test]
2386 fn format_eta_edge_cases() {
2387 assert_eq!(format_eta(None), "?");
2388 assert_eq!(format_eta(Some(f64::NAN)), "?");
2389 assert_eq!(format_eta(Some(f64::INFINITY)), "?");
2390 assert_eq!(format_eta(Some(-1.0)), "?");
2391 assert_eq!(format_eta(Some(0.0)), "0.0s");
2392 assert_eq!(format_eta(Some(5.5)), "5.5s");
2393 }
2394
2395 #[cfg(all(
2396 feature = "download",
2397 any(feature = "model2vec", feature = "fastembed")
2398 ))]
2399 #[test]
2400 fn render_progress_bar_edge_cases() {
2401 let empty_bar = render_progress_bar(0);
2402 assert_eq!(empty_bar.len(), PROGRESS_BAR_WIDTH);
2403 assert!(empty_bar.chars().all(|c| c == ' '));
2404
2405 let full_bar = render_progress_bar(10_000);
2406 assert_eq!(full_bar.len(), PROGRESS_BAR_WIDTH);
2407 assert!(full_bar.chars().all(|c| c == '='));
2408
2409 let half_bar = render_progress_bar(5_000);
2410 assert_eq!(half_bar.len(), PROGRESS_BAR_WIDTH);
2411 let filled = half_bar.chars().filter(|&c| c == '=').count();
2412 assert_eq!(filled, PROGRESS_BAR_WIDTH / 2);
2413
2414 let bar_over = render_progress_bar(20_000);
2416 assert_eq!(bar_over.len(), PROGRESS_BAR_WIDTH);
2417 assert!(bar_over.chars().all(|c| c == '='));
2418 }
2419
2420 #[cfg(all(
2421 feature = "download",
2422 any(feature = "model2vec", feature = "fastembed")
2423 ))]
2424 #[test]
2425 fn parse_bool_flag_whitespace_and_on() {
2426 assert_eq!(parse_bool_flag(" 1 "), Some(true));
2427 assert_eq!(parse_bool_flag(" true "), Some(true));
2428 assert_eq!(parse_bool_flag("on"), Some(true));
2429 assert_eq!(parse_bool_flag("ON"), Some(true));
2430 assert_eq!(parse_bool_flag("On"), Some(true));
2431 assert_eq!(parse_bool_flag("no"), Some(false));
2432 assert_eq!(parse_bool_flag("NO"), Some(false));
2433 assert_eq!(parse_bool_flag("No"), Some(false));
2434 assert_eq!(parse_bool_flag(""), None);
2435 assert_eq!(parse_bool_flag(" "), None);
2436 assert_eq!(parse_bool_flag("maybe"), None);
2437 }
2438
2439 #[cfg(all(
2440 feature = "download",
2441 any(feature = "model2vec", feature = "fastembed")
2442 ))]
2443 #[test]
2444 fn progress_percent_zero_files_completed() {
2445 let progress = DownloadProgress {
2446 file_name: "model.onnx".to_owned(),
2447 bytes_downloaded: 0,
2448 total_bytes: Some(100),
2449 files_completed: 0,
2450 files_total: 2,
2451 speed_bytes_per_sec: 0.0,
2452 eta_seconds: None,
2453 };
2454 assert_eq!(progress_percent_x100(&progress), 0);
2455 }
2456
2457 #[cfg(all(
2458 feature = "download",
2459 any(feature = "model2vec", feature = "fastembed")
2460 ))]
2461 #[test]
2462 fn progress_percent_all_files_completed() {
2463 let progress = DownloadProgress {
2464 file_name: "last.onnx".to_owned(),
2465 bytes_downloaded: 100,
2466 total_bytes: Some(100),
2467 files_completed: 3,
2468 files_total: 4,
2469 speed_bytes_per_sec: 1000.0,
2470 eta_seconds: Some(0.0),
2471 };
2472 assert_eq!(progress_percent_x100(&progress), 10_000);
2474 }
2475
2476 #[cfg(all(
2477 feature = "download",
2478 any(feature = "model2vec", feature = "fastembed")
2479 ))]
2480 #[test]
2481 fn progress_percent_no_total_bytes() {
2482 let progress = DownloadProgress {
2483 file_name: "model.onnx".to_owned(),
2484 bytes_downloaded: 50,
2485 total_bytes: None,
2486 files_completed: 1,
2487 files_total: 2,
2488 speed_bytes_per_sec: 100.0,
2489 eta_seconds: None,
2490 };
2491 assert_eq!(progress_percent_x100(&progress), 5_000);
2494 }
2495
2496 #[cfg(all(
2497 feature = "download",
2498 any(feature = "model2vec", feature = "fastembed")
2499 ))]
2500 #[test]
2501 fn download_policy_can_download_logic() {
2502 use crate::model_manifest::ConsentSource;
2503
2504 let allowed = DownloadPolicy::for_tests(
2505 DownloadConsent::granted(ConsentSource::Programmatic),
2506 false,
2507 false,
2508 );
2509 assert!(allowed.can_download());
2510
2511 let offline = DownloadPolicy::for_tests(
2512 DownloadConsent::granted(ConsentSource::Programmatic),
2513 true,
2514 false,
2515 );
2516 assert!(!offline.can_download());
2517
2518 let denied = DownloadPolicy::for_tests(
2519 DownloadConsent::denied(Some(ConsentSource::Programmatic)),
2520 false,
2521 false,
2522 );
2523 assert!(!denied.can_download());
2524 }
2525
2526 #[cfg(all(
2527 feature = "download",
2528 any(feature = "model2vec", feature = "fastembed")
2529 ))]
2530 #[test]
2531 fn download_policy_blocked_reason_offline() {
2532 let policy = DownloadPolicy::for_tests(
2533 DownloadConsent::granted(ConsentSource::Programmatic),
2534 true,
2535 false,
2536 );
2537 let reason = policy.blocked_reason();
2538 assert!(reason.contains("OFFLINE"));
2539 }
2540
2541 #[cfg(all(
2542 feature = "download",
2543 any(feature = "model2vec", feature = "fastembed")
2544 ))]
2545 #[test]
2546 fn download_policy_blocked_reason_consent_denied() {
2547 let policy = DownloadPolicy::for_tests(
2548 DownloadConsent::denied(Some(ConsentSource::Environment)),
2549 false,
2550 false,
2551 );
2552 let reason = policy.blocked_reason();
2553 assert!(reason.contains("consent denied"));
2554 }
2555
2556 #[cfg(all(
2559 feature = "model2vec",
2560 feature = "hash",
2561 not(feature = "bundled-default-models")
2562 ))]
2563 fn create_test_safetensors(dir: &Path, vocab_size: usize, dimensions: usize) {
2564 use std::collections::HashMap;
2565
2566 let mut data = Vec::with_capacity(vocab_size * dimensions * 4);
2567 for row in 0..vocab_size {
2568 for col in 0..dimensions {
2569 #[allow(clippy::cast_precision_loss)]
2570 let value = (row as f32).mul_add(0.01, (col as f32) * 0.001);
2571 data.extend_from_slice(&value.to_le_bytes());
2572 }
2573 }
2574
2575 let mut tensors = HashMap::new();
2576 tensors.insert(
2577 "embeddings".to_owned(),
2578 safetensors::tensor::TensorView::new(
2579 safetensors::Dtype::F32,
2580 vec![vocab_size, dimensions],
2581 &data,
2582 )
2583 .unwrap(),
2584 );
2585 let encoded = safetensors::tensor::serialize(&tensors, None).unwrap();
2586 fs::write(dir.join("model.safetensors"), encoded).unwrap();
2587 }
2588}