Skip to main content

frankensearch_embed/
auto_detect.rs

1//! Embedder auto-detection and fallback stack assembly.
2
3#[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/// Availability classification for two-tier search.
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum TwoTierAvailability {
101    /// Fast + quality embedders available.
102    Full,
103    /// Semantic fast embedder available, no quality tier.
104    FastOnly,
105    /// Hash-only fallback path.
106    HashOnly,
107}
108
109impl TwoTierAvailability {
110    /// Whether this availability level represents a degraded state.
111    #[must_use]
112    pub const fn is_degraded(self) -> bool {
113        matches!(self, Self::FastOnly | Self::HashOnly)
114    }
115
116    /// Human-readable summary of what is missing at this availability level.
117    #[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/// Diagnostic information about model availability for user-facing messages.
142///
143/// Provides actionable guidance when models are missing, including cache paths,
144/// download URLs, and environment variable hints.
145#[derive(Debug, Clone)]
146pub struct ModelAvailabilityDiagnostic {
147    /// Current availability classification.
148    pub availability: TwoTierAvailability,
149    /// Resolved model cache directory path.
150    pub cache_dir: std::path::PathBuf,
151    /// Whether the system is in offline mode.
152    pub offline: bool,
153    /// Fast-tier model status.
154    pub fast_status: ModelStatus,
155    /// Quality-tier model status.
156    pub quality_status: ModelStatus,
157    /// Suggestions for the user to resolve degraded state.
158    pub suggestions: Vec<String>,
159}
160
161/// Status of an individual model tier.
162#[derive(Debug, Clone)]
163pub enum ModelStatus {
164    /// Model is loaded and ready.
165    Ready {
166        /// Model identifier.
167        id: String,
168    },
169    /// Model files not found locally.
170    NotFound {
171        /// Model name that was searched for.
172        model_name: String,
173        /// `HuggingFace` repository URL for manual download.
174        hf_repo_url: String,
175        /// Paths that were searched.
176        searched_paths: Vec<std::path::PathBuf>,
177    },
178    /// Download was blocked by policy (offline mode or consent denied).
179    DownloadBlocked {
180        /// Model name.
181        model_name: String,
182        /// Reason download was blocked.
183        reason: String,
184    },
185    /// Feature not compiled in.
186    FeatureDisabled {
187        /// The feature flag that would enable this tier.
188        feature_flag: String,
189    },
190    /// Model uses hash fallback (always available).
191    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/// Resolved fast/quality embedder stack for progressive search.
238#[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    /// Build from explicit parts.
261    #[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    /// Auto-detect best available embedders from default search paths.
278    ///
279    /// # Errors
280    ///
281    /// Returns `SearchError::EmbedderUnavailable` when no usable fast embedder is available.
282    pub fn auto_detect() -> SearchResult<Self> {
283        Self::auto_detect_with(None)
284    }
285
286    /// Auto-detect embedders with an optional explicit model root override.
287    ///
288    /// # Errors
289    ///
290    /// Returns `SearchError::EmbedderUnavailable` when no usable fast embedder is available.
291    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    /// Apply MRL-style dimensionality reduction where supported.
363    ///
364    /// # Errors
365    ///
366    /// Returns `SearchError::InvalidConfig` when `target_dim` is zero.
367    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    /// Fast embedder reference.
393    #[must_use]
394    pub fn fast(&self) -> &dyn Embedder {
395        self.fast.as_ref()
396    }
397
398    /// Fast embedder alias for API compatibility.
399    #[must_use]
400    pub fn fast_embedder(&self) -> &dyn Embedder {
401        self.fast()
402    }
403
404    /// Cloned fast embedder handle.
405    #[must_use]
406    pub fn fast_arc(&self) -> Arc<dyn Embedder> {
407        self.fast.clone()
408    }
409
410    /// Optional quality embedder reference.
411    #[must_use]
412    pub fn quality(&self) -> Option<&dyn Embedder> {
413        self.quality.as_deref()
414    }
415
416    /// Optional quality embedder alias for API compatibility.
417    #[must_use]
418    pub fn quality_embedder(&self) -> Option<&dyn Embedder> {
419        self.quality()
420    }
421
422    /// Cloned quality embedder handle.
423    #[must_use]
424    pub fn quality_arc(&self) -> Option<Arc<dyn Embedder>> {
425        self.quality.clone()
426    }
427
428    /// Availability state.
429    #[must_use]
430    pub const fn availability(&self) -> TwoTierAvailability {
431        self.availability
432    }
433
434    /// Generate a user-facing diagnostic report about model availability.
435    ///
436    /// Includes actionable suggestions for resolving degraded states:
437    /// cache directory paths, manual download URLs, environment variable hints.
438    #[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)] // cfg blocks in else arm prevent map_or_else
456        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    /// Returns a user-facing message if operating in a degraded mode, or `None` if fully available.
558    #[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
584/// MRL dimension reduction wrapper.
585pub 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    /// Create a dimension-reduced view of an embedder.
603    ///
604    /// # Errors
605    ///
606    /// Returns `SearchError::InvalidConfig` when requested dimension is invalid.
607    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        // Default to "denied" unless explicitly allowed via FRANKENSEARCH_ALLOW_DOWNLOAD=1.
765        // This keeps first-run search offline-safe and avoids surprise network fetches.
766        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    // When an explicit model_root is provided, skip system-wide discovery
1388    // to ensure test isolation and deterministic behavior.
1389    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    // When an explicit model_root is provided, skip system-wide discovery
1455    // to ensure test isolation and deterministic behavior.
1456    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/// Detect API-based embedder from environment variables.
1530///
1531/// Checks for `OPENAI_API_KEY` or `GEMINI_API_KEY`, with optional
1532/// `FRANKENSEARCH_API_PROVIDER`, `FRANKENSEARCH_API_MODEL`, and
1533/// `FRANKENSEARCH_API_DIMENSION` overrides.
1534///
1535/// Returns a cached `ApiEmbedder` for the quality tier.
1536#[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            // Explicit OpenAI — require OPENAI_API_KEY.
1557            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            // Auto-detect: prefer OpenAI if OPENAI_API_KEY is set,
1567            // otherwise fall back to Gemini if GEMINI_API_KEY is set.
1568            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        // Plant a verification marker so auto-detect skips SHA-256 checks
1715        // against the real manifest (test files have dummy content).
1716        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        // 1 full file + 50% of one file over 4 total files = 37.5%
1848        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        // hash embedder doesn't support MRL, so with_mrl_target_dim should
1973        // pass through without wrapping (target_dim < dimension but !supports_mrl)
1974        let stack = stack.with_mrl_target_dim(64).unwrap();
1975        assert_eq!(stack.availability(), TwoTierAvailability::HashOnly);
1976        // dimension should be unchanged since wrapping was skipped
1977        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    // ─── Offline Fallback Diagnostic Tests ───────────────────────────────────
1991
1992    #[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    // ─── bd-1il3 tests begin ───
2178
2179    #[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        // Verify Clone works for each variant by cloning into a Vec and formatting
2239        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        // Over 100% should clamp
2415        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        // 3 complete files + 100% of current file / 4 total = 100%
2473        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        // No total_bytes means current file contributes 0%
2492        // 1 complete / 2 total = 50% = 5000
2493        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    // ─── bd-1il3 tests end ───
2557
2558    #[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}