Skip to main content

coding_agent_search/search/
model_manager.rs

1//! Semantic model management (local-only detection).
2//!
3//! This module wires the FastEmbed MiniLM embedder into semantic search by:
4//! - validating the local model files
5//! - loading the vector index
6//! - building filter maps from the SQLite database
7//! - detecting model version mismatches
8//!
9//! It does **not** download models. Missing files are surfaced as availability
10//! states so the UI can guide the user. Downloads are handled by [`model_download`].
11
12use std::collections::HashSet;
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15
16use crate::search::embedder::Embedder;
17use crate::search::fastembed_embedder::FastEmbedder;
18use crate::search::hash_embedder::HashEmbedder;
19use crate::search::model_download::{
20    ModelAcquisitionPolicy, ModelCacheState, ModelManifest, classify_model_cache,
21    classify_model_cache_metadata,
22};
23use crate::search::policy::{CliSemanticOverrides, SemanticPolicy};
24use crate::search::semantic_manifest::{
25    SemanticShardManifest, SemanticShardRecord, TierKind, semantic_shard_artifact_path_is_safe,
26};
27use crate::search::vector_index::{
28    ROLE_ASSISTANT, ROLE_USER, SemanticFilterMaps, VectorIndex, vector_index_path,
29};
30use crate::storage::sqlite::FrankenStorage;
31
32/// Unified TUI state machine for semantic search availability.
33///
34/// This enum tracks the full lifecycle of semantic search from the user's perspective:
35/// - Model installation flow (NotInstalled → NeedsConsent → Downloading → Verifying → Ready)
36/// - Index building flow (Ready → IndexBuilding → Ready)
37/// - User preferences (HashFallback, Disabled)
38/// - Error states (LoadFailed, ModelMissing, etc.)
39#[derive(Debug, Clone)]
40pub enum SemanticAvailability {
41    /// Model is ready for use.
42    Ready { embedder_id: String },
43
44    // =========================================================================
45    // TUI-centric states for user flow
46    // =========================================================================
47    /// Model not installed - semantic not available.
48    /// TUI should show option to download or use hash fallback.
49    NotInstalled,
50
51    /// User needs to consent before downloading model.
52    /// TUI should show consent dialog.
53    NeedsConsent,
54
55    /// Model download in progress.
56    Downloading {
57        /// Progress percentage (0-100).
58        progress_pct: u8,
59        /// Bytes downloaded so far.
60        bytes_downloaded: u64,
61        /// Total bytes to download.
62        total_bytes: u64,
63    },
64
65    /// Verifying downloaded model (SHA256 check).
66    Verifying,
67
68    /// Index is being built or rebuilt.
69    IndexBuilding {
70        embedder_id: String,
71        /// Optional progress percentage (0-100).
72        progress_pct: Option<u8>,
73        /// Number of items indexed so far.
74        items_indexed: u64,
75        /// Total items to index.
76        total_items: u64,
77    },
78
79    /// User opted for hash-based fallback (no ML model).
80    HashFallback,
81
82    /// Semantic search disabled by policy or user.
83    Disabled { reason: String },
84
85    // =========================================================================
86    // Diagnostic states for troubleshooting
87    // =========================================================================
88    /// Model files are missing.
89    ModelMissing {
90        model_dir: PathBuf,
91        missing_files: Vec<String>,
92    },
93
94    /// Vector index is missing.
95    IndexMissing { index_path: PathBuf },
96
97    /// Database is unavailable.
98    DatabaseUnavailable { db_path: PathBuf, error: String },
99
100    /// Failed to load semantic context.
101    LoadFailed { context: String },
102
103    /// Model update available - index rebuild needed.
104    UpdateAvailable {
105        embedder_id: String,
106        current_revision: String,
107        latest_revision: String,
108    },
109}
110
111impl SemanticAvailability {
112    /// Check if semantic search is ready to use.
113    pub fn is_ready(&self) -> bool {
114        matches!(self, SemanticAvailability::Ready { .. })
115    }
116
117    /// Check if a model update is available.
118    pub fn has_update(&self) -> bool {
119        matches!(self, SemanticAvailability::UpdateAvailable { .. })
120    }
121
122    /// Check if the index is being rebuilt.
123    pub fn is_building(&self) -> bool {
124        matches!(self, SemanticAvailability::IndexBuilding { .. })
125    }
126
127    /// Check if a download is in progress.
128    pub fn is_downloading(&self) -> bool {
129        matches!(self, SemanticAvailability::Downloading { .. })
130    }
131
132    /// Check if user consent is needed.
133    pub fn needs_consent(&self) -> bool {
134        matches!(self, SemanticAvailability::NeedsConsent)
135    }
136
137    /// Check if hash fallback is active.
138    pub fn is_hash_fallback(&self) -> bool {
139        matches!(self, SemanticAvailability::HashFallback)
140    }
141
142    /// Check if semantic search is disabled.
143    pub fn is_disabled(&self) -> bool {
144        matches!(self, SemanticAvailability::Disabled { .. })
145    }
146
147    /// Check if the model is not installed.
148    pub fn is_not_installed(&self) -> bool {
149        matches!(
150            self,
151            SemanticAvailability::NotInstalled | SemanticAvailability::ModelMissing { .. }
152        )
153    }
154
155    /// Check if any error state is active.
156    pub fn is_error(&self) -> bool {
157        matches!(
158            self,
159            SemanticAvailability::LoadFailed { .. }
160                | SemanticAvailability::DatabaseUnavailable { .. }
161        )
162    }
163
164    /// Check if semantic can be used (ready or hash fallback).
165    pub fn can_search(&self) -> bool {
166        matches!(
167            self,
168            SemanticAvailability::Ready { .. } | SemanticAvailability::HashFallback
169        )
170    }
171
172    /// Get download progress if downloading.
173    pub fn download_progress(&self) -> Option<(u8, u64, u64)> {
174        match self {
175            SemanticAvailability::Downloading {
176                progress_pct,
177                bytes_downloaded,
178                total_bytes,
179            } => Some((*progress_pct, *bytes_downloaded, *total_bytes)),
180            _ => None,
181        }
182    }
183
184    /// Get index building progress if building.
185    pub fn index_progress(&self) -> Option<(Option<u8>, u64, u64)> {
186        match self {
187            SemanticAvailability::IndexBuilding {
188                progress_pct,
189                items_indexed,
190                total_items,
191                ..
192            } => Some((*progress_pct, *items_indexed, *total_items)),
193            _ => None,
194        }
195    }
196
197    /// Get a short status label for display in status bar.
198    pub fn status_label(&self) -> &'static str {
199        match self {
200            SemanticAvailability::Ready { .. } => "SEM",
201            SemanticAvailability::HashFallback => "SEM*",
202            SemanticAvailability::NotInstalled => "LEX",
203            SemanticAvailability::NeedsConsent => "LEX",
204            SemanticAvailability::Downloading { .. } => "DL...",
205            SemanticAvailability::Verifying => "VFY...",
206            SemanticAvailability::IndexBuilding { .. } => "IDX...",
207            SemanticAvailability::Disabled { .. } => "OFF",
208            SemanticAvailability::ModelMissing { .. } => "NOMODEL",
209            SemanticAvailability::IndexMissing { .. } => "NOIDX",
210            SemanticAvailability::DatabaseUnavailable { .. } => "NODB",
211            SemanticAvailability::LoadFailed { .. } => "ERR",
212            SemanticAvailability::UpdateAvailable { .. } => "UPD",
213        }
214    }
215
216    /// Get a detailed summary for display.
217    pub fn summary(&self) -> String {
218        match self {
219            SemanticAvailability::Ready { embedder_id } => {
220                format!("semantic ready ({embedder_id})")
221            }
222            SemanticAvailability::NotInstalled => "model not installed".to_string(),
223            SemanticAvailability::NeedsConsent => "consent required for model download".to_string(),
224            SemanticAvailability::Downloading {
225                progress_pct,
226                bytes_downloaded,
227                total_bytes,
228            } => {
229                let mb_done = *bytes_downloaded as f64 / 1_048_576.0;
230                let mb_total = *total_bytes as f64 / 1_048_576.0;
231                format!("downloading model: {progress_pct}% ({mb_done:.1}/{mb_total:.1} MB)")
232            }
233            SemanticAvailability::Verifying => "verifying model checksum".to_string(),
234            SemanticAvailability::IndexBuilding {
235                items_indexed,
236                total_items,
237                progress_pct,
238                ..
239            } => {
240                if let Some(pct) = progress_pct {
241                    format!("building index: {pct}% ({items_indexed}/{total_items})")
242                } else {
243                    format!("building index: {items_indexed}/{total_items}")
244                }
245            }
246            SemanticAvailability::HashFallback => "using hash-based fallback".to_string(),
247            SemanticAvailability::Disabled { reason } => {
248                format!("semantic disabled: {reason}")
249            }
250            SemanticAvailability::ModelMissing { model_dir, .. } => {
251                format!("model missing at {}", model_dir.display())
252            }
253            SemanticAvailability::IndexMissing { index_path } => {
254                format!("vector index missing at {}", index_path.display())
255            }
256            SemanticAvailability::DatabaseUnavailable { error, .. } => {
257                format!("db unavailable ({error})")
258            }
259            SemanticAvailability::LoadFailed { context } => {
260                format!("semantic load failed ({context})")
261            }
262            SemanticAvailability::UpdateAvailable {
263                current_revision,
264                latest_revision,
265                ..
266            } => {
267                format!("update available: {current_revision} -> {latest_revision}")
268            }
269        }
270    }
271}
272
273pub struct SemanticContext {
274    pub embedder: Arc<dyn Embedder>,
275    pub index: VectorIndex,
276    pub additional_indexes: Vec<VectorIndex>,
277    pub filter_maps: SemanticFilterMaps,
278    pub roles: Option<HashSet<u8>>,
279}
280
281pub struct SemanticSetup {
282    pub availability: SemanticAvailability,
283    pub context: Option<SemanticContext>,
284}
285
286fn semantic_sidecar_path(data_dir: &Path, recorded_path: &str) -> Option<PathBuf> {
287    semantic_shard_artifact_path_is_safe(recorded_path).then(|| data_dir.join(recorded_path))
288}
289
290fn matching_complete_shard_records(
291    data_dir: &Path,
292    tier: TierKind,
293    embedder_id: &str,
294    db_fingerprint: &str,
295) -> Result<Option<Vec<SemanticShardRecord>>, String> {
296    let manifest = match SemanticShardManifest::load(data_dir) {
297        Ok(Some(manifest)) => manifest,
298        Ok(None) => return Ok(None),
299        Err(err) => return Err(format!("semantic shard manifest: {err}")),
300    };
301    let summary = manifest.summary(tier, embedder_id, db_fingerprint);
302    if !summary.complete {
303        return Ok(None);
304    }
305
306    let mut records = manifest
307        .shards
308        .into_iter()
309        .filter(|shard| shard.matches_generation(tier, embedder_id, db_fingerprint))
310        .collect::<Vec<_>>();
311    records.sort_by_key(|shard| shard.shard_index);
312    if records.len() != usize::try_from(summary.shard_count).unwrap_or(usize::MAX) {
313        return Ok(None);
314    }
315
316    let Some(first) = records.first() else {
317        return Ok(None);
318    };
319    for (expected_index, shard) in records.iter().enumerate() {
320        if shard.shard_index != u32::try_from(expected_index).unwrap_or(u32::MAX)
321            || !shard.ready
322            || !shard.mmap_ready
323            || shard.model_revision != first.model_revision
324            || shard.schema_version != crate::search::policy::SEMANTIC_SCHEMA_VERSION
325            || shard.chunking_version != crate::search::policy::CHUNKING_STRATEGY_VERSION
326            || shard.dimension == 0
327            || shard.dimension != first.dimension
328            || shard.total_conversations != first.total_conversations
329        {
330            return Ok(None);
331        }
332        let Some(path) = semantic_sidecar_path(data_dir, &shard.index_path) else {
333            return Ok(None);
334        };
335        if !path.is_file() {
336            return Ok(None);
337        }
338    }
339
340    Ok(Some(records))
341}
342
343fn load_complete_shard_indexes(
344    data_dir: &Path,
345    embedder_id: &str,
346    db_fingerprint: &str,
347) -> Result<Option<Vec<VectorIndex>>, String> {
348    for tier in [TierKind::Quality, TierKind::Fast] {
349        let Some(records) =
350            matching_complete_shard_records(data_dir, tier, embedder_id, db_fingerprint)?
351        else {
352            continue;
353        };
354
355        let mut indexes = Vec::with_capacity(records.len());
356        for shard in records {
357            let Some(path) = semantic_sidecar_path(data_dir, &shard.index_path) else {
358                return Ok(None);
359            };
360            let index = VectorIndex::open(&path)
361                .map_err(|err| format!("semantic shard vector index {}: {err}", path.display()))?;
362            if index.embedder_id() != embedder_id || index.dimension() != shard.dimension {
363                return Err(format!(
364                    "semantic shard vector index {} metadata mismatch",
365                    path.display()
366                ));
367            }
368            indexes.push(index);
369        }
370        if !indexes.is_empty() {
371            tracing::info!(
372                tier = tier.as_str(),
373                embedder = embedder_id,
374                shard_count = indexes.len(),
375                "loaded complete semantic shard generation"
376            );
377            return Ok(Some(indexes));
378        }
379    }
380
381    Ok(None)
382}
383
384fn load_complete_shard_indexes_for_current_db(
385    data_dir: &Path,
386    db_path: &Path,
387    embedder_id: &str,
388    context_label: &'static str,
389) -> Option<Vec<VectorIndex>> {
390    let db_fingerprint = match crate::indexer::lexical_storage_fingerprint_for_db(db_path) {
391        Ok(fingerprint) => fingerprint,
392        Err(err) => {
393            tracing::debug!(
394                error = %err,
395                embedder = embedder_id,
396                context = context_label,
397                "semantic shard context unavailable: failed to fingerprint current DB"
398            );
399            return None;
400        }
401    };
402
403    match load_complete_shard_indexes(data_dir, embedder_id, &db_fingerprint) {
404        Ok(indexes) => indexes,
405        Err(err) => {
406            tracing::debug!(
407                error = %err,
408                embedder = embedder_id,
409                context = context_label,
410                "semantic shard context unavailable"
411            );
412            None
413        }
414    }
415}
416
417/// Load semantic context with optional version mismatch checking.
418///
419/// If `check_for_updates` is true, this function will check if the installed
420/// model version matches the manifest and return `UpdateAvailable` if they differ.
421pub fn load_semantic_context(data_dir: &Path, db_path: &Path) -> SemanticSetup {
422    load_semantic_context_for_embedder(data_dir, db_path, active_policy_embedder_name())
423}
424
425pub fn load_semantic_context_for_embedder(
426    data_dir: &Path,
427    db_path: &Path,
428    embedder_name: &str,
429) -> SemanticSetup {
430    load_semantic_context_inner(data_dir, db_path, true, embedder_name)
431}
432
433/// Probe semantic availability without loading the embedder, vector index, or
434/// DB-backed filter maps. Status/health surfaces use this to report readiness
435/// cheaply; actual semantic search still calls `load_semantic_context`.
436pub(crate) fn probe_semantic_availability(data_dir: &Path) -> SemanticAvailability {
437    probe_semantic_availability_for_embedder(data_dir, active_policy_embedder_name())
438}
439
440pub(crate) fn probe_semantic_availability_for_embedder(
441    data_dir: &Path,
442    embedder_name: &str,
443) -> SemanticAvailability {
444    let canonical_name = FastEmbedder::canonical_name(embedder_name).unwrap_or("minilm");
445    let Some(config) = FastEmbedder::config_for(canonical_name) else {
446        return SemanticAvailability::LoadFailed {
447            context: format!("unknown semantic embedder: {embedder_name}"),
448        };
449    };
450    let Some(model_dir) = FastEmbedder::runtime_model_dir_for(data_dir, canonical_name) else {
451        return SemanticAvailability::LoadFailed {
452            context: format!("no model directory mapping for semantic embedder: {embedder_name}"),
453        };
454    };
455    let manifest =
456        ModelManifest::for_embedder(canonical_name).unwrap_or_else(ModelManifest::minilm_v2);
457    let semantic_policy = SemanticPolicy::resolve(&CliSemanticOverrides::default());
458    let acquisition_policy = ModelAcquisitionPolicy::from_semantic_policy(&semantic_policy);
459    let cache_report = classify_model_cache_metadata(&model_dir, &manifest, &acquisition_policy);
460
461    if let Some(availability) =
462        semantic_availability_from_cache_state(&model_dir, &cache_report.state, true)
463    {
464        return availability;
465    }
466
467    let index_path = vector_index_path(data_dir, &config.embedder_id);
468    if !index_path.is_file() {
469        return SemanticAvailability::IndexMissing { index_path };
470    }
471
472    SemanticAvailability::Ready {
473        embedder_id: config.embedder_id,
474    }
475}
476
477/// Probe hash semantic availability without opening the DB or vector index.
478pub(crate) fn probe_hash_semantic_availability(data_dir: &Path) -> SemanticAvailability {
479    let embedder = HashEmbedder::default();
480    let index_path = vector_index_path(data_dir, embedder.id());
481    if !index_path.is_file() {
482        SemanticAvailability::IndexMissing { index_path }
483    } else {
484        SemanticAvailability::HashFallback
485    }
486}
487
488/// Load hash-based semantic context (no model download required).
489pub fn load_hash_semantic_context(data_dir: &Path, db_path: &Path) -> SemanticSetup {
490    let embedder = HashEmbedder::default();
491    let index_path = vector_index_path(data_dir, embedder.id());
492    let monolithic_present = index_path.is_file();
493    let shard_indexes = load_complete_shard_indexes_for_current_db(
494        data_dir,
495        db_path,
496        embedder.id(),
497        "hash semantic",
498    );
499    if !monolithic_present && shard_indexes.is_none() {
500        return SemanticSetup {
501            availability: SemanticAvailability::IndexMissing { index_path },
502            context: None,
503        };
504    }
505
506    let storage = match FrankenStorage::open_readonly(db_path) {
507        Ok(storage) => storage,
508        Err(err) => {
509            return SemanticSetup {
510                availability: SemanticAvailability::DatabaseUnavailable {
511                    db_path: db_path.to_path_buf(),
512                    error: err.to_string(),
513                },
514                context: None,
515            };
516        }
517    };
518
519    let filter_maps = match SemanticFilterMaps::from_storage(&storage) {
520        Ok(maps) => maps,
521        Err(err) => {
522            return SemanticSetup {
523                availability: SemanticAvailability::LoadFailed {
524                    context: format!("filter maps: {err}"),
525                },
526                context: None,
527            };
528        }
529    };
530
531    let (index, additional_indexes) = if let Some(mut indexes) = shard_indexes {
532        let index = indexes.remove(0);
533        (index, indexes)
534    } else {
535        match VectorIndex::open(&index_path) {
536            Ok(index) => (index, Vec::new()),
537            Err(err) => {
538                return SemanticSetup {
539                    availability: SemanticAvailability::LoadFailed {
540                        context: format!("vector index: {err}"),
541                    },
542                    context: None,
543                };
544            }
545        }
546    };
547
548    let roles = Some(HashSet::from([ROLE_USER, ROLE_ASSISTANT]));
549    let embedder = Arc::new(embedder) as Arc<dyn Embedder>;
550
551    SemanticSetup {
552        availability: SemanticAvailability::HashFallback,
553        context: Some(SemanticContext {
554            embedder,
555            index,
556            additional_indexes,
557            filter_maps,
558            roles,
559        }),
560    }
561}
562
563/// Load semantic context without version checking.
564///
565/// Use this when you've already acknowledged an update and want to load
566/// the model anyway.
567pub fn load_semantic_context_no_version_check(data_dir: &Path, db_path: &Path) -> SemanticSetup {
568    load_semantic_context_inner(data_dir, db_path, false, active_policy_embedder_name())
569}
570
571fn load_semantic_context_inner(
572    data_dir: &Path,
573    db_path: &Path,
574    check_for_updates: bool,
575    embedder_name: &str,
576) -> SemanticSetup {
577    let canonical_name = FastEmbedder::canonical_name(embedder_name).unwrap_or("minilm");
578    let Some(config) = FastEmbedder::config_for(canonical_name) else {
579        return SemanticSetup {
580            availability: SemanticAvailability::LoadFailed {
581                context: format!("unknown semantic embedder: {embedder_name}"),
582            },
583            context: None,
584        };
585    };
586    let Some(model_dir) = FastEmbedder::runtime_model_dir_for(data_dir, canonical_name) else {
587        return SemanticSetup {
588            availability: SemanticAvailability::LoadFailed {
589                context: format!(
590                    "no model directory mapping for semantic embedder: {embedder_name}"
591                ),
592            },
593            context: None,
594        };
595    };
596    let manifest =
597        ModelManifest::for_embedder(canonical_name).unwrap_or_else(ModelManifest::minilm_v2);
598    let semantic_policy = SemanticPolicy::resolve(&CliSemanticOverrides::default());
599    let acquisition_policy = ModelAcquisitionPolicy::from_semantic_policy(&semantic_policy);
600    let cache_report = classify_model_cache(&model_dir, &manifest, &acquisition_policy);
601
602    if let Some(availability) =
603        semantic_availability_from_cache_state(&model_dir, &cache_report.state, check_for_updates)
604    {
605        return SemanticSetup {
606            availability,
607            context: None,
608        };
609    }
610
611    let index_path = vector_index_path(data_dir, &config.embedder_id);
612    let monolithic_present = index_path.is_file();
613    let shard_indexes = load_complete_shard_indexes_for_current_db(
614        data_dir,
615        db_path,
616        &config.embedder_id,
617        "semantic",
618    );
619    if !monolithic_present && shard_indexes.is_none() {
620        return SemanticSetup {
621            availability: SemanticAvailability::IndexMissing { index_path },
622            context: None,
623        };
624    }
625
626    let storage = match FrankenStorage::open_readonly(db_path) {
627        Ok(storage) => storage,
628        Err(err) => {
629            return SemanticSetup {
630                availability: SemanticAvailability::DatabaseUnavailable {
631                    db_path: db_path.to_path_buf(),
632                    error: err.to_string(),
633                },
634                context: None,
635            };
636        }
637    };
638
639    let filter_maps = match SemanticFilterMaps::from_storage(&storage) {
640        Ok(maps) => maps,
641        Err(err) => {
642            return SemanticSetup {
643                availability: SemanticAvailability::LoadFailed {
644                    context: format!("filter maps: {err}"),
645                },
646                context: None,
647            };
648        }
649    };
650
651    let (index, additional_indexes) = if let Some(mut indexes) = shard_indexes {
652        let index = indexes.remove(0);
653        (index, indexes)
654    } else {
655        match VectorIndex::open(&index_path) {
656            Ok(index) => (index, Vec::new()),
657            Err(err) => {
658                return SemanticSetup {
659                    availability: SemanticAvailability::LoadFailed {
660                        context: format!("vector index: {err}"),
661                    },
662                    context: None,
663                };
664            }
665        }
666    };
667
668    let embedder = match FastEmbedder::load_by_name(data_dir, canonical_name) {
669        Ok(embedder) => Arc::new(embedder) as Arc<dyn Embedder>,
670        Err(err) => {
671            return SemanticSetup {
672                availability: SemanticAvailability::LoadFailed {
673                    context: format!("model load: {err}"),
674                },
675                context: None,
676            };
677        }
678    };
679
680    let roles = Some(HashSet::from([ROLE_USER, ROLE_ASSISTANT]));
681
682    SemanticSetup {
683        availability: SemanticAvailability::Ready {
684            embedder_id: embedder.id().to_string(),
685        },
686        context: Some(SemanticContext {
687            embedder,
688            index,
689            additional_indexes,
690            filter_maps,
691            roles,
692        }),
693    }
694}
695
696fn active_policy_embedder_name() -> &'static str {
697    let semantic_policy = SemanticPolicy::resolve(&CliSemanticOverrides::default());
698    FastEmbedder::canonical_name(&semantic_policy.quality_tier_embedder).unwrap_or("minilm")
699}
700
701fn semantic_availability_from_cache_state(
702    model_dir: &Path,
703    state: &ModelCacheState,
704    check_for_updates: bool,
705) -> Option<SemanticAvailability> {
706    match state {
707        ModelCacheState::Acquired { .. }
708        | ModelCacheState::PreseededLocal { .. }
709        | ModelCacheState::MirrorSourced { .. } => None,
710        ModelCacheState::IncompatibleVersion {
711            current_revision,
712            expected_revision,
713        } if check_for_updates => Some(SemanticAvailability::UpdateAvailable {
714            embedder_id: FastEmbedder::embedder_id_static().to_string(),
715            current_revision: current_revision.clone(),
716            latest_revision: expected_revision.clone(),
717        }),
718        ModelCacheState::IncompatibleVersion { .. } => None,
719        ModelCacheState::NotAcquired {
720            missing_files,
721            needs_consent,
722        } => {
723            if *needs_consent {
724                Some(SemanticAvailability::NeedsConsent)
725            } else {
726                Some(SemanticAvailability::ModelMissing {
727                    model_dir: model_dir.to_path_buf(),
728                    missing_files: missing_files.clone(),
729                })
730            }
731        }
732        ModelCacheState::Acquiring {
733            bytes_present,
734            total_bytes,
735            ..
736        } => {
737            let progress_pct = if *total_bytes == 0 {
738                0
739            } else {
740                ((*bytes_present as f64 / *total_bytes as f64) * 100.0).min(100.0) as u8
741            };
742            Some(SemanticAvailability::Downloading {
743                progress_pct,
744                bytes_downloaded: *bytes_present,
745                total_bytes: *total_bytes,
746            })
747        }
748        ModelCacheState::ChecksumMismatch {
749            file,
750            expected,
751            actual,
752        } => Some(SemanticAvailability::LoadFailed {
753            context: format!(
754                "model checksum mismatch for {file}: expected {expected}, got {actual}"
755            ),
756        }),
757        ModelCacheState::DisabledByPolicy { reason } => Some(SemanticAvailability::Disabled {
758            reason: reason.clone(),
759        }),
760        ModelCacheState::BudgetBlocked {
761            required_bytes,
762            max_bytes,
763        } => Some(SemanticAvailability::Disabled {
764            reason: format!(
765                "semantic model requires {required_bytes} bytes but policy allows {max_bytes}"
766            ),
767        }),
768        ModelCacheState::QuarantinedCorrupt {
769            marker_path,
770            reason,
771        } => Some(SemanticAvailability::LoadFailed {
772            context: format!(
773                "model cache quarantined at {}: {reason}",
774                marker_path.display()
775            ),
776        }),
777        ModelCacheState::OfflineBlocked { missing_files } => Some(SemanticAvailability::Disabled {
778            reason: format!(
779                "offline and semantic model is not acquired: missing {}",
780                missing_files.join(", ")
781            ),
782        }),
783    }
784}
785
786/// Check if the vector index needs rebuilding after a model upgrade.
787///
788/// This compares the embedder ID in the vector index header with the expected
789/// embedder ID. If they differ, the index was built with a different model
790/// and needs to be rebuilt.
791///
792/// Returns `true` if rebuild is needed, `false` otherwise.
793pub fn needs_index_rebuild(data_dir: &Path) -> bool {
794    let index_path = vector_index_path(data_dir, FastEmbedder::embedder_id_static());
795
796    if !index_path.is_file() {
797        // Index doesn't exist, so it needs to be built (not rebuilt)
798        return false;
799    }
800
801    // Try to load the index and check its embedder ID
802    match VectorIndex::open(&index_path) {
803        Ok(index) => {
804            // Check if the index was built with a different embedder
805            // The vector index stores the embedder ID in its header
806            let expected_id = FastEmbedder::embedder_id_static();
807            index.embedder_id() != expected_id
808        }
809        Err(_) => {
810            // Index is corrupted or unreadable, needs rebuild
811            true
812        }
813    }
814}
815
816/// Delete the vector index to force a rebuild.
817///
818/// Call this after a model upgrade when the user has consented to rebuilding
819/// the semantic index. The next index run will rebuild from scratch.
820///
821/// # Returns
822///
823/// `Ok(true)` if the index was deleted.
824/// `Ok(false)` if the index didn't exist.
825/// `Err(_)` if deletion failed.
826pub fn delete_vector_index_for_rebuild(data_dir: &Path) -> std::io::Result<bool> {
827    let index_path = vector_index_path(data_dir, FastEmbedder::embedder_id_static());
828
829    if index_path.is_file() {
830        std::fs::remove_file(&index_path)?;
831        Ok(true)
832    } else {
833        Ok(false)
834    }
835}
836
837/// Get the model directory path for the default MiniLM model.
838pub fn default_model_dir(data_dir: &Path) -> PathBuf {
839    FastEmbedder::default_model_dir(data_dir)
840}
841
842/// Get the model manifest for the default MiniLM model.
843pub fn default_model_manifest() -> ModelManifest {
844    ModelManifest::minilm_v2()
845}
846
847#[cfg(test)]
848mod tests {
849    use super::*;
850    use tempfile::tempdir;
851
852    type AvailabilityTuiCase = (
853        SemanticAvailability,
854        &'static str,
855        fn(&SemanticAvailability) -> bool,
856    );
857
858    #[test]
859    fn test_semantic_availability_ready() {
860        let ready = SemanticAvailability::Ready {
861            embedder_id: "test-123".into(),
862        };
863        assert!(ready.summary().contains("semantic ready"));
864        assert!(ready.is_ready());
865        assert!(!ready.has_update());
866        assert!(ready.can_search());
867        assert_eq!(ready.status_label(), "SEM");
868    }
869
870    #[test]
871    fn semantic_sidecar_path_rejects_paths_outside_data_dir() {
872        let tmp = tempdir().unwrap();
873        let safe = semantic_sidecar_path(tmp.path(), "vector_index/shards/hash/shard-0.fsvi")
874            .expect("safe relative shard path");
875        assert_eq!(
876            safe,
877            tmp.path().join("vector_index/shards/hash/shard-0.fsvi")
878        );
879
880        for unsafe_path in [
881            tmp.path()
882                .join("outside.fsvi")
883                .to_string_lossy()
884                .to_string(),
885            "../outside.fsvi".to_string(),
886            "vector_index/../outside.fsvi".to_string(),
887            "./vector_index/shards/hash/shard-0.fsvi".to_string(),
888        ] {
889            assert!(
890                semantic_sidecar_path(tmp.path(), &unsafe_path).is_none(),
891                "unsafe semantic sidecar path should be rejected: {unsafe_path}"
892            );
893        }
894    }
895
896    #[test]
897    fn test_semantic_availability_update() {
898        let update = SemanticAvailability::UpdateAvailable {
899            embedder_id: "test".into(),
900            current_revision: "v1".into(),
901            latest_revision: "v2".into(),
902        };
903        assert!(update.summary().contains("update available"));
904        assert!(!update.is_ready());
905        assert!(update.has_update());
906        assert_eq!(update.status_label(), "UPD");
907    }
908
909    #[test]
910    fn test_semantic_availability_index_building() {
911        let building = SemanticAvailability::IndexBuilding {
912            embedder_id: "test".into(),
913            progress_pct: Some(45),
914            items_indexed: 100,
915            total_items: 200,
916        };
917        assert!(building.summary().contains("building index"));
918        assert!(building.summary().contains("45%"));
919        assert!(building.is_building());
920        assert_eq!(building.status_label(), "IDX...");
921
922        let (pct, done, total) = building.index_progress().unwrap();
923        assert_eq!(pct, Some(45));
924        assert_eq!(done, 100);
925        assert_eq!(total, 200);
926    }
927
928    #[test]
929    fn test_semantic_availability_downloading() {
930        let downloading = SemanticAvailability::Downloading {
931            progress_pct: 50,
932            bytes_downloaded: 10_000_000,
933            total_bytes: 20_000_000,
934        };
935        assert!(downloading.is_downloading());
936        assert!(downloading.summary().contains("downloading"));
937        assert!(downloading.summary().contains("50%"));
938        assert_eq!(downloading.status_label(), "DL...");
939
940        let (pct, bytes, total) = downloading.download_progress().unwrap();
941        assert_eq!(pct, 50);
942        assert_eq!(bytes, 10_000_000);
943        assert_eq!(total, 20_000_000);
944    }
945
946    #[test]
947    fn test_semantic_availability_tui_states() {
948        let cases: &[AvailabilityTuiCase] = &[
949            (
950                SemanticAvailability::NotInstalled,
951                "LEX",
952                SemanticAvailability::is_not_installed,
953            ),
954            (
955                SemanticAvailability::NeedsConsent,
956                "LEX",
957                SemanticAvailability::needs_consent,
958            ),
959            (SemanticAvailability::Verifying, "VFY...", |state| {
960                state.summary().contains("verifying")
961            }),
962            (SemanticAvailability::HashFallback, "SEM*", |state| {
963                state.is_hash_fallback() && state.can_search()
964            }),
965            (
966                SemanticAvailability::Disabled {
967                    reason: "offline mode".into(),
968                },
969                "OFF",
970                |state| state.is_disabled() && state.summary().contains("offline"),
971            ),
972        ];
973
974        for (state, expected_label, predicate) in cases {
975            assert_eq!(state.status_label(), *expected_label, "{state:?}");
976            assert!(predicate(state), "{state:?}");
977        }
978    }
979
980    #[test]
981    fn test_semantic_availability_error_states() {
982        let load_failed = SemanticAvailability::LoadFailed {
983            context: "test error".into(),
984        };
985        assert!(load_failed.is_error());
986        assert_eq!(load_failed.status_label(), "ERR");
987
988        let db_unavail = SemanticAvailability::DatabaseUnavailable {
989            db_path: PathBuf::from("/test"),
990            error: "locked".into(),
991        };
992        assert!(db_unavail.is_error());
993        assert_eq!(db_unavail.status_label(), "NODB");
994    }
995
996    #[test]
997    fn test_needs_index_rebuild_no_index() {
998        let tmp = tempdir().unwrap();
999        assert!(!needs_index_rebuild(tmp.path()));
1000    }
1001
1002    #[test]
1003    fn test_delete_vector_index_no_file() {
1004        let tmp = tempdir().unwrap();
1005        let result = delete_vector_index_for_rebuild(tmp.path());
1006        assert!(result.is_ok());
1007        assert!(!result.unwrap());
1008    }
1009
1010    fn write_hash_vector_index(path: &Path, record_count: usize) {
1011        let embedder = HashEmbedder::default();
1012        if let Some(parent) = path.parent() {
1013            std::fs::create_dir_all(parent).expect("create vector index parent");
1014        }
1015        let mut writer = VectorIndex::create_with_revision(
1016            path,
1017            embedder.id(),
1018            "hash",
1019            embedder.dimension(),
1020            frankensearch::index::Quantization::F16,
1021        )
1022        .expect("create hash vector index");
1023        let mut vector = vec![0.0_f32; embedder.dimension()];
1024        vector[0] = 1.0;
1025        for idx in 0..record_count {
1026            writer
1027                .write_record(&format!("doc-{idx}"), &vector)
1028                .expect("write hash vector record");
1029        }
1030        writer.finish().expect("finish hash vector index");
1031    }
1032
1033    #[test]
1034    fn load_hash_context_prefers_current_complete_shards_over_monolithic_file() {
1035        let tmp = tempdir().unwrap();
1036        let db_path = tmp.path().join("cass.db");
1037        let storage = FrankenStorage::open(&db_path).expect("create cass db");
1038        drop(storage);
1039        let db_fingerprint = crate::indexer::lexical_storage_fingerprint_for_db(&db_path)
1040            .expect("fingerprint cass db");
1041
1042        let embedder = HashEmbedder::default();
1043        write_hash_vector_index(&vector_index_path(tmp.path(), embedder.id()), 1);
1044
1045        let mut records = Vec::new();
1046        for shard_index in 0..2_u32 {
1047            let relative_path = format!("vector_index/shards/hash/shard-{shard_index}.fsvi");
1048            let shard_path = tmp.path().join(&relative_path);
1049            write_hash_vector_index(&shard_path, 1);
1050            records.push(SemanticShardRecord {
1051                tier: TierKind::Fast,
1052                embedder_id: embedder.id().to_string(),
1053                model_revision: "hash".to_string(),
1054                schema_version: crate::search::policy::SEMANTIC_SCHEMA_VERSION,
1055                chunking_version: crate::search::policy::CHUNKING_STRATEGY_VERSION,
1056                dimension: embedder.dimension(),
1057                shard_index,
1058                shard_count: 2,
1059                doc_count: 1,
1060                total_conversations: 1,
1061                db_fingerprint: db_fingerprint.clone(),
1062                index_path: relative_path,
1063                quantization: "f16".to_string(),
1064                mmap_ready: true,
1065                ann_index_path: None,
1066                ann_size_bytes: 0,
1067                ann_ready: false,
1068                size_bytes: std::fs::metadata(&shard_path)
1069                    .expect("stat hash shard")
1070                    .len(),
1071                started_at_ms: 1_733_100_000_000,
1072                completed_at_ms: 1_733_100_000_000 + i64::from(shard_index),
1073                ready: true,
1074            });
1075        }
1076        let mut manifest = SemanticShardManifest {
1077            shards: records,
1078            ..Default::default()
1079        };
1080        manifest.save(tmp.path()).expect("save shard manifest");
1081
1082        let setup = load_hash_semantic_context(tmp.path(), &db_path);
1083        assert!(
1084            matches!(setup.availability, SemanticAvailability::HashFallback),
1085            "hash semantic availability should remain ready: {:?}",
1086            setup.availability
1087        );
1088        let context = setup
1089            .context
1090            .expect("complete current shards should load a semantic context");
1091        assert_eq!(
1092            context.additional_indexes.len(),
1093            1,
1094            "complete current shards must not be shadowed by an older monolithic vector file"
1095        );
1096        let loaded_records = context.index.record_count()
1097            + context
1098                .additional_indexes
1099                .iter()
1100                .map(VectorIndex::record_count)
1101                .sum::<usize>();
1102        assert_eq!(loaded_records, 2);
1103    }
1104}