Skip to main content

aft/
semantic_index.rs

1use crate::cache_freshness::{self, FileFreshness, FreshnessVerdict};
2use crate::config::{
3    SemanticBackend, SemanticBackendConfig, DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS,
4    MAX_SEMANTIC_QUERY_TIMEOUT_MS, MIN_SEMANTIC_QUERY_TIMEOUT_MS,
5};
6use crate::fs_lock;
7use crate::parser::{detect_language, extract_symbols_from_tree, parse_source_with_cached_parser};
8use crate::search_index::{cache_relative_path, cached_path_under_root};
9use crate::symbols::{Symbol, SymbolKind};
10use crate::synapse_embed::SynapseEmbeddingClient;
11use crate::{slog_info, slog_warn};
12
13use crate::local_embed::LocalEmbedder;
14use rayon::prelude::*;
15use reqwest::blocking::Client;
16use serde::{Deserialize, Serialize};
17use std::cell::RefCell;
18use std::collections::{BTreeSet, HashMap, HashSet, VecDeque};
19use std::env;
20use std::error::Error;
21use std::fmt::Display;
22use std::fs::{self, OpenOptions};
23use std::io::{self, BufReader, BufWriter, Cursor, Read, Seek, SeekFrom, Write};
24use std::path::{Path, PathBuf};
25use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
26use std::sync::{Arc, Mutex, OnceLock, Weak};
27use std::time::{Duration, Instant, SystemTime};
28use url::Url;
29
30const DEFAULT_DIMENSION: usize = 384;
31const MAX_ENTRIES: usize = 1_000_000;
32// Covers high-dimensional backends such as OpenAI text-embedding-3-large (3072)
33// and common local models (4096) while keeping a bounded supported shape.
34const MAX_DIMENSION: usize = 4096;
35const F32_BYTES: usize = std::mem::size_of::<f32>();
36const HEADER_BYTES_V1: usize = 9;
37const HEADER_BYTES_V2: usize = 13;
38fn begin_semantic_index_build(
39    project_root: &Path,
40) -> (
41    Option<crate::logging::IndexBuildGuard>,
42    crate::logging::IndexBuildScope,
43    crate::logging::IndexBuildFailureGuard,
44) {
45    if let Some(scope) = crate::logging::current_index_build() {
46        if scope.plane == crate::logging::IndexPlane::Semantic {
47            return (None, scope, crate::logging::IndexBuildFailureGuard::new());
48        }
49    }
50    let key = crate::search_index::artifact_cache_key(project_root);
51    let scope = crate::logging::IndexBuildScope::new(
52        crate::logging::IndexPlane::Semantic,
53        project_root,
54        key,
55    );
56    let guard = crate::logging::install_index_build(scope.clone());
57    crate::logging::log_index_event(crate::logging::IndexEvent::from_scope(
58        crate::logging::IndexEventKind::BuildStarted,
59        &scope,
60    ));
61    (
62        Some(guard),
63        scope,
64        crate::logging::IndexBuildFailureGuard::new(),
65    )
66}
67
68fn finish_semantic_index_build(
69    scope: &crate::logging::IndexBuildScope,
70    failure_guard: &mut crate::logging::IndexBuildFailureGuard,
71    result: &Result<SemanticIndex, String>,
72) {
73    match result {
74        Ok(index) => {
75            crate::logging::log_index_event(
76                crate::logging::IndexEvent::from_scope(
77                    crate::logging::IndexEventKind::BuildReady,
78                    scope,
79                )
80                .field("elapsed_ms", scope.elapsed_ms())
81                .field("files", index.file_mtimes.len())
82                .field("chunks", index.entries.len())
83                .field("skipped_rows", index.skipped_rows),
84            );
85            failure_guard.disarm();
86        }
87        Err(error) if error.contains("superseded") => {
88            crate::logging::log_index_event(
89                crate::logging::IndexEvent::from_scope(
90                    crate::logging::IndexEventKind::BuildSuperseded,
91                    scope,
92                )
93                .field("stage", "embed"),
94            );
95            failure_guard.disarm();
96        }
97        Err(error) => {
98            crate::logging::log_index_event(
99                crate::logging::IndexEvent::from_scope(
100                    crate::logging::IndexEventKind::BuildFailed,
101                    scope,
102                )
103                .field("reason", error),
104            );
105            failure_guard.disarm();
106        }
107    }
108}
109
110const ONNX_RUNTIME_INSTALL_HINT: &str =
111    "ONNX Runtime not found. Install via: brew install onnxruntime (macOS), \
112     apt install libonnxruntime (Linux), or place onnxruntime.dll in your PATH (Windows). \
113     AFT can auto-download ONNX Runtime — run `npx @cortexkit/aft doctor` to diagnose.";
114
115const SEMANTIC_INDEX_VERSION_V1: u8 = 1;
116const SEMANTIC_INDEX_VERSION_V2: u8 = 2;
117/// V3 adds subsec_nanos to the file-mtime table so staleness detection survives
118/// restart round-trips on filesystems with subsecond mtime precision (APFS,
119/// ext4 with nsec, NTFS). V1/V2 persisted whole-second mtimes only, which
120/// caused every restart to flag ~99% of files as stale and re-embed them.
121const SEMANTIC_INDEX_VERSION_V3: u8 = 3;
122/// V4 keeps the V3 on-disk layout but rebuilds persisted snippets once after
123/// fixing symbol ranges that were incorrectly treated as 1-based.
124const SEMANTIC_INDEX_VERSION_V4: u8 = 4;
125/// V5 adds file sizes to the file metadata table so incremental staleness
126/// detection can catch content changes even when mtime precision misses them.
127const SEMANTIC_INDEX_VERSION_V5: u8 = 5;
128/// V6 stores paths relative to project_root and adds content hashes.
129const SEMANTIC_INDEX_VERSION_V6: u8 = 6;
130/// V7 adds qualified symbol names for ranking metadata without changing embeddings.
131const SEMANTIC_INDEX_VERSION_V7: u8 = 7;
132/// A V6/V7 base snapshot may be followed by these checksummed delta frames.
133/// The base stays independently readable, so an incomplete final frame can be discarded.
134const SEMANTIC_SEGMENT_MAGIC: &[u8; 8] = b"AFTSEG01";
135const SEMANTIC_SEGMENT_VERSION: u8 = 1;
136const SEMANTIC_SEGMENT_FRAME_HEADER_BYTES: usize = 8 + 8 + 32;
137const SEMANTIC_COMPACT_SEGMENT_LIMIT: usize = 64;
138const SEMANTIC_COMPACT_BYTE_RATIO_DENOMINATOR: u64 = 4;
139const SEMANTIC_PERSIST_LOCK_MIN_WAIT: Duration = Duration::from_secs(5);
140const SEMANTIC_PERSIST_LOCK_BYTES_PER_SECOND: u64 = 32 * 1024 * 1024;
141const DEFAULT_OPENAI_EMBEDDING_PATH: &str = "/embeddings";
142const DEFAULT_OLLAMA_EMBEDDING_PATH: &str = "/api/embed";
143// Build/refresh embedding requests keep a larger budget because they run on
144// background workers and often batch many texts through a cold local backend.
145const DEFAULT_OPENAI_EMBEDDING_TIMEOUT_MS: u64 = 25_000;
146const DEFAULT_MAX_BATCH_SIZE: usize = 64;
147const QUERY_EMBEDDING_CACHE_CAP: usize = 1_000;
148const QUERY_EMBED_HEALTH_SAMPLE_CAP: usize = 1_000;
149const QUERY_EMBED_OK_LOG_INTERVAL: Duration = Duration::from_secs(60);
150const FALLBACK_BACKEND: &str = "none";
151const EMBEDDING_REQUEST_MAX_ATTEMPTS: usize = 3;
152const EMBEDDING_REQUEST_BACKOFF_MS: [u64; 2] = [500, 1_000];
153const BUILD_EMBEDDING_TIMEOUT_MARKER_PREFIX: &str = "[build-timeout:";
154const BUILD_EMBEDDING_TIMEOUT_MARKER_SUFFIX: &str = "]";
155const ROW_TOO_LONG_MARKER_PREFIX: &str = "[row-too-long:";
156const ROW_TOO_LONG_MARKER_SUFFIX: &str = "]";
157const MAX_ROW_SHRINK_ATTEMPTS: usize = 4;
158const ROW_SHRINK_RATIO_MARGIN: f64 = 0.9;
159const BUILD_PER_ITEM_EMA_ALPHA: f64 = 0.25;
160const BUILD_PER_ITEM_SAFETY_FACTOR: f64 = 2.0;
161const BUILD_INITIAL_BATCH_DIVISOR: u64 = 16;
162const BUILD_BATCH_GROWTH_SUCCESSES: usize = 2;
163static SEMANTIC_LOCK_ACQUIRE_MUTEX: Mutex<()> = Mutex::new(());
164
165#[derive(Debug, Clone)]
166struct BuildEmbeddingRowMetadata {
167    embedded_text: String,
168    skipped_reason: Option<String>,
169}
170
171struct AdaptiveBuildRow {
172    metadata: BuildEmbeddingRowMetadata,
173    vector: Option<Vec<f32>>,
174}
175
176thread_local! {
177    /// `EmbeddingModel::embed` keeps its long-standing vector-only API. The
178    /// semantic builder consumes this same-thread metadata immediately after
179    /// each HTTP build call so it can persist shrunk text and omit skipped rows.
180    static LAST_HTTP_BUILD_METADATA: RefCell<Option<Vec<BuildEmbeddingRowMetadata>>> = const {
181        RefCell::new(None)
182    };
183    #[cfg(test)]
184    static TEST_SKIPPED_ROW_WARNINGS: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
185    #[cfg(test)]
186    static TEST_QUERY_BUDGET_MS: RefCell<Option<u64>> = const { RefCell::new(None) };
187}
188
189fn clear_http_build_metadata() {
190    LAST_HTTP_BUILD_METADATA.with(|slot| slot.borrow_mut().take());
191}
192
193fn set_http_build_metadata(metadata: Vec<BuildEmbeddingRowMetadata>) {
194    LAST_HTTP_BUILD_METADATA.with(|slot| *slot.borrow_mut() = Some(metadata));
195}
196
197fn take_http_build_metadata() -> Option<Vec<BuildEmbeddingRowMetadata>> {
198    LAST_HTTP_BUILD_METADATA.with(|slot| slot.borrow_mut().take())
199}
200
201/// Test-only probe counter for the managed-ONNX resolver (see
202/// `find_managed_onnx_runtime`). Counts storage-tree reads so a negative-control
203/// test can assert a pre-set ORT_DYLIB_PATH short-circuits the resolver.
204#[cfg(test)]
205static MANAGED_ORT_PROBE_READS: AtomicUsize = AtomicUsize::new(0);
206
207/// Per-query request policy kept separate from the background build timeout.
208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
209pub struct QueryBudget {
210    timeout_ms: u64,
211}
212
213impl QueryBudget {
214    pub fn from_config(config: &SemanticBackendConfig) -> Self {
215        #[cfg(test)]
216        if let Some(timeout_ms) = TEST_QUERY_BUDGET_MS.with(|slot| *slot.borrow()) {
217            return Self { timeout_ms };
218        }
219        let configured = if config.query_timeout_ms == 0 {
220            DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS
221        } else {
222            config.query_timeout_ms
223        };
224        Self {
225            timeout_ms: configured
226                .clamp(MIN_SEMANTIC_QUERY_TIMEOUT_MS, MAX_SEMANTIC_QUERY_TIMEOUT_MS),
227        }
228    }
229
230    #[cfg(test)]
231    fn timeout_ms(self) -> u64 {
232        self.timeout_ms
233    }
234}
235
236#[cfg(test)]
237pub(crate) fn with_query_budget_for_test<R>(timeout_ms: u64, action: impl FnOnce() -> R) -> R {
238    struct Reset(Option<u64>);
239    impl Drop for Reset {
240        fn drop(&mut self) {
241            TEST_QUERY_BUDGET_MS.with(|slot| *slot.borrow_mut() = self.0);
242        }
243    }
244
245    let previous = TEST_QUERY_BUDGET_MS.with(|slot| slot.borrow_mut().replace(timeout_ms));
246    let _reset = Reset(previous);
247    action()
248}
249
250#[derive(Debug, Clone, Copy)]
251struct BuildRequestBudget {
252    batch_size: usize,
253    deadline_ms: u64,
254}
255
256#[derive(Debug, Clone, Copy)]
257enum EmbeddingRequestPolicy {
258    Build(BuildRequestBudget),
259    Query(QueryBudget),
260}
261
262impl EmbeddingRequestPolicy {
263    fn max_attempts(self) -> usize {
264        match self {
265            Self::Build(_) => EMBEDDING_REQUEST_MAX_ATTEMPTS,
266            Self::Query(_) => 1,
267        }
268    }
269
270    fn request_timeout(self) -> Duration {
271        match self {
272            Self::Build(budget) => Duration::from_millis(budget.deadline_ms),
273            Self::Query(budget) => Duration::from_millis(budget.timeout_ms),
274        }
275    }
276}
277
278pub struct SemanticIndexLock {
279    _guard: Option<fs_lock::LockGuard>,
280}
281
282impl SemanticIndexLock {
283    pub fn acquire(
284        storage_dir: &Path,
285        project_key: &str,
286        project_root: &Path,
287    ) -> std::io::Result<Self> {
288        let dir = storage_dir.join("semantic").join(project_key);
289        let path = dir.join("cache.lock");
290        let access = crate::root_cache::ArtifactAccess::for_root(project_root);
291        if !access.allows_write(project_key, &path) {
292            return Ok(Self { _guard: None });
293        }
294        fs::create_dir_all(&dir)?;
295        let _acquire_guard = SEMANTIC_LOCK_ACQUIRE_MUTEX
296            .lock()
297            .map_err(|_| std::io::Error::other("semantic cache lock acquisition mutex poisoned"))?;
298        fs_lock::try_acquire(&path, Duration::from_secs(2))
299            .map(|guard| Self {
300                _guard: Some(guard),
301            })
302            .map_err(|error| match error {
303                fs_lock::AcquireError::Timeout => {
304                    std::io::Error::other("timed out acquiring semantic cache lock")
305                }
306                fs_lock::AcquireError::Io(error) => error,
307            })
308    }
309}
310
311#[derive(Debug, Clone, Default, Serialize, Deserialize)]
312pub struct SemanticIndexFingerprint {
313    pub backend: String,
314    pub model: String,
315    #[serde(default)]
316    pub base_url: String,
317    pub dimension: usize,
318    #[serde(default = "default_chunking_version")]
319    pub chunking_version: u32,
320    /// Exact caps used to construct symbol embedding rows. Including them in the
321    /// fingerprint prevents cache reuse across incompatible chunk shapes.
322    #[serde(default)]
323    pub embed_text_caps: EmbedTextCaps,
324    /// The Synapse fingerprint and table epoch identify the served vector space
325    /// so indexes built against incompatible embeddings are rejected.
326    #[serde(default, skip_serializing_if = "Option::is_none")]
327    pub synapse_fingerprint: Option<String>,
328    #[serde(default, skip_serializing_if = "Option::is_none")]
329    pub synapse_table_epoch: Option<u64>,
330    /// Alternative fingerprints that Synapse explicitly declares equivalent to
331    /// this index's fingerprint, allowing those versions to pass compatibility checks.
332    #[serde(default, skip_serializing_if = "Vec::is_empty")]
333    pub synapse_equivalent_to: Vec<String>,
334}
335
336fn default_chunking_version() -> u32 {
337    2
338}
339
340impl SemanticIndexFingerprint {
341    fn from_config(config: &SemanticBackendConfig, dimension: usize) -> Self {
342        // Use normalized URL for fingerprinting so cosmetic differences
343        // (e.g. "http://host/v1" vs "http://host/v1/") don't cause rebuilds.
344        let base_url = config
345            .base_url
346            .as_ref()
347            .and_then(|u| normalize_base_url(u).ok())
348            .unwrap_or_else(|| FALLBACK_BACKEND.to_string());
349        Self {
350            backend: config.backend.as_str().to_string(),
351            model: config.model.clone(),
352            base_url,
353            dimension,
354            chunking_version: default_chunking_version(),
355            embed_text_caps: EmbedTextCaps::from_config(config),
356            synapse_fingerprint: None,
357            synapse_table_epoch: None,
358            synapse_equivalent_to: Vec::new(),
359        }
360    }
361
362    pub fn as_string(&self) -> String {
363        serde_json::to_string(self).unwrap_or_else(|_| String::new())
364    }
365
366    pub(crate) fn for_config_dimension(config: &SemanticBackendConfig, dimension: usize) -> Self {
367        Self::from_config(config, dimension)
368    }
369
370    fn matches_expected(&self, expected: &str) -> bool {
371        let Ok(current) = serde_json::from_str::<Self>(expected) else {
372            return false;
373        };
374        self.matches(&current)
375    }
376
377    fn matches(&self, current: &Self) -> bool {
378        if self.backend != current.backend
379            || self.model != current.model
380            || self.base_url != current.base_url
381            || self.dimension != current.dimension
382            || self.chunking_version != current.chunking_version
383            || self.embed_text_caps != current.embed_text_caps
384            || self.synapse_table_epoch != current.synapse_table_epoch
385        {
386            return false;
387        }
388        match (&self.synapse_fingerprint, &current.synapse_fingerprint) {
389            (None, None) => true,
390            (Some(cached), Some(served)) => {
391                cached == served
392                    || current
393                        .synapse_equivalent_to
394                        .iter()
395                        .any(|alias| alias == cached)
396                    || self
397                        .synapse_equivalent_to
398                        .iter()
399                        .any(|alias| alias == served)
400            }
401            _ => false,
402        }
403    }
404}
405
406fn redacted_base_url_host(base_url: &str) -> String {
407    if base_url.is_empty() {
408        return "<empty>".to_string();
409    }
410    if base_url == FALLBACK_BACKEND {
411        return FALLBACK_BACKEND.to_string();
412    }
413
414    match Url::parse(base_url) {
415        Ok(parsed) => {
416            let host = parsed.host_str().unwrap_or("<missing-host>");
417            match parsed.port() {
418                Some(port) => format!("{host}:{port}"),
419                None => host.to_string(),
420            }
421        }
422        Err(_) => "<invalid>".to_string(),
423    }
424}
425
426fn format_fingerprint_mismatch_details(
427    cached: Option<&SemanticIndexFingerprint>,
428    current: &SemanticIndexFingerprint,
429) -> String {
430    let Some(cached) = cached else {
431        return format!(
432            "cached fingerprint missing; current backend kind={}, model={}, base_url host={}, dimension={}, chunking version={}",
433            current.backend,
434            current.model,
435            redacted_base_url_host(&current.base_url),
436            current.dimension,
437            current.chunking_version,
438        );
439    };
440
441    let mut diffs = Vec::new();
442    if cached.backend != current.backend {
443        diffs.push(format!(
444            "backend kind cached={} current={}",
445            cached.backend, current.backend
446        ));
447    }
448    if cached.model != current.model {
449        diffs.push(format!(
450            "model cached={} current={}",
451            cached.model, current.model
452        ));
453    }
454    if cached.base_url != current.base_url {
455        let cached_host = redacted_base_url_host(&cached.base_url);
456        let current_host = redacted_base_url_host(&current.base_url);
457        if cached_host == current_host {
458            diffs.push(format!(
459                "base_url host cached={} current={} (credentials/path redacted)",
460                cached_host, current_host
461            ));
462        } else {
463            diffs.push(format!(
464                "base_url host cached={} current={}",
465                cached_host, current_host
466            ));
467        }
468    }
469    if cached.dimension != current.dimension {
470        diffs.push(format!(
471            "dimension cached={} current={}",
472            cached.dimension, current.dimension
473        ));
474    }
475    if cached.chunking_version != current.chunking_version {
476        diffs.push(format!(
477            "chunking version cached={} current={}",
478            cached.chunking_version, current.chunking_version
479        ));
480    }
481    if cached.embed_text_caps != current.embed_text_caps {
482        diffs.push(format!(
483            "embed text caps cached={:?} current={:?}",
484            cached.embed_text_caps, current.embed_text_caps
485        ));
486    }
487    if cached.synapse_table_epoch != current.synapse_table_epoch {
488        diffs.push(format!(
489            "synapse table_epoch cached={:?} current={:?}",
490            cached.synapse_table_epoch, current.synapse_table_epoch
491        ));
492    }
493    if !cached.matches(current)
494        && (cached.synapse_fingerprint.is_some() || current.synapse_fingerprint.is_some())
495    {
496        diffs.push(format!(
497            "synapse fingerprint cached={} current={} (equivalence class checked)",
498            cached.synapse_fingerprint.as_deref().unwrap_or("<missing>"),
499            current
500                .synapse_fingerprint
501                .as_deref()
502                .unwrap_or("<missing>")
503        ));
504    }
505
506    if diffs.is_empty() {
507        "fingerprint strings differ but parsed fields match".to_string()
508    } else {
509        diffs.join("; ")
510    }
511}
512
513fn log_fingerprint_mismatch(cached: Option<&SemanticIndexFingerprint>, expected: &str) {
514    match serde_json::from_str::<SemanticIndexFingerprint>(expected) {
515        Ok(current) => slog_warn!(
516            "cached semantic index fingerprint mismatch, rebuilding without deleting the shared artifact: {}",
517            format_fingerprint_mismatch_details(cached, &current)
518        ),
519        Err(error) => slog_warn!(
520            "cached semantic index fingerprint mismatch, rebuilding without deleting the shared artifact: could not parse current fingerprint: {}",
521            error
522        ),
523    }
524}
525
526pub(crate) trait LocalEmbeddingProvider: Send {
527    fn embed(&mut self, texts: &[String]) -> Result<Vec<Vec<f32>>, String>;
528}
529
530impl LocalEmbeddingProvider for LocalEmbedder {
531    fn embed(&mut self, texts: &[String]) -> Result<Vec<Vec<f32>>, String> {
532        LocalEmbedder::embed(self, texts)
533    }
534}
535
536type SharedLocalEmbeddingProvider = Arc<Mutex<Box<dyn LocalEmbeddingProvider>>>;
537
538#[derive(Default)]
539struct QueryEmbeddingCache {
540    query_embedding_cache: HashMap<String, Vec<f32>>,
541    query_embedding_cache_order: VecDeque<String>,
542    hits: u64,
543    misses: u64,
544}
545
546impl QueryEmbeddingCache {
547    fn insert(&mut self, query: String, vector: Vec<f32>) {
548        if self.query_embedding_cache.contains_key(&query) {
549            return;
550        }
551        if self.query_embedding_cache.len() >= QUERY_EMBEDDING_CACHE_CAP {
552            if let Some(oldest) = self.query_embedding_cache_order.pop_front() {
553                self.query_embedding_cache.remove(&oldest);
554            }
555        }
556        self.query_embedding_cache.insert(query.clone(), vector);
557        self.query_embedding_cache_order.push_back(query);
558    }
559}
560
561struct LocalQueryEmbedRequest {
562    texts: Vec<String>,
563    cache_key: String,
564    response: crossbeam_channel::Sender<Result<Vec<Vec<f32>>, String>>,
565}
566
567struct LocalQueryEmbedWorker {
568    requests: crossbeam_channel::Sender<LocalQueryEmbedRequest>,
569    busy: Arc<AtomicBool>,
570}
571
572impl LocalQueryEmbedWorker {
573    fn start(
574        model: SharedLocalEmbeddingProvider,
575        query_cache: Arc<Mutex<QueryEmbeddingCache>>,
576    ) -> Result<Self, String> {
577        let (requests, receiver) = crossbeam_channel::unbounded::<LocalQueryEmbedRequest>();
578        let busy = Arc::new(AtomicBool::new(false));
579        let worker_busy = Arc::clone(&busy);
580        std::thread::Builder::new()
581            .name("aft-local-query-embed".to_string())
582            .spawn(move || {
583                while let Ok(request) = receiver.recv() {
584                    let result = model
585                        .lock()
586                        .unwrap_or_else(std::sync::PoisonError::into_inner)
587                        .embed(&request.texts)
588                        .map_err(|error| format!("failed to embed batch: {error}"));
589                    if let Ok(vectors) = &result {
590                        if let Some(vector) = vectors.first() {
591                            query_cache
592                                .lock()
593                                .unwrap_or_else(std::sync::PoisonError::into_inner)
594                                .insert(request.cache_key, vector.clone());
595                        }
596                    }
597                    worker_busy.store(false, Ordering::Release);
598                    let _ = request.response.send(result);
599                }
600            })
601            .map_err(|error| format!("failed to start local query embed worker: {error}"))?;
602        Ok(Self { requests, busy })
603    }
604
605    fn try_submit(
606        &self,
607        texts: Vec<String>,
608        cache_key: String,
609    ) -> Option<crossbeam_channel::Receiver<Result<Vec<Vec<f32>>, String>>> {
610        if self
611            .busy
612            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
613            .is_err()
614        {
615            return None;
616        }
617        let (response, receiver) = crossbeam_channel::bounded(1);
618        if self
619            .requests
620            .send(LocalQueryEmbedRequest {
621                texts,
622                cache_key,
623                response,
624            })
625            .is_err()
626        {
627            self.busy.store(false, Ordering::Release);
628            return None;
629        }
630        Some(receiver)
631    }
632}
633
634struct LocalEmbeddingEngine {
635    model: SharedLocalEmbeddingProvider,
636    query_worker: LocalQueryEmbedWorker,
637}
638
639impl LocalEmbeddingEngine {
640    fn new(
641        model: Box<dyn LocalEmbeddingProvider>,
642        query_cache: Arc<Mutex<QueryEmbeddingCache>>,
643    ) -> Result<Self, String> {
644        let model = Arc::new(Mutex::new(model));
645        let query_worker = LocalQueryEmbedWorker::start(Arc::clone(&model), query_cache)?;
646        Ok(Self {
647            model,
648            query_worker,
649        })
650    }
651}
652
653#[derive(Default)]
654struct QueryEmbedHealthState {
655    timeouts: u64,
656    elapsed_ms: VecDeque<u64>,
657}
658
659#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
660pub(crate) struct QueryEmbedHealthSnapshot {
661    pub(crate) query_embed_timeouts: u64,
662    pub(crate) query_embed_p50_ms: u64,
663}
664
665fn query_embed_health_registry() -> &'static Mutex<HashMap<PathBuf, QueryEmbedHealthState>> {
666    static REGISTRY: OnceLock<Mutex<HashMap<PathBuf, QueryEmbedHealthState>>> = OnceLock::new();
667    REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
668}
669
670fn record_query_embed_observation(root: Option<&Path>, elapsed_ms: u64, timed_out: bool) {
671    let Some(root) = root else {
672        return;
673    };
674    let mut registry = query_embed_health_registry()
675        .lock()
676        .unwrap_or_else(std::sync::PoisonError::into_inner);
677    let state = registry.entry(root.to_path_buf()).or_default();
678    if timed_out {
679        state.timeouts = state.timeouts.saturating_add(1);
680    }
681    if state.elapsed_ms.len() >= QUERY_EMBED_HEALTH_SAMPLE_CAP {
682        state.elapsed_ms.pop_front();
683    }
684    state.elapsed_ms.push_back(elapsed_ms);
685}
686
687pub(crate) fn query_embed_health_snapshot(root: &Path) -> QueryEmbedHealthSnapshot {
688    let registry = query_embed_health_registry()
689        .lock()
690        .unwrap_or_else(std::sync::PoisonError::into_inner);
691    let Some(state) = registry.get(root) else {
692        return QueryEmbedHealthSnapshot::default();
693    };
694    let mut elapsed_ms = state.elapsed_ms.iter().copied().collect::<Vec<_>>();
695    elapsed_ms.sort_unstable();
696    let query_embed_p50_ms = elapsed_ms
697        .get(elapsed_ms.len().saturating_sub(1) / 2)
698        .copied()
699        .unwrap_or(0);
700    QueryEmbedHealthSnapshot {
701        query_embed_timeouts: state.timeouts,
702        query_embed_p50_ms,
703    }
704}
705
706#[cfg(test)]
707pub(crate) fn record_query_embed_observation_for_test(
708    root: &Path,
709    elapsed_ms: u64,
710    timed_out: bool,
711) {
712    record_query_embed_observation(Some(root), elapsed_ms, timed_out);
713}
714
715enum SemanticEmbeddingEngine {
716    /// Local ONNX embedder (all-MiniLM-L6-v2 via raw `ort`). The config-facing
717    /// backend string stays "fastembed" for index-fingerprint compatibility.
718    Local(LocalEmbeddingEngine),
719    OpenAiCompatible {
720        client: Client,
721        model: String,
722        base_url: String,
723        api_key: Option<String>,
724    },
725    Ollama {
726        client: Client,
727        model: String,
728        base_url: String,
729    },
730    Synapse(SynapseEmbeddingClient),
731}
732
733pub struct SemanticEmbeddingModel {
734    backend: SemanticBackend,
735    model: String,
736    base_url: Option<String>,
737    timeout_ms: u64,
738    max_batch_size: usize,
739    adaptive_build_batch_size: usize,
740    successful_build_batches_at_size: usize,
741    per_item_ema_ms: Option<f64>,
742    dimension: Option<usize>,
743    engine: SemanticEmbeddingEngine,
744    query_embedding_cache: Arc<Mutex<QueryEmbeddingCache>>,
745    local_query_last_ok_log: Option<Instant>,
746    query_instruction: Option<String>,
747    query_instruction_logged: bool,
748    query_instruction_root: Option<PathBuf>,
749}
750
751pub type EmbeddingModel = SemanticEmbeddingModel;
752
753/// Count-only half of [`validate_embedding_batch`]: the build path allows an
754/// empty vector for a row the backend rejected, so it validates dimensions per
755/// row itself and shares only this shape check.
756fn validate_embedding_batch_count(
757    vectors: &[Vec<f32>],
758    expected_count: usize,
759    context: &str,
760) -> Result<(), String> {
761    if expected_count > 0 && vectors.is_empty() {
762        return Err(format!(
763            "{context} returned no vectors for {expected_count} inputs"
764        ));
765    }
766    if vectors.len() != expected_count {
767        return Err(format!(
768            "{context} returned {} vectors for {} inputs",
769            vectors.len(),
770            expected_count
771        ));
772    }
773    Ok(())
774}
775
776fn validate_embedding_batch(
777    vectors: &[Vec<f32>],
778    expected_count: usize,
779    context: &str,
780) -> Result<(), String> {
781    if expected_count > 0 && vectors.is_empty() {
782        return Err(format!(
783            "{context} returned no vectors for {expected_count} inputs"
784        ));
785    }
786
787    if vectors.len() != expected_count {
788        return Err(format!(
789            "{context} returned {} vectors for {} inputs",
790            vectors.len(),
791            expected_count
792        ));
793    }
794
795    let Some(first_vector) = vectors.first() else {
796        return Ok(());
797    };
798    let expected_dimension = first_vector.len();
799    validate_embedding_dimension(expected_dimension)
800        .map_err(|error| format!("{context} returned {error}"))?;
801    for (index, vector) in vectors.iter().enumerate() {
802        if vector.len() != expected_dimension {
803            return Err(format!(
804                "{context} returned inconsistent embedding dimensions: vector 0 has length {expected_dimension}, vector {index} has length {}",
805                vector.len()
806            ));
807        }
808    }
809
810    Ok(())
811}
812
813fn validate_embedding_dimension(dimension: usize) -> Result<(), String> {
814    if dimension == 0 || dimension > MAX_DIMENSION {
815        return Err(format!(
816            "invalid embedding dimension: {dimension}; supported range is 1..={MAX_DIMENSION}"
817        ));
818    }
819
820    Ok(())
821}
822
823/// Normalize a base URL: validate scheme and strip trailing slash.
824/// Does NOT perform SSRF/private-IP validation — call
825/// `validate_base_url_no_ssrf` separately when processing user-supplied config.
826fn normalize_base_url(raw: &str) -> Result<String, String> {
827    let parsed = Url::parse(raw).map_err(|error| format!("invalid base_url '{raw}': {error}"))?;
828    let scheme = parsed.scheme();
829    if scheme != "http" && scheme != "https" {
830        return Err(format!(
831            "unsupported URL scheme '{}' — only http:// and https:// are allowed",
832            scheme
833        ));
834    }
835    Ok(parsed.to_string().trim_end_matches('/').to_string())
836}
837
838/// Validate that a base URL does not point to a private/loopback address.
839/// Call this on user-supplied config (at configure time) to prevent SSRF.
840/// Not called for programmatically constructed configs (e.g. tests).
841///
842/// **Loopback is allowed.** Self-hosted embedding backends (e.g. Ollama at
843/// `http://127.0.0.1:11434`) are a primary use case for `aft_search`. Loopback
844/// addresses by definition cannot be exploited as SSRF targets — they only
845/// reach services on the same machine. Allowing loopback unblocks Ollama at its
846/// default config without opening up SSRF to LAN/intranet services, which
847/// remain rejected.
848///
849/// **mDNS `.local` is rejected.** mDNS hostnames typically resolve to LAN
850/// devices (printers, homelab servers); rejecting them before DNS lookup keeps
851/// the SSRF guard meaningful for non-loopback private networks.
852pub fn validate_base_url_no_ssrf(raw: &str) -> Result<(), String> {
853    use std::net::{IpAddr, ToSocketAddrs};
854
855    let parsed = Url::parse(raw).map_err(|error| format!("invalid base_url '{raw}': {error}"))?;
856
857    let host = parsed.host_str().unwrap_or("");
858
859    // Loopback hostnames are explicitly allowed. RFC 6761 mandates that
860    // `localhost` and `*.localhost` resolve to loopback;
861    // `localhost.localdomain` is a historical alias used on some Linux
862    // distros. Self-hosted backends like Ollama use these by default.
863    let is_loopback_host =
864        host == "localhost" || host == "localhost.localdomain" || host.ends_with(".localhost");
865    if is_loopback_host {
866        return Ok(());
867    }
868
869    // mDNS hostnames are typically LAN devices, not loopback. Reject before
870    // DNS lookup so users get a clear error rather than a private-IP error.
871    if host.ends_with(".local") {
872        return Err(format!(
873            "base_url host '{host}' is an mDNS name — only loopback (localhost / 127.0.0.1) and public endpoints are allowed"
874        ));
875    }
876
877    // Resolve the hostname. Reject private/link-local/CGNAT IPs but NOT
878    // loopback (which is by definition same-machine and not an SSRF target).
879    let port = parsed.port_or_known_default().unwrap_or(443);
880    let addr_str = format!("{host}:{port}");
881    let addrs: Vec<IpAddr> = addr_str
882        .to_socket_addrs()
883        .map(|iter| iter.map(|sa| sa.ip()).collect())
884        .unwrap_or_default();
885    for ip in &addrs {
886        if is_private_non_loopback_ip(ip) {
887            return Err(format!(
888                "base_url '{raw}' resolves to a private/reserved IP — only loopback (127.0.0.1) and public endpoints are allowed"
889            ));
890        }
891    }
892
893    Ok(())
894}
895
896/// Returns true for IPv4/IPv6 addresses in private/link-local/CGNAT/benchmark/
897/// multicast/reserved ranges, EXCLUDING loopback (127.0.0.0/8 and ::1). Loopback
898/// is considered safe for SSRF purposes (same-machine, e.g. a local Ollama
899/// endpoint) — see [`validate_base_url_no_ssrf`] for rationale.
900///
901/// Delegates to [`crate::url_fetch::is_private_or_reserved_ip`] so there is one
902/// authoritative reserved-range list (the url_fetch copy is the maintained one;
903/// this used to be a drifting subset that missed e.g. 198.18.0.0/15 and the
904/// multicast/reserved blocks). We only re-add the loopback carve-out the
905/// url_fetch guard deliberately does not make.
906fn is_private_non_loopback_ip(ip: &std::net::IpAddr) -> bool {
907    // Canonicalize so an IPv4-mapped loopback (`::ffff:127.0.0.1`) is also
908    // recognized as loopback, matching the prior carve-out.
909    if ip.to_canonical().is_loopback() {
910        return false;
911    }
912    crate::url_fetch::is_private_or_reserved_ip(*ip)
913}
914
915fn build_openai_embeddings_endpoint(base_url: &str) -> String {
916    if base_url.ends_with("/v1") {
917        format!("{base_url}{DEFAULT_OPENAI_EMBEDDING_PATH}")
918    } else {
919        format!("{base_url}/v1{}", DEFAULT_OPENAI_EMBEDDING_PATH)
920    }
921}
922
923fn build_ollama_embeddings_endpoint(base_url: &str) -> String {
924    if base_url.ends_with("/api") {
925        format!("{base_url}/embed")
926    } else {
927        format!("{base_url}{DEFAULT_OLLAMA_EMBEDDING_PATH}")
928    }
929}
930
931fn normalize_api_key(value: Option<String>) -> Option<String> {
932    value.and_then(|token| {
933        let token = token.trim();
934        if token.is_empty() {
935            None
936        } else {
937            Some(token.to_string())
938        }
939    })
940}
941
942fn is_retryable_embedding_status(status: reqwest::StatusCode) -> bool {
943    status.is_server_error() || status == reqwest::StatusCode::TOO_MANY_REQUESTS
944}
945
946/// Local backends (LM Studio, Ollama, llama.cpp) can return a 4xx — usually
947/// 400/409 — while a model is loading or was just unloaded. Only narrowly known
948/// local-backend loading/unloaded payloads are classified transient; generic
949/// 4xx bodies that merely mention phrases like "loading model" remain
950/// permanent so misconfigurations do not retry forever.
951fn embedding_response_body_is_transient(status: reqwest::StatusCode, raw: &str) -> bool {
952    if !matches!(
953        status,
954        reqwest::StatusCode::BAD_REQUEST
955            | reqwest::StatusCode::CONFLICT
956            | reqwest::StatusCode::REQUEST_TIMEOUT
957            | reqwest::StatusCode::LOCKED
958            | reqwest::StatusCode::TOO_EARLY
959    ) {
960        return false;
961    }
962
963    let lower = raw.to_ascii_lowercase();
964    let normalized = lower.trim();
965
966    normalized.contains("model was unloaded while the request was still in queue")
967        || normalized == "model is loading"
968        || normalized.starts_with("model is loading,")
969        || normalized.contains(r#""error":"model is loading"#)
970        || normalized.contains(r#""message":"model is loading"#)
971        || normalized == "model not loaded"
972        || normalized.contains(r#""error":"model not loaded""#)
973        || normalized.contains(r#""message":"model not loaded""#)
974        || normalized == "loading model into memory"
975        || normalized.contains(r#""error":"loading model into memory""#)
976        || normalized.contains(r#""message":"loading model into memory""#)
977        || normalized == "model is being loaded"
978        || normalized.contains(r#""error":"model is being loaded""#)
979        || normalized.contains(r#""message":"model is being loaded""#)
980        || normalized == "model is currently loading"
981        || normalized.contains(r#""error":"model is currently loading""#)
982        || normalized.contains(r#""message":"model is currently loading""#)
983}
984
985#[derive(Debug, Clone, Copy, PartialEq, Eq)]
986struct RowTooLongDetails {
987    limit_tokens: Option<usize>,
988    actual_tokens: Option<usize>,
989}
990
991fn json_token_count(value: &serde_json::Value, keys: &[&str]) -> Option<usize> {
992    match value {
993        serde_json::Value::Object(fields) => {
994            for (key, value) in fields {
995                if keys
996                    .iter()
997                    .any(|candidate| key.eq_ignore_ascii_case(candidate))
998                {
999                    if let Some(count) =
1000                        value.as_u64().and_then(|count| usize::try_from(count).ok())
1001                    {
1002                        return Some(count);
1003                    }
1004                }
1005            }
1006            fields
1007                .values()
1008                .find_map(|value| json_token_count(value, keys))
1009        }
1010        serde_json::Value::Array(values) => values
1011            .iter()
1012            .find_map(|value| json_token_count(value, keys)),
1013        _ => None,
1014    }
1015}
1016
1017fn number_after_phrase(value: &str, phrases: &[&str]) -> Option<usize> {
1018    phrases.iter().find_map(|phrase| {
1019        let rest = value.split_once(phrase)?.1.trim_start();
1020        let digits = rest
1021            .chars()
1022            .take_while(char::is_ascii_digit)
1023            .collect::<String>();
1024        (!digits.is_empty()).then(|| digits.parse().ok()).flatten()
1025    })
1026}
1027
1028/// Classify only known context-window rejections. Generic 4xx responses remain
1029/// permanent errors so authentication and model configuration failures abort.
1030fn embedding_response_row_too_long(
1031    status: reqwest::StatusCode,
1032    raw: &str,
1033) -> Option<RowTooLongDetails> {
1034    if !status.is_client_error() {
1035        return None;
1036    }
1037
1038    let lower = raw.to_ascii_lowercase();
1039    let known_overflow = lower.contains("exceed_context_size_error")
1040        || lower.contains("input is too large to process")
1041        || lower.contains("maximum context length is")
1042        || lower.contains("this model's maximum context length")
1043        || lower.contains("input length exceeds");
1044    if !known_overflow {
1045        return None;
1046    }
1047
1048    let parsed = serde_json::from_str::<serde_json::Value>(raw).ok();
1049    let limit_tokens = parsed
1050        .as_ref()
1051        .and_then(|value| json_token_count(value, &["n_ctx", "max_context_length"]))
1052        .or_else(|| {
1053            number_after_phrase(
1054                &lower,
1055                &[
1056                    "maximum context length is ",
1057                    "this model's maximum context length is ",
1058                    "n_ctx=",
1059                    "n_ctx: ",
1060                ],
1061            )
1062        });
1063    let actual_tokens = parsed
1064        .as_ref()
1065        .and_then(|value| {
1066            json_token_count(value, &["n_prompt_tokens", "input_tokens", "prompt_tokens"])
1067        })
1068        .or_else(|| {
1069            number_after_phrase(
1070                &lower,
1071                &[
1072                    "resulted in ",
1073                    "you requested ",
1074                    "requested ",
1075                    "n_prompt_tokens=",
1076                    "n_prompt_tokens: ",
1077                ],
1078            )
1079        });
1080
1081    Some(RowTooLongDetails {
1082        limit_tokens,
1083        actual_tokens,
1084    })
1085}
1086
1087fn is_retryable_embedding_error(error: &reqwest::Error) -> bool {
1088    // Retryable == transient-at-send-stage: a backend that refused, timed
1089    // out, or died mid-exchange deserves the same in-request retry ladder.
1090    embedding_send_error_is_transient(error)
1091}
1092
1093/// Whether a send-time error means the backend is *unreachable or temporarily
1094/// failing* (vs. a real misconfiguration). Build requests retry both connection
1095/// failures and timeouts; query requests use the same classification but have a
1096/// one-attempt policy.
1097fn embedding_send_error_is_transient(error: &reqwest::Error) -> bool {
1098    // TLS trust failures are reported by reqwest as connect errors, but they
1099    // cannot recover by retrying. Check the source chain before the broad
1100    // connect/timeout classification so private-CA failures become terminal.
1101    if embedding_error_is_certificate_trust_failure(error) {
1102        return false;
1103    }
1104    if error.is_connect() || error.is_timeout() {
1105        return true;
1106    }
1107    // A connection reset/abort mid-request is the backend dying between
1108    // accept and response (local backends do this when they crash or restart
1109    // under load) — the same "temporarily failing" class as a refused
1110    // connection, just later in the exchange. reqwest surfaces it as a plain
1111    // send error. Classify from the io source chain where one exists; hyper
1112    // errors like IncompleteMessage ("connection closed before message
1113    // completed") and UnexpectedMessage ("received unexpected message from
1114    // connection" — the peer wrote a partial reply and closed while the
1115    // request was still being sent, observed on Windows CI where the socket
1116    // closes with unread request bytes) carry no io source, so fall back to
1117    // known phrases in the chain's rendered messages.
1118    let mut source = std::error::Error::source(error);
1119    while let Some(inner) = source {
1120        if let Some(io) = inner.downcast_ref::<std::io::Error>() {
1121            if matches!(
1122                io.kind(),
1123                std::io::ErrorKind::ConnectionReset
1124                    | std::io::ErrorKind::ConnectionAborted
1125                    | std::io::ErrorKind::BrokenPipe
1126                    | std::io::ErrorKind::UnexpectedEof
1127            ) {
1128                return true;
1129            }
1130        }
1131        let rendered = inner.to_string().to_ascii_lowercase();
1132        if rendered.contains("connection reset")
1133            || rendered.contains("connection aborted")
1134            || rendered.contains("connection closed")
1135            || rendered.contains("broken pipe")
1136            || rendered.contains("unexpected end of file")
1137            || rendered.contains("unexpected message from connection")
1138        {
1139            return true;
1140        }
1141        source = std::error::Error::source(inner);
1142    }
1143    false
1144}
1145
1146fn render_error_source_chain(error: &dyn Error) -> String {
1147    let mut rendered = error.to_string();
1148    let mut source = error.source();
1149    while let Some(cause) = source {
1150        rendered.push_str(": ");
1151        rendered.push_str(&cause.to_string());
1152        source = cause.source();
1153    }
1154    rendered
1155}
1156
1157fn embedding_error_is_certificate_trust_failure(error: &reqwest::Error) -> bool {
1158    let rendered = render_error_source_chain(error).to_ascii_lowercase();
1159    [
1160        "unknownissuer",
1161        "unknown issuer",
1162        "invalid peer certificate",
1163        "certificate verify failed",
1164        "certificate validation failed",
1165        "certificate error",
1166    ]
1167    .iter()
1168    .any(|marker| rendered.contains(marker))
1169}
1170
1171fn embedding_response_read_error_is_transient(error: &reqwest::Error) -> bool {
1172    embedding_send_error_is_transient(error) || error.is_body() || error.is_decode()
1173}
1174
1175/// Returns the query-timeout marker for a request error when the active policy
1176/// is a `Query(budget)` and reqwest classifies the error as a timeout. Returns
1177/// an empty string otherwise — build-policy timeouts and non-timeout query
1178/// errors carry no marker. This is the single site that decides whether a
1179/// failure is "the configured query budget fired", so the fallback message can
1180/// name the knob (`semantic.query_timeout_ms`) without re-parsing reqwest text.
1181fn query_timeout_marker_for_error(
1182    error: &reqwest::Error,
1183    policy: EmbeddingRequestPolicy,
1184) -> String {
1185    match policy {
1186        EmbeddingRequestPolicy::Query(budget) if error.is_timeout() => {
1187            query_embedding_timeout_marker(budget.timeout_ms)
1188        }
1189        _ => String::new(),
1190    }
1191}
1192
1193/// Stable machine marker prefixed onto embedding error strings whose root cause
1194/// is transient — the backend is down, timing out, or returning 5xx/429, not
1195/// misconfigured. The build and corpus-refresh layers key retry-vs-give-up on
1196/// this marker (see [`embedding_failure_is_transient`]) instead of re-parsing
1197/// error text, so transience stays authoritative at the one site that knows it.
1198/// Stripped before any user-facing display via [`strip_transient_embedding_marker`].
1199pub const TRANSIENT_EMBEDDING_MARKER: &str = "[transient] ";
1200
1201/// True when an embedding error carries the transient marker — i.e. retrying
1202/// once the backend recovers is the right move, not surfacing a hard failure.
1203pub fn embedding_failure_is_transient(error: &str) -> bool {
1204    error.contains(TRANSIENT_EMBEDDING_MARKER)
1205}
1206
1207/// Remove the machine transient marker so the message is clean for display.
1208pub fn strip_transient_embedding_marker(error: &str) -> String {
1209    error.replace(TRANSIENT_EMBEDDING_MARKER, "")
1210}
1211
1212#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1213struct BuildTimeoutDetails {
1214    batch_size: usize,
1215    deadline_ms: u64,
1216    attempts: usize,
1217}
1218
1219fn build_embedding_timeout_marker(details: BuildTimeoutDetails) -> String {
1220    format!(
1221        "{BUILD_EMBEDDING_TIMEOUT_MARKER_PREFIX}{}:{}:{}{BUILD_EMBEDDING_TIMEOUT_MARKER_SUFFIX}",
1222        details.batch_size, details.deadline_ms, details.attempts
1223    )
1224}
1225
1226fn build_embedding_timeout_details(error: &str) -> Option<BuildTimeoutDetails> {
1227    let start = error.find(BUILD_EMBEDDING_TIMEOUT_MARKER_PREFIX)?
1228        + BUILD_EMBEDDING_TIMEOUT_MARKER_PREFIX.len();
1229    let end = error[start..].find(BUILD_EMBEDDING_TIMEOUT_MARKER_SUFFIX)? + start;
1230    let mut fields = error[start..end].split(':');
1231    let details = BuildTimeoutDetails {
1232        batch_size: fields.next()?.parse().ok()?,
1233        deadline_ms: fields.next()?.parse().ok()?,
1234        attempts: fields.next()?.parse().ok()?,
1235    };
1236    fields.next().is_none().then_some(details)
1237}
1238
1239fn row_too_long_marker(details: RowTooLongDetails) -> String {
1240    format!(
1241        "{ROW_TOO_LONG_MARKER_PREFIX}{}:{}{ROW_TOO_LONG_MARKER_SUFFIX}",
1242        details.limit_tokens.unwrap_or(0),
1243        details.actual_tokens.unwrap_or(0),
1244    )
1245}
1246
1247fn row_too_long_details(error: &str) -> Option<RowTooLongDetails> {
1248    let start = error.find(ROW_TOO_LONG_MARKER_PREFIX)? + ROW_TOO_LONG_MARKER_PREFIX.len();
1249    let end = error[start..].find(ROW_TOO_LONG_MARKER_SUFFIX)? + start;
1250    let mut fields = error[start..end].split(':');
1251    let limit_tokens = fields.next()?.parse::<usize>().ok()?;
1252    let actual_tokens = fields.next()?.parse::<usize>().ok()?;
1253    if fields.next().is_some() {
1254        return None;
1255    }
1256    Some(RowTooLongDetails {
1257        limit_tokens: (limit_tokens > 0).then_some(limit_tokens),
1258        actual_tokens: (actual_tokens > 0).then_some(actual_tokens),
1259    })
1260}
1261
1262fn strip_row_too_long_marker(error: &str) -> String {
1263    let Some(details) = row_too_long_details(error) else {
1264        return error.to_string();
1265    };
1266    error.replace(&row_too_long_marker(details), "")
1267}
1268
1269/// Remove only body text; the identifying name/file/kind/signature prefix is
1270/// retained so a shortened embedding remains attributable to its source row.
1271fn shrink_embed_text(text: &str, details: RowTooLongDetails) -> Option<String> {
1272    let body_marker = " body:";
1273    let body_start = text.find(body_marker)?;
1274    let header = &text[..body_start];
1275    let body = &text[body_start + body_marker.len()..];
1276    if body.is_empty() {
1277        return None;
1278    }
1279
1280    let ratio_target = details
1281        .limit_tokens
1282        .zip(details.actual_tokens)
1283        .filter(|(_, actual)| *actual > 0)
1284        .map(|(limit, actual)| {
1285            (body.len() as f64 * limit as f64 / actual as f64 * ROW_SHRINK_RATIO_MARGIN) as usize
1286        });
1287    let target_bytes = ratio_target
1288        .unwrap_or_else(|| body.len() / 2)
1289        .min(body.len().saturating_sub(1));
1290    if target_bytes == 0 {
1291        return Some(header.to_string());
1292    }
1293
1294    let shortened_body = &body[..body.floor_char_boundary(target_bytes)];
1295    if shortened_body.is_empty() {
1296        Some(header.to_string())
1297    } else {
1298        Some(format!("{header}{body_marker}{shortened_body}"))
1299    }
1300}
1301
1302/// Stable machine marker prefixed onto a *query* embedding error string when
1303/// the failure was a request timeout — i.e. reqwest's `is_timeout()` fired
1304/// while running under a `Query(budget)` policy. The marker carries the budget
1305/// that fired (`[query-timeout:{ms}]`) so the consumer can name both the
1306/// mechanism and the knob (`semantic.query_timeout_ms`) without re-parsing
1307/// reqwest's rendered error text, which varies by backend and locale.
1308///
1309/// Classification lives here — next to the one site that knows both the policy
1310/// (Query with a budget) and the typed reqwest error — so it cannot drift from
1311/// the error shape. Stripped before user-facing display via
1312/// [`strip_query_embedding_timeout_marker`]; the budget is recovered via
1313/// [`query_embedding_timeout_budget`].
1314pub const QUERY_EMBEDDING_TIMEOUT_MARKER_PREFIX: &str = "[query-timeout:";
1315pub const QUERY_EMBEDDING_TIMEOUT_MARKER_SUFFIX: &str = "]";
1316
1317/// Build the timeout marker for a given query budget. Kept here so the format
1318/// and the parser below stay in lockstep. `pub(crate)` so the classification
1319/// test in `semantic_search` can construct a marked error without duplicating
1320/// the format string.
1321pub(crate) fn query_embedding_timeout_marker(timeout_ms: u64) -> String {
1322    format!("{QUERY_EMBEDDING_TIMEOUT_MARKER_PREFIX}{timeout_ms}{QUERY_EMBEDDING_TIMEOUT_MARKER_SUFFIX}")
1323}
1324
1325/// Recover the timeout budget (ms) a query embedding error carries, or `None`
1326/// when the failure was not a query timeout. This is the single authoritative
1327/// way to detect the timeout case — never substring-match on reqwest's text.
1328pub fn query_embedding_timeout_budget(error: &str) -> Option<u64> {
1329    let start = error.find(QUERY_EMBEDDING_TIMEOUT_MARKER_PREFIX)?;
1330    let rest = &error[start + QUERY_EMBEDDING_TIMEOUT_MARKER_PREFIX.len()..];
1331    let end = rest.find(QUERY_EMBEDDING_TIMEOUT_MARKER_SUFFIX)?;
1332    rest[..end].parse::<u64>().ok()
1333}
1334
1335/// Remove the query-timeout marker so the message is clean for display. The
1336/// budget is recovered separately via [`query_embedding_timeout_budget`] before
1337/// stripping.
1338pub fn strip_query_embedding_timeout_marker(error: &str) -> String {
1339    if let (Some(start), Some(budget)) = (
1340        error.find(QUERY_EMBEDDING_TIMEOUT_MARKER_PREFIX),
1341        query_embedding_timeout_budget(error),
1342    ) {
1343        let marker = query_embedding_timeout_marker(budget);
1344        let end = start + marker.len();
1345        let mut cleaned = error.to_string();
1346        cleaned.replace_range(start..end, "");
1347        cleaned
1348    } else {
1349        error.to_string()
1350    }
1351}
1352
1353const QUERY_EMBEDDING_BUSY_MARKER: &str = "[query-embed-busy]";
1354
1355pub(crate) fn query_embedding_is_busy(error: &str) -> bool {
1356    error.contains(QUERY_EMBEDDING_BUSY_MARKER)
1357}
1358
1359pub(crate) fn strip_query_embedding_busy_marker(error: &str) -> String {
1360    error.replace(QUERY_EMBEDDING_BUSY_MARKER, "")
1361}
1362
1363fn sleep_before_embedding_retry(attempt_index: usize) {
1364    if let Some(delay_ms) = EMBEDDING_REQUEST_BACKOFF_MS.get(attempt_index) {
1365        std::thread::sleep(Duration::from_millis(*delay_ms));
1366    }
1367}
1368
1369const QUERY_EMBEDDING_CANCELLED_MARKER: &str = "__AFT_QUERY_EMBEDDING_CANCELLED__";
1370const QUERY_EMBEDDING_CANCEL_POLL: Duration = Duration::from_millis(10);
1371
1372enum EmbeddingExchange {
1373    SendFailed(reqwest::Error),
1374    Response {
1375        status: reqwest::StatusCode,
1376        body: Result<String, reqwest::Error>,
1377    },
1378}
1379
1380fn execute_embedding_exchange(request: reqwest::blocking::RequestBuilder) -> EmbeddingExchange {
1381    match request.send() {
1382        Ok(response) => EmbeddingExchange::Response {
1383            status: response.status(),
1384            body: response.text(),
1385        },
1386        Err(error) => EmbeddingExchange::SendFailed(error),
1387    }
1388}
1389
1390fn execute_query_embedding_exchange(
1391    request: reqwest::blocking::RequestBuilder,
1392) -> Result<EmbeddingExchange, String> {
1393    let Some(cancellation) = crate::executor::current_job_cancellation() else {
1394        return Ok(execute_embedding_exchange(request));
1395    };
1396    if cancellation.cancel_requested_before_commit() {
1397        return Err(QUERY_EMBEDDING_CANCELLED_MARKER.to_string());
1398    }
1399
1400    let (tx, rx) = crossbeam_channel::bounded(1);
1401    std::thread::spawn(move || {
1402        let _ = tx.send(execute_embedding_exchange(request));
1403    });
1404    loop {
1405        match rx.try_recv() {
1406            Ok(exchange) => {
1407                if cancellation.cancel_requested_before_commit() {
1408                    return Err(QUERY_EMBEDDING_CANCELLED_MARKER.to_string());
1409                }
1410                return Ok(exchange);
1411            }
1412            Err(crossbeam_channel::TryRecvError::Disconnected) => {
1413                return Err("embedding request worker disconnected".to_string());
1414            }
1415            Err(crossbeam_channel::TryRecvError::Empty) => {}
1416        }
1417        if cancellation.wait_for_cancellation(QUERY_EMBEDDING_CANCEL_POLL) {
1418            return Err(QUERY_EMBEDDING_CANCELLED_MARKER.to_string());
1419        }
1420    }
1421}
1422
1423fn send_embedding_request<F>(
1424    mut make_request: F,
1425    backend_label: &str,
1426    policy: EmbeddingRequestPolicy,
1427) -> Result<String, String>
1428where
1429    F: FnMut() -> reqwest::blocking::RequestBuilder,
1430{
1431    let max_attempts = policy.max_attempts();
1432    for attempt_index in 0..max_attempts {
1433        let last_attempt = attempt_index + 1 == max_attempts;
1434        let request = make_request().timeout(policy.request_timeout());
1435
1436        let exchange = match policy {
1437            EmbeddingRequestPolicy::Build(_) => execute_embedding_exchange(request),
1438            EmbeddingRequestPolicy::Query(_) => execute_query_embedding_exchange(request)?,
1439        };
1440        let (status, raw) = match exchange {
1441            EmbeddingExchange::SendFailed(error) => {
1442                if let EmbeddingRequestPolicy::Build(budget) = policy {
1443                    if error.is_timeout() {
1444                        let details = BuildTimeoutDetails {
1445                            batch_size: budget.batch_size,
1446                            deadline_ms: budget.deadline_ms,
1447                            attempts: attempt_index + 1,
1448                        };
1449                        return Err(format!(
1450                            "{TRANSIENT_EMBEDDING_MARKER}{}{} request timed out: {}",
1451                            build_embedding_timeout_marker(details),
1452                            backend_label,
1453                            render_error_source_chain(&error),
1454                        ));
1455                    }
1456                }
1457                // A refused connection is already conclusive unreachable evidence;
1458                // retrying the same socket target only delays the circuit breaker.
1459                if error.is_connect() && embedding_send_error_is_transient(&error) {
1460                    return Err(format!(
1461                        "{TRANSIENT_EMBEDDING_MARKER}embedding backend unreachable (connection refused or connect failure): {}",
1462                        render_error_source_chain(&error),
1463                    ));
1464                }
1465                if !last_attempt && is_retryable_embedding_error(&error) {
1466                    sleep_before_embedding_retry(attempt_index);
1467                    continue;
1468                }
1469                let marker = if embedding_send_error_is_transient(&error) {
1470                    TRANSIENT_EMBEDDING_MARKER
1471                } else {
1472                    ""
1473                };
1474                // A query-timeout is a distinct, actionable failure: the
1475                // configured `semantic.query_timeout_ms` budget fired. Tag it
1476                // here — the only site that has both the typed reqwest error
1477                // and the Query budget — so the fallback can name the knob
1478                // without guessing at reqwest's rendered text.
1479                let timeout_marker = query_timeout_marker_for_error(&error, policy);
1480                return Err(format!(
1481                    "{timeout_marker}{marker}{backend_label} request failed: {}",
1482                    render_error_source_chain(&error)
1483                ));
1484            }
1485            EmbeddingExchange::Response {
1486                status,
1487                body: Ok(raw),
1488            } => (status, raw),
1489            EmbeddingExchange::Response {
1490                status: _,
1491                body: Err(error),
1492            } => {
1493                if let EmbeddingRequestPolicy::Build(budget) = policy {
1494                    if error.is_timeout() {
1495                        let details = BuildTimeoutDetails {
1496                            batch_size: budget.batch_size,
1497                            deadline_ms: budget.deadline_ms,
1498                            attempts: attempt_index + 1,
1499                        };
1500                        return Err(format!(
1501                            "{TRANSIENT_EMBEDDING_MARKER}{}{} response timed out: {}",
1502                            build_embedding_timeout_marker(details),
1503                            backend_label,
1504                            render_error_source_chain(&error),
1505                        ));
1506                    }
1507                }
1508                if !last_attempt && embedding_response_read_error_is_transient(&error) {
1509                    sleep_before_embedding_retry(attempt_index);
1510                    continue;
1511                }
1512                let marker = if embedding_response_read_error_is_transient(&error) {
1513                    TRANSIENT_EMBEDDING_MARKER
1514                } else {
1515                    ""
1516                };
1517                // A body-read timeout under a Query policy is the same budget
1518                // firing mid-exchange; tag it identically to the send case.
1519                let timeout_marker = query_timeout_marker_for_error(&error, policy);
1520                return Err(format!(
1521                    "{timeout_marker}{marker}{backend_label} response read failed: {}",
1522                    render_error_source_chain(&error)
1523                ));
1524            }
1525        };
1526
1527        if status.is_success() {
1528            return Ok(raw);
1529        }
1530
1531        if let Some(details) = embedding_response_row_too_long(status, &raw) {
1532            return Err(format!(
1533                "{}{} request failed (HTTP {}): {}",
1534                row_too_long_marker(details),
1535                backend_label,
1536                status,
1537                raw,
1538            ));
1539        }
1540
1541        // A 4xx whose body says the model is loading/unloaded is transient on
1542        // local backends (LM Studio/Ollama), so treat it like a retryable
1543        // status: ride it out at both the in-request and build-retry layers.
1544        let body_transient = embedding_response_body_is_transient(status, &raw);
1545        if !last_attempt && (is_retryable_embedding_status(status) || body_transient) {
1546            sleep_before_embedding_retry(attempt_index);
1547            continue;
1548        }
1549
1550        // 5xx / 429 are server-side and transient — the backend is overloaded
1551        // or briefly unavailable, not misconfigured. A 4xx whose body indicates
1552        // the model is (un)loading is also transient (local backend mid-swap).
1553        // Other 4xx (auth, bad request, model-not-found) is a real error the
1554        // user must fix; no marker.
1555        let marker = if is_retryable_embedding_status(status) || body_transient {
1556            TRANSIENT_EMBEDDING_MARKER
1557        } else {
1558            ""
1559        };
1560        return Err(format!(
1561            "{marker}{backend_label} request failed (HTTP {}): {}",
1562            status, raw
1563        ));
1564    }
1565
1566    unreachable!("embedding request retries exhausted without returning")
1567}
1568
1569fn configured_embedding_timeout_ms(config: &SemanticBackendConfig) -> u64 {
1570    if config.timeout_ms == 0 {
1571        DEFAULT_OPENAI_EMBEDDING_TIMEOUT_MS
1572    } else {
1573        config.timeout_ms
1574    }
1575}
1576
1577fn query_embedding_text(query: &str, instruction: Option<&str>) -> String {
1578    match instruction {
1579        Some(task) => format!("Instruct: {task}\nQuery: {query}"),
1580        None => query.to_string(),
1581    }
1582}
1583
1584impl SemanticEmbeddingModel {
1585    pub fn from_config(config: &SemanticBackendConfig) -> Result<Self, String> {
1586        Self::from_config_with_timeout_ms(config, configured_embedding_timeout_ms(config))
1587    }
1588
1589    pub fn from_config_for_query(config: &SemanticBackendConfig) -> Result<Self, String> {
1590        // The model may later be reused by a background build, so retain the build
1591        // client's timeout. QueryBudget overrides each interactive HTTP request.
1592        Self::from_config(config)
1593    }
1594
1595    fn from_config_with_timeout_ms(
1596        config: &SemanticBackendConfig,
1597        timeout_ms: u64,
1598    ) -> Result<Self, String> {
1599        let max_batch_size = if config.max_batch_size == 0 {
1600            DEFAULT_MAX_BATCH_SIZE
1601        } else {
1602            config.max_batch_size
1603        };
1604
1605        let api_key_env = normalize_api_key(config.api_key_env.clone());
1606        let model = config.model.clone();
1607
1608        let query_embedding_cache = Arc::new(Mutex::new(QueryEmbeddingCache::default()));
1609        let tls_config = crate::platform_tls::client_config()
1610            .map_err(|error| format!("failed to configure embedding client TLS: {error}"))?;
1611        let client = Client::builder()
1612            .timeout(Duration::from_millis(timeout_ms))
1613            .redirect(reqwest::redirect::Policy::none())
1614            .use_preconfigured_tls(tls_config)
1615            .build()
1616            .map_err(|error| format!("failed to configure embedding client: {error}"))?;
1617
1618        let engine = match config.backend {
1619            SemanticBackend::Fastembed => {
1620                SemanticEmbeddingEngine::Local(LocalEmbeddingEngine::new(
1621                    Box::new(LocalEmbedder::new(&model)?),
1622                    Arc::clone(&query_embedding_cache),
1623                )?)
1624            }
1625            SemanticBackend::OpenAiCompatible => {
1626                let raw = config.base_url.as_ref().ok_or_else(|| {
1627                    "base_url is required for openai_compatible backend".to_string()
1628                })?;
1629                let base_url = normalize_base_url(raw)?;
1630
1631                let api_key = match api_key_env {
1632                    Some(var_name) => Some(env::var(&var_name).map_err(|_| {
1633                        format!("missing api_key_env '{var_name}' for openai_compatible backend")
1634                    })?),
1635                    None => None,
1636                };
1637
1638                SemanticEmbeddingEngine::OpenAiCompatible {
1639                    client,
1640                    model,
1641                    base_url,
1642                    api_key,
1643                }
1644            }
1645            SemanticBackend::Ollama => {
1646                let raw = config
1647                    .base_url
1648                    .as_ref()
1649                    .ok_or_else(|| "base_url is required for ollama backend".to_string())?;
1650                let base_url = normalize_base_url(raw)?;
1651
1652                SemanticEmbeddingEngine::Ollama {
1653                    client,
1654                    model,
1655                    base_url,
1656                }
1657            }
1658            SemanticBackend::Synapse => SemanticEmbeddingEngine::Synapse(
1659                SynapseEmbeddingClient::from_config(config).map_err(|error| error.to_string())?,
1660            ),
1661        };
1662        let max_batch_size = match &engine {
1663            SemanticEmbeddingEngine::Synapse(client) => client.metadata().recommended_rows,
1664            _ => max_batch_size,
1665        };
1666
1667        Ok(Self {
1668            backend: config.backend,
1669            model: config.model.clone(),
1670            base_url: config.base_url.clone(),
1671            timeout_ms,
1672            max_batch_size,
1673            adaptive_build_batch_size: max_batch_size,
1674            successful_build_batches_at_size: 0,
1675            per_item_ema_ms: None,
1676            dimension: None,
1677            engine,
1678            query_embedding_cache,
1679            local_query_last_ok_log: None,
1680            query_instruction: config.resolved_query_instruction().map(str::to_string),
1681            query_instruction_logged: false,
1682            query_instruction_root: config.route_project_root.clone(),
1683        })
1684    }
1685
1686    #[cfg(test)]
1687    pub(crate) fn from_local_provider_for_test(
1688        provider: Box<dyn LocalEmbeddingProvider>,
1689        project_root: PathBuf,
1690    ) -> Self {
1691        let query_embedding_cache = Arc::new(Mutex::new(QueryEmbeddingCache::default()));
1692        let engine = SemanticEmbeddingEngine::Local(
1693            LocalEmbeddingEngine::new(provider, Arc::clone(&query_embedding_cache))
1694                .expect("start test local query embed worker"),
1695        );
1696        Self {
1697            backend: SemanticBackend::Fastembed,
1698            model: "test-local".to_string(),
1699            base_url: None,
1700            timeout_ms: DEFAULT_OPENAI_EMBEDDING_TIMEOUT_MS,
1701            max_batch_size: DEFAULT_MAX_BATCH_SIZE,
1702            adaptive_build_batch_size: DEFAULT_MAX_BATCH_SIZE,
1703            successful_build_batches_at_size: 0,
1704            per_item_ema_ms: None,
1705            dimension: None,
1706            engine,
1707            query_embedding_cache,
1708            local_query_last_ok_log: None,
1709            query_instruction: None,
1710            query_instruction_logged: false,
1711            query_instruction_root: Some(project_root),
1712        }
1713    }
1714
1715    pub fn backend(&self) -> SemanticBackend {
1716        self.backend
1717    }
1718
1719    pub fn model(&self) -> &str {
1720        &self.model
1721    }
1722
1723    pub fn base_url(&self) -> Option<&str> {
1724        self.base_url.as_deref()
1725    }
1726
1727    pub fn max_batch_size(&self) -> usize {
1728        self.max_batch_size
1729    }
1730
1731    pub fn timeout_ms(&self) -> u64 {
1732        self.timeout_ms
1733    }
1734
1735    pub fn fingerprint(
1736        &mut self,
1737        config: &SemanticBackendConfig,
1738    ) -> Result<SemanticIndexFingerprint, String> {
1739        let dimension = self.dimension()?;
1740        let mut fingerprint = SemanticIndexFingerprint::from_config(config, dimension);
1741        if let SemanticEmbeddingEngine::Synapse(client) = &self.engine {
1742            let identity = client.identity();
1743            fingerprint.synapse_fingerprint = Some(identity.fingerprint.clone());
1744            fingerprint.synapse_table_epoch = Some(identity.table_epoch);
1745            fingerprint.synapse_equivalent_to = identity.equivalent_to.clone();
1746        }
1747        Ok(fingerprint)
1748    }
1749
1750    fn uses_http_embedding_backend(&self) -> bool {
1751        matches!(
1752            &self.engine,
1753            SemanticEmbeddingEngine::OpenAiCompatible { .. }
1754                | SemanticEmbeddingEngine::Ollama { .. }
1755        )
1756    }
1757
1758    fn build_request_deadline_ms(&self, batch_size: usize) -> u64 {
1759        let batch_size = batch_size.max(1);
1760        match self.per_item_ema_ms {
1761            Some(per_item_ms) => {
1762                let scaled =
1763                    (per_item_ms * batch_size as f64 * BUILD_PER_ITEM_SAFETY_FACTOR).ceil();
1764                let scaled = if scaled.is_finite() {
1765                    scaled.min(u64::MAX as f64) as u64
1766                } else {
1767                    u64::MAX
1768                };
1769                self.timeout_ms.max(scaled)
1770            }
1771            None => self.timeout_ms.max(
1772                self.timeout_ms
1773                    .saturating_mul(batch_size as u64)
1774                    .div_ceil(BUILD_INITIAL_BATCH_DIVISOR),
1775            ),
1776        }
1777    }
1778
1779    fn build_request_budget(&self, batch_size: usize) -> BuildRequestBudget {
1780        BuildRequestBudget {
1781            batch_size,
1782            deadline_ms: self.build_request_deadline_ms(batch_size),
1783        }
1784    }
1785
1786    fn note_successful_build_batch(&mut self, batch_size: usize, elapsed: Duration) {
1787        let measured_per_item_ms = elapsed.as_secs_f64() * 1_000.0 / batch_size.max(1) as f64;
1788        self.per_item_ema_ms = Some(match self.per_item_ema_ms {
1789            Some(previous) => {
1790                previous * (1.0 - BUILD_PER_ITEM_EMA_ALPHA)
1791                    + measured_per_item_ms * BUILD_PER_ITEM_EMA_ALPHA
1792            }
1793            None => measured_per_item_ms,
1794        });
1795
1796        if batch_size != self.adaptive_build_batch_size {
1797            return;
1798        }
1799        self.successful_build_batches_at_size =
1800            self.successful_build_batches_at_size.saturating_add(1);
1801        if self.successful_build_batches_at_size < BUILD_BATCH_GROWTH_SUCCESSES
1802            || self.adaptive_build_batch_size >= self.max_batch_size
1803        {
1804            return;
1805        }
1806
1807        let old_size = self.adaptive_build_batch_size;
1808        self.adaptive_build_batch_size = old_size.saturating_mul(2).min(self.max_batch_size);
1809        self.successful_build_batches_at_size = 0;
1810        slog_info!(
1811            "semantic embed batch size {} -> {} after successful batches (per_item_ms={:.0})",
1812            old_size,
1813            self.adaptive_build_batch_size,
1814            self.per_item_ema_ms.unwrap_or(measured_per_item_ms),
1815        );
1816    }
1817
1818    fn embed_http_batch_overflow_resilient(
1819        &mut self,
1820        texts: Vec<String>,
1821    ) -> Result<Vec<AdaptiveBuildRow>, String> {
1822        let budget = self.build_request_budget(texts.len());
1823        match self.embed_texts(texts.clone(), EmbeddingRequestPolicy::Build(budget)) {
1824            Ok(vectors) => {
1825                validate_embedding_batch(&vectors, texts.len(), "embedding backend")?;
1826                Ok(texts
1827                    .into_iter()
1828                    .zip(vectors)
1829                    .map(|(embedded_text, vector)| AdaptiveBuildRow {
1830                        metadata: BuildEmbeddingRowMetadata {
1831                            embedded_text,
1832                            skipped_reason: None,
1833                        },
1834                        vector: Some(vector),
1835                    })
1836                    .collect())
1837            }
1838            Err(error) => {
1839                let Some(details) = row_too_long_details(&error) else {
1840                    return Err(error);
1841                };
1842
1843                if texts.len() > 1 {
1844                    let right = texts.len().div_ceil(2);
1845                    let mut left_rows =
1846                        self.embed_http_batch_overflow_resilient(texts[..right].to_vec())?;
1847                    let mut right_rows =
1848                        self.embed_http_batch_overflow_resilient(texts[right..].to_vec())?;
1849                    left_rows.append(&mut right_rows);
1850                    return Ok(left_rows);
1851                }
1852
1853                let mut embedded_text = texts
1854                    .into_iter()
1855                    .next()
1856                    .expect("overflow response had at least one input");
1857                let mut latest_details = details;
1858                let mut latest_error = error;
1859                for _ in 0..MAX_ROW_SHRINK_ATTEMPTS {
1860                    let Some(shortened) = shrink_embed_text(&embedded_text, latest_details) else {
1861                        break;
1862                    };
1863                    embedded_text = shortened;
1864                    let budget = self.build_request_budget(1);
1865                    match self.embed_texts(
1866                        vec![embedded_text.clone()],
1867                        EmbeddingRequestPolicy::Build(budget),
1868                    ) {
1869                        Ok(mut vectors) => {
1870                            validate_embedding_batch(&vectors, 1, "embedding backend")?;
1871                            return Ok(vec![AdaptiveBuildRow {
1872                                metadata: BuildEmbeddingRowMetadata {
1873                                    embedded_text,
1874                                    skipped_reason: None,
1875                                },
1876                                vector: Some(vectors.remove(0)),
1877                            }]);
1878                        }
1879                        Err(error) => {
1880                            let Some(details) = row_too_long_details(&error) else {
1881                                return Err(error);
1882                            };
1883                            latest_details = details;
1884                            latest_error = error;
1885                        }
1886                    }
1887                }
1888
1889                Ok(vec![AdaptiveBuildRow {
1890                    metadata: BuildEmbeddingRowMetadata {
1891                        embedded_text,
1892                        skipped_reason: Some(strip_row_too_long_marker(&latest_error)),
1893                    },
1894                    vector: None,
1895                }])
1896            }
1897        }
1898    }
1899
1900    fn embed_build_http_adaptive(&mut self, texts: Vec<String>) -> Result<Vec<Vec<f32>>, String> {
1901        clear_http_build_metadata();
1902        let mut rows = Vec::with_capacity(texts.len());
1903        let mut cursor = 0usize;
1904
1905        while cursor < texts.len() {
1906            let batch_size = self
1907                .adaptive_build_batch_size
1908                .max(1)
1909                .min(texts.len() - cursor);
1910            let batch = texts[cursor..cursor + batch_size].to_vec();
1911            let started = Instant::now();
1912            match self.embed_http_batch_overflow_resilient(batch) {
1913                Ok(mut batch_rows) => {
1914                    self.note_successful_build_batch(batch_size, started.elapsed());
1915                    rows.append(&mut batch_rows);
1916                    cursor += batch_size;
1917                }
1918                Err(error) => {
1919                    let Some(timeout) = build_embedding_timeout_details(&error) else {
1920                        return Err(error);
1921                    };
1922                    if timeout.batch_size == 1 {
1923                        return Err(format!(
1924                            "{TRANSIENT_EMBEDDING_MARKER}single-item request timed out at {} ms: treating as down ({} attempt(s))",
1925                            self.timeout_ms, timeout.attempts,
1926                        ));
1927                    }
1928
1929                    let new_size = timeout.batch_size.div_ceil(2).max(1);
1930                    self.adaptive_build_batch_size = new_size;
1931                    self.successful_build_batches_at_size = 0;
1932                    let per_item_ms = self
1933                        .per_item_ema_ms
1934                        .unwrap_or(self.timeout_ms as f64 / BUILD_INITIAL_BATCH_DIVISOR as f64);
1935                    slog_info!(
1936                        "semantic embed batch size {} -> {} after timeout (per_item_ms={:.0}, deadline_ms={})",
1937                        timeout.batch_size,
1938                        new_size,
1939                        per_item_ms,
1940                        timeout.deadline_ms,
1941                    );
1942                }
1943            }
1944        }
1945
1946        let mut metadata = Vec::with_capacity(rows.len());
1947        let mut vectors = Vec::with_capacity(rows.len());
1948        for row in rows {
1949            metadata.push(row.metadata);
1950            vectors.push(row.vector.unwrap_or_default());
1951        }
1952        set_http_build_metadata(metadata);
1953        Ok(vectors)
1954    }
1955
1956    pub fn dimension(&mut self) -> Result<usize, String> {
1957        if let Some(dimension) = self.dimension {
1958            return Ok(dimension);
1959        }
1960
1961        let dimension = if self.uses_http_embedding_backend() {
1962            let vectors = self.embed(vec!["semantic index fingerprint probe".to_string()])?;
1963            vectors
1964                .first()
1965                .map(|v| v.len())
1966                .ok_or_else(|| "embedding backend returned no vectors".to_string())?
1967        } else {
1968            match &mut self.engine {
1969                SemanticEmbeddingEngine::Local(engine) => {
1970                    let vectors = engine
1971                        .model
1972                        .lock()
1973                        .unwrap_or_else(std::sync::PoisonError::into_inner)
1974                        .embed(&["semantic index fingerprint probe".to_string()])?;
1975                    vectors
1976                        .first()
1977                        .map(|v| v.len())
1978                        .ok_or_else(|| "embedding backend returned no vectors".to_string())?
1979                }
1980                SemanticEmbeddingEngine::Synapse(client) => client
1981                    .probe_dimension(Duration::from_millis(self.timeout_ms))
1982                    .map_err(|error| error.to_string())?,
1983                SemanticEmbeddingEngine::OpenAiCompatible { .. }
1984                | SemanticEmbeddingEngine::Ollama { .. } => {
1985                    unreachable!("HTTP backends are handled above")
1986                }
1987            }
1988        };
1989
1990        self.dimension = Some(dimension);
1991        Ok(dimension)
1992    }
1993
1994    pub fn embed(&mut self, texts: Vec<String>) -> Result<Vec<Vec<f32>>, String> {
1995        if self.uses_http_embedding_backend() {
1996            self.embed_build_http_adaptive(texts)
1997        } else {
1998            let budget = self.build_request_budget(texts.len());
1999            self.embed_texts(texts, EmbeddingRequestPolicy::Build(budget))
2000        }
2001    }
2002
2003    pub fn embed_query_cached(
2004        &mut self,
2005        query: &str,
2006        budget: QueryBudget,
2007    ) -> Result<Vec<f32>, String> {
2008        if !self.query_instruction_logged {
2009            let root = self
2010                .query_instruction_root
2011                .as_deref()
2012                .map(|path| path.display().to_string())
2013                .unwrap_or_else(|| "<unscoped>".to_string());
2014            match self.query_instruction.as_deref() {
2015                Some(instruction) => slog_info!(
2016                    "semantic query instruction for root {} model {}: {:?}",
2017                    root,
2018                    self.model,
2019                    instruction
2020                ),
2021                None => slog_info!(
2022                    "semantic query instruction for root {} model {}: none",
2023                    root,
2024                    self.model
2025                ),
2026            }
2027            self.query_instruction_logged = true;
2028        }
2029        let query_text = query_embedding_text(query, self.query_instruction.as_deref());
2030        self.embed_texts(vec![query_text], EmbeddingRequestPolicy::Query(budget))?
2031            .into_iter()
2032            .next()
2033            .ok_or_else(|| "embedding model returned no query vector".to_string())
2034    }
2035
2036    pub fn query_embedding_cache_stats(&self) -> (u64, u64, usize) {
2037        let cache = self
2038            .query_embedding_cache
2039            .lock()
2040            .unwrap_or_else(std::sync::PoisonError::into_inner);
2041        (cache.hits, cache.misses, cache.query_embedding_cache.len())
2042    }
2043
2044    fn log_local_query_embed(&mut self, elapsed: Duration, budget: QueryBudget, outcome: &str) {
2045        let elapsed_ms = elapsed.as_millis().min(u128::from(u64::MAX)) as u64;
2046        if outcome != "busy" {
2047            record_query_embed_observation(
2048                self.query_instruction_root.as_deref(),
2049                elapsed_ms,
2050                outcome == "timeout",
2051            );
2052        }
2053        let should_log = if outcome == "ok" {
2054            self.local_query_last_ok_log
2055                .is_none_or(|last| last.elapsed() >= QUERY_EMBED_OK_LOG_INTERVAL)
2056        } else {
2057            true
2058        };
2059        if !should_log {
2060            return;
2061        }
2062        if outcome == "ok" {
2063            self.local_query_last_ok_log = Some(Instant::now());
2064        }
2065        slog_info!(
2066            "semantic query embed: backend=fastembed model={} elapsed_ms={} budget_ms={} outcome={}",
2067            self.model,
2068            elapsed_ms,
2069            budget.timeout_ms,
2070            outcome,
2071        );
2072    }
2073
2074    fn embed_local_query(
2075        &mut self,
2076        texts: Vec<String>,
2077        cache_key: String,
2078        budget: QueryBudget,
2079    ) -> Result<Vec<Vec<f32>>, String> {
2080        let started = Instant::now();
2081        let receiver = match &self.engine {
2082            SemanticEmbeddingEngine::Local(engine) => {
2083                engine.query_worker.try_submit(texts, cache_key)
2084            }
2085            _ => unreachable!("local query path requires the local embedding engine"),
2086        };
2087        let Some(receiver) = receiver else {
2088            self.log_local_query_embed(started.elapsed(), budget, "busy");
2089            return Err(format!(
2090                "{QUERY_EMBEDDING_BUSY_MARKER}fastembed query embedder is busy finishing an earlier inference"
2091            ));
2092        };
2093        match receiver.recv_timeout(Duration::from_millis(budget.timeout_ms)) {
2094            Ok(result) => {
2095                self.log_local_query_embed(started.elapsed(), budget, "ok");
2096                result
2097            }
2098            Err(crossbeam_channel::RecvTimeoutError::Timeout) => {
2099                self.log_local_query_embed(started.elapsed(), budget, "timeout");
2100                Err(format!(
2101                    "{}{TRANSIENT_EMBEDDING_MARKER}fastembed query embedding timed out after {}ms",
2102                    query_embedding_timeout_marker(budget.timeout_ms),
2103                    budget.timeout_ms,
2104                ))
2105            }
2106            Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
2107                self.log_local_query_embed(started.elapsed(), budget, "busy");
2108                Err(format!(
2109                    "{QUERY_EMBEDDING_BUSY_MARKER}fastembed query embed worker disconnected"
2110                ))
2111            }
2112        }
2113    }
2114
2115    fn embed_texts(
2116        &mut self,
2117        texts: Vec<String>,
2118        policy: EmbeddingRequestPolicy,
2119    ) -> Result<Vec<Vec<f32>>, String> {
2120        let query_cache_key = match policy {
2121            EmbeddingRequestPolicy::Build(_) => None,
2122            EmbeddingRequestPolicy::Query(_) => texts.first().cloned(),
2123        };
2124        let cached_vectors = query_cache_key.as_ref().and_then(|query| {
2125            let mut cache = self
2126                .query_embedding_cache
2127                .lock()
2128                .unwrap_or_else(std::sync::PoisonError::into_inner);
2129            let vector = cache.query_embedding_cache.get(query).cloned();
2130            if vector.is_some() {
2131                cache.hits = cache.hits.saturating_add(1);
2132            } else {
2133                cache.misses = cache.misses.saturating_add(1);
2134            }
2135            vector.map(|vector| vec![vector])
2136        });
2137        let cache_hit = u64::from(cached_vectors.is_some());
2138        let requested = if query_cache_key.is_some() && cached_vectors.is_none() {
2139            texts.len() as u64
2140        } else {
2141            0
2142        };
2143        let local_query_result = if cached_vectors.is_none() {
2144            match policy {
2145                EmbeddingRequestPolicy::Query(budget)
2146                    if matches!(&self.engine, SemanticEmbeddingEngine::Local(_)) =>
2147                {
2148                    Some(
2149                        self.embed_local_query(
2150                            texts.clone(),
2151                            query_cache_key
2152                                .clone()
2153                                .expect("query policy has a cache key"),
2154                            budget,
2155                        ),
2156                    )
2157                }
2158                _ => None,
2159            }
2160        } else {
2161            None
2162        };
2163        let local_worker_busy = local_query_result.as_ref().is_some_and(|result| {
2164            result
2165                .as_ref()
2166                .is_err_and(|error| query_embedding_is_busy(error))
2167        });
2168        let live_calls =
2169            u64::from(requested > 0 && self.is_live_query_provider() && !local_worker_busy);
2170        crate::search_b2::embed_counter::record(crate::search_b2::embed_counter::EmbedCounts {
2171            requested,
2172            cache_hits: cache_hit,
2173            live_calls,
2174        });
2175        if let Some(vectors) = cached_vectors {
2176            return Ok(vectors);
2177        }
2178        if let Some(result) = local_query_result {
2179            return result;
2180        }
2181
2182        let result = match &mut self.engine {
2183            SemanticEmbeddingEngine::Local(engine) => engine
2184                .model
2185                .lock()
2186                .unwrap_or_else(std::sync::PoisonError::into_inner)
2187                .embed(&texts)
2188                .map_err(|error| format!("failed to embed batch: {error}")),
2189            SemanticEmbeddingEngine::OpenAiCompatible {
2190                client,
2191                model,
2192                base_url,
2193                api_key,
2194            } => {
2195                let expected_text_count = texts.len();
2196                let endpoint = build_openai_embeddings_endpoint(base_url);
2197                let body = serde_json::json!({
2198                    "input": texts,
2199                    "model": model,
2200                });
2201
2202                let raw = send_embedding_request(
2203                    || {
2204                        // `.json(&body)` sets Content-Type: application/json
2205                        // automatically. Do NOT add `.header("Content-Type",
2206                        // "application/json")` afterwards — RequestBuilder::header()
2207                        // calls HeaderMap::append, which produces TWO Content-Type
2208                        // headers on the wire. OpenAI's /v1/embeddings endpoint
2209                        // treats duplicate Content-Type as malformed and rejects
2210                        // the body with 400 "you must provide a model parameter"
2211                        // even when `model` is set. Verified end-to-end against
2212                        // api.openai.com. See issue #36.
2213                        let mut request = client.post(&endpoint).json(&body);
2214
2215                        if let Some(api_key) = api_key {
2216                            request = request.header("Authorization", format!("Bearer {api_key}"));
2217                        }
2218
2219                        request
2220                    },
2221                    "openai compatible",
2222                    policy,
2223                )?;
2224
2225                #[derive(Deserialize)]
2226                struct OpenAiResponse {
2227                    data: Vec<OpenAiEmbeddingResult>,
2228                }
2229
2230                #[derive(Deserialize)]
2231                struct OpenAiEmbeddingResult {
2232                    embedding: Vec<f32>,
2233                    index: Option<u32>,
2234                }
2235
2236                let parsed: OpenAiResponse = serde_json::from_str(&raw)
2237                    .map_err(|error| format!("invalid openai compatible response: {error}"))?;
2238                if parsed.data.len() != expected_text_count {
2239                    return Err(format!(
2240                        "openai compatible response returned {} embeddings for {} inputs",
2241                        parsed.data.len(),
2242                        expected_text_count
2243                    ));
2244                }
2245
2246                let mut vectors = vec![Vec::new(); parsed.data.len()];
2247                for (i, item) in parsed.data.into_iter().enumerate() {
2248                    let index = item.index.unwrap_or(i as u32) as usize;
2249                    if index >= vectors.len() {
2250                        return Err(
2251                            "openai compatible response contains invalid vector index".to_string()
2252                        );
2253                    }
2254                    vectors[index] = item.embedding;
2255                }
2256
2257                for vector in &vectors {
2258                    if vector.is_empty() {
2259                        return Err(
2260                            "openai compatible response contained missing vectors".to_string()
2261                        );
2262                    }
2263                }
2264
2265                self.dimension = vectors.first().map(Vec::len);
2266                Ok(vectors)
2267            }
2268            SemanticEmbeddingEngine::Ollama {
2269                client,
2270                model,
2271                base_url,
2272            } => {
2273                let expected_text_count = texts.len();
2274                let endpoint = build_ollama_embeddings_endpoint(base_url);
2275
2276                #[derive(Serialize)]
2277                struct OllamaPayload<'a> {
2278                    model: &'a str,
2279                    input: Vec<String>,
2280                }
2281
2282                let payload = OllamaPayload {
2283                    model,
2284                    input: texts,
2285                };
2286
2287                let raw = send_embedding_request(
2288                    || {
2289                        // `.json(&payload)` sets Content-Type automatically.
2290                        // Same duplicate-header trap as the OpenAI branch above
2291                        // — most Ollama servers tolerate it, but the
2292                        // single-Content-Type form is the correct one.
2293                        client.post(&endpoint).json(&payload)
2294                    },
2295                    "ollama",
2296                    policy,
2297                )?;
2298
2299                #[derive(Deserialize)]
2300                struct OllamaResponse {
2301                    embeddings: Vec<Vec<f32>>,
2302                }
2303
2304                let parsed: OllamaResponse = serde_json::from_str(&raw)
2305                    .map_err(|error| format!("invalid ollama response: {error}"))?;
2306                if parsed.embeddings.is_empty() {
2307                    return Err("ollama response returned no embeddings".to_string());
2308                }
2309                if parsed.embeddings.len() != expected_text_count {
2310                    return Err(format!(
2311                        "ollama response returned {} embeddings for {} inputs",
2312                        parsed.embeddings.len(),
2313                        expected_text_count
2314                    ));
2315                }
2316
2317                let vectors = parsed.embeddings;
2318                for vector in &vectors {
2319                    if vector.is_empty() {
2320                        return Err("ollama response contained empty embeddings".to_string());
2321                    }
2322                }
2323
2324                self.dimension = vectors.first().map(Vec::len);
2325                Ok(vectors)
2326            }
2327            SemanticEmbeddingEngine::Synapse(client) => {
2328                let vectors = match policy {
2329                    EmbeddingRequestPolicy::Build(_) => client
2330                        .embed_batch(&texts)
2331                        .map_err(|error| error.to_string())?,
2332                    EmbeddingRequestPolicy::Query(budget) => {
2333                        let timeout = Duration::from_millis(budget.timeout_ms);
2334                        texts
2335                            .iter()
2336                            .map(|text| client.embed_query(text, timeout))
2337                            .collect::<Result<Vec<_>, _>>()
2338                            .map_err(|error| error.to_string())?
2339                    }
2340                };
2341                self.dimension = vectors.first().map(Vec::len);
2342                Ok(vectors)
2343            }
2344        };
2345
2346        if let (Some(query), Ok(vectors)) = (query_cache_key, &result) {
2347            if let Some(vector) = vectors.first() {
2348                self.query_embedding_cache
2349                    .lock()
2350                    .unwrap_or_else(std::sync::PoisonError::into_inner)
2351                    .insert(query, vector.clone());
2352            }
2353        }
2354
2355        result
2356    }
2357
2358    fn is_live_query_provider(&self) -> bool {
2359        match &self.engine {
2360            // The offline fixture serves checked-in vectors over HTTP, so crossing
2361            // that socket is observable work but not a live model invocation.
2362            SemanticEmbeddingEngine::OpenAiCompatible { model, .. } => {
2363                model != crate::search_b2::embed_counter::FIXTURE_PROVIDER_MODEL
2364            }
2365            SemanticEmbeddingEngine::Local(_)
2366            | SemanticEmbeddingEngine::Ollama { .. }
2367            | SemanticEmbeddingEngine::Synapse(_) => true,
2368        }
2369    }
2370}
2371
2372/// Platform library filename for the plugin-managed ONNX Runtime.
2373///
2374/// Mirrors `ORT_PLATFORM_MAP` in packages/aft-bridge/src/onnx-runtime.ts. A
2375/// layout change on either side must update both — the plugin downloads the
2376/// runtime into `<storage_dir>/onnxruntime/<version>/` and this resolver must
2377/// find it at the same path.
2378#[cfg(target_os = "linux")]
2379const MANAGED_ORT_LIB_NAME: &str = "libonnxruntime.so";
2380#[cfg(target_os = "macos")]
2381const MANAGED_ORT_LIB_NAME: &str = "libonnxruntime.dylib";
2382#[cfg(target_os = "windows")]
2383const MANAGED_ORT_LIB_NAME: &str = "onnxruntime.dll";
2384
2385/// Minimum managed ONNX Runtime minor version this resolver will accept.
2386///
2387/// Mirrors the `REQUIRED_ORT_MIN_MINOR` floor in onnx-runtime.ts and the 1.20
2388/// floor `pre_validate_onnx_runtime` enforces. A managed install below this
2389/// would be handed to ort and rejected there, so the resolver must skip it.
2390const MANAGED_ORT_MIN_MINOR: u32 = 20;
2391
2392/// Resolve the plugin-managed ONNX Runtime under the ACTIVE storage dir and
2393/// export it as `ORT_DYLIB_PATH` for the process.
2394///
2395/// The plugin (packages/aft-bridge/src/onnx-runtime.ts) downloads the runtime
2396/// to `<storage_dir>/onnxruntime/<version>/<libname>` and exports ORT_DYLIB_PATH
2397/// into the child env. A bare `aft` binary has no such step: without this
2398/// resolver, `pre_validate_onnx_runtime` dlopens the bare soname, which only
2399/// works with a system-installed runtime. This makes the standalone binary pick
2400/// up the runtime the plugin already downloaded.
2401///
2402/// Resolution order:
2403///   1. If `ORT_DYLIB_PATH` is non-empty (an explicit user override, or the
2404///      plugin already exported it), do nothing — the caller's choice wins and
2405///      the resolver must not run at all.
2406///   2. Enumerate `<storage_dir>/onnxruntime/` version directories, keep only
2407///      parseable `1.x.y` with x >= 20, pick the highest, and if its library
2408///      file exists set `ORT_DYLIB_PATH` to it.
2409///   3. Otherwise leave the env untouched; `pre_validate_onnx_runtime` falls
2410///      back to the bare soname + doctor hint as before.
2411///
2412/// # Process-global env mutation
2413/// This sets a process-wide env var and must run ONCE at startup, before any
2414/// worker threads spawn (the warmup CLI main and the standalone main's semantic
2415/// init path). Setting it lazily from a worker thread would race ort's own
2416/// dlopen and other threads reading the env. The function is idempotent: once
2417/// `ORT_DYLIB_PATH` is set, subsequent calls short-circuit.
2418pub fn resolve_managed_onnx_runtime(storage_dir: &Path) {
2419    if onnx_runtime_override_configured_with(|name| std::env::var_os(name)) {
2420        return;
2421    }
2422    let Some(lib_path) = find_managed_onnx_runtime(storage_dir) else {
2423        return;
2424    };
2425    std::env::set_var("ORT_DYLIB_PATH", &lib_path);
2426    slog_info!(
2427        "using plugin-managed ONNX Runtime at {}",
2428        lib_path.display()
2429    );
2430}
2431
2432fn onnx_runtime_override_configured_with(
2433    lookup: impl FnOnce(&str) -> Option<std::ffi::OsString>,
2434) -> bool {
2435    lookup("ORT_DYLIB_PATH").is_some_and(|value| !value.is_empty())
2436}
2437
2438/// Find the highest compatible managed ONNX Runtime library under
2439/// `<storage_dir>/onnxruntime/`, or None when absent/incompatible.
2440///
2441/// Mirrors the plugin's `resolveCachedOnnxRuntimeDir`: the library may live at
2442/// the version root (the plugin's own flattened install) or under a `lib/`
2443/// subdir (manual Microsoft-archive installs, issue #71).
2444fn find_managed_onnx_runtime(storage_dir: &Path) -> Option<PathBuf> {
2445    let base = storage_dir.join("onnxruntime");
2446    let entries = std::fs::read_dir(&base).ok()?;
2447    #[cfg(test)]
2448    {
2449        // Test-only probe: counts how many times the resolver actually reads
2450        // the storage tree. Lets a negative-control test assert that a pre-set
2451        // ORT_DYLIB_PATH short-circuits the resolver without touching the tree.
2452        MANAGED_ORT_PROBE_READS.fetch_add(1, Ordering::Relaxed);
2453    }
2454    let mut best: Option<(u32, u32, PathBuf)> = None;
2455    for entry in entries.flatten() {
2456        let path = entry.path();
2457        if !path.is_dir() {
2458            continue;
2459        }
2460        let Some((major, minor)) = parse_managed_ort_version(&entry.file_name().to_string_lossy())
2461        else {
2462            continue;
2463        };
2464        if major != 1 || minor < MANAGED_ORT_MIN_MINOR {
2465            continue;
2466        }
2467        let Some(lib_path) = managed_ort_lib_in_version_dir(&path) else {
2468            continue;
2469        };
2470        if best
2471            .as_ref()
2472            .is_none_or(|(best_major, best_minor, _)| (major, minor) > (*best_major, *best_minor))
2473        {
2474            best = Some((major, minor, lib_path));
2475        }
2476    }
2477    best.map(|(_, _, path)| path)
2478}
2479
2480/// Locate the library file inside one `<version>` directory, preferring the
2481/// version root over a `lib/` subdir (mirrors `resolveCachedOnnxRuntimeDir`).
2482fn managed_ort_lib_in_version_dir(version_dir: &Path) -> Option<PathBuf> {
2483    let root = version_dir.join(MANAGED_ORT_LIB_NAME);
2484    if root.is_file() {
2485        return Some(root);
2486    }
2487    let lib_subdir = version_dir.join("lib").join(MANAGED_ORT_LIB_NAME);
2488    if lib_subdir.is_file() {
2489        return Some(lib_subdir);
2490    }
2491    None
2492}
2493
2494/// Parse a `major.minor.patch` triple from a version directory name. Returns
2495/// None for anything that is not exactly a three-part numeric version (so
2496/// non-version dirs and malformed names are ignored).
2497fn parse_managed_ort_version(name: &str) -> Option<(u32, u32)> {
2498    let mut parts = name.split('.');
2499    let major = parts.next()?.parse::<u32>().ok()?;
2500    let minor = parts.next()?.parse::<u32>().ok()?;
2501    let _patch = parts.next()?.parse::<u32>().ok()?;
2502    // Reject trailing junk like "1.24.4.tmp" or "1.24.4.5".
2503    if parts.next().is_some() {
2504        return None;
2505    }
2506    Some((major, minor))
2507}
2508
2509/// Pre-validate ONNX Runtime by attempting a raw dlopen before ort touches it.
2510/// This catches broken/incompatible .so files without risking a panic in the ort crate.
2511/// Also checks the runtime version via OrtGetApiBase if available.
2512pub fn pre_validate_onnx_runtime() -> Result<(), String> {
2513    let dylib_path = std::env::var("ORT_DYLIB_PATH").ok();
2514
2515    #[cfg(any(target_os = "linux", target_os = "macos"))]
2516    {
2517        #[cfg(target_os = "linux")]
2518        let default_name = "libonnxruntime.so";
2519        #[cfg(target_os = "macos")]
2520        let default_name = "libonnxruntime.dylib";
2521
2522        let lib_name = dylib_path.as_deref().unwrap_or(default_name);
2523
2524        unsafe {
2525            let c_name = std::ffi::CString::new(lib_name)
2526                .map_err(|e| format!("invalid library path: {}", e))?;
2527            let handle = libc::dlopen(c_name.as_ptr(), libc::RTLD_NOW);
2528            if handle.is_null() {
2529                let err = libc::dlerror();
2530                let msg = if err.is_null() {
2531                    "unknown dlopen error".to_string()
2532                } else {
2533                    std::ffi::CStr::from_ptr(err).to_string_lossy().into_owned()
2534                };
2535                return Err(format!(
2536                    "ONNX Runtime not found. dlopen('{}') failed: {}. \
2537                     Run `npx @cortexkit/aft doctor` to diagnose.",
2538                    lib_name, msg
2539                ));
2540            }
2541
2542            // Try to detect the runtime version from the actual loaded library
2543            // path first. A bare dlopen("libonnxruntime.so") may resolve to an
2544            // older system ORT through loader search paths; checking only the
2545            // caller-supplied soname would miss that and let ort fail opaquely.
2546            let (detected_version, version_source) =
2547                detect_ort_version_from_loaded_library(handle, lib_name);
2548
2549            libc::dlclose(handle);
2550
2551            // Check version compatibility — we need 1.20+.
2552            if let Some(ref version) = detected_version {
2553                let parts: Vec<&str> = version.split('.').collect();
2554                if let (Some(major), Some(minor)) = (
2555                    parts.first().and_then(|s| s.parse::<u32>().ok()),
2556                    parts.get(1).and_then(|s| s.parse::<u32>().ok()),
2557                ) {
2558                    if major != 1 || minor < 20 {
2559                        return Err(format_ort_version_mismatch(version, &version_source));
2560                    }
2561                }
2562            }
2563        }
2564    }
2565
2566    #[cfg(target_os = "windows")]
2567    {
2568        // Validate ONNX Runtime availability on Windows by loading the DLL
2569        // via LoadLibraryExW before the ort crate attempts its own LoadLibrary.
2570        // This way we can produce a friendly error (with installation hints)
2571        // instead of a raw LoadLibrary failure from deep inside fastembed.
2572        let lib_name = dylib_path.as_deref().unwrap_or("onnxruntime.dll");
2573
2574        // Use kernel32 LoadLibraryExW for the validation — built-in, no
2575        // crate dependency required. GetModuleFileNameW resolves the loaded
2576        // DLL path for version probing via the version.dll API.
2577        #[link(name = "kernel32")]
2578        extern "system" {
2579            fn LoadLibraryExW(
2580                lpLibFileName: *const u16,
2581                hFile: *mut std::ffi::c_void,
2582                dwFlags: u32,
2583            ) -> *mut std::ffi::c_void;
2584            fn FreeLibrary(hLibModule: *mut std::ffi::c_void) -> i32;
2585            fn GetModuleFileNameW(
2586                hModule: *mut std::ffi::c_void,
2587                lpFilename: *mut u16,
2588                nSize: u32,
2589            ) -> u32;
2590        }
2591
2592        #[link(name = "version")]
2593        extern "system" {
2594            fn GetFileVersionInfoSizeW(lptstrFilename: *const u16, lpdwHandle: *mut u32) -> u32;
2595            fn GetFileVersionInfoW(
2596                lptstrFilename: *const u16,
2597                dwHandle: u32,
2598                dwLen: u32,
2599                lpData: *mut std::ffi::c_void,
2600            ) -> i32;
2601            fn VerQueryValueW(
2602                pBlock: *mut std::ffi::c_void,
2603                lpSubBlock: *const u16,
2604                lplpBuffer: *mut *mut std::ffi::c_void,
2605                puLen: *mut u32,
2606            ) -> i32;
2607        }
2608
2609        #[repr(C)]
2610        struct VS_FIXEDFILEINFO {
2611            dw_signature: u32,
2612            dw_struc_version: u32,
2613            dw_file_version_ms: u32, // HIWORD major, LOWORD minor
2614            dw_file_version_ls: u32, // HIWORD build, LOWORD revision
2615            dw_product_version_ms: u32,
2616            dw_product_version_ls: u32,
2617            dw_file_flags_mask: u32,
2618            dw_file_flags: u32,
2619            dw_file_os: u32,
2620            dw_file_type: u32,
2621            dw_file_subtype: u32,
2622            dw_file_date_ms: u32,
2623            dw_file_date_ls: u32,
2624        }
2625
2626        unsafe {
2627            use std::os::windows::ffi::OsStrExt;
2628            let wide: Vec<u16> = std::ffi::OsStr::new(lib_name)
2629                .encode_wide()
2630                .chain(std::iter::once(0))
2631                .collect();
2632
2633            let handle = LoadLibraryExW(wide.as_ptr(), std::ptr::null_mut(), 0);
2634            if handle.is_null() {
2635                let err = std::io::Error::last_os_error();
2636                return Err(format!(
2637                    "ONNX Runtime not found. LoadLibraryExW('{}') failed: {}. \
2638                     Run `npx @cortexkit/aft doctor` to diagnose.",
2639                    lib_name, err
2640                ));
2641            }
2642
2643            // Probe the file version from PE resources so we can reject
2644            // outdated DLLs (e.g. v1.9.x) before the ort crate panics.
2645            let mut detected_major: u32 = 0;
2646            let mut detected_minor: u32 = 0;
2647            // Use MAX_UNICODEPATH (32767) so deeply nested ORT paths (e.g.
2648            // long NuGet package paths under %USERPROFILE%) never truncate.
2649            // GetModuleFileNameW truncates silently when the buffer is too
2650            // small, which causes version probing to fail and the version
2651            // check to be bypassed — better to allocate generously.
2652            let mut path_buf = [0u16; 32767];
2653            let path_len = GetModuleFileNameW(handle, path_buf.as_mut_ptr(), 32767);
2654            if path_len > 0 {
2655                let mut dummy_handle: u32 = 0;
2656                let info_size = GetFileVersionInfoSizeW(path_buf.as_ptr(), &mut dummy_handle);
2657                if info_size > 0 {
2658                    let mut info = vec![0u8; info_size as usize];
2659                    if GetFileVersionInfoW(
2660                        path_buf.as_ptr(),
2661                        0,
2662                        info_size,
2663                        info.as_mut_ptr() as *mut std::ffi::c_void,
2664                    ) != 0
2665                    {
2666                        let sub_block = "\\\0".encode_utf16().collect::<Vec<u16>>();
2667                        let mut vs_info: *mut std::ffi::c_void = std::ptr::null_mut();
2668                        let mut vs_len: u32 = 0;
2669                        if VerQueryValueW(
2670                            info.as_mut_ptr() as *mut std::ffi::c_void,
2671                            sub_block.as_ptr(),
2672                            &mut vs_info,
2673                            &mut vs_len,
2674                        ) != 0
2675                            && !vs_info.is_null()
2676                        {
2677                            let fixed = vs_info as *const VS_FIXEDFILEINFO;
2678                            detected_major = (*fixed).dw_file_version_ms >> 16;
2679                            detected_minor = (*fixed).dw_file_version_ms & 0xFFFF;
2680                        }
2681                    }
2682                }
2683            }
2684
2685            FreeLibrary(handle);
2686
2687            // Version compatibility check (mirrors the Linux/macOS path).
2688            // If version could not be detected (detected_major == 0) we let
2689            // the load succeed — the ort crate will diagnose further.
2690            if detected_major != 0 && (detected_major != 1 || detected_minor < 20) {
2691                let ver = format!("{}.{}", detected_major, detected_minor);
2692                return Err(format_ort_version_mismatch(&ver, lib_name));
2693            }
2694        }
2695    }
2696
2697    Ok(())
2698}
2699
2700#[cfg(any(target_os = "linux", target_os = "macos"))]
2701unsafe fn loaded_library_path_from_handle(handle: *mut std::ffi::c_void) -> Option<String> {
2702    let symbol_name = std::ffi::CString::new("OrtGetApiBase").ok()?;
2703    let symbol = unsafe { libc::dlsym(handle, symbol_name.as_ptr()) };
2704    if symbol.is_null() {
2705        return None;
2706    }
2707
2708    let mut info = std::mem::MaybeUninit::<libc::Dl_info>::uninit();
2709    if unsafe { libc::dladdr(symbol, info.as_mut_ptr()) } == 0 {
2710        return None;
2711    }
2712
2713    let info = unsafe { info.assume_init() };
2714    if info.dli_fname.is_null() {
2715        return None;
2716    }
2717
2718    Some(
2719        unsafe { std::ffi::CStr::from_ptr(info.dli_fname) }
2720            .to_string_lossy()
2721            .into_owned(),
2722    )
2723}
2724
2725#[cfg(any(target_os = "linux", target_os = "macos"))]
2726fn detect_ort_version_from_resolved_or_requested(
2727    resolved_path: Option<String>,
2728    requested_lib_name: &str,
2729) -> (Option<String>, String) {
2730    if let Some(path) = resolved_path {
2731        if let Some(version) = detect_ort_version_from_path(&path) {
2732            return (Some(version), path);
2733        }
2734        return (detect_ort_version_from_path(requested_lib_name), path);
2735    }
2736
2737    (
2738        detect_ort_version_from_path(requested_lib_name),
2739        requested_lib_name.to_string(),
2740    )
2741}
2742
2743#[cfg(any(target_os = "linux", target_os = "macos"))]
2744fn detect_ort_version_from_loaded_library(
2745    handle: *mut std::ffi::c_void,
2746    requested_lib_name: &str,
2747) -> (Option<String>, String) {
2748    detect_ort_version_from_resolved_or_requested(
2749        unsafe { loaded_library_path_from_handle(handle) },
2750        requested_lib_name,
2751    )
2752}
2753
2754/// Try to extract the ORT version from the library filename or resolved symlink.
2755/// Examples: "libonnxruntime.so.1.19.0" → "1.19.0", "libonnxruntime.1.24.4.dylib" → "1.24.4"
2756#[cfg(any(target_os = "linux", target_os = "macos"))]
2757fn detect_ort_version_from_path(lib_path: &str) -> Option<String> {
2758    let path = std::path::Path::new(lib_path);
2759
2760    // Try the path as given, then follow symlinks
2761    for candidate in [Some(path.to_path_buf()), std::fs::canonicalize(path).ok()]
2762        .into_iter()
2763        .flatten()
2764    {
2765        if let Some(name) = candidate.file_name().and_then(|n| n.to_str()) {
2766            if let Some(version) = extract_version_from_filename(name) {
2767                return Some(version);
2768            }
2769        }
2770    }
2771
2772    // Also check for versioned siblings in the same directory
2773    if let Some(parent) = path.parent() {
2774        if let Ok(entries) = std::fs::read_dir(parent) {
2775            for entry in entries.flatten() {
2776                if let Some(name) = entry.file_name().to_str() {
2777                    if name.starts_with("libonnxruntime") {
2778                        if let Some(version) = extract_version_from_filename(name) {
2779                            return Some(version);
2780                        }
2781                    }
2782                }
2783            }
2784        }
2785    }
2786
2787    None
2788}
2789
2790/// Extract version from filenames like "libonnxruntime.so.1.19.0" or "libonnxruntime.1.24.4.dylib"
2791#[cfg(any(target_os = "linux", target_os = "macos"))]
2792fn extract_version_from_filename(name: &str) -> Option<String> {
2793    // Match patterns: .so.X.Y.Z or .X.Y.Z.dylib or .X.Y.Z.so
2794    let re = regex::Regex::new(r"(\d+\.\d+\.\d+)").ok()?;
2795    re.find(name).map(|m| m.as_str().to_string())
2796}
2797
2798fn suggest_removal_command(lib_path: &str) -> String {
2799    if lib_path.starts_with("/usr/local/lib")
2800        || lib_path == "libonnxruntime.so"
2801        || lib_path == "libonnxruntime.dylib"
2802    {
2803        #[cfg(target_os = "linux")]
2804        return "   sudo rm /usr/local/lib/libonnxruntime* && sudo ldconfig".to_string();
2805        #[cfg(target_os = "macos")]
2806        return "   sudo rm /usr/local/lib/libonnxruntime*".to_string();
2807    }
2808    format!("   rm '{}'", lib_path)
2809}
2810
2811/// Build the user-facing error message for an incompatible ONNX Runtime
2812/// install. Extracted as a pure helper so we can unit-test the wording
2813/// stability — the auto-fix recommendation must always come first because
2814/// it's the only safe option, and the system-rm step must remain present
2815/// because some users prefer the system-wide cleanup path.
2816pub(crate) fn format_ort_version_mismatch(version: &str, lib_name: &str) -> String {
2817    format!(
2818        "ONNX Runtime version mismatch: found v{} at '{}', but AFT requires v1.20+. \
2819         Solutions:\n\
2820         1. Auto-fix (recommended): run `npx @cortexkit/aft doctor --fix`. \
2821         This downloads AFT-managed ONNX Runtime v1.24 into AFT's storage and \
2822         configures the bridge to load it instead of the system library — no \
2823         changes to '{}'.\n\
2824         2. Remove the old library and restart (AFT auto-downloads the correct version on next start):\n\
2825         {}\n\
2826         3. Or install ONNX Runtime 1.24 system-wide: https://github.com/microsoft/onnxruntime/releases/tag/v1.24.0\n\
2827         4. Run `npx @cortexkit/aft doctor` for full diagnostics.",
2828        version,
2829        lib_name,
2830        lib_name,
2831        suggest_removal_command(lib_name),
2832    )
2833}
2834
2835pub fn is_onnx_runtime_unavailable(message: &str) -> bool {
2836    if message.trim_start().starts_with("ONNX Runtime not found.") {
2837        return true;
2838    }
2839
2840    let message = message.to_ascii_lowercase();
2841    let mentions_onnx_runtime = ["onnx runtime", "onnxruntime", "libonnxruntime"]
2842        .iter()
2843        .any(|pattern| message.contains(pattern));
2844    let mentions_dynamic_load_failure = [
2845        "shared library",
2846        "dynamic library",
2847        "failed to load",
2848        "could not load",
2849        "unable to load",
2850        "dlopen",
2851        "loadlibrary",
2852        "no such file",
2853        "not found",
2854    ]
2855    .iter()
2856    .any(|pattern| message.contains(pattern));
2857
2858    mentions_onnx_runtime && mentions_dynamic_load_failure
2859}
2860
2861pub fn format_embedding_init_error(error: impl Display) -> String {
2862    let message = error.to_string();
2863
2864    if is_onnx_runtime_unavailable(&message) {
2865        return format!("{ONNX_RUNTIME_INSTALL_HINT} Original error: {message}");
2866    }
2867
2868    format!("failed to initialize semantic embedding model: {message}")
2869}
2870
2871/// A chunk of code ready for embedding — derived from a Symbol with context enrichment
2872#[derive(Debug, Clone)]
2873pub struct SemanticChunk {
2874    /// Absolute file path
2875    pub file: PathBuf,
2876    /// Symbol name
2877    pub name: String,
2878    /// Fully-qualified symbol name, when known from the outline scope chain.
2879    pub qualified_name: Option<String>,
2880    /// Symbol kind (function, class, struct, etc.)
2881    pub kind: SymbolKind,
2882    /// Line range (0-based internally, inclusive)
2883    pub start_line: u32,
2884    pub end_line: u32,
2885    /// Whether the symbol is exported
2886    pub exported: bool,
2887    /// The enriched text that gets embedded (name + file + kind + signature + body snippet)
2888    pub embed_text: String,
2889    /// Short code snippet for display in results
2890    pub snippet: String,
2891}
2892
2893/// A stored embedding entry — chunk metadata + vector
2894#[derive(Debug, Clone)]
2895pub struct EmbeddingEntry {
2896    chunk: SemanticChunk,
2897    vector: Vec<f32>,
2898    /// Cached L2 norm so searches only recompute the query norm. Remote embedding
2899    /// backends do not guarantee unit vectors, so keep the actual norm instead of
2900    /// assuming it is 1.0.
2901    norm: f32,
2902}
2903
2904impl EmbeddingEntry {
2905    fn new(chunk: SemanticChunk, vector: Vec<f32>) -> Self {
2906        let norm = vector_norm(&vector);
2907        Self {
2908            chunk,
2909            vector,
2910            norm,
2911        }
2912    }
2913}
2914
2915enum BuildEmbeddingRow {
2916    Embedded {
2917        embedded_text: String,
2918        vector: Vec<f32>,
2919    },
2920    Skipped {
2921        embedded_text: String,
2922        reason: String,
2923    },
2924}
2925
2926fn execute_build_embedding_batch<F>(
2927    texts: Vec<String>,
2928    embed_fn: &mut F,
2929) -> Result<Vec<BuildEmbeddingRow>, String>
2930where
2931    F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
2932{
2933    clear_http_build_metadata();
2934    let requested_texts = texts.clone();
2935    let vectors = embed_fn(texts)?;
2936    let metadata = take_http_build_metadata();
2937
2938    // Skipped rows carry an empty vector on purpose; the count check still
2939    // applies because every requested row must have exactly one slot.
2940    validate_embedding_batch_count(&vectors, requested_texts.len(), "embedding backend")?;
2941    if metadata
2942        .as_ref()
2943        .is_some_and(|metadata| metadata.len() != requested_texts.len())
2944    {
2945        return Err("embedding backend returned mismatched row metadata".to_string());
2946    }
2947
2948    let metadata = metadata.unwrap_or_else(|| {
2949        requested_texts
2950            .into_iter()
2951            .map(|embedded_text| BuildEmbeddingRowMetadata {
2952                embedded_text,
2953                skipped_reason: None,
2954            })
2955            .collect()
2956    });
2957    let mut expected_dimension = None;
2958    let mut rows = Vec::with_capacity(vectors.len());
2959    for (metadata, vector) in metadata.into_iter().zip(vectors) {
2960        if let Some(reason) = metadata.skipped_reason {
2961            if !vector.is_empty() {
2962                return Err("skipped embedding row unexpectedly returned a vector".to_string());
2963            }
2964            rows.push(BuildEmbeddingRow::Skipped {
2965                embedded_text: metadata.embedded_text,
2966                reason,
2967            });
2968            continue;
2969        }
2970
2971        validate_embedding_dimension(vector.len())
2972            .map_err(|error| format!("embedding backend returned {error}"))?;
2973        match expected_dimension {
2974            None => expected_dimension = Some(vector.len()),
2975            Some(expected) if expected != vector.len() => {
2976                return Err(format!(
2977                    "embedding backend returned inconsistent embedding dimensions: expected {expected}, got {}",
2978                    vector.len()
2979                ));
2980            }
2981            _ => {}
2982        }
2983        rows.push(BuildEmbeddingRow::Embedded {
2984            embedded_text: metadata.embedded_text,
2985            vector,
2986        });
2987    }
2988
2989    Ok(rows)
2990}
2991
2992fn format_skipped_row_warning(chunk: &SemanticChunk, embedded_text: &str, reason: &str) -> String {
2993    format!(
2994        "semantic embed skipped row: file={} symbol={} chars={} reason={}",
2995        chunk.file.display(),
2996        chunk.name,
2997        embedded_text.chars().count(),
2998        reason,
2999    )
3000}
3001
3002fn log_skipped_row_warning(chunk: &SemanticChunk, embedded_text: &str, reason: &str) {
3003    let warning = format_skipped_row_warning(chunk, embedded_text, reason);
3004    #[cfg(test)]
3005    TEST_SKIPPED_ROW_WARNINGS.with(|warnings| warnings.borrow_mut().push(warning.clone()));
3006    slog_warn!("{}", warning);
3007}
3008
3009#[cfg(test)]
3010fn take_test_skipped_row_warnings() -> Vec<String> {
3011    TEST_SKIPPED_ROW_WARNINGS.with(|warnings| std::mem::take(&mut *warnings.borrow_mut()))
3012}
3013
3014#[derive(Debug)]
3015struct SharedSemanticBase {
3016    entries: Vec<EmbeddingEntry>,
3017    file_mtimes: HashMap<PathBuf, SystemTime>,
3018    file_sizes: HashMap<PathBuf, u64>,
3019    any_missing_sizes: bool,
3020    file_hashes: HashMap<PathBuf, blake3::Hash>,
3021    dimension: usize,
3022    fingerprint: Option<SemanticIndexFingerprint>,
3023    deferred_files: HashSet<PathBuf>,
3024    skipped_rows: usize,
3025    dirty_paths: Arc<Mutex<Option<BTreeSet<PathBuf>>>>,
3026    persistence: Arc<Mutex<Option<SemanticPersistenceState>>>,
3027}
3028
3029#[derive(Debug, Clone, PartialEq, Eq, Hash)]
3030struct SharedSemanticBaseKey {
3031    artifact_cache_key: String,
3032    fingerprint: String,
3033    artifact_content_hash: blake3::Hash,
3034}
3035
3036type SharedSemanticBaseRegistry = HashMap<SharedSemanticBaseKey, Weak<SharedSemanticBase>>;
3037
3038fn shared_semantic_bases() -> &'static Mutex<SharedSemanticBaseRegistry> {
3039    static REGISTRY: OnceLock<Mutex<SharedSemanticBaseRegistry>> = OnceLock::new();
3040    REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
3041}
3042
3043static SHARED_SEMANTIC_BASE_LOADS: AtomicUsize = AtomicUsize::new(0);
3044static SHARED_SEMANTIC_BASE_HITS: AtomicUsize = AtomicUsize::new(0);
3045
3046impl SharedSemanticBase {
3047    fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
3048        let vector_bytes = self.entries.iter().fold(0u64, |bytes, entry| {
3049            bytes.saturating_add(
3050                crate::memory::usize_to_u64(entry.vector.len())
3051                    .saturating_mul(std::mem::size_of::<f32>() as u64),
3052            )
3053        });
3054        let text_bytes = self.entries.iter().fold(0u64, |bytes, entry| {
3055            bytes
3056                .saturating_add(crate::memory::path_bytes(&entry.chunk.file))
3057                .saturating_add(crate::memory::usize_to_u64(entry.chunk.name.len()))
3058                .saturating_add(
3059                    entry
3060                        .chunk
3061                        .qualified_name
3062                        .as_ref()
3063                        .map(|name| crate::memory::usize_to_u64(name.len()))
3064                        .unwrap_or(0),
3065                )
3066                .saturating_add(crate::memory::usize_to_u64(entry.chunk.embed_text.len()))
3067                .saturating_add(crate::memory::usize_to_u64(entry.chunk.snippet.len()))
3068        });
3069        let metadata_bytes = crate::memory::usize_to_u64(self.entries.len())
3070            .saturating_mul(std::mem::size_of::<EmbeddingEntry>() as u64)
3071            .saturating_add(
3072                self.file_mtimes
3073                    .keys()
3074                    .chain(self.file_sizes.keys())
3075                    .chain(self.file_hashes.keys())
3076                    .chain(self.deferred_files.iter())
3077                    .map(|path| crate::memory::path_bytes(path))
3078                    .fold(0u64, u64::saturating_add),
3079            )
3080            .saturating_add(
3081                crate::memory::usize_to_u64(self.file_mtimes.len())
3082                    .saturating_mul(std::mem::size_of::<SystemTime>() as u64),
3083            )
3084            .saturating_add(
3085                crate::memory::usize_to_u64(self.file_sizes.len())
3086                    .saturating_mul(std::mem::size_of::<u64>() as u64),
3087            )
3088            .saturating_add(
3089                crate::memory::usize_to_u64(self.file_hashes.len())
3090                    .saturating_mul(std::mem::size_of::<blake3::Hash>() as u64),
3091            );
3092        crate::memory::MemoryEstimate::estimated(
3093            vector_bytes
3094                .saturating_add(text_bytes)
3095                .saturating_add(metadata_bytes),
3096        )
3097        .count("entries", self.entries.len())
3098        .count("indexed_files", self.file_mtimes.len())
3099        .count_u64("vector_bytes", vector_bytes)
3100        .count_u64("text_bytes", text_bytes)
3101        .count_u64("metadata_bytes", metadata_bytes)
3102    }
3103}
3104
3105pub(crate) fn shared_semantic_bases_memory() -> crate::memory::MemoryEstimate {
3106    let mut registry = shared_semantic_bases()
3107        .lock()
3108        .unwrap_or_else(std::sync::PoisonError::into_inner);
3109    registry.retain(|_, base| base.strong_count() > 0);
3110    let bases = registry
3111        .values()
3112        .filter_map(Weak::upgrade)
3113        .collect::<Vec<_>>();
3114    let estimates = bases
3115        .iter()
3116        .map(|base| base.estimated_memory())
3117        .collect::<Vec<_>>();
3118    let bytes = estimates.iter().fold(0u64, |sum, estimate| {
3119        sum.saturating_add(estimate.estimated_bytes.unwrap_or(0))
3120    });
3121    let count_bytes = |name: &str| {
3122        estimates.iter().fold(0u64, |sum, estimate| {
3123            sum.saturating_add(estimate.counts.get(name).copied().unwrap_or(0))
3124        })
3125    };
3126    crate::memory::MemoryEstimate::estimated(bytes)
3127        .count("bases", bases.len())
3128        .count("entries", bases.iter().map(|base| base.entries.len()).sum())
3129        .count_u64("vector_bytes", count_bytes("vector_bytes"))
3130        .count_u64("text_bytes", count_bytes("text_bytes"))
3131        .count_u64("metadata_bytes", count_bytes("metadata_bytes"))
3132        .count_u64(
3133            "loads",
3134            SHARED_SEMANTIC_BASE_LOADS.load(Ordering::Relaxed) as u64,
3135        )
3136        .count_u64(
3137            "hits",
3138            SHARED_SEMANTIC_BASE_HITS.load(Ordering::Relaxed) as u64,
3139        )
3140}
3141
3142fn borrowed_artifact_identity(data_path: &Path) -> Result<(String, blake3::Hash), String> {
3143    let mut file = fs::File::open(data_path).map_err(|error| error.to_string())?;
3144    let mut hasher = blake3::Hasher::new();
3145    hasher
3146        .update_reader(&mut file)
3147        .map_err(|error| error.to_string())?;
3148    let artifact_content_hash = hasher.finalize();
3149
3150    let mut header = BufReader::new(fs::File::open(data_path).map_err(|error| error.to_string())?);
3151    let mut fixed = [0u8; HEADER_BYTES_V2];
3152    header
3153        .read_exact(&mut fixed)
3154        .map_err(|error| error.to_string())?;
3155    if fixed[0] != SEMANTIC_INDEX_VERSION_V6 && fixed[0] != SEMANTIC_INDEX_VERSION_V7 {
3156        return Err(format!(
3157            "unsupported semantic artifact version {}",
3158            fixed[0]
3159        ));
3160    }
3161    let fingerprint_len = u32::from_le_bytes(fixed[9..13].try_into().unwrap()) as usize;
3162    if fingerprint_len == 0 || fingerprint_len > 64 * 1024 {
3163        return Err("semantic artifact fingerprint is missing or oversized".to_string());
3164    }
3165    let mut fingerprint = vec![0u8; fingerprint_len];
3166    header
3167        .read_exact(&mut fingerprint)
3168        .map_err(|error| error.to_string())?;
3169    let fingerprint = String::from_utf8(fingerprint).map_err(|error| error.to_string())?;
3170    Ok((fingerprint, artifact_content_hash))
3171}
3172
3173/// The semantic index — stores embeddings for all symbols in a project.
3174/// Borrow-only roots retain only a root path plus an Arc to immutable relative data.
3175#[derive(Debug, Clone)]
3176pub struct SemanticIndex {
3177    entries: Vec<EmbeddingEntry>,
3178    /// Track which files are indexed and their mtime for staleness detection
3179    file_mtimes: HashMap<PathBuf, SystemTime>,
3180    /// Track indexed file sizes alongside mtimes for staleness detection
3181    file_sizes: HashMap<PathBuf, u64>,
3182    /// Avoid walking every indexed path on warm refreshes once size metadata is complete.
3183    any_missing_sizes: bool,
3184    file_hashes: HashMap<PathBuf, blake3::Hash>,
3185    /// Embedding dimension (384 for MiniLM-L6-v2)
3186    dimension: usize,
3187    fingerprint: Option<SemanticIndexFingerprint>,
3188    project_root: PathBuf,
3189    deferred_files: HashSet<PathBuf>,
3190    shared_base: Option<Arc<SharedSemanticBase>>,
3191    /// Paths whose complete persisted rows must replace prior rows. `None` is
3192    /// reserved for indexes created by callers that cannot report mutations.
3193    dirty_paths: Arc<Mutex<Option<BTreeSet<PathBuf>>>>,
3194    persistence: Arc<Mutex<Option<SemanticPersistenceState>>>,
3195    last_append_read_bytes: Arc<AtomicUsize>,
3196    /// Rows rejected by the backend even after bounded body shrinking.
3197    skipped_rows: usize,
3198    #[cfg(test)]
3199    removal_retain_passes: usize,
3200}
3201
3202#[derive(Debug, Clone, Copy)]
3203struct IndexedFileMetadata {
3204    mtime: SystemTime,
3205    size: u64,
3206    content_hash: blake3::Hash,
3207}
3208
3209#[derive(Debug, Default, Clone, Copy)]
3210struct SemanticCollectPhaseTimings {
3211    sched: Duration,
3212    read_hash: Duration,
3213    parse: Duration,
3214    extract: Duration,
3215    build: Duration,
3216}
3217
3218impl SemanticCollectPhaseTimings {
3219    fn add_assign(&mut self, other: Self) {
3220        self.sched += other.sched;
3221        self.read_hash += other.read_hash;
3222        self.parse += other.parse;
3223        self.extract += other.extract;
3224        self.build += other.build;
3225    }
3226}
3227
3228type CollectedSemanticFile = (
3229    PathBuf,
3230    Result<(IndexedFileMetadata, Vec<SemanticChunk>), String>,
3231    SemanticCollectPhaseTimings,
3232);
3233
3234/// Result of an incremental refresh of the semantic index. Counts are file
3235/// counts; `total_processed` is the number of current/deleted files considered.
3236#[derive(Debug, Default, Clone, Copy)]
3237pub struct RefreshSummary {
3238    pub changed: usize,
3239    pub added: usize,
3240    pub deleted: usize,
3241    pub total_processed: usize,
3242}
3243
3244impl RefreshSummary {
3245    /// True when no files were touched.
3246    pub fn is_noop(&self) -> bool {
3247        self.changed == 0 && self.added == 0 && self.deleted == 0
3248    }
3249}
3250
3251#[derive(Debug, Default)]
3252pub struct InvalidatedFilesRefresh {
3253    /// Full replacement entries for `completed_paths`, not just newly embedded
3254    /// chunks. `apply_refresh_update` removes completed paths before extending
3255    /// this set, so reused chunks must travel in this delta too.
3256    pub added_entries: Vec<EmbeddingEntry>,
3257    pub updated_metadata: Vec<(PathBuf, FileFreshness)>,
3258    pub completed_paths: Vec<PathBuf>,
3259    pub summary: RefreshSummary,
3260}
3261
3262#[derive(Debug, Clone)]
3263struct ReusableEmbedding {
3264    embed_text: String,
3265    vector: Vec<f32>,
3266}
3267
3268type ChunkReuseMap = HashMap<PathBuf, HashMap<blake3::Hash, Vec<ReusableEmbedding>>>;
3269
3270const SEMANTIC_BLOB_PAYLOAD_VERSION: u8 = 1;
3271
3272fn extend_reuse_map_from_semantic_blob(
3273    reuse_map: &mut ChunkReuseMap,
3274    file: &Path,
3275    payload: &[u8],
3276    expected_fingerprint: &str,
3277    expected_dimension: usize,
3278) -> Result<(), String> {
3279    let mut reader = CountingReader::with_bytes_read(Cursor::new(payload), 0);
3280    let version = read_u8_stream(&mut reader, "missing semantic blob version")?;
3281    if version != SEMANTIC_BLOB_PAYLOAD_VERSION {
3282        return Err(format!("unsupported semantic blob version {version}"));
3283    }
3284    for (label, expected) in [
3285        ("chunker", crate::blob_store::SEMANTIC_PRODUCER_VERSION),
3286        ("template", crate::blob_store::SEMANTIC_PRODUCER_VERSION),
3287        ("model", expected_fingerprint),
3288    ] {
3289        let actual = read_string_stream(&mut reader, Some(payload.len()))?;
3290        if actual != expected {
3291            return Err(format!("semantic blob {label} fingerprint mismatch"));
3292        }
3293    }
3294    let entry_count = read_u32_stream(&mut reader)? as usize;
3295    if entry_count > MAX_ENTRIES {
3296        return Err(format!("too many semantic blob entries {entry_count}"));
3297    }
3298    let vector_bytes = expected_dimension
3299        .checked_mul(F32_BYTES)
3300        .ok_or_else(|| "semantic blob vector length overflow".to_string())?;
3301    for _ in 0..entry_count {
3302        let _name = read_string_stream(&mut reader, Some(payload.len()))?;
3303        let _qualified_name = read_string_stream(&mut reader, Some(payload.len()))?;
3304        let _kind = read_u8_stream(&mut reader, "missing semantic blob symbol kind")?;
3305        let _start_line = read_u32_stream(&mut reader)?;
3306        let _end_line = read_u32_stream(&mut reader)?;
3307        let _exported = read_u8_stream(&mut reader, "missing semantic blob export flag")?;
3308        let _snippet = read_string_stream(&mut reader, Some(payload.len()))?;
3309        let embed_text = read_string_stream(&mut reader, Some(payload.len()))?;
3310        let raw_vector = read_blob_bytes(&mut reader, payload.len())?;
3311        if raw_vector.len() != vector_bytes {
3312            return Err(format!(
3313                "semantic blob vector has {} bytes, expected {vector_bytes}",
3314                raw_vector.len()
3315            ));
3316        }
3317        let vector = raw_vector
3318            .chunks_exact(F32_BYTES)
3319            .map(|bytes| f32::from_le_bytes(bytes.try_into().expect("four-byte float")))
3320            .collect::<Vec<_>>();
3321        reuse_map
3322            .entry(file.to_path_buf())
3323            .or_default()
3324            .entry(blake3::hash(embed_text.as_bytes()))
3325            .or_default()
3326            .push(ReusableEmbedding { embed_text, vector });
3327    }
3328    if reader.bytes_read() != payload.len() {
3329        return Err("trailing bytes after semantic blob payload".to_string());
3330    }
3331    Ok(())
3332}
3333
3334fn read_blob_bytes<R: Read>(
3335    reader: &mut CountingReader<R>,
3336    total_len: usize,
3337) -> Result<Vec<u8>, String> {
3338    let len = read_u32_stream(reader)? as usize;
3339    if reader.bytes_read().saturating_add(len) > total_len {
3340        return Err("unexpected end of semantic blob bytes".to_string());
3341    }
3342    let mut bytes = vec![0; len];
3343    read_exact_stream(reader, &mut bytes, "unexpected end of semantic blob bytes")?;
3344    Ok(bytes)
3345}
3346
3347/// Search result from a semantic query
3348#[derive(Debug, Clone)]
3349pub struct SemanticResult {
3350    pub file: PathBuf,
3351    pub name: String,
3352    pub qualified_name: Option<String>,
3353    pub kind: SymbolKind,
3354    pub start_line: u32,
3355    pub end_line: u32,
3356    pub exported: bool,
3357    pub snippet: String,
3358    pub score: f32,
3359    pub rank_score: f32,
3360    pub cap_protected: bool,
3361    pub source: &'static str,
3362}
3363
3364fn relativize_semantic_map<T>(
3365    project_root: &Path,
3366    map: HashMap<PathBuf, T>,
3367) -> Option<HashMap<PathBuf, T>> {
3368    map.into_iter()
3369        .map(|(path, value)| cache_relative_path(project_root, &path).map(|path| (path, value)))
3370        .collect()
3371}
3372
3373#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3374struct SemanticArtifactIdentity {
3375    bytes: u64,
3376    modified_nanos: Option<u128>,
3377}
3378
3379#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3380struct SemanticPersistenceState {
3381    identity: SemanticArtifactIdentity,
3382    base_bytes: usize,
3383    segment_count: usize,
3384    segment_bytes: usize,
3385    valid_bytes: usize,
3386}
3387
3388#[derive(Debug, Clone, Copy)]
3389struct SemanticArtifactLayout {
3390    identity: SemanticArtifactIdentity,
3391    base_bytes: usize,
3392    valid_bytes: usize,
3393    segment_count: usize,
3394    segment_bytes: usize,
3395    torn_tail: bool,
3396    bytes_read: usize,
3397}
3398
3399#[derive(Debug)]
3400struct LoadedSemanticArtifact {
3401    index: SemanticIndex,
3402    base_bytes: usize,
3403    valid_bytes: usize,
3404    segment_count: usize,
3405    segment_bytes: usize,
3406    torn_tail: bool,
3407}
3408
3409fn semantic_artifact_identity(path: &Path) -> Option<SemanticArtifactIdentity> {
3410    let metadata = path.metadata().ok()?;
3411    let modified_nanos = metadata
3412        .modified()
3413        .ok()
3414        .and_then(|modified| modified.duration_since(SystemTime::UNIX_EPOCH).ok())
3415        .map(|duration| duration.as_nanos());
3416    Some(SemanticArtifactIdentity {
3417        bytes: metadata.len(),
3418        modified_nanos,
3419    })
3420}
3421
3422fn semantic_persistence_lock_wait(artifact_bytes: u64) -> Duration {
3423    let proportional_seconds = artifact_bytes
3424        .div_ceil(SEMANTIC_PERSIST_LOCK_BYTES_PER_SECOND)
3425        .saturating_add(2);
3426    SEMANTIC_PERSIST_LOCK_MIN_WAIT.max(Duration::from_secs(proportional_seconds))
3427}
3428
3429fn acquire_semantic_persistence_lock(
3430    dir: &Path,
3431    artifact_bytes: u64,
3432) -> io::Result<fs_lock::LockGuard> {
3433    fs_lock::try_acquire(
3434        &dir.join("semantic.persist.lock"),
3435        semantic_persistence_lock_wait(artifact_bytes),
3436    )
3437    .map_err(|error| match error {
3438        fs_lock::AcquireError::Timeout => {
3439            io::Error::other("timed out acquiring semantic persistence lock")
3440        }
3441        fs_lock::AcquireError::Io(error) => error,
3442    })
3443}
3444
3445fn semantic_entry_cmp(left: &&EmbeddingEntry, right: &&EmbeddingEntry) -> std::cmp::Ordering {
3446    let left = *left;
3447    let right = *right;
3448    left.chunk
3449        .file
3450        .cmp(&right.chunk.file)
3451        .then_with(|| left.chunk.name.cmp(&right.chunk.name))
3452        .then_with(|| left.chunk.qualified_name.cmp(&right.chunk.qualified_name))
3453        .then_with(|| {
3454            symbol_kind_to_u8(&left.chunk.kind).cmp(&symbol_kind_to_u8(&right.chunk.kind))
3455        })
3456        .then_with(|| left.chunk.start_line.cmp(&right.chunk.start_line))
3457        .then_with(|| left.chunk.end_line.cmp(&right.chunk.end_line))
3458        .then_with(|| left.chunk.exported.cmp(&right.chunk.exported))
3459        .then_with(|| left.chunk.snippet.cmp(&right.chunk.snippet))
3460        .then_with(|| left.chunk.embed_text.cmp(&right.chunk.embed_text))
3461        .then_with(|| {
3462            left.vector
3463                .iter()
3464                .map(|value| value.to_bits())
3465                .cmp(right.vector.iter().map(|value| value.to_bits()))
3466        })
3467}
3468
3469fn semantic_entry_persistence_eq(left: &EmbeddingEntry, right: &EmbeddingEntry) -> bool {
3470    left.chunk.file == right.chunk.file
3471        && left.chunk.name == right.chunk.name
3472        && left.chunk.qualified_name == right.chunk.qualified_name
3473        && left.chunk.kind == right.chunk.kind
3474        && left.chunk.start_line == right.chunk.start_line
3475        && left.chunk.end_line == right.chunk.end_line
3476        && left.chunk.exported == right.chunk.exported
3477        && left.chunk.snippet == right.chunk.snippet
3478        && left.chunk.embed_text == right.chunk.embed_text
3479        && left.vector.len() == right.vector.len()
3480        && left
3481            .vector
3482            .iter()
3483            .zip(&right.vector)
3484            .all(|(left, right)| left.to_bits() == right.to_bits())
3485}
3486
3487fn semantic_entries_by_file(index: &SemanticIndex) -> HashMap<&Path, Vec<&EmbeddingEntry>> {
3488    let mut by_file: HashMap<&Path, Vec<&EmbeddingEntry>> = HashMap::new();
3489    for entry in &index.entries {
3490        by_file
3491            .entry(entry.chunk.file.as_path())
3492            .or_default()
3493            .push(entry);
3494    }
3495    for entries in by_file.values_mut() {
3496        entries.sort_by(semantic_entry_cmp);
3497    }
3498    by_file
3499}
3500
3501fn semantic_changed_paths(previous: &SemanticIndex, current: &SemanticIndex) -> BTreeSet<PathBuf> {
3502    let previous_entries = semantic_entries_by_file(previous);
3503    let current_entries = semantic_entries_by_file(current);
3504    let mut paths = BTreeSet::new();
3505    paths.extend(previous.file_mtimes.keys().cloned());
3506    paths.extend(current.file_mtimes.keys().cloned());
3507    paths.extend(previous_entries.keys().map(|path| (*path).to_path_buf()));
3508    paths.extend(current_entries.keys().map(|path| (*path).to_path_buf()));
3509    paths
3510        .into_iter()
3511        .filter(|path| {
3512            if previous.file_mtimes.get(path) != current.file_mtimes.get(path)
3513                || previous.file_sizes.get(path) != current.file_sizes.get(path)
3514                || previous.file_hashes.get(path) != current.file_hashes.get(path)
3515            {
3516                return true;
3517            }
3518            let previous = previous_entries
3519                .get(path.as_path())
3520                .map(Vec::as_slice)
3521                .unwrap_or_default();
3522            let current = current_entries
3523                .get(path.as_path())
3524                .map(Vec::as_slice)
3525                .unwrap_or_default();
3526            previous.len() != current.len()
3527                || !previous
3528                    .iter()
3529                    .zip(current)
3530                    .all(|(previous, current)| semantic_entry_persistence_eq(previous, current))
3531        })
3532        .collect()
3533}
3534
3535impl SemanticIndex {
3536    fn from_shared_base(project_root: PathBuf, shared_base: Arc<SharedSemanticBase>) -> Self {
3537        debug_assert!(project_root.is_absolute());
3538        Self {
3539            entries: Vec::new(),
3540            file_mtimes: HashMap::new(),
3541            file_sizes: HashMap::new(),
3542            any_missing_sizes: false,
3543            file_hashes: HashMap::new(),
3544            dimension: shared_base.dimension,
3545            fingerprint: shared_base.fingerprint.clone(),
3546            project_root,
3547            deferred_files: HashSet::new(),
3548            dirty_paths: Arc::clone(&shared_base.dirty_paths),
3549            persistence: Arc::clone(&shared_base.persistence),
3550            last_append_read_bytes: Arc::new(AtomicUsize::new(0)),
3551            skipped_rows: shared_base.skipped_rows,
3552            shared_base: Some(shared_base),
3553            #[cfg(test)]
3554            removal_retain_passes: 0,
3555        }
3556    }
3557
3558    pub(crate) fn adopt_frozen_base_for_root(
3559        &mut self,
3560        project_root: &Path,
3561        config: &SemanticBackendConfig,
3562    ) -> Option<Self> {
3563        let expected = SemanticIndexFingerprint::for_config_dimension(config, self.dimension());
3564        if !self
3565            .fingerprint()
3566            .is_some_and(|fingerprint| fingerprint.matches(&expected))
3567        {
3568            return None;
3569        }
3570
3571        if let Some(base) = self.shared_base.as_ref() {
3572            return Some(Self::from_shared_base(
3573                project_root.to_path_buf(),
3574                Arc::clone(base),
3575            ));
3576        }
3577
3578        if !self.paths_are_shareable() {
3579            return None;
3580        }
3581
3582        // Move the resident vectors into one immutable relative-path base rather
3583        // than cloning them. The owner and each matching worktree then retain
3584        // only an Arc plus their own root for path projection.
3585        let owner_root = self.project_root.clone();
3586        let placeholder = Self::new(owner_root.clone(), self.dimension());
3587        let private = std::mem::replace(self, placeholder);
3588        let base = match private.into_shared_base() {
3589            Ok(base) => Arc::new(base),
3590            Err(private) => {
3591                // Unreachable after the shareability check (this index is held
3592                // exclusively, so no path can appear between the check and the
3593                // move), but a private index is never worth a process: restore
3594                // it and decline to share.
3595                crate::slog_warn!(
3596                    "semantic index for {} could not be frozen into a shared base; keeping it private",
3597                    owner_root.display()
3598                );
3599                *self = private;
3600                return None;
3601            }
3602        };
3603        *self = Self::from_shared_base(owner_root, Arc::clone(&base));
3604        Some(Self::from_shared_base(project_root.to_path_buf(), base))
3605    }
3606
3607    /// Every path this index carries must be expressible relative to its own
3608    /// root before the index can be frozen into a base shared across roots.
3609    /// The dirty-path set belongs here too: it is persisted with the base, and
3610    /// a delta path outside the root once turned the freeze into a panic.
3611    fn paths_are_shareable(&self) -> bool {
3612        let shareable = |path: &Path| cache_relative_path(&self.project_root, path).is_some();
3613        self.entries
3614            .iter()
3615            .all(|entry| shareable(&entry.chunk.file))
3616            && self
3617                .file_mtimes
3618                .keys()
3619                .chain(self.file_sizes.keys())
3620                .chain(self.file_hashes.keys())
3621                .chain(self.deferred_files.iter())
3622                .all(|path| shareable(path))
3623            && self
3624                .dirty_paths
3625                .lock()
3626                .unwrap_or_else(std::sync::PoisonError::into_inner)
3627                .as_ref()
3628                .is_none_or(|paths| paths.iter().all(|path| shareable(path)))
3629    }
3630
3631    fn into_shared_base(mut self) -> Result<SharedSemanticBase, Self> {
3632        // Relativize every path before moving anything, so a path outside the
3633        // root hands the index back intact instead of leaving a half-moved
3634        // one behind. Only the path strings are copied here; the vectors move.
3635        let root = self.project_root.clone();
3636        let relative = |path: &Path| cache_relative_path(&root, path);
3637        let Some(entry_files) = self
3638            .entries
3639            .iter()
3640            .map(|entry| relative(&entry.chunk.file))
3641            .collect::<Option<Vec<_>>>()
3642        else {
3643            return Err(self);
3644        };
3645        let Some(deferred_files) = self
3646            .deferred_files
3647            .iter()
3648            .map(|path| relative(path))
3649            .collect::<Option<HashSet<_>>>()
3650        else {
3651            return Err(self);
3652        };
3653        let dirty_paths = {
3654            let guard = self
3655                .dirty_paths
3656                .lock()
3657                .unwrap_or_else(std::sync::PoisonError::into_inner);
3658            match guard.as_ref() {
3659                Some(paths) => paths
3660                    .iter()
3661                    .map(|path| relative(path))
3662                    .collect::<Option<BTreeSet<_>>>()
3663                    .map(Some),
3664                None => Some(None),
3665            }
3666        };
3667        let Some(dirty_paths) = dirty_paths else {
3668            return Err(self);
3669        };
3670        let (Some(file_mtimes), Some(file_sizes), Some(file_hashes)) = (
3671            relativize_semantic_map(&root, self.file_mtimes.clone()),
3672            relativize_semantic_map(&root, self.file_sizes.clone()),
3673            relativize_semantic_map(&root, self.file_hashes.clone()),
3674        ) else {
3675            return Err(self);
3676        };
3677        for (entry, file) in self.entries.iter_mut().zip(entry_files) {
3678            entry.chunk.file = file;
3679        }
3680        let persistence = *self
3681            .persistence
3682            .lock()
3683            .unwrap_or_else(std::sync::PoisonError::into_inner);
3684        Ok(SharedSemanticBase {
3685            entries: self.entries,
3686            file_mtimes,
3687            file_sizes,
3688            any_missing_sizes: self.any_missing_sizes,
3689            file_hashes,
3690            dimension: self.dimension,
3691            fingerprint: self.fingerprint,
3692            deferred_files,
3693            skipped_rows: self.skipped_rows,
3694            dirty_paths: Arc::new(Mutex::new(dirty_paths)),
3695            persistence: Arc::new(Mutex::new(persistence)),
3696        })
3697    }
3698
3699    fn materialize_shared_base(&mut self) {
3700        let Some(base) = self.shared_base.take() else {
3701            return;
3702        };
3703        self.entries = base
3704            .entries
3705            .iter()
3706            .cloned()
3707            .map(|mut entry| {
3708                entry.chunk.file = self.project_root.join(&entry.chunk.file);
3709                entry
3710            })
3711            .collect();
3712        self.file_mtimes = base
3713            .file_mtimes
3714            .iter()
3715            .map(|(path, value)| (self.project_root.join(path), *value))
3716            .collect();
3717        self.file_sizes = base
3718            .file_sizes
3719            .iter()
3720            .map(|(path, value)| (self.project_root.join(path), *value))
3721            .collect();
3722        self.any_missing_sizes = base.any_missing_sizes;
3723        self.file_hashes = base
3724            .file_hashes
3725            .iter()
3726            .map(|(path, value)| (self.project_root.join(path), *value))
3727            .collect();
3728        self.dimension = base.dimension;
3729        self.fingerprint = base.fingerprint.clone();
3730        self.skipped_rows = base.skipped_rows;
3731        self.deferred_files = base
3732            .deferred_files
3733            .iter()
3734            .map(|path| self.project_root.join(path))
3735            .collect();
3736        let dirty_paths = base
3737            .dirty_paths
3738            .lock()
3739            .unwrap_or_else(std::sync::PoisonError::into_inner)
3740            .as_ref()
3741            .map(|paths| {
3742                paths
3743                    .iter()
3744                    .map(|path| self.project_root.join(path))
3745                    .collect()
3746            });
3747        let persistence = *base
3748            .persistence
3749            .lock()
3750            .unwrap_or_else(std::sync::PoisonError::into_inner);
3751        self.set_dirty_paths(dirty_paths);
3752        self.set_persistence(persistence);
3753    }
3754
3755    pub fn new(project_root: PathBuf, dimension: usize) -> Self {
3756        debug_assert!(project_root.is_absolute());
3757        Self {
3758            entries: Vec::new(),
3759            file_mtimes: HashMap::new(),
3760            file_sizes: HashMap::new(),
3761            any_missing_sizes: false,
3762            file_hashes: HashMap::new(),
3763            dimension,
3764            fingerprint: None,
3765            project_root,
3766            deferred_files: HashSet::new(),
3767            shared_base: None,
3768            dirty_paths: Arc::new(Mutex::new(None)),
3769            persistence: Arc::new(Mutex::new(None)),
3770            last_append_read_bytes: Arc::new(AtomicUsize::new(0)),
3771            skipped_rows: 0,
3772            #[cfg(test)]
3773            removal_retain_passes: 0,
3774        }
3775    }
3776
3777    /// Number of rows omitted because the backend still rejected their header floor.
3778    pub fn skipped_rows(&self) -> usize {
3779        self.skipped_rows
3780    }
3781
3782    /// Number of embedded symbol entries.
3783    pub fn entry_count(&self) -> usize {
3784        self.shared_base
3785            .as_ref()
3786            .map(|base| base.entries.len())
3787            .unwrap_or_else(|| self.entries.len())
3788    }
3789
3790    /// Estimate resident semantic-index bytes from the vectors and metadata
3791    /// actually held by each entry. This intentionally excludes allocator and
3792    /// hash-table bucket overhead, which are not cheaply observable.
3793    pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
3794        if let Some(base) = &self.shared_base {
3795            return crate::memory::MemoryEstimate::estimated(0)
3796                .count("entries", base.entries.len())
3797                .count("dimensions", base.dimension)
3798                .count("indexed_files", base.file_mtimes.len())
3799                .count("shared_base_entries", base.entries.len())
3800                .count("overlay_entries", 0)
3801                .count_u64("vector_bytes", 0)
3802                .count_u64("text_bytes", 0)
3803                .count_u64("metadata_bytes", 0);
3804        }
3805        if self.entries.is_empty()
3806            && self.file_mtimes.is_empty()
3807            && self.file_sizes.is_empty()
3808            && self.file_hashes.is_empty()
3809            && self.deferred_files.is_empty()
3810        {
3811            return crate::memory::MemoryEstimate::estimated(0)
3812                .count("entries", 0)
3813                .count("dimensions", self.dimension)
3814                .count("indexed_files", 0)
3815                .count_u64("vector_bytes", 0)
3816                .count_u64("text_bytes", 0)
3817                .count_u64("metadata_bytes", 0)
3818                .count_u64("average_text_bytes", 0)
3819                .count_u64("average_metadata_bytes", 0);
3820        }
3821        let vector_bytes = self.entries.iter().fold(0u64, |bytes, entry| {
3822            bytes.saturating_add(
3823                crate::memory::usize_to_u64(entry.vector.len())
3824                    .saturating_mul(std::mem::size_of::<f32>() as u64),
3825            )
3826        });
3827        let text_bytes = self.entries.iter().fold(0u64, |bytes, entry| {
3828            let chunk = &entry.chunk;
3829            bytes
3830                .saturating_add(crate::memory::path_bytes(&chunk.file))
3831                .saturating_add(crate::memory::usize_to_u64(chunk.name.len()))
3832                .saturating_add(
3833                    chunk
3834                        .qualified_name
3835                        .as_ref()
3836                        .map(|name| crate::memory::usize_to_u64(name.len()))
3837                        .unwrap_or(0),
3838                )
3839                .saturating_add(crate::memory::usize_to_u64(chunk.embed_text.len()))
3840                .saturating_add(crate::memory::usize_to_u64(chunk.snippet.len()))
3841        });
3842        let entry_metadata_bytes = crate::memory::usize_to_u64(self.entries.len())
3843            .saturating_mul(std::mem::size_of::<EmbeddingEntry>() as u64);
3844        let file_metadata_bytes = self
3845            .file_mtimes
3846            .keys()
3847            .chain(self.file_sizes.keys())
3848            .chain(self.file_hashes.keys())
3849            .chain(self.deferred_files.iter())
3850            .map(|path| crate::memory::path_bytes(path))
3851            .fold(0u64, u64::saturating_add)
3852            .saturating_add(
3853                crate::memory::usize_to_u64(self.file_mtimes.len())
3854                    .saturating_mul(std::mem::size_of::<SystemTime>() as u64),
3855            )
3856            .saturating_add(
3857                crate::memory::usize_to_u64(self.file_sizes.len())
3858                    .saturating_mul(std::mem::size_of::<u64>() as u64),
3859            )
3860            .saturating_add(
3861                crate::memory::usize_to_u64(self.file_hashes.len())
3862                    .saturating_mul(std::mem::size_of::<blake3::Hash>() as u64),
3863            );
3864        let index_metadata_bytes = crate::memory::path_bytes(&self.project_root).saturating_add(
3865            self.fingerprint
3866                .as_ref()
3867                .map(|fingerprint| {
3868                    crate::memory::usize_to_u64(fingerprint.backend.len())
3869                        .saturating_add(crate::memory::usize_to_u64(fingerprint.model.len()))
3870                        .saturating_add(crate::memory::usize_to_u64(fingerprint.base_url.len()))
3871                })
3872                .unwrap_or(0),
3873        );
3874        let metadata_bytes = entry_metadata_bytes
3875            .saturating_add(file_metadata_bytes)
3876            .saturating_add(index_metadata_bytes);
3877        let entry_count = crate::memory::usize_to_u64(self.entries.len());
3878        crate::memory::MemoryEstimate::estimated(
3879            vector_bytes
3880                .saturating_add(text_bytes)
3881                .saturating_add(metadata_bytes),
3882        )
3883        .count("entries", self.entries.len())
3884        .count("dimensions", self.dimension)
3885        .count("indexed_files", self.file_mtimes.len())
3886        .count_u64("vector_bytes", vector_bytes)
3887        .count_u64("text_bytes", text_bytes)
3888        .count_u64("metadata_bytes", metadata_bytes)
3889        .count_u64(
3890            "average_text_bytes",
3891            text_bytes.checked_div(entry_count).unwrap_or(0),
3892        )
3893        .count_u64(
3894            "average_metadata_bytes",
3895            metadata_bytes.checked_div(entry_count).unwrap_or(0),
3896        )
3897    }
3898
3899    /// Number of files currently tracked by the semantic index.
3900    pub fn indexed_file_count(&self) -> usize {
3901        self.shared_base
3902            .as_ref()
3903            .map(|base| base.file_mtimes.len())
3904            .unwrap_or_else(|| self.file_mtimes.len())
3905    }
3906
3907    /// Human-readable status label for the index.
3908    pub fn status_label(&self) -> &'static str {
3909        if self.entry_count() == 0 {
3910            "empty"
3911        } else {
3912            "ready"
3913        }
3914    }
3915
3916    fn collect_chunks(
3917        project_root: &Path,
3918        files: &[PathBuf],
3919        embed_text_caps: EmbedTextCaps,
3920    ) -> (Vec<SemanticChunk>, HashMap<PathBuf, IndexedFileMetadata>) {
3921        let collect_started = Instant::now();
3922        let collect_one = |file: &Path, sched: Duration| {
3923            let mut phases = SemanticCollectPhaseTimings {
3924                sched,
3925                ..SemanticCollectPhaseTimings::default()
3926            };
3927            let result = collect_semantic_file(project_root, file, embed_text_caps, &mut phases);
3928            (file.to_path_buf(), result, phases)
3929        };
3930        let per_file: Vec<CollectedSemanticFile> = if files.len() <= 2 {
3931            files
3932                .iter()
3933                .map(|file| collect_one(file, Duration::ZERO))
3934                .collect()
3935        } else {
3936            files
3937                .par_iter()
3938                .map(|file| collect_one(file, collect_started.elapsed()))
3939                .collect()
3940        };
3941
3942        let mut chunks: Vec<SemanticChunk> = Vec::new();
3943        let mut file_metadata: HashMap<PathBuf, IndexedFileMetadata> = HashMap::new();
3944        let mut phases = SemanticCollectPhaseTimings::default();
3945
3946        for (file, result, file_phases) in per_file {
3947            phases.add_assign(file_phases);
3948            match result {
3949                Ok((metadata, file_chunks)) => {
3950                    file_metadata.insert(file, metadata);
3951                    chunks.extend(file_chunks);
3952                }
3953                Err(error) => {
3954                    // "unsupported file extension" is expected for non-code files
3955                    // (json, xml, .gitignore, etc.) that get included in the
3956                    // project walk. Pre-fix this was swallowed by .unwrap_or_default();
3957                    // we now skip silently to keep the log clean. Only real read/parse
3958                    // errors are worth surfacing.
3959                    if error == "unsupported file extension" {
3960                        continue;
3961                    }
3962                    slog_warn!(
3963                        "failed to collect semantic chunks for {}: {}",
3964                        file.display(),
3965                        error
3966                    );
3967                }
3968            }
3969        }
3970
3971        let collect_ms = collect_started
3972            .elapsed()
3973            .as_millis()
3974            .min(u128::from(u64::MAX)) as u64;
3975        crate::logging::note_semantic_collect(chunks.len(), file_metadata.len(), collect_ms);
3976        slog_info!(
3977            "semantic collect: {} chunks from {} files in {} ms",
3978            chunks.len(),
3979            file_metadata.len(),
3980            collect_ms
3981        );
3982        if let Some(scope) = crate::logging::current_index_build() {
3983            if scope.plane == crate::logging::IndexPlane::Semantic {
3984                crate::logging::log_index_event(
3985                    crate::logging::IndexEvent::from_scope(
3986                        crate::logging::IndexEventKind::BuildProgress,
3987                        &scope,
3988                    )
3989                    .field("stage", "collect")
3990                    .field("completed", 1)
3991                    .field("total", 1)
3992                    .field("elapsed_ms", scope.elapsed_ms())
3993                    .field("chunks", chunks.len())
3994                    .field("files", file_metadata.len()),
3995                );
3996            }
3997        }
3998        if collect_ms > 50 {
3999            slog_info!(
4000                "semantic collect phases: sched={}ms read_hash={}ms parse={}ms extract={}ms build={}ms",
4001                phases.sched.as_millis(),
4002                phases.read_hash.as_millis(),
4003                phases.parse.as_millis(),
4004                phases.extract.as_millis(),
4005                phases.build.as_millis(),
4006            );
4007        }
4008
4009        (chunks, file_metadata)
4010    }
4011
4012    fn build_chunk_reuse_map(&self, files: &[PathBuf]) -> ChunkReuseMap {
4013        let requested: HashSet<&Path> = files.iter().map(PathBuf::as_path).collect();
4014        let mut reuse_map: ChunkReuseMap = HashMap::new();
4015
4016        for entry in &self.entries {
4017            if !requested.contains(entry.chunk.file.as_path()) {
4018                continue;
4019            }
4020
4021            // `embed_text` is already persisted in the current on-disk format,
4022            // so refresh-time reuse can hash it in memory and confirm the exact
4023            // string without bumping `SEMANTIC_INDEX_VERSION` and forcing every
4024            // user through a full rebuild.
4025            let hash = blake3::hash(entry.chunk.embed_text.as_bytes());
4026            reuse_map
4027                .entry(entry.chunk.file.clone())
4028                .or_default()
4029                .entry(hash)
4030                .or_default()
4031                .push(ReusableEmbedding {
4032                    embed_text: entry.chunk.embed_text.clone(),
4033                    vector: entry.vector.clone(),
4034                });
4035        }
4036
4037        reuse_map
4038    }
4039
4040    fn extend_reuse_map_from_blob_store<R>(
4041        &self,
4042        project_root: &Path,
4043        files: impl IntoIterator<Item = PathBuf>,
4044        reuse_map: &mut ChunkReuseMap,
4045        reuse_blob: &mut R,
4046    ) where
4047        R: FnMut(&Path) -> Option<Vec<u8>>,
4048    {
4049        let Some(fingerprint) = self.fingerprint().map(SemanticIndexFingerprint::as_string) else {
4050            return;
4051        };
4052        let mut reused_files = 0usize;
4053        for file in files {
4054            let Some(payload) = reuse_blob(&file) else {
4055                continue;
4056            };
4057            match extend_reuse_map_from_semantic_blob(
4058                reuse_map,
4059                &file,
4060                &payload,
4061                &fingerprint,
4062                self.dimension,
4063            ) {
4064                Ok(()) => reused_files += 1,
4065                Err(error) => slog_warn!(
4066                    "semantic blob reuse rejected for {}: {}",
4067                    file.display(),
4068                    error
4069                ),
4070            }
4071        }
4072        if reused_files > 0 {
4073            slog_info!(
4074                "semantic refresh reused content-addressed vectors: root={} files={}",
4075                project_root.display(),
4076                reused_files
4077            );
4078        }
4079    }
4080
4081    fn reusable_vector_for_chunk(
4082        reuse_map: &ChunkReuseMap,
4083        chunk: &SemanticChunk,
4084    ) -> Option<Vec<f32>> {
4085        let hash = blake3::hash(chunk.embed_text.as_bytes());
4086        reuse_map
4087            .get(&chunk.file)?
4088            .get(&hash)?
4089            .iter()
4090            .find(|candidate| candidate.embed_text == chunk.embed_text)
4091            .map(|candidate| candidate.vector.clone())
4092    }
4093
4094    fn entries_for_chunks_with_reuse<F, P>(
4095        chunks: Vec<SemanticChunk>,
4096        reuse_map: &ChunkReuseMap,
4097        embed_fn: &mut F,
4098        max_batch_size: usize,
4099        initial_observed_dimension: Option<usize>,
4100        refresh_label: &str,
4101        progress: &mut P,
4102    ) -> Result<(Vec<EmbeddingEntry>, Option<usize>, usize), String>
4103    where
4104        F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
4105        P: FnMut(usize, usize),
4106    {
4107        let total_chunks = chunks.len();
4108        progress(0, total_chunks);
4109
4110        let mut entries_by_chunk: Vec<Option<EmbeddingEntry>> = vec![None; total_chunks];
4111        let mut misses: Vec<(usize, SemanticChunk)> = Vec::new();
4112
4113        for (chunk_index, chunk) in chunks.into_iter().enumerate() {
4114            if let Some(vector) = Self::reusable_vector_for_chunk(reuse_map, &chunk) {
4115                entries_by_chunk[chunk_index] = Some(EmbeddingEntry::new(chunk, vector));
4116            } else {
4117                misses.push((chunk_index, chunk));
4118            }
4119        }
4120
4121        let mut completed = total_chunks.saturating_sub(misses.len());
4122        if completed > 0 {
4123            progress(completed, total_chunks);
4124        }
4125
4126        let batch_size = max_batch_size.max(1);
4127        let mut observed_dimension = initial_observed_dimension;
4128        let mut skipped_rows = 0usize;
4129
4130        for batch_start in (0..misses.len()).step_by(batch_size) {
4131            let batch_end = (batch_start + batch_size).min(misses.len());
4132            let batch_texts: Vec<String> = misses[batch_start..batch_end]
4133                .iter()
4134                .map(|(_, chunk)| chunk.embed_text.clone())
4135                .collect();
4136
4137            let rows = execute_build_embedding_batch(batch_texts, embed_fn)?;
4138            for (i, row) in rows.into_iter().enumerate() {
4139                let (chunk_index, mut chunk) = misses[batch_start + i].clone();
4140                match row {
4141                    BuildEmbeddingRow::Embedded {
4142                        embedded_text,
4143                        vector,
4144                    } => {
4145                        match observed_dimension {
4146                            None => observed_dimension = Some(vector.len()),
4147                            Some(expected) if vector.len() != expected => {
4148                                return Err(format!(
4149                                    "embedding dimension changed during {refresh_label}: cached index uses {expected}, new vectors use {}",
4150                                    vector.len()
4151                                ));
4152                            }
4153                            _ => {}
4154                        }
4155                        chunk.embed_text = embedded_text;
4156                        entries_by_chunk[chunk_index] = Some(EmbeddingEntry::new(chunk, vector));
4157                    }
4158                    BuildEmbeddingRow::Skipped {
4159                        embedded_text,
4160                        reason,
4161                    } => {
4162                        log_skipped_row_warning(&chunk, &embedded_text, &reason);
4163                        skipped_rows = skipped_rows.saturating_add(1);
4164                    }
4165                }
4166            }
4167
4168            completed += batch_end - batch_start;
4169            progress(completed, total_chunks);
4170        }
4171
4172        let entries = entries_by_chunk.into_iter().flatten().collect();
4173
4174        Ok((entries, observed_dimension, skipped_rows))
4175    }
4176
4177    fn build_from_chunks<F, P, C>(
4178        project_root: &Path,
4179        chunks: Vec<SemanticChunk>,
4180        file_metadata: HashMap<PathBuf, IndexedFileMetadata>,
4181        embed_fn: &mut F,
4182        max_batch_size: usize,
4183        mut progress: Option<&mut P>,
4184        should_continue: &mut C,
4185    ) -> Result<Self, String>
4186    where
4187        F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
4188        P: FnMut(usize, usize),
4189        C: FnMut() -> bool,
4190    {
4191        debug_assert!(project_root.is_absolute());
4192        let total_chunks = chunks.len();
4193
4194        if chunks.is_empty() {
4195            return Ok(Self {
4196                entries: Vec::new(),
4197                file_mtimes: file_metadata
4198                    .iter()
4199                    .map(|(path, metadata)| (path.clone(), metadata.mtime))
4200                    .collect(),
4201                file_sizes: file_metadata
4202                    .iter()
4203                    .map(|(path, metadata)| (path.clone(), metadata.size))
4204                    .collect(),
4205                any_missing_sizes: false,
4206                file_hashes: file_metadata
4207                    .into_iter()
4208                    .map(|(path, metadata)| (path, metadata.content_hash))
4209                    .collect(),
4210                dimension: DEFAULT_DIMENSION,
4211                fingerprint: None,
4212                project_root: project_root.to_path_buf(),
4213                deferred_files: HashSet::new(),
4214                shared_base: None,
4215                dirty_paths: Arc::new(Mutex::new(None)),
4216                persistence: Arc::new(Mutex::new(None)),
4217                last_append_read_bytes: Arc::new(AtomicUsize::new(0)),
4218                skipped_rows: 0,
4219                #[cfg(test)]
4220                removal_retain_passes: 0,
4221            });
4222        }
4223
4224        let mut entries: Vec<EmbeddingEntry> = Vec::with_capacity(chunks.len());
4225        let mut expected_dimension: Option<usize> = None;
4226        let mut skipped_rows = 0usize;
4227        let mut completed_rows = 0usize;
4228        let batch_size = max_batch_size.max(1);
4229        let embed_started = std::time::Instant::now();
4230        let batch_count = total_chunks.div_ceil(batch_size);
4231        for (batch_index, batch_start) in (0..chunks.len()).step_by(batch_size).enumerate() {
4232            if !should_continue() {
4233                slog_info!(
4234                    "semantic embed superseded, stopping after {}/{} batches",
4235                    batch_index,
4236                    batch_count
4237                );
4238                return Err(format!(
4239                    "semantic build superseded after {batch_index}/{batch_count} batches"
4240                ));
4241            }
4242            let batch_end = (batch_start + batch_size).min(chunks.len());
4243            let batch_texts: Vec<String> = chunks[batch_start..batch_end]
4244                .iter()
4245                .map(|chunk| chunk.embed_text.clone())
4246                .collect();
4247
4248            let rows = execute_build_embedding_batch(batch_texts, embed_fn)?;
4249            for (i, row) in rows.into_iter().enumerate() {
4250                let mut chunk = chunks[batch_start + i].clone();
4251                match row {
4252                    BuildEmbeddingRow::Embedded {
4253                        embedded_text,
4254                        vector,
4255                    } => {
4256                        match expected_dimension {
4257                            None => expected_dimension = Some(vector.len()),
4258                            Some(expected) if vector.len() != expected => {
4259                                return Err(format!(
4260                                    "embedding dimension changed across batches: expected {expected}, got {}",
4261                                    vector.len()
4262                                ));
4263                            }
4264                            _ => {}
4265                        }
4266                        chunk.embed_text = embedded_text;
4267                        entries.push(EmbeddingEntry::new(chunk, vector));
4268                    }
4269                    BuildEmbeddingRow::Skipped {
4270                        embedded_text,
4271                        reason,
4272                    } => {
4273                        log_skipped_row_warning(&chunk, &embedded_text, &reason);
4274                        skipped_rows = skipped_rows.saturating_add(1);
4275                    }
4276                }
4277            }
4278
4279            completed_rows += batch_end - batch_start;
4280            if let Some(callback) = progress.as_mut() {
4281                callback(completed_rows, total_chunks);
4282            }
4283            if let Some(scope) = crate::logging::current_index_build() {
4284                if scope.plane == crate::logging::IndexPlane::Semantic {
4285                    crate::logging::log_index_event(
4286                        crate::logging::IndexEvent::from_scope(
4287                            crate::logging::IndexEventKind::BuildProgress,
4288                            &scope,
4289                        )
4290                        .field("stage", "embed")
4291                        .field("batch", batch_index + 1)
4292                        .field("total_batches", batch_count)
4293                        .field("chunks_done", completed_rows)
4294                        .field("completed", completed_rows)
4295                        .field("total", total_chunks)
4296                        .field("elapsed_ms", scope.elapsed_ms()),
4297                    );
4298                }
4299            }
4300            if (batch_index + 1) % 25 == 0 {
4301                slog_info!(
4302                    "semantic embed progress: batch {}/{} ({} / {} chunks)",
4303                    batch_index + 1,
4304                    batch_count,
4305                    completed_rows,
4306                    total_chunks
4307                );
4308            }
4309        }
4310
4311        let embed_ms = embed_started.elapsed().as_millis();
4312        let rate = (total_chunks as u128 * 1000)
4313            .checked_div(embed_ms)
4314            .unwrap_or(0) as u64;
4315        slog_info!(
4316            "semantic embed: {} chunks in {} batches, {} ms ({} chunks/s), skipped_rows={}",
4317            total_chunks,
4318            batch_count,
4319            embed_ms,
4320            rate,
4321            skipped_rows,
4322        );
4323
4324        let dimension = entries
4325            .first()
4326            .map(|entry| entry.vector.len())
4327            .unwrap_or(DEFAULT_DIMENSION);
4328
4329        Ok(Self {
4330            entries,
4331            file_mtimes: file_metadata
4332                .iter()
4333                .map(|(path, metadata)| (path.clone(), metadata.mtime))
4334                .collect(),
4335            file_sizes: file_metadata
4336                .iter()
4337                .map(|(path, metadata)| (path.clone(), metadata.size))
4338                .collect(),
4339            any_missing_sizes: false,
4340            file_hashes: file_metadata
4341                .into_iter()
4342                .map(|(path, metadata)| (path, metadata.content_hash))
4343                .collect(),
4344            dimension,
4345            fingerprint: None,
4346            project_root: project_root.to_path_buf(),
4347            deferred_files: HashSet::new(),
4348            shared_base: None,
4349            dirty_paths: Arc::new(Mutex::new(None)),
4350            persistence: Arc::new(Mutex::new(None)),
4351            last_append_read_bytes: Arc::new(AtomicUsize::new(0)),
4352            skipped_rows,
4353            #[cfg(test)]
4354            removal_retain_passes: 0,
4355        })
4356    }
4357
4358    /// Build the semantic index from a set of files using the provided embedding function.
4359    /// `embed_fn` takes a batch of texts and returns a batch of embedding vectors.
4360    pub fn build<F>(
4361        project_root: &Path,
4362        files: &[PathBuf],
4363        embed_fn: &mut F,
4364        max_batch_size: usize,
4365    ) -> Result<Self, String>
4366    where
4367        F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
4368    {
4369        Self::build_with_caps(
4370            project_root,
4371            files,
4372            embed_fn,
4373            max_batch_size,
4374            EmbedTextCaps::default(),
4375        )
4376    }
4377
4378    /// Build using explicitly resolved symbol-row caps.
4379    pub fn build_with_caps<F>(
4380        project_root: &Path,
4381        files: &[PathBuf],
4382        embed_fn: &mut F,
4383        max_batch_size: usize,
4384        embed_text_caps: EmbedTextCaps,
4385    ) -> Result<Self, String>
4386    where
4387        F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
4388    {
4389        let (_guard, scope, mut failure_guard) = begin_semantic_index_build(project_root);
4390        let (chunks, file_mtimes) = Self::collect_chunks(project_root, files, embed_text_caps);
4391        let mut should_continue = || true;
4392        let result = Self::build_from_chunks(
4393            project_root,
4394            chunks,
4395            file_mtimes,
4396            embed_fn,
4397            max_batch_size,
4398            Option::<&mut fn(usize, usize)>::None,
4399            &mut should_continue,
4400        );
4401        finish_semantic_index_build(&scope, &mut failure_guard, &result);
4402        result
4403    }
4404
4405    /// Build the semantic index and report embedding progress using entry counts.
4406    pub fn build_with_progress<F, P>(
4407        project_root: &Path,
4408        files: &[PathBuf],
4409        embed_fn: &mut F,
4410        max_batch_size: usize,
4411        progress: &mut P,
4412    ) -> Result<Self, String>
4413    where
4414        F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
4415        P: FnMut(usize, usize),
4416    {
4417        let (_guard, scope, mut failure_guard) = begin_semantic_index_build(project_root);
4418        let (chunks, file_mtimes) =
4419            Self::collect_chunks(project_root, files, EmbedTextCaps::default());
4420        let total_chunks = chunks.len();
4421        progress(0, total_chunks);
4422        let mut should_continue = || true;
4423        let result = Self::build_from_chunks(
4424            project_root,
4425            chunks,
4426            file_mtimes,
4427            embed_fn,
4428            max_batch_size,
4429            Some(progress),
4430            &mut should_continue,
4431        );
4432        finish_semantic_index_build(&scope, &mut failure_guard, &result);
4433        result
4434    }
4435
4436    /// Build the semantic index while checking cancellation before every embed
4437    /// batch. A batch already in flight is allowed to finish, then the partial
4438    /// result is discarded before the next request can start.
4439    pub fn build_with_progress_and_cancellation<F, P, C>(
4440        project_root: &Path,
4441        files: &[PathBuf],
4442        embed_fn: &mut F,
4443        max_batch_size: usize,
4444        progress: &mut P,
4445        should_continue: &mut C,
4446    ) -> Result<Self, String>
4447    where
4448        F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
4449        P: FnMut(usize, usize),
4450        C: FnMut() -> bool,
4451    {
4452        Self::build_with_progress_and_cancellation_caps(
4453            project_root,
4454            files,
4455            embed_fn,
4456            max_batch_size,
4457            EmbedTextCaps::default(),
4458            progress,
4459            should_continue,
4460        )
4461    }
4462
4463    pub fn build_with_progress_and_cancellation_caps<F, P, C>(
4464        project_root: &Path,
4465        files: &[PathBuf],
4466        embed_fn: &mut F,
4467        max_batch_size: usize,
4468        embed_text_caps: EmbedTextCaps,
4469        progress: &mut P,
4470        should_continue: &mut C,
4471    ) -> Result<Self, String>
4472    where
4473        F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
4474        P: FnMut(usize, usize),
4475        C: FnMut() -> bool,
4476    {
4477        let (_guard, scope, mut failure_guard) = begin_semantic_index_build(project_root);
4478        let (chunks, file_mtimes) = Self::collect_chunks(project_root, files, embed_text_caps);
4479        let total_chunks = chunks.len();
4480        progress(0, total_chunks);
4481        let result = Self::build_from_chunks(
4482            project_root,
4483            chunks,
4484            file_mtimes,
4485            embed_fn,
4486            max_batch_size,
4487            Some(progress),
4488            should_continue,
4489        );
4490        finish_semantic_index_build(&scope, &mut failure_guard, &result);
4491        result
4492    }
4493
4494    /// Incrementally refresh entries for changed/new files only, preserving cached
4495    /// embeddings for unchanged files. Used when loading the index from disk and
4496    /// finding that a small fraction of files have moved on, deleted, or appeared.
4497    ///
4498    /// Returns `RefreshSummary` describing what changed. On success, `self` is
4499    /// mutated in place and remains a valid index.
4500    ///
4501    /// `current_files` is the full set of files the project considers indexable
4502    /// (typically `walk_project_files(...)`). Files in the cache that are no
4503    /// longer in this set are treated as deleted.
4504    pub fn refresh_stale_files<F, P>(
4505        &mut self,
4506        project_root: &Path,
4507        current_files: &[PathBuf],
4508        embed_fn: &mut F,
4509        max_batch_size: usize,
4510        progress: &mut P,
4511    ) -> Result<RefreshSummary, String>
4512    where
4513        F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
4514        P: FnMut(usize, usize),
4515    {
4516        self.refresh_stale_files_with_strategy(
4517            project_root,
4518            current_files,
4519            embed_fn,
4520            max_batch_size,
4521            progress,
4522            cache_freshness::VerifyStrategy::Strict,
4523        )
4524    }
4525
4526    pub(crate) fn refresh_stale_files_with_strategy<F, P>(
4527        &mut self,
4528        project_root: &Path,
4529        current_files: &[PathBuf],
4530        embed_fn: &mut F,
4531        max_batch_size: usize,
4532        progress: &mut P,
4533        verify_strategy: cache_freshness::VerifyStrategy,
4534    ) -> Result<RefreshSummary, String>
4535    where
4536        F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
4537        P: FnMut(usize, usize),
4538    {
4539        self.refresh_stale_files_with_strategy_and_blob_reuse(
4540            project_root,
4541            current_files,
4542            embed_fn,
4543            max_batch_size,
4544            progress,
4545            verify_strategy,
4546            &mut |_| None,
4547            None,
4548        )
4549    }
4550
4551    pub(crate) fn refresh_stale_files_with_strategy_and_blob_reuse<F, P, R>(
4552        &mut self,
4553        project_root: &Path,
4554        current_files: &[PathBuf],
4555        embed_fn: &mut F,
4556        max_batch_size: usize,
4557        progress: &mut P,
4558        verify_strategy: cache_freshness::VerifyStrategy,
4559        reuse_blob: &mut R,
4560        mut recovery_paths: Option<&mut Vec<PathBuf>>,
4561    ) -> Result<RefreshSummary, String>
4562    where
4563        F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
4564        P: FnMut(usize, usize),
4565        R: FnMut(&Path) -> Option<Vec<u8>>,
4566    {
4567        self.materialize_shared_base();
4568        self.backfill_missing_file_sizes();
4569
4570        // 1. Bucket files into deleted / changed / added.
4571        let current_set: HashSet<&Path> = current_files.iter().map(PathBuf::as_path).collect();
4572        self.deferred_files
4573            .retain(|path| current_set.contains(path.as_path()));
4574        let total_processed = current_set.len() + self.file_mtimes.len()
4575            - self
4576                .file_mtimes
4577                .keys()
4578                .filter(|path| current_set.contains(path.as_path()))
4579                .count();
4580
4581        // Files in cache that disappeared from disk OR are no longer in the
4582        // walked set. Both cases need their entries dropped.
4583        enum IndexedFileCheck {
4584            Deleted(PathBuf),
4585            MissingMetadata(PathBuf),
4586            Verified(PathBuf, FreshnessVerdict),
4587        }
4588
4589        let mut deleted: Vec<PathBuf> = Vec::new();
4590        let mut changed: Vec<PathBuf> = Vec::new();
4591        let indexed_paths: Vec<PathBuf> = self.file_mtimes.keys().cloned().collect();
4592        let mut checks: Vec<Option<IndexedFileCheck>> = Vec::with_capacity(indexed_paths.len());
4593        let mut strict_verify_inputs: Vec<(usize, PathBuf, FileFreshness)> = Vec::new();
4594
4595        for indexed_path in indexed_paths {
4596            let check_index = checks.len();
4597            if !current_set.contains(indexed_path.as_path()) {
4598                checks.push(Some(IndexedFileCheck::Deleted(indexed_path)));
4599                continue;
4600            }
4601            let cached = match (
4602                self.file_mtimes.get(&indexed_path),
4603                self.file_sizes.get(&indexed_path),
4604                self.file_hashes.get(&indexed_path),
4605            ) {
4606                (Some(mtime), Some(size), Some(hash)) => Some(FileFreshness {
4607                    mtime: *mtime,
4608                    size: *size,
4609                    content_hash: *hash,
4610                }),
4611                _ => None,
4612            };
4613            if let Some(freshness) = cached {
4614                strict_verify_inputs.push((check_index, indexed_path, freshness));
4615                checks.push(None);
4616            } else {
4617                checks.push(Some(IndexedFileCheck::MissingMetadata(indexed_path)));
4618            }
4619        }
4620
4621        let verified = match verify_strategy {
4622            cache_freshness::VerifyStrategy::StatFirst => cache_freshness::verify_files_bounded(
4623                strict_verify_inputs,
4624                cache_freshness::VerifyStrategy::StatFirst,
4625            ),
4626            cache_freshness::VerifyStrategy::Strict => {
4627                cache_freshness::verify_files_strict_bounded(strict_verify_inputs)
4628            }
4629        };
4630        for (check_index, path, verdict) in verified {
4631            checks[check_index] = Some(IndexedFileCheck::Verified(path, verdict));
4632        }
4633
4634        for check in checks {
4635            match check.expect("freshness check should be populated") {
4636                IndexedFileCheck::Deleted(path) => deleted.push(path),
4637                IndexedFileCheck::MissingMetadata(path) => changed.push(path),
4638                IndexedFileCheck::Verified(_path, FreshnessVerdict::HotFresh) => {}
4639                IndexedFileCheck::Verified(
4640                    path,
4641                    FreshnessVerdict::ContentFresh {
4642                        new_mtime,
4643                        new_size,
4644                    },
4645                ) => {
4646                    self.file_mtimes.insert(path.clone(), new_mtime);
4647                    self.file_sizes.insert(path, new_size);
4648                }
4649                IndexedFileCheck::Verified(
4650                    path,
4651                    FreshnessVerdict::Stale | FreshnessVerdict::Deleted,
4652                ) => {
4653                    changed.push(path);
4654                }
4655            }
4656        }
4657
4658        // Files in walk that were never indexed.
4659        let mut added: Vec<PathBuf> = Vec::new();
4660        for path in current_files {
4661            if !self.file_mtimes.contains_key(path) {
4662                added.push(path.clone());
4663            }
4664        }
4665
4666        // Fast path: nothing to do.
4667        if deleted.is_empty() && changed.is_empty() && added.is_empty() {
4668            progress(0, 0);
4669            return Ok(RefreshSummary {
4670                total_processed,
4671                ..RefreshSummary::default()
4672            });
4673        }
4674
4675        // 2. Drop entries for deleted files immediately. Changed files are only
4676        //    replaced after successful re-extraction + embedding so transient
4677        //    read/parse errors keep the stale-but-valid cache entry.
4678        if !deleted.is_empty() {
4679            self.remove_indexed_files(&deleted);
4680        }
4681
4682        // 3. Embed the changed + added set, if any.
4683        let mut to_embed: Vec<PathBuf> = Vec::with_capacity(changed.len() + added.len());
4684        to_embed.extend(changed.iter().cloned());
4685        to_embed.extend(added.iter().cloned());
4686        if let Some(paths) = recovery_paths.as_mut() {
4687            paths.clear();
4688            paths.extend(deleted.iter().cloned());
4689            paths.extend(to_embed.iter().cloned());
4690            paths.sort();
4691            paths.dedup();
4692        }
4693
4694        if to_embed.is_empty() {
4695            // Only deletions happened.
4696            progress(0, 0);
4697            return Ok(RefreshSummary {
4698                changed: 0,
4699                added: 0,
4700                deleted: deleted.len(),
4701                total_processed,
4702            });
4703        }
4704
4705        let mut reuse_map = self.build_chunk_reuse_map(&changed);
4706        let embed_text_caps = self
4707            .fingerprint
4708            .as_ref()
4709            .map(|fingerprint| fingerprint.embed_text_caps)
4710            .unwrap_or_default();
4711        let (chunks, fresh_metadata) =
4712            Self::collect_chunks(project_root, &to_embed, embed_text_caps);
4713        self.extend_reuse_map_from_blob_store(
4714            project_root,
4715            fresh_metadata.keys().cloned(),
4716            &mut reuse_map,
4717            reuse_blob,
4718        );
4719        let changed_set: HashSet<&Path> = changed.iter().map(PathBuf::as_path).collect();
4720        let vanished = to_embed
4721            .iter()
4722            .filter(|path| {
4723                changed_set.contains(path.as_path())
4724                    && !fresh_metadata.contains_key(*path)
4725                    && !path.exists()
4726            })
4727            .cloned()
4728            .collect::<Vec<_>>();
4729        if !vanished.is_empty() {
4730            self.remove_indexed_files(&vanished);
4731            deleted.extend(vanished);
4732        }
4733
4734        if chunks.is_empty() {
4735            progress(0, 0);
4736            let successful_files: HashSet<PathBuf> = fresh_metadata.keys().cloned().collect();
4737            for file in &successful_files {
4738                self.deferred_files.remove(file);
4739            }
4740            if !successful_files.is_empty() {
4741                self.entries
4742                    .retain(|entry| !successful_files.contains(&entry.chunk.file));
4743            }
4744            let changed_count = changed
4745                .iter()
4746                .filter(|path| successful_files.contains(*path))
4747                .count();
4748            let added_count = added
4749                .iter()
4750                .filter(|path| successful_files.contains(*path))
4751                .count();
4752            for (file, metadata) in fresh_metadata {
4753                self.file_mtimes.insert(file.clone(), metadata.mtime);
4754                self.file_sizes.insert(file.clone(), metadata.size);
4755                self.file_hashes.insert(file.clone(), metadata.content_hash);
4756            }
4757            self.extend_dirty_paths(successful_files.iter().cloned());
4758            return Ok(RefreshSummary {
4759                changed: changed_count,
4760                added: added_count,
4761                deleted: deleted.len(),
4762                total_processed,
4763            });
4764        }
4765
4766        // 4. Build the full replacement set, reusing cached vectors for chunks
4767        //    whose embed_text is unchanged and embedding only cache misses.
4768        let existing_dimension = if self.entries.is_empty() {
4769            None
4770        } else {
4771            Some(self.dimension)
4772        };
4773        let (new_entries, observed_dimension, skipped_rows) = Self::entries_for_chunks_with_reuse(
4774            chunks,
4775            &reuse_map,
4776            embed_fn,
4777            max_batch_size,
4778            existing_dimension,
4779            "incremental refresh",
4780            progress,
4781        )?;
4782        self.skipped_rows = self.skipped_rows.saturating_add(skipped_rows);
4783
4784        let successful_files: HashSet<PathBuf> = fresh_metadata.keys().cloned().collect();
4785        for file in &successful_files {
4786            self.deferred_files.remove(file);
4787        }
4788        if !successful_files.is_empty() {
4789            self.entries
4790                .retain(|entry| !successful_files.contains(&entry.chunk.file));
4791        }
4792
4793        self.entries.extend(new_entries);
4794        for (file, metadata) in fresh_metadata {
4795            self.file_mtimes.insert(file.clone(), metadata.mtime);
4796            self.file_sizes.insert(file.clone(), metadata.size);
4797            self.file_hashes.insert(file, metadata.content_hash);
4798        }
4799        if let Some(dim) = observed_dimension {
4800            self.dimension = dim;
4801        }
4802        self.extend_dirty_paths(successful_files.iter().cloned());
4803
4804        Ok(RefreshSummary {
4805            changed: changed
4806                .iter()
4807                .filter(|path| successful_files.contains(*path))
4808                .count(),
4809            added: added
4810                .iter()
4811                .filter(|path| successful_files.contains(*path))
4812                .count(),
4813            deleted: deleted.len(),
4814            total_processed,
4815        })
4816    }
4817
4818    /// Refresh exactly the files invalidated by the live watcher, without
4819    /// treating the provided path list as the whole project. This is the
4820    /// watcher-side counterpart to `refresh_stale_files`: it drops any stale
4821    /// entries for the requested paths from this in-memory index, re-extracts
4822    /// whatever still exists on disk, embeds those chunks, and returns the
4823    /// delta needed for another in-memory index to apply the same update.
4824    pub fn refresh_invalidated_files<F, P>(
4825        &mut self,
4826        project_root: &Path,
4827        paths: &[PathBuf],
4828        embed_fn: &mut F,
4829        max_batch_size: usize,
4830        max_files: usize,
4831        progress: &mut P,
4832    ) -> Result<InvalidatedFilesRefresh, String>
4833    where
4834        F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
4835        P: FnMut(usize, usize),
4836    {
4837        self.refresh_invalidated_files_with_blob_reuse(
4838            project_root,
4839            paths,
4840            embed_fn,
4841            max_batch_size,
4842            max_files,
4843            progress,
4844            &mut |_| None,
4845        )
4846    }
4847
4848    pub(crate) fn refresh_invalidated_files_with_blob_reuse<F, P, R>(
4849        &mut self,
4850        project_root: &Path,
4851        paths: &[PathBuf],
4852        embed_fn: &mut F,
4853        max_batch_size: usize,
4854        max_files: usize,
4855        progress: &mut P,
4856        reuse_blob: &mut R,
4857    ) -> Result<InvalidatedFilesRefresh, String>
4858    where
4859        F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
4860        P: FnMut(usize, usize),
4861        R: FnMut(&Path) -> Option<Vec<u8>>,
4862    {
4863        self.materialize_shared_base();
4864        self.backfill_missing_file_sizes();
4865
4866        self.deferred_files.retain(|path| path.exists());
4867        let mut requested_paths = paths.to_vec();
4868        requested_paths.extend(self.deferred_files.iter().cloned());
4869        requested_paths.sort();
4870        requested_paths.dedup();
4871        let total_processed = requested_paths.len();
4872
4873        if requested_paths.is_empty() {
4874            progress(0, 0);
4875            return Ok(InvalidatedFilesRefresh {
4876                summary: RefreshSummary {
4877                    total_processed,
4878                    ..RefreshSummary::default()
4879                },
4880                ..InvalidatedFilesRefresh::default()
4881            });
4882        }
4883
4884        let previously_indexed: HashSet<PathBuf> = requested_paths
4885            .iter()
4886            .filter(|path| self.file_mtimes.contains_key(*path))
4887            .cloned()
4888            .collect();
4889        let mut reuse_map = self.build_chunk_reuse_map(&requested_paths);
4890
4891        // The watcher path has already invalidated these files in the request
4892        // thread's live index. Mirror that behavior here before inserting any
4893        // fresh chunks so parse/read failures do not resurrect stale entries.
4894        self.remove_indexed_files(&requested_paths);
4895
4896        let existing_paths = requested_paths
4897            .iter()
4898            .filter(|path| path.exists())
4899            .cloned()
4900            .collect::<Vec<_>>();
4901        let deleted = requested_paths
4902            .iter()
4903            .filter(|path| !path.exists() && previously_indexed.contains(path.as_path()))
4904            .count();
4905
4906        if existing_paths.is_empty() {
4907            for path in &requested_paths {
4908                if !path.exists() {
4909                    self.deferred_files.remove(path);
4910                }
4911            }
4912            progress(0, 0);
4913            return Ok(InvalidatedFilesRefresh {
4914                completed_paths: requested_paths,
4915                summary: RefreshSummary {
4916                    deleted,
4917                    total_processed,
4918                    ..RefreshSummary::default()
4919                },
4920                ..InvalidatedFilesRefresh::default()
4921            });
4922        }
4923
4924        let embed_text_caps = self
4925            .fingerprint
4926            .as_ref()
4927            .map(|fingerprint| fingerprint.embed_text_caps)
4928            .unwrap_or_default();
4929        let (mut chunks, mut fresh_metadata) =
4930            Self::collect_chunks(project_root, &existing_paths, embed_text_caps);
4931        self.extend_reuse_map_from_blob_store(
4932            project_root,
4933            fresh_metadata.keys().cloned(),
4934            &mut reuse_map,
4935            reuse_blob,
4936        );
4937
4938        let retained_file_count = self.file_mtimes.len();
4939        let changed_successful_count = existing_paths
4940            .iter()
4941            .filter(|path| {
4942                previously_indexed.contains(path.as_path()) && fresh_metadata.contains_key(*path)
4943            })
4944            .count();
4945        let available_new_files =
4946            max_files.saturating_sub(retained_file_count.saturating_add(changed_successful_count));
4947        let new_successful_files = existing_paths
4948            .iter()
4949            .filter(|path| {
4950                !previously_indexed.contains(path.as_path()) && fresh_metadata.contains_key(*path)
4951            })
4952            .cloned()
4953            .collect::<Vec<_>>();
4954        if new_successful_files.len() > available_new_files {
4955            let allowed_new_files = new_successful_files
4956                .iter()
4957                .take(available_new_files)
4958                .cloned()
4959                .collect::<HashSet<_>>();
4960            let deferred_new_files = new_successful_files
4961                .into_iter()
4962                .filter(|path| !allowed_new_files.contains(path))
4963                .collect::<HashSet<_>>();
4964
4965            fresh_metadata.retain(|file, _| {
4966                previously_indexed.contains(file.as_path()) || allowed_new_files.contains(file)
4967            });
4968            chunks.retain(|chunk| !deferred_new_files.contains(&chunk.file));
4969
4970            if !deferred_new_files.is_empty() {
4971                for path in &deferred_new_files {
4972                    self.deferred_files.insert(path.clone());
4973                }
4974                slog_warn!(
4975                    "semantic refresh deferred {} new file(s): indexed-file cap {} is reached",
4976                    deferred_new_files.len(),
4977                    max_files
4978                );
4979            }
4980        }
4981
4982        let successful_files: HashSet<PathBuf> = fresh_metadata.keys().cloned().collect();
4983        for file in &successful_files {
4984            self.deferred_files.remove(file);
4985        }
4986        let changed = successful_files
4987            .iter()
4988            .filter(|path| previously_indexed.contains(path.as_path()))
4989            .count();
4990        let added = successful_files.len().saturating_sub(changed);
4991        let mut updated_metadata = Vec::with_capacity(fresh_metadata.len());
4992
4993        if chunks.is_empty() {
4994            progress(0, 0);
4995            for (file, metadata) in fresh_metadata {
4996                let freshness = FileFreshness {
4997                    mtime: metadata.mtime,
4998                    size: metadata.size,
4999                    content_hash: metadata.content_hash,
5000                };
5001                self.file_mtimes.insert(file.clone(), freshness.mtime);
5002                self.file_sizes.insert(file.clone(), freshness.size);
5003                self.file_hashes
5004                    .insert(file.clone(), freshness.content_hash);
5005                updated_metadata.push((file, freshness));
5006            }
5007
5008            return Ok(InvalidatedFilesRefresh {
5009                updated_metadata,
5010                completed_paths: requested_paths,
5011                summary: RefreshSummary {
5012                    changed,
5013                    added,
5014                    deleted,
5015                    total_processed,
5016                },
5017                ..InvalidatedFilesRefresh::default()
5018            });
5019        }
5020
5021        let initial_observed_dimension = if self.entries.is_empty() && previously_indexed.is_empty()
5022        {
5023            None
5024        } else {
5025            Some(self.dimension)
5026        };
5027        let (new_entries, observed_dimension, skipped_rows) = Self::entries_for_chunks_with_reuse(
5028            chunks,
5029            &reuse_map,
5030            embed_fn,
5031            max_batch_size,
5032            initial_observed_dimension,
5033            "invalidated-file refresh",
5034            progress,
5035        )?;
5036        self.skipped_rows = self.skipped_rows.saturating_add(skipped_rows);
5037
5038        let added_entries = new_entries.clone();
5039        self.entries.extend(new_entries);
5040        for (file, metadata) in fresh_metadata {
5041            let freshness = FileFreshness {
5042                mtime: metadata.mtime,
5043                size: metadata.size,
5044                content_hash: metadata.content_hash,
5045            };
5046            self.file_mtimes.insert(file.clone(), freshness.mtime);
5047            self.file_sizes.insert(file.clone(), freshness.size);
5048            self.file_hashes
5049                .insert(file.clone(), freshness.content_hash);
5050            updated_metadata.push((file, freshness));
5051        }
5052        if let Some(dim) = observed_dimension {
5053            self.dimension = dim;
5054        }
5055
5056        Ok(InvalidatedFilesRefresh {
5057            added_entries,
5058            updated_metadata,
5059            completed_paths: requested_paths,
5060            summary: RefreshSummary {
5061                changed,
5062                added,
5063                deleted,
5064                total_processed,
5065            },
5066        })
5067    }
5068
5069    pub fn apply_refresh_update(
5070        &mut self,
5071        added_entries: Vec<EmbeddingEntry>,
5072        updated_metadata: Vec<(PathBuf, FileFreshness)>,
5073        completed_paths: &[PathBuf],
5074    ) {
5075        self.materialize_shared_base();
5076        // `added_entries` is the complete replacement set for completed paths:
5077        // freshly embedded misses plus reused chunks carrying refreshed metadata.
5078        // Removing first is safe only because producers include both kinds.
5079        self.remove_indexed_files(completed_paths);
5080
5081        let observed_dimension = added_entries.first().map(|entry| entry.vector.len());
5082        self.entries.extend(added_entries);
5083        for (file, freshness) in updated_metadata {
5084            self.file_mtimes.insert(file.clone(), freshness.mtime);
5085            self.file_sizes.insert(file.clone(), freshness.size);
5086            self.file_hashes.insert(file, freshness.content_hash);
5087        }
5088        if let Some(dim) = observed_dimension {
5089            self.dimension = dim;
5090        }
5091    }
5092
5093    fn dirty_paths_snapshot(&self) -> Option<BTreeSet<PathBuf>> {
5094        self.dirty_paths
5095            .lock()
5096            .unwrap_or_else(std::sync::PoisonError::into_inner)
5097            .clone()
5098    }
5099
5100    fn set_dirty_paths(&self, paths: Option<BTreeSet<PathBuf>>) {
5101        *self
5102            .dirty_paths
5103            .lock()
5104            .unwrap_or_else(std::sync::PoisonError::into_inner) = paths;
5105    }
5106
5107    fn persistence_snapshot(&self) -> Option<SemanticPersistenceState> {
5108        *self
5109            .persistence
5110            .lock()
5111            .unwrap_or_else(std::sync::PoisonError::into_inner)
5112    }
5113
5114    fn set_persistence(&self, state: Option<SemanticPersistenceState>) {
5115        *self
5116            .persistence
5117            .lock()
5118            .unwrap_or_else(std::sync::PoisonError::into_inner) = state;
5119    }
5120
5121    fn extend_dirty_paths(&self, paths: impl IntoIterator<Item = PathBuf>) {
5122        if let Some(dirty_paths) = self
5123            .dirty_paths
5124            .lock()
5125            .unwrap_or_else(std::sync::PoisonError::into_inner)
5126            .as_mut()
5127        {
5128            dirty_paths.extend(paths);
5129        }
5130    }
5131
5132    fn mark_all_dirty(&self) {
5133        let mut paths = BTreeSet::new();
5134        paths.extend(self.file_mtimes.keys().cloned());
5135        paths.extend(self.entries.iter().map(|entry| entry.chunk.file.clone()));
5136        self.set_dirty_paths(Some(paths));
5137    }
5138
5139    fn remove_indexed_file_keys(
5140        &mut self,
5141        entry_files: &HashSet<PathBuf>,
5142        metadata_files: &[PathBuf],
5143    ) {
5144        #[cfg(test)]
5145        {
5146            self.removal_retain_passes += 1;
5147        }
5148        self.entries
5149            .retain(|entry| !entry_files.contains(&entry.chunk.file));
5150        for path in metadata_files {
5151            self.file_mtimes.remove(path);
5152            self.file_sizes.remove(path);
5153            self.file_hashes.remove(path);
5154        }
5155        self.extend_dirty_paths(metadata_files.iter().cloned());
5156    }
5157
5158    fn remove_indexed_files(&mut self, files: &[PathBuf]) {
5159        let deleted_set = files.iter().cloned().collect();
5160        self.remove_indexed_file_keys(&deleted_set, files);
5161    }
5162
5163    /// Search the index with a query embedding, returning top-K results sorted by relevance.
5164    pub fn search(&self, query_vector: &[f32], top_k: usize) -> Vec<SemanticResult> {
5165        self.search_filtered(query_vector, top_k, |_| true)
5166    }
5167
5168    /// Search only entries whose resolved source path satisfies `include`.
5169    ///
5170    /// Filtering before top-K selection prevents excluded files from consuming the
5171    /// bounded candidate window and hiding lower-ranked eligible results.
5172    pub(crate) fn search_filtered<F>(
5173        &self,
5174        query_vector: &[f32],
5175        top_k: usize,
5176        include: F,
5177    ) -> Vec<SemanticResult>
5178    where
5179        F: Fn(&Path) -> bool,
5180    {
5181        let (entries, dimension) = self
5182            .shared_base
5183            .as_ref()
5184            .map(|base| (base.entries.as_slice(), base.dimension))
5185            .unwrap_or_else(|| (self.entries.as_slice(), self.dimension));
5186        if entries.is_empty() || query_vector.len() != dimension {
5187            return Vec::new();
5188        }
5189
5190        // Query norms are shared by every entry; entry norms are cached because
5191        // remote embedding backends may return non-normalized vectors.
5192        let query_norm = vector_norm(query_vector);
5193        let cancellation = crate::executor::current_job_cancellation();
5194        let mut scored: Vec<(f32, usize)> = Vec::with_capacity(entries.len());
5195        for (i, entry) in entries.iter().enumerate() {
5196            if i % 64 == 0
5197                && cancellation
5198                    .as_ref()
5199                    .is_some_and(|token| token.cancel_requested_before_commit())
5200            {
5201                break;
5202            }
5203            let included = if self.shared_base.is_some() {
5204                include(&self.project_root.join(&entry.chunk.file))
5205            } else {
5206                include(&entry.chunk.file)
5207            };
5208            if !included {
5209                continue;
5210            }
5211
5212            let dot = if query_vector.len() == entry.vector.len() {
5213                dot_product(query_vector, &entry.vector)
5214            } else {
5215                0.0
5216            };
5217            let denom = query_norm * entry.norm;
5218            let mut score = if denom == 0.0 { 0.0 } else { dot / denom };
5219            if entry.chunk.exported {
5220                score *= 1.1;
5221            }
5222            scored.push((score, i));
5223        }
5224
5225        let keep = top_k.min(scored.len());
5226        if keep == 0 {
5227            return Vec::new();
5228        }
5229
5230        if keep < scored.len() {
5231            scored.select_nth_unstable_by(keep, semantic_score_order);
5232            scored.truncate(keep);
5233        }
5234        scored.sort_by(semantic_score_order);
5235
5236        scored
5237            .into_iter()
5238            // Keep the selected best-first slice mapped without reintroducing the
5239            // old `> 0.0` floor: top_k has already been selected, and zero-score
5240            // tail entries remain observable when requested.
5241            .map(|(score, idx)| {
5242                let entry = &entries[idx];
5243                SemanticResult {
5244                    file: if self.shared_base.is_some() {
5245                        self.project_root.join(&entry.chunk.file)
5246                    } else {
5247                        entry.chunk.file.clone()
5248                    },
5249                    name: entry.chunk.name.clone(),
5250                    qualified_name: entry.chunk.qualified_name.clone(),
5251                    kind: entry.chunk.kind.clone(),
5252                    start_line: entry.chunk.start_line,
5253                    end_line: entry.chunk.end_line,
5254                    exported: entry.chunk.exported,
5255                    snippet: entry.chunk.snippet.clone(),
5256                    score,
5257                    rank_score: score,
5258                    cap_protected: false,
5259                    source: "semantic",
5260                }
5261            })
5262            .collect()
5263    }
5264
5265    /// Number of indexed entries
5266    pub fn len(&self) -> usize {
5267        self.entry_count()
5268    }
5269
5270    /// Check if a file needs re-indexing based on mtime/size
5271    pub fn is_file_stale(&self, file: &Path) -> bool {
5272        let relative;
5273        let (file_mtimes, file_sizes, file_hashes, lookup) = if let Some(base) = &self.shared_base {
5274            relative = file
5275                .strip_prefix(&self.project_root)
5276                .unwrap_or(file)
5277                .to_path_buf();
5278            (
5279                &base.file_mtimes,
5280                &base.file_sizes,
5281                &base.file_hashes,
5282                relative.as_path(),
5283            )
5284        } else {
5285            (&self.file_mtimes, &self.file_sizes, &self.file_hashes, file)
5286        };
5287        let Some(stored_mtime) = file_mtimes.get(lookup) else {
5288            return true;
5289        };
5290        let Some(stored_size) = file_sizes.get(lookup) else {
5291            return true;
5292        };
5293        let Some(stored_hash) = file_hashes.get(lookup) else {
5294            return true;
5295        };
5296        let cached = FileFreshness {
5297            mtime: *stored_mtime,
5298            size: *stored_size,
5299            content_hash: *stored_hash,
5300        };
5301        match cache_freshness::verify_file_strict(file, &cached) {
5302            FreshnessVerdict::HotFresh => false,
5303            FreshnessVerdict::ContentFresh { .. } => false,
5304            FreshnessVerdict::Stale | FreshnessVerdict::Deleted => true,
5305        }
5306    }
5307
5308    fn backfill_missing_file_sizes(&mut self) {
5309        if !self.any_missing_sizes {
5310            return;
5311        }
5312
5313        for path in self.file_mtimes.keys() {
5314            if self.file_sizes.contains_key(path) {
5315                continue;
5316            }
5317            if let Ok(metadata) = fs::metadata(path) {
5318                self.file_sizes.insert(path.clone(), metadata.len());
5319                if let Ok(Some(hash)) = cache_freshness::hash_file_if_small(path, metadata.len()) {
5320                    self.file_hashes.insert(path.clone(), hash);
5321                }
5322            }
5323        }
5324        self.any_missing_sizes = self
5325            .file_mtimes
5326            .keys()
5327            .any(|path| !self.file_sizes.contains_key(path));
5328    }
5329
5330    /// Remove entries for a specific file.
5331    pub fn remove_file(&mut self, file: &Path) {
5332        self.invalidate_file(file);
5333    }
5334
5335    pub fn invalidate_file(&mut self, file: &Path) {
5336        let file = file.to_path_buf();
5337        self.invalidate_files(std::slice::from_ref(&file));
5338    }
5339
5340    pub fn invalidate_files(&mut self, files: &[PathBuf]) {
5341        if files.is_empty() {
5342            return;
5343        }
5344        self.materialize_shared_base();
5345
5346        // Watchers may report a symlinked spelling while persisted metadata uses
5347        // the canonical spelling (or vice versa), so both keys must be removed.
5348        let mut invalidated = HashSet::with_capacity(files.len().saturating_mul(2));
5349        let mut metadata_keys = Vec::with_capacity(files.len().saturating_mul(2));
5350        for file in files {
5351            metadata_keys.push(file.clone());
5352            invalidated.insert(file.clone());
5353            let canonical = canonicalize_existing_or_deleted_path(file);
5354            if canonical != *file {
5355                metadata_keys.push(canonical.clone());
5356                invalidated.insert(canonical);
5357            }
5358        }
5359        self.remove_indexed_file_keys(&invalidated, &metadata_keys);
5360    }
5361
5362    #[cfg(test)]
5363    pub(crate) fn removal_retain_passes_for_test(&self) -> usize {
5364        self.removal_retain_passes
5365    }
5366
5367    #[cfg(test)]
5368    pub(crate) fn uses_shared_base_for_test(&self) -> bool {
5369        self.shared_base.is_some()
5370    }
5371
5372    /// Get the embedding dimension
5373    pub fn dimension(&self) -> usize {
5374        self.shared_base
5375            .as_ref()
5376            .map(|base| base.dimension)
5377            .unwrap_or(self.dimension)
5378    }
5379
5380    pub fn fingerprint(&self) -> Option<&SemanticIndexFingerprint> {
5381        self.shared_base
5382            .as_ref()
5383            .and_then(|base| base.fingerprint.as_ref())
5384            .or(self.fingerprint.as_ref())
5385    }
5386
5387    pub fn backend_label(&self) -> Option<&str> {
5388        self.fingerprint().map(|f| f.backend.as_str())
5389    }
5390
5391    pub fn model_label(&self) -> Option<&str> {
5392        self.fingerprint().map(|f| f.model.as_str())
5393    }
5394
5395    pub fn set_fingerprint(&mut self, fingerprint: SemanticIndexFingerprint) {
5396        self.materialize_shared_base();
5397        self.fingerprint = Some(fingerprint);
5398    }
5399
5400    fn scan_artifact_for_append(
5401        data_path: &Path,
5402        expected: SemanticPersistenceState,
5403        expected_fingerprint: &str,
5404        expected_dimension: usize,
5405    ) -> Result<SemanticArtifactLayout, String> {
5406        let mut file = fs::File::open(data_path).map_err(|error| error.to_string())?;
5407        let identity = semantic_artifact_identity(data_path)
5408            .ok_or_else(|| "semantic artifact identity unavailable".to_string())?;
5409        if identity != expected.identity {
5410            return Err("semantic artifact changed since it was loaded".to_string());
5411        }
5412        let file_len = usize::try_from(identity.bytes)
5413            .map_err(|_| "semantic artifact is too large for this platform".to_string())?;
5414        if expected.base_bytes < HEADER_BYTES_V2 || expected.base_bytes > file_len {
5415            return Err("persisted semantic base boundary is invalid".to_string());
5416        }
5417
5418        let mut fixed = [0_u8; HEADER_BYTES_V2];
5419        file.read_exact(&mut fixed)
5420            .map_err(|error| error.to_string())?;
5421        if fixed[0] != SEMANTIC_INDEX_VERSION_V6 && fixed[0] != SEMANTIC_INDEX_VERSION_V7 {
5422            return Err(format!(
5423                "unsupported on-disk semantic version: {}",
5424                fixed[0]
5425            ));
5426        }
5427        let dimension = u32::from_le_bytes(fixed[1..5].try_into().unwrap()) as usize;
5428        if dimension != expected_dimension {
5429            return Err("semantic artifact dimension changed".to_string());
5430        }
5431        let fingerprint_len = u32::from_le_bytes(fixed[9..13].try_into().unwrap()) as usize;
5432        if fingerprint_len > 64 * 1024 {
5433            return Err("semantic artifact fingerprint is oversized".to_string());
5434        }
5435        let mut fingerprint = vec![0_u8; fingerprint_len];
5436        file.read_exact(&mut fingerprint)
5437            .map_err(|error| error.to_string())?;
5438        if fingerprint != expected_fingerprint.as_bytes() {
5439            return Err("semantic artifact fingerprint changed".to_string());
5440        }
5441
5442        file.seek(SeekFrom::Start(expected.base_bytes as u64))
5443            .map_err(|error| error.to_string())?;
5444        let mut valid_bytes = expected.base_bytes;
5445        let mut segment_count = 0usize;
5446        let mut torn_tail = false;
5447        let mut bytes_read = HEADER_BYTES_V2.saturating_add(fingerprint_len);
5448        while valid_bytes < file_len {
5449            let remaining = file_len.saturating_sub(valid_bytes);
5450            if remaining < SEMANTIC_SEGMENT_FRAME_HEADER_BYTES {
5451                torn_tail = true;
5452                break;
5453            }
5454            let mut header = [0_u8; SEMANTIC_SEGMENT_FRAME_HEADER_BYTES];
5455            file.read_exact(&mut header)
5456                .map_err(|error| error.to_string())?;
5457            bytes_read = bytes_read.saturating_add(header.len());
5458            if &header[..8] != SEMANTIC_SEGMENT_MAGIC {
5459                torn_tail = true;
5460                break;
5461            }
5462            let payload_len =
5463                usize::try_from(u64::from_le_bytes(header[8..16].try_into().unwrap()))
5464                    .map_err(|_| "semantic segment length exceeds this platform".to_string())?;
5465            let frame_len = SEMANTIC_SEGMENT_FRAME_HEADER_BYTES
5466                .checked_add(payload_len)
5467                .ok_or_else(|| "semantic segment frame length overflow".to_string())?;
5468            if frame_len > remaining {
5469                torn_tail = true;
5470                break;
5471            }
5472            let frame_end = valid_bytes.saturating_add(frame_len);
5473            if frame_end == file_len {
5474                let mut payload = vec![0_u8; payload_len];
5475                file.read_exact(&mut payload)
5476                    .map_err(|error| error.to_string())?;
5477                bytes_read = bytes_read.saturating_add(payload_len);
5478                if blake3::hash(&payload).as_bytes()
5479                    != &header[16..SEMANTIC_SEGMENT_FRAME_HEADER_BYTES]
5480                {
5481                    torn_tail = true;
5482                    break;
5483                }
5484            } else {
5485                file.seek(SeekFrom::Current(payload_len as i64))
5486                    .map_err(|error| error.to_string())?;
5487            }
5488            valid_bytes = frame_end;
5489            segment_count = segment_count.saturating_add(1);
5490        }
5491
5492        Ok(SemanticArtifactLayout {
5493            identity,
5494            base_bytes: expected.base_bytes,
5495            valid_bytes,
5496            segment_count,
5497            segment_bytes: valid_bytes.saturating_sub(expected.base_bytes),
5498            torn_tail,
5499            bytes_read,
5500        })
5501    }
5502
5503    fn persistence_state_from_loaded(
5504        data_path: &Path,
5505        loaded: &LoadedSemanticArtifact,
5506    ) -> Option<SemanticPersistenceState> {
5507        Some(SemanticPersistenceState {
5508            identity: semantic_artifact_identity(data_path)?,
5509            base_bytes: loaded.base_bytes,
5510            segment_count: loaded.segment_count,
5511            segment_bytes: loaded.segment_bytes,
5512            valid_bytes: loaded.valid_bytes,
5513        })
5514    }
5515
5516    fn write_full_snapshot_at(
5517        &self,
5518        dir: &Path,
5519        data_path: &Path,
5520        pause_before_swap: bool,
5521    ) -> io::Result<usize> {
5522        let tmp_path = dir.join(format!(
5523            "semantic.bin.tmp.{}.{}",
5524            std::process::id(),
5525            SystemTime::now()
5526                .duration_since(SystemTime::UNIX_EPOCH)
5527                .unwrap_or(Duration::ZERO)
5528                .as_nanos()
5529        ));
5530        let write_result = (|| -> io::Result<usize> {
5531            let file = fs::File::create(&tmp_path)?;
5532            let mut writer = BufWriter::new(file);
5533            let bytes_written = self.write_to_writer(&mut writer)?;
5534            writer.flush()?;
5535            writer.get_ref().sync_all()?;
5536            Ok(bytes_written)
5537        })();
5538        let bytes_written = match write_result {
5539            Ok(bytes_written) => bytes_written,
5540            Err(error) => {
5541                let _ = fs::remove_file(&tmp_path);
5542                return Err(error);
5543            }
5544        };
5545
5546        #[cfg(debug_assertions)]
5547        if pause_before_swap {
5548            if let Some(ready) = env::var_os("AFT_TEST_SEMANTIC_COMPACTION_READY") {
5549                let ready = PathBuf::from(ready);
5550                fs::write(&ready, b"ready")?;
5551                let release = ready.with_extension("release");
5552                let started = Instant::now();
5553                while !release.is_file() {
5554                    if started.elapsed() >= Duration::from_secs(30) {
5555                        let _ = fs::remove_file(&tmp_path);
5556                        return Err(io::Error::new(
5557                            io::ErrorKind::TimedOut,
5558                            "timed out waiting at semantic compaction swap test seam",
5559                        ));
5560                    }
5561                    std::thread::sleep(Duration::from_millis(10));
5562                }
5563            }
5564        }
5565        #[cfg(not(debug_assertions))]
5566        let _ = pause_before_swap;
5567
5568        if let Err(error) = crate::fs_lock::rename_over(&tmp_path, data_path) {
5569            let _ = fs::remove_file(&tmp_path);
5570            return Err(error);
5571        }
5572        crate::fs_lock::sync_parent(data_path);
5573        Ok(bytes_written)
5574    }
5575
5576    fn persistence_identity_matches(&self, previous: &Self) -> bool {
5577        self.dimension == previous.dimension
5578            && self
5579                .fingerprint
5580                .as_ref()
5581                .map(SemanticIndexFingerprint::as_string)
5582                == previous
5583                    .fingerprint
5584                    .as_ref()
5585                    .map(SemanticIndexFingerprint::as_string)
5586    }
5587
5588    fn delta_for_paths(&self, paths: &BTreeSet<PathBuf>) -> Self {
5589        Self {
5590            entries: self
5591                .entries
5592                .iter()
5593                .filter(|entry| paths.contains(&entry.chunk.file))
5594                .cloned()
5595                .collect(),
5596            file_mtimes: self
5597                .file_mtimes
5598                .iter()
5599                .filter(|(path, _)| paths.contains(*path))
5600                .map(|(path, value)| (path.clone(), *value))
5601                .collect(),
5602            file_sizes: self
5603                .file_sizes
5604                .iter()
5605                .filter(|(path, _)| paths.contains(*path))
5606                .map(|(path, value)| (path.clone(), *value))
5607                .collect(),
5608            any_missing_sizes: false,
5609            file_hashes: self
5610                .file_hashes
5611                .iter()
5612                .filter(|(path, _)| paths.contains(*path))
5613                .map(|(path, value)| (path.clone(), *value))
5614                .collect(),
5615            dimension: self.dimension,
5616            fingerprint: self.fingerprint.clone(),
5617            project_root: self.project_root.clone(),
5618            deferred_files: HashSet::new(),
5619            shared_base: None,
5620            dirty_paths: Arc::new(Mutex::new(None)),
5621            persistence: Arc::new(Mutex::new(None)),
5622            last_append_read_bytes: Arc::new(AtomicUsize::new(0)),
5623            skipped_rows: self.skipped_rows,
5624            #[cfg(test)]
5625            removal_retain_passes: 0,
5626        }
5627    }
5628
5629    fn build_segment_frame(
5630        &self,
5631        sequence: u64,
5632        changed_paths: &BTreeSet<PathBuf>,
5633    ) -> Result<Vec<u8>, String> {
5634        let fingerprint = self
5635            .fingerprint
5636            .as_ref()
5637            .map(SemanticIndexFingerprint::as_string)
5638            .unwrap_or_default();
5639        let mut payload = Vec::new();
5640        payload.push(SEMANTIC_SEGMENT_VERSION);
5641        payload.extend_from_slice(&sequence.to_le_bytes());
5642        payload.extend_from_slice(&(fingerprint.len() as u32).to_le_bytes());
5643        payload.extend_from_slice(fingerprint.as_bytes());
5644        payload.extend_from_slice(&(self.dimension as u32).to_le_bytes());
5645        payload.extend_from_slice(&(changed_paths.len() as u32).to_le_bytes());
5646        for path in changed_paths {
5647            let relative = cache_relative_path(&self.project_root, path).ok_or_else(|| {
5648                format!(
5649                    "semantic segment tombstone escapes project root: {}",
5650                    path.display()
5651                )
5652            })?;
5653            let relative = relative.to_string_lossy();
5654            payload.extend_from_slice(&(relative.len() as u32).to_le_bytes());
5655            payload.extend_from_slice(relative.as_bytes());
5656        }
5657
5658        let delta_bytes = self.delta_for_paths(changed_paths).to_bytes();
5659        payload.extend_from_slice(&(delta_bytes.len() as u64).to_le_bytes());
5660        payload.extend_from_slice(&delta_bytes);
5661
5662        let checksum = blake3::hash(&payload);
5663        let mut frame = Vec::with_capacity(SEMANTIC_SEGMENT_FRAME_HEADER_BYTES + payload.len());
5664        frame.extend_from_slice(SEMANTIC_SEGMENT_MAGIC);
5665        frame.extend_from_slice(&(payload.len() as u64).to_le_bytes());
5666        frame.extend_from_slice(checksum.as_bytes());
5667        frame.extend_from_slice(&payload);
5668        Ok(frame)
5669    }
5670
5671    fn append_segment_frame(data_path: &Path, frame: &[u8]) -> io::Result<()> {
5672        let mut file = OpenOptions::new().append(true).open(data_path)?;
5673
5674        #[cfg(debug_assertions)]
5675        if let Some(ready) = env::var_os("AFT_TEST_SEMANTIC_SEGMENT_TEAR_READY") {
5676            let cut = (frame.len() / 2).max(SEMANTIC_SEGMENT_FRAME_HEADER_BYTES);
5677            file.write_all(&frame[..cut])?;
5678            file.sync_all()?;
5679            fs::write(ready, b"ready")?;
5680            loop {
5681                std::thread::sleep(Duration::from_secs(1));
5682            }
5683        }
5684
5685        file.write_all(frame)?;
5686        file.sync_all()
5687    }
5688
5689    fn compact_path_if_unchanged(
5690        dir: &Path,
5691        data_path: &Path,
5692        project_root: &Path,
5693        expected: SemanticArtifactIdentity,
5694    ) -> bool {
5695        let Ok(_lock) = acquire_semantic_persistence_lock(dir, expected.bytes) else {
5696            return false;
5697        };
5698        if semantic_artifact_identity(data_path) != Some(expected) {
5699            return false;
5700        }
5701        let loaded = match Self::load_artifact_path(data_path, project_root) {
5702            Ok(loaded) if !loaded.torn_tail && loaded.valid_bytes as u64 == expected.bytes => {
5703                loaded
5704            }
5705            Ok(_) => return false,
5706            Err(error) => {
5707                slog_warn!("failed to load semantic index for compaction: {}", error);
5708                return false;
5709            }
5710        };
5711        slog_info!(
5712            "semantic index compaction started: root=\"{}\" segments={} segment_bytes={}",
5713            project_root.display(),
5714            loaded.segment_count,
5715            loaded.segment_bytes
5716        );
5717        let started = Instant::now();
5718        match loaded.index.write_full_snapshot_at(dir, data_path, true) {
5719            Ok(bytes_written) => {
5720                slog_info!(
5721                    "semantic index compaction finished: root=\"{}\" segments={} segment_bytes={} entries={} bytes={} elapsed_ms={}",
5722                    project_root.display(),
5723                    loaded.segment_count,
5724                    loaded.segment_bytes,
5725                    loaded.index.entries.len(),
5726                    bytes_written,
5727                    started.elapsed().as_millis()
5728                );
5729                true
5730            }
5731            Err(error) => {
5732                slog_warn!("failed to compact semantic index: {}", error);
5733                false
5734            }
5735        }
5736    }
5737
5738    fn schedule_compaction_if_needed(
5739        &self,
5740        dir: &Path,
5741        data_path: &Path,
5742        layout: &SemanticArtifactLayout,
5743        appended_bytes: usize,
5744    ) {
5745        let segment_count = layout.segment_count.saturating_add(1);
5746        let segment_bytes = layout.segment_bytes.saturating_add(appended_bytes);
5747        let byte_bound_crossed = (segment_bytes as u64)
5748            > (layout.base_bytes as u64 / SEMANTIC_COMPACT_BYTE_RATIO_DENOMINATOR);
5749        if segment_count <= SEMANTIC_COMPACT_SEGMENT_LIMIT && !byte_bound_crossed {
5750            return;
5751        }
5752        let Some(expected) = semantic_artifact_identity(data_path) else {
5753            return;
5754        };
5755        slog_info!(
5756            "semantic index compaction scheduled: root=\"{}\" segments={} segment_bytes={}",
5757            self.project_root.display(),
5758            segment_count,
5759            segment_bytes
5760        );
5761        let project_root = self.project_root.clone();
5762        let dir = dir.to_path_buf();
5763        let data_path = data_path.to_path_buf();
5764        let _ = std::thread::Builder::new()
5765            .name("semantic-index-compaction".to_string())
5766            .spawn(move || {
5767                Self::compact_path_if_unchanged(&dir, &data_path, &project_root, expected);
5768            });
5769    }
5770
5771    /// Write a cold base snapshot or append one checksummed file-replacement segment.
5772    /// A final partial segment is ignored (and truncated by an owning reader/writer),
5773    /// so SIGKILL during append leaves every previously committed refresh loadable.
5774    pub fn write_to_disk(&self, storage_dir: &Path, project_key: &str) -> bool {
5775        if self.shared_base.is_some() {
5776            let mut private = self.clone();
5777            private.materialize_shared_base();
5778            return private.write_to_disk(storage_dir, project_key);
5779        }
5780        let dir = storage_dir.join("semantic").join(project_key);
5781        let data_path = dir.join("semantic.bin");
5782        let access = crate::root_cache::ArtifactAccess::for_root(&self.project_root);
5783        if !access.allows_write(project_key, &data_path) {
5784            return false;
5785        }
5786        if let Err(error) = fs::create_dir_all(&dir) {
5787            slog_warn!("failed to create semantic cache dir: {}", error);
5788            return false;
5789        }
5790        let artifact_bytes = semantic_artifact_identity(&data_path)
5791            .map(|identity| identity.bytes)
5792            .unwrap_or_default();
5793        let _persistence_lock = match acquire_semantic_persistence_lock(&dir, artifact_bytes) {
5794            Ok(lock) => lock,
5795            Err(error) => {
5796                slog_warn!("failed to acquire semantic persistence lock: {}", error);
5797                return false;
5798            }
5799        };
5800
5801        if data_path.is_file() {
5802            let fingerprint = self
5803                .fingerprint
5804                .as_ref()
5805                .map(SemanticIndexFingerprint::as_string)
5806                .unwrap_or_default();
5807            let layout = self.persistence_snapshot().and_then(|persistence| {
5808                match Self::scan_artifact_for_append(
5809                    &data_path,
5810                    persistence,
5811                    &fingerprint,
5812                    self.dimension,
5813                ) {
5814                    Ok(layout) => Some(layout),
5815                    Err(error) => {
5816                        slog_info!(
5817                            "semantic delta metadata unavailable ({}); using structural fallback",
5818                            error
5819                        );
5820                        None
5821                    }
5822                }
5823            });
5824            let (layout, changed_paths) = if let (Some(layout), Some(dirty_paths)) =
5825                (layout, self.dirty_paths_snapshot())
5826            {
5827                (layout, dirty_paths.clone())
5828            } else {
5829                match Self::load_artifact_path(&data_path, &self.project_root) {
5830                    Ok(loaded) if self.persistence_identity_matches(&loaded.index) => {
5831                        let changed_paths = semantic_changed_paths(&loaded.index, self);
5832                        let identity = match semantic_artifact_identity(&data_path) {
5833                            Some(identity) => identity,
5834                            None => return false,
5835                        };
5836                        let layout = SemanticArtifactLayout {
5837                            identity,
5838                            base_bytes: loaded.base_bytes,
5839                            valid_bytes: loaded.valid_bytes,
5840                            segment_count: loaded.segment_count,
5841                            segment_bytes: loaded.segment_bytes,
5842                            torn_tail: loaded.torn_tail,
5843                            bytes_read: identity.bytes as usize,
5844                        };
5845                        (layout, changed_paths)
5846                    }
5847                    Ok(_) => {
5848                        self.set_persistence(None);
5849                        self.mark_all_dirty();
5850                        return self
5851                            .write_full_snapshot_at(&dir, &data_path, false)
5852                            .is_ok_and(|bytes_written| {
5853                                let Some(identity) = semantic_artifact_identity(&data_path) else {
5854                                    return false;
5855                                };
5856                                self.set_persistence(Some(SemanticPersistenceState {
5857                                    identity,
5858                                    base_bytes: bytes_written,
5859                                    segment_count: 0,
5860                                    segment_bytes: 0,
5861                                    valid_bytes: bytes_written,
5862                                }));
5863                                self.set_dirty_paths(Some(BTreeSet::new()));
5864                                slog_info!(
5865                                    "semantic index persisted: {} entries, {:.1} KB",
5866                                    self.entries.len(),
5867                                    bytes_written as f64 / 1024.0
5868                                );
5869                                true
5870                            });
5871                    }
5872                    Err(error) => {
5873                        slog_warn!(
5874                            "semantic index delta baseline unavailable ({}); replacing base snapshot",
5875                            error
5876                        );
5877                        self.set_persistence(None);
5878                        self.mark_all_dirty();
5879                        return self
5880                            .write_full_snapshot_at(&dir, &data_path, false)
5881                            .is_ok_and(|bytes_written| {
5882                                let Some(identity) = semantic_artifact_identity(&data_path) else {
5883                                    return false;
5884                                };
5885                                self.set_persistence(Some(SemanticPersistenceState {
5886                                    identity,
5887                                    base_bytes: bytes_written,
5888                                    segment_count: 0,
5889                                    segment_bytes: 0,
5890                                    valid_bytes: bytes_written,
5891                                }));
5892                                self.set_dirty_paths(Some(BTreeSet::new()));
5893                                true
5894                            });
5895                    }
5896                }
5897            };
5898
5899            self.last_append_read_bytes
5900                .store(layout.bytes_read, Ordering::Relaxed);
5901            if layout.torn_tail {
5902                match OpenOptions::new()
5903                    .write(true)
5904                    .open(&data_path)
5905                    .and_then(|file| {
5906                        file.set_len(layout.valid_bytes as u64)?;
5907                        file.sync_all()
5908                    }) {
5909                    Ok(()) => {}
5910                    Err(error) => {
5911                        slog_warn!("failed to truncate torn semantic segment: {}", error);
5912                        return false;
5913                    }
5914                }
5915            }
5916            if changed_paths.is_empty() {
5917                self.set_dirty_paths(Some(BTreeSet::new()));
5918                self.set_persistence(Some(SemanticPersistenceState {
5919                    identity: layout.identity,
5920                    base_bytes: layout.base_bytes,
5921                    segment_count: layout.segment_count,
5922                    segment_bytes: layout.segment_bytes,
5923                    valid_bytes: layout.valid_bytes,
5924                }));
5925                return true;
5926            }
5927            let frame = match self.build_segment_frame(
5928                layout.segment_count.saturating_add(1) as u64,
5929                &changed_paths,
5930            ) {
5931                Ok(frame) => frame,
5932                Err(error) => {
5933                    slog_warn!("failed to encode semantic delta: {}", error);
5934                    return false;
5935                }
5936            };
5937            if let Err(error) = Self::append_segment_frame(&data_path, &frame) {
5938                slog_warn!("failed to append semantic delta: {}", error);
5939                return false;
5940            }
5941            let Some(identity) = semantic_artifact_identity(&data_path) else {
5942                return false;
5943            };
5944            self.set_persistence(Some(SemanticPersistenceState {
5945                identity,
5946                base_bytes: layout.base_bytes,
5947                segment_count: layout.segment_count.saturating_add(1),
5948                segment_bytes: layout.segment_bytes.saturating_add(frame.len()),
5949                valid_bytes: layout.valid_bytes.saturating_add(frame.len()),
5950            }));
5951            self.set_dirty_paths(Some(BTreeSet::new()));
5952            slog_info!(
5953                "semantic index delta persisted: {} files, {:.1} KB, artifact_read_bytes={}",
5954                changed_paths.len(),
5955                frame.len() as f64 / 1024.0,
5956                layout.bytes_read
5957            );
5958            self.schedule_compaction_if_needed(&dir, &data_path, &layout, frame.len());
5959            return true;
5960        }
5961
5962        match self.write_full_snapshot_at(&dir, &data_path, false) {
5963            Ok(bytes_written) => {
5964                let Some(identity) = semantic_artifact_identity(&data_path) else {
5965                    return false;
5966                };
5967                self.set_persistence(Some(SemanticPersistenceState {
5968                    identity,
5969                    base_bytes: bytes_written,
5970                    segment_count: 0,
5971                    segment_bytes: 0,
5972                    valid_bytes: bytes_written,
5973                }));
5974                self.set_dirty_paths(Some(BTreeSet::new()));
5975                slog_info!(
5976                    "semantic index persisted: {} entries, {:.1} KB",
5977                    self.entries.len(),
5978                    bytes_written as f64 / 1024.0
5979                );
5980                true
5981            }
5982            Err(error) => {
5983                slog_warn!("failed to write semantic index: {}", error);
5984                false
5985            }
5986        }
5987    }
5988
5989    #[doc(hidden)]
5990    pub fn segment_frames_for_test(
5991        &self,
5992        previous: &Self,
5993        sequence: u64,
5994    ) -> Option<(Vec<u8>, Vec<u8>)> {
5995        let dirty_paths = self.dirty_paths_snapshot()?;
5996        let structural_paths = semantic_changed_paths(previous, self);
5997        Some((
5998            self.build_segment_frame(sequence, &dirty_paths).ok()?,
5999            self.build_segment_frame(sequence, &structural_paths).ok()?,
6000        ))
6001    }
6002
6003    #[doc(hidden)]
6004    pub fn extend_dirty_paths_for_test(&self, paths: impl IntoIterator<Item = PathBuf>) {
6005        self.extend_dirty_paths(paths);
6006    }
6007
6008    #[doc(hidden)]
6009    pub fn last_append_read_bytes_for_test(&self) -> usize {
6010        self.last_append_read_bytes.load(Ordering::Relaxed)
6011    }
6012
6013    #[doc(hidden)]
6014    pub fn append_scan_bytes_for_test(
6015        &self,
6016        storage_dir: &Path,
6017        project_key: &str,
6018    ) -> Option<usize> {
6019        let data_path = storage_dir
6020            .join("semantic")
6021            .join(project_key)
6022            .join("semantic.bin");
6023        let persistence = self.persistence_snapshot()?;
6024        let fingerprint = self
6025            .fingerprint
6026            .as_ref()
6027            .map(SemanticIndexFingerprint::as_string)
6028            .unwrap_or_default();
6029        Self::scan_artifact_for_append(&data_path, persistence, &fingerprint, self.dimension)
6030            .ok()
6031            .map(|layout| layout.bytes_read)
6032    }
6033
6034    #[doc(hidden)]
6035    pub fn compact_to_disk_for_test(&self, storage_dir: &Path, project_key: &str) -> bool {
6036        let dir = storage_dir.join("semantic").join(project_key);
6037        let data_path = dir.join("semantic.bin");
6038        let Some(expected) = semantic_artifact_identity(&data_path) else {
6039            return false;
6040        };
6041        Self::compact_path_if_unchanged(&dir, &data_path, &self.project_root, expected)
6042    }
6043
6044    #[doc(hidden)]
6045    pub fn persistence_stats_for_test(
6046        storage_dir: &Path,
6047        project_key: &str,
6048        project_root: &Path,
6049    ) -> Option<(usize, usize, usize)> {
6050        let data_path = storage_dir
6051            .join("semantic")
6052            .join(project_key)
6053            .join("semantic.bin");
6054        let loaded = Self::load_artifact_path(&data_path, project_root).ok()?;
6055        Some((
6056            loaded.base_bytes,
6057            loaded.segment_count,
6058            loaded.segment_bytes,
6059        ))
6060    }
6061
6062    fn decode_segment_payload(
6063        payload: &[u8],
6064        expected_sequence: u64,
6065        current_canonical_root: &Path,
6066        base_fingerprint: Option<&SemanticIndexFingerprint>,
6067        base_dimension: usize,
6068    ) -> Result<(BTreeSet<PathBuf>, Self), String> {
6069        let mut reader = CountingReader::with_bytes_read(Cursor::new(payload), 0);
6070        let segment_version = read_u8_stream(&mut reader, "semantic segment is empty")?;
6071        if segment_version != SEMANTIC_SEGMENT_VERSION {
6072            return Err(format!(
6073                "unsupported semantic segment version: {segment_version}"
6074            ));
6075        }
6076        let sequence = read_u64_stream(&mut reader)?;
6077        if sequence != expected_sequence {
6078            return Err(format!(
6079                "semantic segment order mismatch: expected {expected_sequence}, found {sequence}"
6080            ));
6081        }
6082        let fingerprint_len = read_u32_stream(&mut reader)? as usize;
6083        if reader.bytes_read().saturating_add(fingerprint_len) > payload.len() {
6084            return Err("unexpected end of semantic segment fingerprint".to_string());
6085        }
6086        let mut fingerprint = vec![0_u8; fingerprint_len];
6087        read_exact_stream(
6088            &mut reader,
6089            &mut fingerprint,
6090            "unexpected end of semantic segment fingerprint",
6091        )?;
6092        let fingerprint = String::from_utf8(fingerprint)
6093            .map_err(|error| format!("invalid semantic segment fingerprint: {error}"))?;
6094        let expected_fingerprint = base_fingerprint
6095            .map(SemanticIndexFingerprint::as_string)
6096            .unwrap_or_default();
6097        if fingerprint != expected_fingerprint {
6098            return Err("semantic segment fingerprint does not match base snapshot".to_string());
6099        }
6100
6101        let dimension = read_u32_stream(&mut reader)? as usize;
6102        if dimension != base_dimension {
6103            return Err(format!(
6104                "semantic segment dimension mismatch: base={base_dimension}, segment={dimension}"
6105            ));
6106        }
6107        let tombstone_count = read_u32_stream(&mut reader)? as usize;
6108        if tombstone_count > MAX_ENTRIES {
6109            return Err(format!(
6110                "too many semantic segment tombstones: {tombstone_count}"
6111            ));
6112        }
6113        let mut tombstones = BTreeSet::new();
6114        for _ in 0..tombstone_count {
6115            let relative = PathBuf::from(read_string_stream(&mut reader, Some(payload.len()))?);
6116            let path = cached_path_under_root(current_canonical_root, &relative)
6117                .ok_or_else(|| "semantic segment tombstone escapes project root".to_string())?;
6118            if !tombstones.insert(path) {
6119                return Err("semantic segment contains a duplicate tombstone".to_string());
6120            }
6121        }
6122
6123        let delta_len = usize::try_from(read_u64_stream(&mut reader)?)
6124            .map_err(|_| "semantic segment delta is too large".to_string())?;
6125        if reader.bytes_read().saturating_add(delta_len) != payload.len() {
6126            return Err("semantic segment delta length does not match payload".to_string());
6127        }
6128        let mut delta_bytes = vec![0_u8; delta_len];
6129        read_exact_stream(
6130            &mut reader,
6131            &mut delta_bytes,
6132            "unexpected end of semantic segment delta",
6133        )?;
6134        let delta = Self::from_bytes(&delta_bytes, current_canonical_root)?;
6135        if delta.dimension != base_dimension
6136            || delta
6137                .fingerprint
6138                .as_ref()
6139                .map(SemanticIndexFingerprint::as_string)
6140                != base_fingerprint.map(SemanticIndexFingerprint::as_string)
6141        {
6142            return Err("semantic segment replacement snapshot identity mismatch".to_string());
6143        }
6144
6145        let replacement_paths = delta
6146            .file_mtimes
6147            .keys()
6148            .chain(delta.file_sizes.keys())
6149            .chain(delta.file_hashes.keys())
6150            .cloned()
6151            .chain(delta.entries.iter().map(|entry| entry.chunk.file.clone()))
6152            .collect::<BTreeSet<_>>();
6153        if !replacement_paths.is_subset(&tombstones) {
6154            return Err(
6155                "semantic segment replacement contains a file without a tombstone".to_string(),
6156            );
6157        }
6158        Ok((tombstones, delta))
6159    }
6160
6161    fn apply_segment_log<R: Read>(
6162        reader: &mut R,
6163        mut index: Self,
6164        total_len: usize,
6165        base_bytes: usize,
6166    ) -> Result<LoadedSemanticArtifact, String> {
6167        let mut valid_bytes = base_bytes;
6168        let mut segment_count = 0usize;
6169        let mut torn_tail = false;
6170
6171        while valid_bytes < total_len {
6172            let remaining = total_len.saturating_sub(valid_bytes);
6173            if remaining < SEMANTIC_SEGMENT_FRAME_HEADER_BYTES {
6174                torn_tail = true;
6175                break;
6176            }
6177            let mut header = [0_u8; SEMANTIC_SEGMENT_FRAME_HEADER_BYTES];
6178            if reader.read_exact(&mut header).is_err() {
6179                torn_tail = true;
6180                break;
6181            }
6182            if &header[..SEMANTIC_SEGMENT_MAGIC.len()] != SEMANTIC_SEGMENT_MAGIC {
6183                torn_tail = true;
6184                break;
6185            }
6186            let payload_len = usize::try_from(u64::from_le_bytes(
6187                header[8..16]
6188                    .try_into()
6189                    .expect("semantic segment length field"),
6190            ))
6191            .map_err(|_| "semantic segment length exceeds this platform".to_string())?;
6192            let frame_len = SEMANTIC_SEGMENT_FRAME_HEADER_BYTES
6193                .checked_add(payload_len)
6194                .ok_or_else(|| "semantic segment frame length overflow".to_string())?;
6195            if frame_len > remaining {
6196                torn_tail = true;
6197                break;
6198            }
6199            let mut payload = vec![0_u8; payload_len];
6200            if reader.read_exact(&mut payload).is_err() {
6201                torn_tail = true;
6202                break;
6203            }
6204            let expected_checksum = &header[16..SEMANTIC_SEGMENT_FRAME_HEADER_BYTES];
6205            if blake3::hash(&payload).as_bytes() != expected_checksum {
6206                torn_tail = true;
6207                break;
6208            }
6209
6210            let expected_sequence = segment_count.saturating_add(1) as u64;
6211            let (tombstones, delta) = Self::decode_segment_payload(
6212                &payload,
6213                expected_sequence,
6214                &index.project_root,
6215                index.fingerprint.as_ref(),
6216                index.dimension,
6217            )?;
6218            let tombstones = tombstones.into_iter().collect::<Vec<_>>();
6219            index.remove_indexed_files(&tombstones);
6220            index.entries.extend(delta.entries);
6221            index.file_mtimes.extend(delta.file_mtimes);
6222            index.file_sizes.extend(delta.file_sizes);
6223            index.file_hashes.extend(delta.file_hashes);
6224            index.any_missing_sizes = index
6225                .file_mtimes
6226                .keys()
6227                .any(|path| !index.file_sizes.contains_key(path));
6228
6229            valid_bytes = valid_bytes.saturating_add(frame_len);
6230            segment_count = segment_count.saturating_add(1);
6231        }
6232
6233        Ok(LoadedSemanticArtifact {
6234            index,
6235            base_bytes,
6236            valid_bytes,
6237            segment_count,
6238            segment_bytes: valid_bytes.saturating_sub(base_bytes),
6239            torn_tail,
6240        })
6241    }
6242
6243    fn load_artifact_path(
6244        data_path: &Path,
6245        current_canonical_root: &Path,
6246    ) -> Result<LoadedSemanticArtifact, String> {
6247        let file = fs::File::open(data_path).map_err(|error| error.to_string())?;
6248        let file_len =
6249            usize::try_from(file.metadata().map_err(|error| error.to_string())?.len())
6250                .map_err(|_| "semantic artifact is too large for this platform".to_string())?;
6251        if file_len < HEADER_BYTES_V1 {
6252            return Err(format!("data too short: {file_len} bytes"));
6253        }
6254        let mut reader = BufReader::new(file);
6255        let mut version_buf = [0_u8; 1];
6256        reader
6257            .read_exact(&mut version_buf)
6258            .map_err(|error| error.to_string())?;
6259        let version = version_buf[0];
6260        if version != SEMANTIC_INDEX_VERSION_V6 && version != SEMANTIC_INDEX_VERSION_V7 {
6261            return Err(format!("unsupported on-disk semantic version: {version}"));
6262        }
6263        let (index, base_bytes) = Self::from_reader_after_version(
6264            &mut reader,
6265            version,
6266            current_canonical_root,
6267            Some(file_len),
6268            1,
6269        )?;
6270        let loaded = Self::apply_segment_log(&mut reader, index, file_len, base_bytes)?;
6271        loaded.index.set_dirty_paths(Some(BTreeSet::new()));
6272        loaded
6273            .index
6274            .set_persistence(Self::persistence_state_from_loaded(data_path, &loaded));
6275        Ok(loaded)
6276    }
6277
6278    /// Read the semantic base snapshot and apply every committed delta in sequence.
6279    pub fn read_from_disk(
6280        storage_dir: &Path,
6281        project_key: &str,
6282        current_canonical_root: &Path,
6283        is_worktree_bridge: bool,
6284        expected_fingerprint: Option<&str>,
6285    ) -> Option<Self> {
6286        debug_assert!(current_canonical_root.is_absolute());
6287        let data_path = storage_dir
6288            .join("semantic")
6289            .join(project_key)
6290            .join("semantic.bin");
6291        let file_len = usize::try_from(data_path.metadata().ok()?.len()).ok()?;
6292        if file_len < HEADER_BYTES_V1 {
6293            slog_warn!(
6294                "corrupt semantic index (too small: {} bytes), removing",
6295                file_len
6296            );
6297            if !is_worktree_bridge {
6298                let _ = fs::remove_file(&data_path);
6299            }
6300            return None;
6301        }
6302        let mut version_buf = [0_u8; 1];
6303        fs::File::open(&data_path)
6304            .ok()?
6305            .read_exact(&mut version_buf)
6306            .ok()?;
6307        let version = version_buf[0];
6308        if version != SEMANTIC_INDEX_VERSION_V6 && version != SEMANTIC_INDEX_VERSION_V7 {
6309            slog_info!(
6310                "cached semantic index version {} is not compatible with {}, rebuilding without deleting the shared artifact",
6311                version,
6312                SEMANTIC_INDEX_VERSION_V7
6313            );
6314            return None;
6315        }
6316
6317        match Self::load_artifact_path(&data_path, current_canonical_root) {
6318            Ok(loaded) => {
6319                if let Some(expected) = expected_fingerprint {
6320                    let matches = loaded
6321                        .index
6322                        .fingerprint()
6323                        .map(|fingerprint| fingerprint.matches_expected(expected))
6324                        .unwrap_or(false);
6325                    if !matches {
6326                        log_fingerprint_mismatch(loaded.index.fingerprint(), expected);
6327                        return None;
6328                    }
6329                }
6330                if loaded.torn_tail {
6331                    slog_warn!(
6332                        "ignoring torn semantic segment tail after {} committed bytes",
6333                        loaded.valid_bytes
6334                    );
6335                    if !is_worktree_bridge {
6336                        let truncate_result = OpenOptions::new()
6337                            .write(true)
6338                            .open(&data_path)
6339                            .and_then(|file| {
6340                                file.set_len(loaded.valid_bytes as u64)?;
6341                                file.sync_all()
6342                            });
6343                        if let Err(error) = truncate_result {
6344                            slog_warn!("failed to truncate torn semantic segment: {}", error);
6345                        }
6346                    }
6347                }
6348                slog_info!(
6349                    "loaded semantic index from disk: {} entries ({} delta segments)",
6350                    loaded.index.entries.len(),
6351                    loaded.segment_count
6352                );
6353                Some(loaded.index)
6354            }
6355            Err(error) => {
6356                slog_warn!("corrupt semantic index, rebuilding: {}", error);
6357                if !is_worktree_bridge {
6358                    let _ = fs::remove_file(&data_path);
6359                }
6360                None
6361            }
6362        }
6363    }
6364
6365    pub(crate) fn read_from_disk_borrow_tolerant(
6366        storage_dir: &Path,
6367        project_key: &str,
6368        current_canonical_root: &Path,
6369    ) -> Option<Self> {
6370        let load_started = Instant::now();
6371        let loaded = Self::read_from_disk_borrow_tolerant_inner(
6372            storage_dir,
6373            project_key,
6374            current_canonical_root,
6375        );
6376        let outcome = if loaded.is_some() { "ready" } else { "denied" };
6377        let build_id = crate::logging::in_flight_build_id(
6378            crate::logging::IndexPlane::Semantic,
6379            current_canonical_root,
6380        )
6381        .unwrap_or_else(crate::logging::mint_index_build_id);
6382        crate::logging::log_index_event(
6383            crate::logging::IndexEvent::new(
6384                crate::logging::IndexEventKind::ArtifactLoaded,
6385                crate::logging::IndexPlane::Semantic,
6386                build_id,
6387                current_canonical_root,
6388                project_key,
6389            )
6390            .field("outcome", outcome)
6391            .field("borrowed", "true")
6392            .field(
6393                "elapsed_ms",
6394                load_started.elapsed().as_millis().min(u64::MAX as u128) as u64,
6395            ),
6396        );
6397        crate::logging::note_tool_call_wait(
6398            crate::run_tool_call::WaitingOn::ArtifactLoad,
6399            None,
6400            load_started.elapsed().as_millis().min(u64::MAX as u128) as u64,
6401        );
6402        loaded
6403    }
6404
6405    fn read_from_disk_borrow_tolerant_inner(
6406        storage_dir: &Path,
6407        project_key: &str,
6408        current_canonical_root: &Path,
6409    ) -> Option<Self> {
6410        let data_path = storage_dir
6411            .join("semantic")
6412            .join(project_key)
6413            .join("semantic.bin");
6414        let (fingerprint, artifact_content_hash) = match borrowed_artifact_identity(&data_path) {
6415            Ok(identity) => identity,
6416            Err(error) => {
6417                slog_warn!(
6418                    "semantic shared-base identity unavailable ({}); loading a private borrowed copy",
6419                    error
6420                );
6421                return Self::read_from_disk(
6422                    storage_dir,
6423                    project_key,
6424                    current_canonical_root,
6425                    true,
6426                    None,
6427                );
6428            }
6429        };
6430        let key = SharedSemanticBaseKey {
6431            artifact_cache_key: project_key.to_string(),
6432            fingerprint,
6433            artifact_content_hash,
6434        };
6435
6436        {
6437            let mut registry = shared_semantic_bases()
6438                .lock()
6439                .unwrap_or_else(std::sync::PoisonError::into_inner);
6440            registry.retain(|_, base| base.strong_count() > 0);
6441            if let Some(base) = registry.get(&key).and_then(Weak::upgrade) {
6442                SHARED_SEMANTIC_BASE_HITS.fetch_add(1, Ordering::Relaxed);
6443                return Some(Self::from_shared_base(
6444                    current_canonical_root.to_path_buf(),
6445                    base,
6446                ));
6447            }
6448            if registry.keys().any(|existing| {
6449                existing.artifact_cache_key == key.artifact_cache_key && existing != &key
6450            }) {
6451                slog_warn!(
6452                    "semantic shared-base fingerprint or artifact hash changed for key {}; loading a private borrowed copy",
6453                    project_key
6454                );
6455                return Self::read_from_disk(
6456                    storage_dir,
6457                    project_key,
6458                    current_canonical_root,
6459                    true,
6460                    None,
6461                );
6462            }
6463        }
6464
6465        let private = Self::read_from_disk(
6466            storage_dir,
6467            project_key,
6468            current_canonical_root,
6469            true,
6470            Some(&key.fingerprint),
6471        )?;
6472        let Ok(base) = private.clone().into_shared_base() else {
6473            slog_warn!(
6474                "semantic shared-base paths could not be normalized for key {}; loading a private borrowed copy",
6475                project_key
6476            );
6477            return Some(private);
6478        };
6479        let base = Arc::new(base);
6480
6481        let mut registry = shared_semantic_bases()
6482            .lock()
6483            .unwrap_or_else(std::sync::PoisonError::into_inner);
6484        registry.retain(|_, base| base.strong_count() > 0);
6485        if let Some(existing) = registry.get(&key).and_then(Weak::upgrade) {
6486            SHARED_SEMANTIC_BASE_HITS.fetch_add(1, Ordering::Relaxed);
6487            return Some(Self::from_shared_base(
6488                current_canonical_root.to_path_buf(),
6489                existing,
6490            ));
6491        }
6492        if registry.keys().any(|existing| {
6493            existing.artifact_cache_key == key.artifact_cache_key && existing != &key
6494        }) {
6495            slog_warn!(
6496                "semantic shared-base identity changed while loading key {}; retaining a private borrowed copy",
6497                project_key
6498            );
6499            return Some(private);
6500        }
6501        registry.insert(key, Arc::downgrade(&base));
6502        SHARED_SEMANTIC_BASE_LOADS.fetch_add(1, Ordering::Relaxed);
6503        Some(Self::from_shared_base(
6504            current_canonical_root.to_path_buf(),
6505            base,
6506        ))
6507    }
6508
6509    /// Serialize the index to bytes for disk persistence
6510    pub fn to_bytes(&self) -> Vec<u8> {
6511        if self.shared_base.is_some() {
6512            let mut private = self.clone();
6513            private.materialize_shared_base();
6514            return private.to_bytes();
6515        }
6516        let mut buf = Vec::new();
6517        self.write_to_writer(&mut buf)
6518            .expect("writing semantic index to Vec cannot fail");
6519        buf
6520    }
6521
6522    fn write_to_writer<W: Write>(&self, writer: &mut W) -> io::Result<usize> {
6523        let mut bytes_written = 0usize;
6524        let fingerprint = self.fingerprint.as_ref().and_then(|fingerprint| {
6525            let encoded = fingerprint.as_string();
6526            if encoded.is_empty() {
6527                None
6528            } else {
6529                Some(encoded)
6530            }
6531        });
6532        let fp_bytes_ref = fingerprint.as_deref().map(str::as_bytes).unwrap_or(&[]);
6533        let mut file_metadata = self
6534            .file_mtimes
6535            .iter()
6536            .filter_map(|(path, mtime)| {
6537                cache_relative_path(&self.project_root, path)
6538                    .map(|relative| (relative, path, mtime))
6539            })
6540            .collect::<Vec<_>>();
6541        file_metadata.sort_by(|left, right| left.0.cmp(&right.0));
6542        let mut persisted_entries = self
6543            .entries
6544            .iter()
6545            .filter_map(|entry| {
6546                cache_relative_path(&self.project_root, &entry.chunk.file)
6547                    .map(|relative| (relative, entry))
6548            })
6549            .collect::<Vec<_>>();
6550        persisted_entries.sort_by(|left, right| {
6551            left.0
6552                .cmp(&right.0)
6553                .then_with(|| semantic_entry_cmp(&left.1, &right.1))
6554        });
6555        let file_mtime_count = file_metadata.len();
6556        let entry_count = persisted_entries.len();
6557
6558        // Header: version(1) + dimension(4) + entry_count(4) + fingerprint_len(4) + fingerprint
6559        //
6560        // V7 is the single write format. Layout extends V6 with per-entry
6561        // qualified_name metadata while preserving the embedding fingerprint:
6562        //   - fingerprint is always represented (absent ⇒ fingerprint_len=0,
6563        //     no bytes follow). Uniform format simplifies the reader.
6564        //   - paths are relative to project_root.
6565        //   - file metadata stored as secs(u64) + subsec_nanos(u32) + size(u64) + blake3(32).
6566        //     Preserves full APFS/ext4/NTFS precision and catches mtime ties.
6567        //
6568        // V1/V2 remain readable for backward compatibility (see from_bytes).
6569        // V3/V4 load as compatible formats but are rejected on disk so snippets
6570        // and file sizes are rebuilt once. V6 remains accepted on disk and
6571        // yields qualified_name=None until the next V7 write.
6572        let version = SEMANTIC_INDEX_VERSION_V7;
6573        write_counted(writer, &[version], &mut bytes_written)?;
6574        write_counted(
6575            writer,
6576            &(self.dimension as u32).to_le_bytes(),
6577            &mut bytes_written,
6578        )?;
6579        write_counted(
6580            writer,
6581            &(entry_count as u32).to_le_bytes(),
6582            &mut bytes_written,
6583        )?;
6584        write_counted(
6585            writer,
6586            &(fp_bytes_ref.len() as u32).to_le_bytes(),
6587            &mut bytes_written,
6588        )?;
6589        write_counted(writer, fp_bytes_ref, &mut bytes_written)?;
6590
6591        // File mtime table: count(4) + entries
6592        // V3 layout per entry: path_len(4) + path + secs(8) + subsec_nanos(4)
6593        write_counted(
6594            writer,
6595            &(file_mtime_count as u32).to_le_bytes(),
6596            &mut bytes_written,
6597        )?;
6598        for (relative, path, mtime) in file_metadata {
6599            let relative = relative.to_string_lossy();
6600            let path_bytes = relative.as_bytes();
6601            write_counted(
6602                writer,
6603                &(path_bytes.len() as u32).to_le_bytes(),
6604                &mut bytes_written,
6605            )?;
6606            write_counted(writer, path_bytes, &mut bytes_written)?;
6607            let duration = mtime
6608                .duration_since(SystemTime::UNIX_EPOCH)
6609                .unwrap_or_default();
6610            write_counted(
6611                writer,
6612                &duration.as_secs().to_le_bytes(),
6613                &mut bytes_written,
6614            )?;
6615            write_counted(
6616                writer,
6617                &duration.subsec_nanos().to_le_bytes(),
6618                &mut bytes_written,
6619            )?;
6620            let size = self.file_sizes.get(path).copied().unwrap_or_default();
6621            write_counted(writer, &size.to_le_bytes(), &mut bytes_written)?;
6622            let hash = self
6623                .file_hashes
6624                .get(path)
6625                .copied()
6626                .unwrap_or_else(cache_freshness::zero_hash);
6627            write_counted(writer, hash.as_bytes(), &mut bytes_written)?;
6628        }
6629
6630        // Entries: each is metadata + vector. Canonical ordering lets parity
6631        // compare structure directly even when HashMap insertion order differs.
6632        for (relative, entry) in persisted_entries {
6633            let c = &entry.chunk;
6634
6635            // File path
6636            let relative = relative.to_string_lossy();
6637            let file_bytes = relative.as_bytes();
6638            write_counted(
6639                writer,
6640                &(file_bytes.len() as u32).to_le_bytes(),
6641                &mut bytes_written,
6642            )?;
6643            write_counted(writer, file_bytes, &mut bytes_written)?;
6644
6645            // Name
6646            let name_bytes = c.name.as_bytes();
6647            write_counted(
6648                writer,
6649                &(name_bytes.len() as u32).to_le_bytes(),
6650                &mut bytes_written,
6651            )?;
6652            write_counted(writer, name_bytes, &mut bytes_written)?;
6653
6654            // Qualified name (V7 metadata; absent is encoded as length 0)
6655            let qualified_name_bytes = c.qualified_name.as_deref().unwrap_or_default().as_bytes();
6656            write_counted(
6657                writer,
6658                &(qualified_name_bytes.len() as u32).to_le_bytes(),
6659                &mut bytes_written,
6660            )?;
6661            write_counted(writer, qualified_name_bytes, &mut bytes_written)?;
6662
6663            // Kind (1 byte)
6664            write_counted(writer, &[symbol_kind_to_u8(&c.kind)], &mut bytes_written)?;
6665
6666            // Lines + exported
6667            write_counted(
6668                writer,
6669                &(c.start_line as u32).to_le_bytes(),
6670                &mut bytes_written,
6671            )?;
6672            write_counted(
6673                writer,
6674                &(c.end_line as u32).to_le_bytes(),
6675                &mut bytes_written,
6676            )?;
6677            write_counted(writer, &[c.exported as u8], &mut bytes_written)?;
6678
6679            // Snippet
6680            let snippet_bytes = c.snippet.as_bytes();
6681            write_counted(
6682                writer,
6683                &(snippet_bytes.len() as u32).to_le_bytes(),
6684                &mut bytes_written,
6685            )?;
6686            write_counted(writer, snippet_bytes, &mut bytes_written)?;
6687
6688            // Embed text
6689            let embed_bytes = c.embed_text.as_bytes();
6690            write_counted(
6691                writer,
6692                &(embed_bytes.len() as u32).to_le_bytes(),
6693                &mut bytes_written,
6694            )?;
6695            write_counted(writer, embed_bytes, &mut bytes_written)?;
6696
6697            // Vector (f32 array)
6698            for &val in &entry.vector {
6699                write_counted(writer, &val.to_le_bytes(), &mut bytes_written)?;
6700            }
6701        }
6702
6703        Ok(bytes_written)
6704    }
6705
6706    /// Deserialize a base snapshot and any committed delta segments.
6707    pub fn from_bytes(data: &[u8], current_canonical_root: &Path) -> Result<Self, String> {
6708        debug_assert!(current_canonical_root.is_absolute());
6709        if data.len() < HEADER_BYTES_V1 {
6710            return Err("data too short".to_string());
6711        }
6712
6713        let mut reader = Cursor::new(&data[1..]);
6714        let (index, base_bytes) = Self::from_reader_after_version(
6715            &mut reader,
6716            data[0],
6717            current_canonical_root,
6718            Some(data.len()),
6719            1,
6720        )?;
6721        Self::apply_segment_log(&mut reader, index, data.len(), base_bytes)
6722            .map(|loaded| loaded.index)
6723    }
6724
6725    fn from_reader_after_version<R: Read>(
6726        reader: R,
6727        version: u8,
6728        current_canonical_root: &Path,
6729        total_len: Option<usize>,
6730        bytes_read: usize,
6731    ) -> Result<(Self, usize), String> {
6732        debug_assert!(current_canonical_root.is_absolute());
6733        let mut reader = CountingReader::with_bytes_read(reader, bytes_read);
6734
6735        if version != SEMANTIC_INDEX_VERSION_V1
6736            && version != SEMANTIC_INDEX_VERSION_V2
6737            && version != SEMANTIC_INDEX_VERSION_V3
6738            && version != SEMANTIC_INDEX_VERSION_V4
6739            && version != SEMANTIC_INDEX_VERSION_V5
6740            && version != SEMANTIC_INDEX_VERSION_V6
6741            && version != SEMANTIC_INDEX_VERSION_V7
6742        {
6743            return Err(format!("unsupported version: {}", version));
6744        }
6745        // V2 and newer share the same header layout (V3/V4/V5 only differ from
6746        // V2 in the per-mtime entry layout): version(1) + dimension(4) +
6747        // entry_count(4) + fingerprint_len(4) + fingerprint bytes.
6748        if (version == SEMANTIC_INDEX_VERSION_V2
6749            || version == SEMANTIC_INDEX_VERSION_V3
6750            || version == SEMANTIC_INDEX_VERSION_V4
6751            || version == SEMANTIC_INDEX_VERSION_V5
6752            || version == SEMANTIC_INDEX_VERSION_V6
6753            || version == SEMANTIC_INDEX_VERSION_V7)
6754            && total_len.is_some_and(|len| len < HEADER_BYTES_V2)
6755        {
6756            return Err("data too short for semantic index v2/v3/v4/v5/v6/v7 header".to_string());
6757        }
6758
6759        let dimension = read_u32_stream(&mut reader)? as usize;
6760        let entry_count = read_u32_stream(&mut reader)? as usize;
6761        validate_embedding_dimension(dimension)?;
6762        if entry_count > MAX_ENTRIES {
6763            return Err(format!("too many semantic index entries: {}", entry_count));
6764        }
6765
6766        // Fingerprint handling:
6767        //   - V1: no fingerprint field at all.
6768        //   - V2: fingerprint_len + fingerprint bytes; always present (writer
6769        //     only emitted V2 when fingerprint was Some).
6770        //   - V3+: fingerprint_len always present; fingerprint_len==0 ⇒ None.
6771        let has_fingerprint_field = version == SEMANTIC_INDEX_VERSION_V2
6772            || version == SEMANTIC_INDEX_VERSION_V3
6773            || version == SEMANTIC_INDEX_VERSION_V4
6774            || version == SEMANTIC_INDEX_VERSION_V5
6775            || version == SEMANTIC_INDEX_VERSION_V6
6776            || version == SEMANTIC_INDEX_VERSION_V7;
6777        let fingerprint = if has_fingerprint_field {
6778            let fingerprint_len = read_u32_stream(&mut reader)? as usize;
6779            if total_len
6780                .is_some_and(|len| reader.bytes_read().saturating_add(fingerprint_len) > len)
6781            {
6782                return Err("unexpected end of data reading fingerprint".to_string());
6783            }
6784            if fingerprint_len == 0 {
6785                None
6786            } else {
6787                let mut raw = vec![0u8; fingerprint_len];
6788                read_exact_stream(
6789                    &mut reader,
6790                    &mut raw,
6791                    "unexpected end of data reading fingerprint",
6792                )?;
6793                let raw = String::from_utf8_lossy(&raw).to_string();
6794                Some(
6795                    serde_json::from_str::<SemanticIndexFingerprint>(&raw)
6796                        .map_err(|error| format!("invalid semantic fingerprint: {error}"))?,
6797                )
6798            }
6799        } else {
6800            None
6801        };
6802
6803        // File mtimes
6804        let mtime_count = read_u32_stream(&mut reader)? as usize;
6805        if mtime_count > MAX_ENTRIES {
6806            return Err(format!("too many semantic file mtimes: {}", mtime_count));
6807        }
6808
6809        let vector_bytes = entry_count
6810            .checked_mul(dimension)
6811            .and_then(|count| count.checked_mul(F32_BYTES))
6812            .ok_or_else(|| "semantic vector allocation overflow".to_string())?;
6813        if total_len.is_some_and(|len| vector_bytes > len.saturating_sub(reader.bytes_read())) {
6814            return Err("semantic index vectors exceed available data".to_string());
6815        }
6816
6817        let mut file_mtimes = HashMap::with_capacity(mtime_count);
6818        let mut file_sizes = HashMap::with_capacity(mtime_count);
6819        let mut file_hashes = HashMap::with_capacity(mtime_count);
6820        for _ in 0..mtime_count {
6821            let path = read_string_stream(&mut reader, total_len)?;
6822            let secs = read_u64_stream(&mut reader)?;
6823            // V3+ persists subsec_nanos alongside secs so staleness checks
6824            // survive restart round-trips. V1/V2 load with 0 nanos, which
6825            // causes one rebuild on upgrade (they never matched live APFS
6826            // mtimes anyway — the bug v0.15.2 fixes). After that rebuild,
6827            // the cache is persisted as V3 and stabilises.
6828            let nanos = if version == SEMANTIC_INDEX_VERSION_V3
6829                || version == SEMANTIC_INDEX_VERSION_V4
6830                || version == SEMANTIC_INDEX_VERSION_V5
6831                || version == SEMANTIC_INDEX_VERSION_V6
6832                || version == SEMANTIC_INDEX_VERSION_V7
6833            {
6834                read_u32_stream(&mut reader)?
6835            } else {
6836                0
6837            };
6838            let size = if version == SEMANTIC_INDEX_VERSION_V5
6839                || version == SEMANTIC_INDEX_VERSION_V6
6840                || version == SEMANTIC_INDEX_VERSION_V7
6841            {
6842                read_u64_stream(&mut reader)?
6843            } else {
6844                0
6845            };
6846            let content_hash =
6847                if version == SEMANTIC_INDEX_VERSION_V6 || version == SEMANTIC_INDEX_VERSION_V7 {
6848                    let mut hash_bytes = [0u8; 32];
6849                    read_exact_stream(
6850                        &mut reader,
6851                        &mut hash_bytes,
6852                        "unexpected end of data reading content hash",
6853                    )?;
6854                    blake3::Hash::from_bytes(hash_bytes)
6855                } else {
6856                    cache_freshness::zero_hash()
6857                };
6858            // Hardening against corrupt / maliciously crafted cache files
6859            // (v0.15.2). `Duration::new(secs, nanos)` can panic when the
6860            // nanosecond carry overflows the second counter, and
6861            // `SystemTime + Duration` can panic on carry past the platform's
6862            // upper bound. Explicit validation keeps a corrupted semantic.bin
6863            // from taking down the whole aft process.
6864            if nanos >= 1_000_000_000 {
6865                return Err(format!(
6866                    "invalid semantic mtime: nanos {} >= 1_000_000_000",
6867                    nanos
6868                ));
6869            }
6870            let duration = std::time::Duration::new(secs, nanos);
6871            let mtime = SystemTime::UNIX_EPOCH
6872                .checked_add(duration)
6873                .ok_or_else(|| {
6874                    format!(
6875                        "invalid semantic mtime: secs={} nanos={} overflows SystemTime",
6876                        secs, nanos
6877                    )
6878                })?;
6879            let path = if version == SEMANTIC_INDEX_VERSION_V6
6880                || version == SEMANTIC_INDEX_VERSION_V7
6881            {
6882                cached_path_under_root(current_canonical_root, &PathBuf::from(path))
6883                    .ok_or_else(|| "cached semantic mtime path escapes project root".to_string())?
6884            } else {
6885                PathBuf::from(path)
6886            };
6887            file_mtimes.insert(path.clone(), mtime);
6888            file_sizes.insert(path.clone(), size);
6889            file_hashes.insert(path, content_hash);
6890        }
6891
6892        // Entries
6893        let mut entries = Vec::with_capacity(entry_count);
6894        for _ in 0..entry_count {
6895            let raw_file = PathBuf::from(read_string_stream(&mut reader, total_len)?);
6896            let file = if version == SEMANTIC_INDEX_VERSION_V6
6897                || version == SEMANTIC_INDEX_VERSION_V7
6898            {
6899                cached_path_under_root(current_canonical_root, &raw_file)
6900                    .ok_or_else(|| "cached semantic entry path escapes project root".to_string())?
6901            } else {
6902                raw_file
6903            };
6904            let name = read_string_stream(&mut reader, total_len)?;
6905            let qualified_name = if version == SEMANTIC_INDEX_VERSION_V7 {
6906                let qualified_name = read_string_stream(&mut reader, total_len)?;
6907                if qualified_name.is_empty() {
6908                    None
6909                } else {
6910                    Some(qualified_name)
6911                }
6912            } else {
6913                None
6914            };
6915
6916            let kind = u8_to_symbol_kind(read_u8_stream(&mut reader, "unexpected end of data")?);
6917
6918            let start_line = read_u32_stream(&mut reader)?;
6919            let end_line = read_u32_stream(&mut reader)?;
6920
6921            let exported = read_u8_stream(&mut reader, "unexpected end of data")? != 0;
6922
6923            let snippet = read_string_stream(&mut reader, total_len)?;
6924            let embed_text = read_string_stream(&mut reader, total_len)?;
6925
6926            // Vector
6927            let vec_bytes = dimension
6928                .checked_mul(F32_BYTES)
6929                .ok_or_else(|| "semantic vector allocation overflow".to_string())?;
6930            if total_len.is_some_and(|len| reader.bytes_read().saturating_add(vec_bytes) > len) {
6931                return Err("unexpected end of data reading vector".to_string());
6932            }
6933            let mut vector = Vec::with_capacity(dimension);
6934            for _ in 0..dimension {
6935                let mut bytes = [0u8; F32_BYTES];
6936                read_exact_stream(
6937                    &mut reader,
6938                    &mut bytes,
6939                    "unexpected end of data reading vector",
6940                )?;
6941                vector.push(f32::from_le_bytes(bytes));
6942            }
6943
6944            entries.push(EmbeddingEntry::new(
6945                SemanticChunk {
6946                    file,
6947                    name,
6948                    qualified_name,
6949                    kind,
6950                    start_line,
6951                    end_line,
6952                    exported,
6953                    embed_text,
6954                    snippet,
6955                },
6956                vector,
6957            ));
6958        }
6959
6960        if entries.len() != entry_count {
6961            return Err(format!(
6962                "semantic cache entry count drift: header={} decoded={}",
6963                entry_count,
6964                entries.len()
6965            ));
6966        }
6967        for entry in &entries {
6968            if !file_mtimes.contains_key(&entry.chunk.file) {
6969                return Err(format!(
6970                    "semantic cache metadata missing for entry file {}",
6971                    entry.chunk.file.display()
6972                ));
6973            }
6974        }
6975
6976        let any_missing_sizes = file_mtimes
6977            .keys()
6978            .any(|path| !file_sizes.contains_key(path));
6979        let bytes_read = reader.bytes_read();
6980        Ok((
6981            Self {
6982                entries,
6983                file_mtimes,
6984                file_sizes,
6985                any_missing_sizes,
6986                file_hashes,
6987                dimension,
6988                fingerprint,
6989                project_root: current_canonical_root.to_path_buf(),
6990                deferred_files: HashSet::new(),
6991                shared_base: None,
6992                dirty_paths: Arc::new(Mutex::new(None)),
6993                persistence: Arc::new(Mutex::new(None)),
6994                last_append_read_bytes: Arc::new(AtomicUsize::new(0)),
6995                skipped_rows: 0,
6996                #[cfg(test)]
6997                removal_retain_passes: 0,
6998            },
6999            bytes_read,
7000        ))
7001    }
7002}
7003
7004fn write_counted<W: Write>(
7005    writer: &mut W,
7006    bytes: &[u8],
7007    bytes_written: &mut usize,
7008) -> io::Result<()> {
7009    writer.write_all(bytes)?;
7010    *bytes_written = bytes_written.saturating_add(bytes.len());
7011    Ok(())
7012}
7013
7014struct CountingReader<R> {
7015    inner: R,
7016    bytes_read: usize,
7017}
7018
7019impl<R> CountingReader<R> {
7020    fn with_bytes_read(inner: R, bytes_read: usize) -> Self {
7021        Self { inner, bytes_read }
7022    }
7023
7024    fn bytes_read(&self) -> usize {
7025        self.bytes_read
7026    }
7027}
7028
7029impl<R: Read> Read for CountingReader<R> {
7030    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
7031        let read = self.inner.read(buf)?;
7032        self.bytes_read = self.bytes_read.saturating_add(read);
7033        Ok(read)
7034    }
7035}
7036
7037fn read_exact_stream<R: Read>(
7038    reader: &mut CountingReader<R>,
7039    buf: &mut [u8],
7040    eof_message: &'static str,
7041) -> Result<(), String> {
7042    reader.read_exact(buf).map_err(|error| {
7043        if error.kind() == io::ErrorKind::UnexpectedEof {
7044            eof_message.to_string()
7045        } else {
7046            format!("{eof_message}: {error}")
7047        }
7048    })
7049}
7050
7051fn read_u8_stream<R: Read>(
7052    reader: &mut CountingReader<R>,
7053    eof_message: &'static str,
7054) -> Result<u8, String> {
7055    let mut bytes = [0u8; 1];
7056    read_exact_stream(reader, &mut bytes, eof_message)?;
7057    Ok(bytes[0])
7058}
7059
7060fn read_u32_stream<R: Read>(reader: &mut CountingReader<R>) -> Result<u32, String> {
7061    let mut bytes = [0u8; 4];
7062    read_exact_stream(reader, &mut bytes, "unexpected end of data reading u32")?;
7063    Ok(u32::from_le_bytes(bytes))
7064}
7065
7066fn read_u64_stream<R: Read>(reader: &mut CountingReader<R>) -> Result<u64, String> {
7067    let mut bytes = [0u8; 8];
7068    read_exact_stream(reader, &mut bytes, "unexpected end of data reading u64")?;
7069    Ok(u64::from_le_bytes(bytes))
7070}
7071
7072fn read_string_stream<R: Read>(
7073    reader: &mut CountingReader<R>,
7074    total_len: Option<usize>,
7075) -> Result<String, String> {
7076    let len = read_u32_stream(reader)? as usize;
7077    if total_len.is_some_and(|total_len| reader.bytes_read().saturating_add(len) > total_len) {
7078        return Err("unexpected end of data reading string".to_string());
7079    }
7080    let mut bytes = vec![0u8; len];
7081    read_exact_stream(reader, &mut bytes, "unexpected end of data reading string")?;
7082    Ok(String::from_utf8_lossy(&bytes).to_string())
7083}
7084
7085struct SourceLineCache<'a> {
7086    lines: Vec<&'a str>,
7087    line_starts: Vec<usize>,
7088}
7089
7090impl<'a> SourceLineCache<'a> {
7091    fn new(source: &'a str) -> Self {
7092        let lines: Vec<&'a str> = source.lines().collect();
7093        let mut line_starts = Vec::with_capacity(lines.len());
7094        let bytes = source.as_bytes();
7095        let mut offset = 0usize;
7096        for line in &lines {
7097            line_starts.push(offset);
7098            offset += line.len();
7099            if bytes.get(offset) == Some(&b'\r') && bytes.get(offset + 1) == Some(&b'\n') {
7100                offset += 2;
7101            } else if bytes.get(offset) == Some(&b'\n') {
7102                offset += 1;
7103            }
7104        }
7105        Self { lines, line_starts }
7106    }
7107
7108    fn len(&self) -> usize {
7109        debug_assert_eq!(self.lines.len(), self.line_starts.len());
7110        self.line_starts.len()
7111    }
7112}
7113
7114/// Build enriched embedding text from a symbol with cAST-style context.
7115fn build_embed_text_with_lines_and_caps(
7116    symbol: &Symbol,
7117    line_cache: &SourceLineCache<'_>,
7118    file: &Path,
7119    project_root: &Path,
7120    caps: EmbedTextCaps,
7121) -> String {
7122    let relative = file
7123        .strip_prefix(project_root)
7124        .unwrap_or(file)
7125        .to_string_lossy();
7126
7127    let kind_label = match &symbol.kind {
7128        SymbolKind::Function => "function",
7129        SymbolKind::Kernel => "kernel",
7130        SymbolKind::Class => "class",
7131        SymbolKind::Method => "method",
7132        SymbolKind::Struct => "struct",
7133        SymbolKind::Interface => "interface",
7134        SymbolKind::Enum => "enum",
7135        SymbolKind::TypeAlias => "type",
7136        SymbolKind::Variable => "variable",
7137        SymbolKind::Heading => "heading",
7138        SymbolKind::FileSummary => "file-summary",
7139    };
7140
7141    // Build: "file:relative/path kind:function name:validateAuth signature:fn validateAuth(token: &str) -> bool"
7142    let name = &symbol.name;
7143    let mut text = format!(
7144        "name:{name} file:{} kind:{} name:{name}",
7145        relative, kind_label
7146    );
7147
7148    if let Some(sig) = &symbol.signature {
7149        // Cap the signature: structured parsers (e.g. YAML/Kubernetes) pack
7150        // entire inline scripts (CronJob/Job `command:` bodies, multi-KB) into
7151        // the signature. Appending it unbounded produces a single embed_text
7152        // that overflows the embedding backend's physical batch (e.g. a
7153        // llama.cpp server's 512-token cap), aborting the whole index build
7154        // and silently degrading every search to lexical. 400 chars keeps the
7155        // identifying head of the signature without blowing the budget.
7156        text.push_str(&format!(
7157            " signature:{}",
7158            truncate_chars(sig, caps.signature_chars)
7159        ));
7160    }
7161
7162    // Add the leading symbol body within the resolved backend budget.
7163    let start = (symbol.range.start_line as usize).min(line_cache.len());
7164    // range.end_line is inclusive 0-based; +1 makes it an exclusive slice bound.
7165    let end = (symbol.range.end_line as usize + 1).min(line_cache.len());
7166    if start < end {
7167        let body: String = line_cache.lines[start..end]
7168            .iter()
7169            .take(caps.body_lines)
7170            .copied()
7171            .collect::<Vec<&str>>()
7172            .join("\n");
7173        let snippet = if body.len() > caps.body_chars {
7174            format!("{}...", &body[..body.floor_char_boundary(caps.body_chars)])
7175        } else {
7176            body
7177        };
7178        text.push_str(&format!(" body:{}", snippet));
7179    }
7180
7181    // Final defense-in-depth clamp: no single embed_text may exceed the
7182    // resolved backend budget regardless of which field grew.
7183    truncate_chars(&text, caps.total_chars)
7184}
7185
7186#[cfg(test)]
7187fn build_embed_text(symbol: &Symbol, source: &str, file: &Path, project_root: &Path) -> String {
7188    let line_cache = SourceLineCache::new(source);
7189    build_embed_text_with_lines_and_caps(
7190        symbol,
7191        &line_cache,
7192        file,
7193        project_root,
7194        EmbedTextCaps::default(),
7195    )
7196}
7197
7198/// Legacy whole-row character cap retained when no remote token budget is set.
7199const MAX_EMBED_TEXT_CHARS: usize = 1600;
7200const DEFAULT_SIGNATURE_CHARS: usize = 400;
7201const DEFAULT_BODY_LINES: usize = 15;
7202const DEFAULT_BODY_CHARS: usize = 300;
7203/// Maximum `name/file/kind/name` header measured across the six September 2026
7204/// semantic-census corpora. Reserving this many characters keeps the configured
7205/// token budget an upper bound even for the longest observed header.
7206pub const MAX_EMBED_TEXT_HEADER_CHARS: usize = 457;
7207const CHARS_PER_TOKEN_NUMERATOR: usize = 7;
7208const CHARS_PER_TOKEN_DENOMINATOR: usize = 2;
7209
7210#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
7211pub struct EmbedTextCaps {
7212    pub signature_chars: usize,
7213    pub body_lines: usize,
7214    pub body_chars: usize,
7215    pub total_chars: usize,
7216}
7217
7218impl EmbedTextCaps {
7219    pub fn from_config(config: &SemanticBackendConfig) -> Self {
7220        let defaults = Self::default();
7221        if config.backend == SemanticBackend::Fastembed {
7222            return defaults;
7223        }
7224        let Some(max_input_tokens) = config.max_input_tokens else {
7225            return defaults;
7226        };
7227
7228        let total_chars = max_input_tokens.saturating_mul(CHARS_PER_TOKEN_NUMERATOR)
7229            / CHARS_PER_TOKEN_DENOMINATOR;
7230        let body_chars = total_chars.saturating_sub(
7231            defaults
7232                .signature_chars
7233                .saturating_add(MAX_EMBED_TEXT_HEADER_CHARS),
7234        );
7235        Self {
7236            signature_chars: defaults.signature_chars,
7237            body_lines: usize::MAX,
7238            body_chars,
7239            total_chars,
7240        }
7241    }
7242}
7243
7244impl Default for EmbedTextCaps {
7245    fn default() -> Self {
7246        Self {
7247            signature_chars: DEFAULT_SIGNATURE_CHARS,
7248            body_lines: DEFAULT_BODY_LINES,
7249            body_chars: DEFAULT_BODY_CHARS,
7250            total_chars: MAX_EMBED_TEXT_CHARS,
7251        }
7252    }
7253}
7254
7255fn truncate_chars(value: &str, max_chars: usize) -> String {
7256    value.chars().take(max_chars).collect()
7257}
7258
7259fn first_leading_doc_comment(line_cache: &SourceLineCache<'_>) -> String {
7260    let Some((start, first)) = line_cache
7261        .lines
7262        .iter()
7263        .enumerate()
7264        .find(|(_, line)| !line.trim().is_empty())
7265    else {
7266        return String::new();
7267    };
7268
7269    let trimmed = first.trim_start();
7270    if trimmed.starts_with("/**") {
7271        let mut comment = Vec::new();
7272        for line in line_cache.lines.iter().skip(start) {
7273            comment.push(*line);
7274            if line.contains("*/") {
7275                break;
7276            }
7277        }
7278        return truncate_chars(&comment.join("\n"), 200);
7279    }
7280
7281    if trimmed.starts_with("///") || trimmed.starts_with("//!") {
7282        let comment = line_cache
7283            .lines
7284            .iter()
7285            .skip(start)
7286            .take_while(|line| {
7287                let trimmed = line.trim_start();
7288                trimmed.starts_with("///") || trimmed.starts_with("//!")
7289            })
7290            .copied()
7291            .collect::<Vec<_>>()
7292            .join("\n");
7293        return truncate_chars(&comment, 200);
7294    }
7295
7296    String::new()
7297}
7298
7299pub fn build_file_summary_chunk(
7300    file: &Path,
7301    project_root: &Path,
7302    source: &str,
7303    top_exports: &[&str],
7304    top_export_signatures: &[Option<&str>],
7305) -> SemanticChunk {
7306    let line_cache = SourceLineCache::new(source);
7307    build_file_summary_chunk_with_lines(
7308        file,
7309        project_root,
7310        &line_cache,
7311        top_exports,
7312        top_export_signatures,
7313    )
7314}
7315
7316fn build_file_summary_chunk_with_lines(
7317    file: &Path,
7318    project_root: &Path,
7319    line_cache: &SourceLineCache<'_>,
7320    top_exports: &[&str],
7321    top_export_signatures: &[Option<&str>],
7322) -> SemanticChunk {
7323    build_file_summary_chunk_with_lines_and_caps(
7324        file,
7325        project_root,
7326        line_cache,
7327        top_exports,
7328        top_export_signatures,
7329        EmbedTextCaps::default(),
7330    )
7331}
7332
7333fn build_file_summary_chunk_with_lines_and_caps(
7334    file: &Path,
7335    project_root: &Path,
7336    line_cache: &SourceLineCache<'_>,
7337    top_exports: &[&str],
7338    top_export_signatures: &[Option<&str>],
7339    caps: EmbedTextCaps,
7340) -> SemanticChunk {
7341    let relative = file.strip_prefix(project_root).unwrap_or(file);
7342    let rel_path = relative.to_string_lossy();
7343    let parent_dir = relative
7344        .parent()
7345        .map(|parent| parent.to_string_lossy().to_string())
7346        .unwrap_or_default();
7347    let name = file
7348        .file_stem()
7349        .map(|stem| stem.to_string_lossy().to_string())
7350        .unwrap_or_default();
7351    let doc = first_leading_doc_comment(line_cache);
7352    let exports = top_exports
7353        .iter()
7354        .take(5)
7355        .copied()
7356        .collect::<Vec<_>>()
7357        .join(",");
7358    let snippet = if doc.is_empty() {
7359        top_export_signatures
7360            .first()
7361            .and_then(|signature| signature.as_deref())
7362            .map(|signature| truncate_chars(signature, 200))
7363            .unwrap_or_default()
7364    } else {
7365        doc.clone()
7366    };
7367
7368    SemanticChunk {
7369        file: file.to_path_buf(),
7370        name,
7371        qualified_name: None,
7372        kind: SymbolKind::FileSummary,
7373        start_line: 0,
7374        end_line: 0,
7375        exported: false,
7376        embed_text: truncate_chars(
7377            &format!(
7378                "file:{rel_path} kind:file-summary name:{} parent:{parent_dir} doc:{doc} exports:{exports}",
7379                file.file_stem()
7380                    .map(|stem| stem.to_string_lossy().to_string())
7381                    .unwrap_or_default()
7382            ),
7383            caps.total_chars,
7384        ),
7385        snippet,
7386    }
7387}
7388
7389pub fn is_semantic_indexed_extension(path: &Path) -> bool {
7390    if path.file_name().and_then(|name| name.to_str()) == Some("Jenkinsfile") {
7391        return true;
7392    }
7393
7394    matches!(
7395        path.extension().and_then(|extension| extension.to_str()),
7396        Some(
7397            "ts" | "tsx"
7398                | "js"
7399                | "jsx"
7400                | "py"
7401                | "rs"
7402                | "go"
7403                | "c"
7404                | "h"
7405                | "cc"
7406                | "cpp"
7407                | "cxx"
7408                | "hpp"
7409                | "hh"
7410                | "cu"
7411                | "cuh"
7412                | "metal"
7413                | "zig"
7414                | "cs"
7415                | "sh"
7416                | "bash"
7417                | "zsh"
7418                | "inc"
7419                | "php"
7420                | "sol"
7421                | "scss"
7422                | "vue"
7423                | "yaml"
7424                | "yml"
7425                | "pas"
7426                | "pp"
7427                | "dpr"
7428                | "dpk"
7429                | "lpr"
7430                | "java"
7431                | "kt"
7432                | "kts"
7433                | "rb"
7434                | "swift"
7435                | "scala"
7436                | "sc"
7437                | "lua"
7438                | "pl"
7439                | "pm"
7440                | "t"
7441                | "r"
7442                | "R"
7443                | "groovy"
7444                | "gvy"
7445                | "gy"
7446                | "gsh"
7447                | "gradle"
7448                | "m"
7449                | "mm"
7450                | "toml",
7451        )
7452    )
7453}
7454
7455fn canonicalize_existing_or_deleted_path(path: &Path) -> PathBuf {
7456    if let Ok(canonical) = fs::canonicalize(path) {
7457        return canonical;
7458    }
7459
7460    let Some(parent) = path.parent() else {
7461        return path.to_path_buf();
7462    };
7463    let Some(file_name) = path.file_name() else {
7464        return path.to_path_buf();
7465    };
7466
7467    fs::canonicalize(parent)
7468        .map(|canonical_parent| canonical_parent.join(file_name))
7469        .unwrap_or_else(|_| path.to_path_buf())
7470}
7471
7472/// Files larger than this are skipped for semantic chunking. The read +
7473/// tree-sitter parse is transiently O(file size) (tree-sitter can use several×
7474/// the source bytes), and `par_iter` collection parses many files at once, so an
7475/// unbounded read here is an OOM vector on a repo with a few multi-MB generated/
7476/// vendored/minified files. A file this large yields almost no useful embedding
7477/// anyway (each chunk's embed_text is bounded by its resolved backend caps), so we
7478/// track it (0 chunks) instead of reading it — freshness then skips it on later
7479/// refreshes. 4 MiB keeps essentially all hand-written source while capping the
7480/// pathological tail.
7481const MAX_SEMANTIC_FILE_BYTES: u64 = 4 * 1024 * 1024;
7482
7483fn collect_semantic_file(
7484    project_root: &Path,
7485    file: &Path,
7486    embed_text_caps: EmbedTextCaps,
7487    phases: &mut SemanticCollectPhaseTimings,
7488) -> Result<(IndexedFileMetadata, Vec<SemanticChunk>), String> {
7489    let read_hash_started = Instant::now();
7490    let read_result = (|| {
7491        let metadata = fs::metadata(file).map_err(|error| error.to_string())?;
7492        if !metadata.is_file() {
7493            return Err("not a regular file".to_string());
7494        }
7495        let mtime = metadata.modified().map_err(|error| error.to_string())?;
7496        let size = metadata.len();
7497
7498        if !is_semantic_indexed_extension(file) {
7499            return Err("unsupported file extension".to_string());
7500        }
7501        let lang = detect_language(file).ok_or_else(|| "unsupported file extension".to_string())?;
7502
7503        let mut indexed_metadata = IndexedFileMetadata {
7504            mtime,
7505            size,
7506            content_hash: cache_freshness::zero_hash(),
7507        };
7508
7509        // OOM backstop: skip oversized files before the read + parse (tracked with
7510        // zero chunks by the caller, so freshness won't re-read them every refresh).
7511        if size > MAX_SEMANTIC_FILE_BYTES {
7512            return Ok((indexed_metadata, lang, None));
7513        }
7514
7515        let source = fs::read_to_string(file).map_err(|error| error.to_string())?;
7516        indexed_metadata.content_hash = if size <= cache_freshness::CONTENT_HASH_SIZE_CAP {
7517            cache_freshness::hash_bytes(source.as_bytes())
7518        } else {
7519            cache_freshness::zero_hash()
7520        };
7521        Ok((indexed_metadata, lang, Some(source)))
7522    })();
7523    phases.read_hash += read_hash_started.elapsed();
7524    let (indexed_metadata, lang, source) = read_result?;
7525    let Some(source) = source else {
7526        return Ok((indexed_metadata, Vec::new()));
7527    };
7528
7529    let chunks = collect_file_chunks_from_source_timed(
7530        project_root,
7531        file,
7532        lang,
7533        &source,
7534        embed_text_caps,
7535        phases,
7536    )?;
7537    Ok((indexed_metadata, chunks))
7538}
7539
7540#[cfg(feature = "semantic-chunk-census")]
7541#[doc(hidden)]
7542pub fn collect_file_chunks_for_census(
7543    project_root: &Path,
7544    file: &Path,
7545    census_caps: EmbedTextCaps,
7546) -> Result<(Vec<SemanticChunk>, Vec<SemanticChunk>), String> {
7547    if !is_semantic_indexed_extension(file) {
7548        return Err("unsupported file extension".to_string());
7549    }
7550    let lang = detect_language(file).ok_or_else(|| "unsupported file extension".to_string())?;
7551    if fs::metadata(file).is_ok_and(|metadata| metadata.len() > MAX_SEMANTIC_FILE_BYTES) {
7552        return Ok((Vec::new(), Vec::new()));
7553    }
7554
7555    let source = fs::read_to_string(file).map_err(|error| error.to_string())?;
7556    let tree =
7557        parse_source_with_cached_parser(file, &source, lang).map_err(|error| error.to_string())?;
7558    let symbols =
7559        extract_symbols_from_tree(&source, &tree, lang).map_err(|error| error.to_string())?;
7560    let today = symbols_to_chunks(file, &symbols, &source, project_root);
7561    let census = symbols_to_chunks_with_caps(file, &symbols, &source, project_root, census_caps);
7562    Ok((today, census))
7563}
7564
7565#[cfg(test)]
7566fn collect_file_chunks(project_root: &Path, file: &Path) -> Result<Vec<SemanticChunk>, String> {
7567    if !is_semantic_indexed_extension(file) {
7568        return Err("unsupported file extension".to_string());
7569    }
7570    let lang = detect_language(file).ok_or_else(|| "unsupported file extension".to_string())?;
7571    // OOM backstop: skip oversized files before the read + parse (tracked with
7572    // zero chunks by the caller, so freshness won't re-read them every refresh).
7573    if fs::metadata(file).is_ok_and(|m| m.len() > MAX_SEMANTIC_FILE_BYTES) {
7574        return Ok(Vec::new());
7575    }
7576    let source = fs::read_to_string(file).map_err(|error| error.to_string())?;
7577    collect_file_chunks_from_source(project_root, file, lang, &source)
7578}
7579
7580#[cfg(test)]
7581fn collect_file_chunks_from_source(
7582    project_root: &Path,
7583    file: &Path,
7584    lang: crate::parser::LangId,
7585    source: &str,
7586) -> Result<Vec<SemanticChunk>, String> {
7587    collect_file_chunks_from_source_timed(
7588        project_root,
7589        file,
7590        lang,
7591        source,
7592        EmbedTextCaps::default(),
7593        &mut SemanticCollectPhaseTimings::default(),
7594    )
7595}
7596
7597fn collect_file_chunks_from_source_timed(
7598    project_root: &Path,
7599    file: &Path,
7600    lang: crate::parser::LangId,
7601    source: &str,
7602    embed_text_caps: EmbedTextCaps,
7603    phases: &mut SemanticCollectPhaseTimings,
7604) -> Result<Vec<SemanticChunk>, String> {
7605    let parse_started = Instant::now();
7606    let tree_result =
7607        parse_source_with_cached_parser(file, source, lang).map_err(|error| error.to_string());
7608    phases.parse += parse_started.elapsed();
7609    let tree = tree_result?;
7610
7611    let extract_started = Instant::now();
7612    let symbols_result =
7613        extract_symbols_from_tree(source, &tree, lang).map_err(|error| error.to_string());
7614    phases.extract += extract_started.elapsed();
7615    let symbols = symbols_result?;
7616
7617    let build_started = Instant::now();
7618    let chunks = symbols_to_chunks_with_caps(file, &symbols, source, project_root, embed_text_caps);
7619    phases.build += build_started.elapsed();
7620    Ok(chunks)
7621}
7622
7623/// Build a display snippet from a symbol's source
7624fn build_snippet_with_lines(symbol: &Symbol, line_cache: &SourceLineCache<'_>) -> String {
7625    let start = (symbol.range.start_line as usize).min(line_cache.len());
7626    // range.end_line is inclusive 0-based; +1 makes it an exclusive slice bound.
7627    let end = (symbol.range.end_line as usize + 1).min(line_cache.len());
7628    if start < end {
7629        let snippet_lines: Vec<&str> = line_cache.lines[start..end]
7630            .iter()
7631            .take(5)
7632            .copied()
7633            .collect();
7634        let mut snippet = snippet_lines.join("\n");
7635        if end - start > 5 {
7636            snippet.push_str("\n  ...");
7637        }
7638        if snippet.len() > 300 {
7639            snippet = format!("{}...", &snippet[..snippet.floor_char_boundary(300)]);
7640        }
7641        snippet
7642    } else {
7643        String::new()
7644    }
7645}
7646
7647#[cfg(test)]
7648fn build_snippet(symbol: &Symbol, source: &str) -> String {
7649    let line_cache = SourceLineCache::new(source);
7650    build_snippet_with_lines(symbol, &line_cache)
7651}
7652
7653fn qualified_name_for_symbol(symbol: &Symbol) -> Option<String> {
7654    let mut parts = symbol
7655        .scope_chain
7656        .iter()
7657        .filter(|part| !part.is_empty())
7658        .cloned()
7659        .collect::<Vec<_>>();
7660    if !symbol.name.is_empty() {
7661        parts.push(symbol.name.clone());
7662    }
7663    (!parts.is_empty()).then(|| parts.join("."))
7664}
7665
7666/// Convert symbols to semantic chunks with enriched context
7667#[cfg(any(test, feature = "semantic-chunk-census"))]
7668fn symbols_to_chunks(
7669    file: &Path,
7670    symbols: &[Symbol],
7671    source: &str,
7672    project_root: &Path,
7673) -> Vec<SemanticChunk> {
7674    symbols_to_chunks_with_caps(
7675        file,
7676        symbols,
7677        source,
7678        project_root,
7679        EmbedTextCaps::default(),
7680    )
7681}
7682
7683fn symbols_to_chunks_with_caps(
7684    file: &Path,
7685    symbols: &[Symbol],
7686    source: &str,
7687    project_root: &Path,
7688    caps: EmbedTextCaps,
7689) -> Vec<SemanticChunk> {
7690    let line_cache = SourceLineCache::new(source);
7691    let mut chunks = Vec::new();
7692    let top_exports_with_signatures = symbols
7693        .iter()
7694        .filter(|symbol| {
7695            symbol.exported
7696                && symbol.parent.is_none()
7697                && !matches!(symbol.kind, SymbolKind::Heading)
7698        })
7699        .map(|symbol| (symbol.name.as_str(), symbol.signature.as_deref()))
7700        .collect::<Vec<_>>();
7701
7702    let has_only_headings = !symbols.is_empty()
7703        && symbols
7704            .iter()
7705            .all(|symbol| matches!(symbol.kind, SymbolKind::Heading));
7706    if top_exports_with_signatures.len() <= 2 && !has_only_headings {
7707        let top_exports = top_exports_with_signatures
7708            .iter()
7709            .map(|(name, _)| *name)
7710            .collect::<Vec<_>>();
7711        let top_export_signatures = top_exports_with_signatures
7712            .iter()
7713            .map(|(_, signature)| *signature)
7714            .collect::<Vec<_>>();
7715        chunks.push(build_file_summary_chunk_with_lines(
7716            file,
7717            project_root,
7718            &line_cache,
7719            &top_exports,
7720            &top_export_signatures,
7721        ));
7722    }
7723
7724    for symbol in symbols {
7725        // Skip Markdown / HTML heading chunks: empirically they dominate result
7726        // lists even for code-shaped queries because heading prose embeds well.
7727        // Agents querying for code lose the actual matches under doc noise.
7728        // README/docs queries are still served by grep on the same files.
7729        if matches!(symbol.kind, SymbolKind::Heading) {
7730            continue;
7731        }
7732
7733        // Skip very small symbols (single-line variables, etc.)
7734        let line_count = symbol
7735            .range
7736            .end_line
7737            .saturating_sub(symbol.range.start_line)
7738            + 1;
7739        if line_count < 2 && !matches!(symbol.kind, SymbolKind::Variable) {
7740            continue;
7741        }
7742
7743        let embed_text =
7744            build_embed_text_with_lines_and_caps(symbol, &line_cache, file, project_root, caps);
7745        let snippet = build_snippet_with_lines(symbol, &line_cache);
7746
7747        chunks.push(SemanticChunk {
7748            file: file.to_path_buf(),
7749            name: symbol.name.clone(),
7750            qualified_name: qualified_name_for_symbol(symbol),
7751            kind: symbol.kind.clone(),
7752            start_line: symbol.range.start_line,
7753            end_line: symbol.range.end_line,
7754            exported: symbol.exported,
7755            embed_text,
7756            snippet,
7757        });
7758
7759        // Note: Nested symbols are handled separately by the outline system
7760        // Each symbol is indexed individually
7761    }
7762
7763    chunks
7764}
7765
7766fn semantic_score_order(a: &(f32, usize), b: &(f32, usize)) -> std::cmp::Ordering {
7767    b.0.partial_cmp(&a.0)
7768        .unwrap_or(std::cmp::Ordering::Equal)
7769        .then_with(|| a.1.cmp(&b.1))
7770}
7771
7772/// Compute an embedding's L2 norm for its in-memory search cache.
7773fn vector_norm(vector: &[f32]) -> f32 {
7774    vector.iter().map(|value| value * value).sum::<f32>().sqrt()
7775}
7776
7777fn dot_product(a: &[f32], b: &[f32]) -> f32 {
7778    a.iter().zip(b).map(|(a, b)| a * b).sum::<f32>()
7779}
7780
7781/// Cosine similarity reference retained for focused unit tests.
7782#[cfg(test)]
7783fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
7784    if a.len() != b.len() {
7785        return 0.0;
7786    }
7787
7788    let mut dot = 0.0f32;
7789    let mut norm_a = 0.0f32;
7790    let mut norm_b = 0.0f32;
7791
7792    for i in 0..a.len() {
7793        dot += a[i] * b[i];
7794        norm_a += a[i] * a[i];
7795        norm_b += b[i] * b[i];
7796    }
7797
7798    let denom = norm_a.sqrt() * norm_b.sqrt();
7799    if denom == 0.0 {
7800        0.0
7801    } else {
7802        dot / denom
7803    }
7804}
7805
7806// Serialization helpers
7807fn symbol_kind_to_u8(kind: &SymbolKind) -> u8 {
7808    match kind {
7809        SymbolKind::Function => 0,
7810        SymbolKind::Class => 1,
7811        SymbolKind::Method => 2,
7812        SymbolKind::Struct => 3,
7813        SymbolKind::Interface => 4,
7814        SymbolKind::Enum => 5,
7815        SymbolKind::TypeAlias => 6,
7816        SymbolKind::Variable => 7,
7817        SymbolKind::Heading => 8,
7818        SymbolKind::FileSummary => 9,
7819        SymbolKind::Kernel => 10,
7820    }
7821}
7822
7823fn u8_to_symbol_kind(v: u8) -> SymbolKind {
7824    match v {
7825        0 => SymbolKind::Function,
7826        1 => SymbolKind::Class,
7827        2 => SymbolKind::Method,
7828        3 => SymbolKind::Struct,
7829        4 => SymbolKind::Interface,
7830        5 => SymbolKind::Enum,
7831        6 => SymbolKind::TypeAlias,
7832        7 => SymbolKind::Variable,
7833        8 => SymbolKind::Heading,
7834        9 => SymbolKind::FileSummary,
7835        10 => SymbolKind::Kernel,
7836        _ => SymbolKind::Heading,
7837    }
7838}
7839
7840#[cfg(test)]
7841mod tests {
7842    use super::*;
7843    use crate::config::{SemanticBackend, SemanticBackendConfig};
7844    use crate::parser::FileParser;
7845    use std::io::{Read, Write};
7846    use std::net::{TcpListener, TcpStream};
7847    use std::process::Command;
7848    use std::sync::atomic::{AtomicBool, AtomicU64};
7849    use std::thread;
7850    use tempfile::NamedTempFile;
7851
7852    // Only the unix-gated baseline test consumes these (see its comment for
7853    // why Windows cannot reproduce the hash); keep Windows -D warnings clean.
7854    #[cfg(unix)]
7855    const RUST_QUERY_BASELINE_OUTPUT_HASH: &str =
7856        "36315439db74ed8e186076f79ed261079b2b13a4443ed4272861a2518c78d98b";
7857
7858    struct CountingLocalProvider {
7859        calls: Arc<AtomicUsize>,
7860        threads: Arc<Mutex<Vec<std::thread::ThreadId>>>,
7861    }
7862
7863    impl LocalEmbeddingProvider for CountingLocalProvider {
7864        fn embed(&mut self, texts: &[String]) -> Result<Vec<Vec<f32>>, String> {
7865            self.calls.fetch_add(1, Ordering::SeqCst);
7866            self.threads
7867                .lock()
7868                .unwrap_or_else(std::sync::PoisonError::into_inner)
7869                .push(std::thread::current().id());
7870            Ok(vec![vec![0.25, 0.5, 0.75]; texts.len()])
7871        }
7872    }
7873
7874    #[test]
7875    fn local_build_embeddings_stay_on_the_build_caller_and_run_once() {
7876        let calls = Arc::new(AtomicUsize::new(0));
7877        let threads = Arc::new(Mutex::new(Vec::new()));
7878        let mut model = SemanticEmbeddingModel::from_local_provider_for_test(
7879            Box::new(CountingLocalProvider {
7880                calls: Arc::clone(&calls),
7881                threads: Arc::clone(&threads),
7882            }),
7883            PathBuf::from("/build-counting-test"),
7884        );
7885        let caller = std::thread::current().id();
7886
7887        let vectors = model
7888            .embed(vec![
7889                "first build row".to_string(),
7890                "second build row".to_string(),
7891            ])
7892            .expect("build embedding");
7893
7894        assert_eq!(vectors.len(), 2);
7895        assert_eq!(calls.load(Ordering::SeqCst), 1);
7896        assert_eq!(
7897            threads
7898                .lock()
7899                .unwrap_or_else(std::sync::PoisonError::into_inner)
7900                .as_slice(),
7901            &[caller]
7902        );
7903    }
7904
7905    #[cfg(unix)]
7906    fn rust_fixture_semantic_output_fingerprint(project_root: &Path) -> (usize, usize, String) {
7907        let fixture_root = project_root.join("tests/fixtures");
7908        // Re-materialize the fixtures with LF bytes before collecting: Windows
7909        // checkouts (core.autocrlf) hand collect_chunks CRLF sources, and the
7910        // extra byte per line shifts snippet/embed-text cap boundaries — so
7911        // post-hoc \r stripping cannot reproduce the LF-computed baseline.
7912        let lf_root = tempfile::tempdir().expect("lf fixture root");
7913        let fixture_files = [
7914            "imports_rs.rs",
7915            "member_rs.rs",
7916            "sample.rs",
7917            "structure_rs.rs",
7918        ]
7919        .map(|name| {
7920            let source = std::fs::read_to_string(fixture_root.join(name))
7921                .expect("read fixture")
7922                .replace("\r\n", "\n");
7923            // Preserve the tests/fixtures/<name> layout: chunk identity fields
7924            // (relative path, qualified name, embed-text header) derive from the
7925            // path relative to the project root, so a flat layout re-keys them.
7926            let path = lf_root.path().join("tests/fixtures").join(name);
7927            std::fs::create_dir_all(path.parent().unwrap()).expect("fixture dirs");
7928            std::fs::write(&path, source).expect("write LF fixture");
7929            path
7930        });
7931        let project_root = lf_root.path();
7932        let (chunks, _) =
7933            SemanticIndex::collect_chunks(project_root, &fixture_files, EmbedTextCaps::default());
7934        let normalized = chunks
7935            .iter()
7936            .map(|chunk| {
7937                (
7938                    chunk
7939                        .file
7940                        .strip_prefix(project_root)
7941                        .unwrap()
7942                        .to_string_lossy()
7943                        .replace('\\', "/"),
7944                    &chunk.name,
7945                    &chunk.qualified_name,
7946                    &chunk.kind,
7947                    chunk.start_line,
7948                    chunk.end_line,
7949                    chunk.exported,
7950                    &chunk.embed_text,
7951                    &chunk.snippet,
7952                )
7953            })
7954            .collect::<Vec<_>>();
7955        let output = format!("{normalized:#?}");
7956        (
7957            chunks.len(),
7958            output.len(),
7959            blake3::hash(output.as_bytes()).to_hex().to_string(),
7960        )
7961    }
7962
7963    // Unix-only: chunk embed text bakes the OS-native relative path into its
7964    // header (file-summary chunks), so a Windows run hashes "tests\fixtures\…"
7965    // and can never reproduce the unix-captured baseline even with LF-forced
7966    // sources. The property under test — the query-free Rust walk reproduces
7967    // the old RS_QUERY output byte-for-byte — is platform-independent and is
7968    // pinned where the baseline was captured.
7969    #[cfg(unix)]
7970    #[test]
7971    fn rust_semantic_fixture_output_matches_query_baseline() {
7972        let project_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
7973        let (_, _, output_hash) = rust_fixture_semantic_output_fingerprint(&project_root);
7974        assert_eq!(output_hash, RUST_QUERY_BASELINE_OUTPUT_HASH);
7975    }
7976
7977    #[test]
7978    #[ignore = "manual single-file semantic collect phase benchmark"]
7979    fn profile_rust_single_file_semantic_collect() {
7980        let crate_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
7981        let workspace_root = crate_root
7982            .parent()
7983            .and_then(Path::parent)
7984            .expect("workspace root");
7985        let files = [
7986            workspace_root.join("crates/aft/src/bash_background/registry.rs"),
7987            workspace_root.join("crates/aft-tokenizer/src/claude_data.rs"),
7988        ];
7989
7990        for file in files {
7991            let source = fs::read_to_string(&file).expect("read benchmark source");
7992            for run in 1..=5 {
7993                let mut phases = SemanticCollectPhaseTimings::default();
7994                let started = Instant::now();
7995                let chunks = collect_file_chunks_from_source_timed(
7996                    workspace_root,
7997                    &file,
7998                    crate::parser::LangId::Rust,
7999                    &source,
8000                    EmbedTextCaps::default(),
8001                    &mut phases,
8002                )
8003                .unwrap();
8004                eprintln!(
8005                    "semantic single-file file={} bytes={} run={run}: total={:?} parse={:?} extract={:?} build={:?} chunks={}",
8006                    file.strip_prefix(workspace_root).unwrap().display(),
8007                    source.len(),
8008                    started.elapsed(),
8009                    phases.parse,
8010                    phases.extract,
8011                    phases.build,
8012                    chunks.len()
8013                );
8014            }
8015        }
8016    }
8017
8018    #[test]
8019    fn semantic_index_includes_php_inc_and_scss_extensions() {
8020        for file in ["partial.inc", "index.php", "styles.scss"] {
8021            assert!(
8022                is_semantic_indexed_extension(Path::new(file)),
8023                "{file} should be semantic-index eligible"
8024            );
8025        }
8026    }
8027
8028    #[test]
8029    fn semantic_index_includes_groovy_extensions_and_jenkinsfile() {
8030        for file in [
8031            "script.groovy",
8032            "script.gvy",
8033            "script.gy",
8034            "shell.gsh",
8035            "build.gradle",
8036            "Jenkinsfile",
8037        ] {
8038            assert!(
8039                is_semantic_indexed_extension(Path::new(file)),
8040                "{file} should be semantic-index eligible"
8041            );
8042        }
8043        assert!(is_semantic_indexed_extension(Path::new("build.gradle.kts")));
8044    }
8045
8046    #[test]
8047    fn transient_marker_round_trips_and_classifies() {
8048        // A marked transient error is recognized and the marker is stripped for
8049        // display, leaving a clean message.
8050        let marked = format!("{TRANSIENT_EMBEDDING_MARKER}openai compatible request failed: error sending request for url (http://localhost:1234/v1/embeddings)");
8051        assert!(embedding_failure_is_transient(&marked));
8052        let clean = strip_transient_embedding_marker(&marked);
8053        assert!(!clean.contains(TRANSIENT_EMBEDDING_MARKER));
8054        assert!(clean.starts_with("openai compatible request failed:"));
8055
8056        // Permanent errors (HTTP 4xx, dimension mismatch) carry no marker and
8057        // are not classified transient — they must fail fast.
8058        for permanent in [
8059            "openai compatible request failed (HTTP 401): Unauthorized",
8060            "embedding dimension mismatch: index has 384, model returned 768",
8061            "too many files (>20000) for semantic indexing (max 20000)",
8062        ] {
8063            assert!(
8064                !embedding_failure_is_transient(permanent),
8065                "{permanent:?} must not be transient"
8066            );
8067            // Stripping a marker-free string is a no-op.
8068            assert_eq!(strip_transient_embedding_marker(permanent), permanent);
8069        }
8070    }
8071
8072    #[test]
8073    fn send_error_transience_separates_connect_timeout_from_4xx() {
8074        // 5xx / 429 are transient; other client errors are not.
8075        assert!(is_retryable_embedding_status(
8076            reqwest::StatusCode::INTERNAL_SERVER_ERROR
8077        ));
8078        assert!(is_retryable_embedding_status(
8079            reqwest::StatusCode::TOO_MANY_REQUESTS
8080        ));
8081        assert!(!is_retryable_embedding_status(
8082            reqwest::StatusCode::UNAUTHORIZED
8083        ));
8084        assert!(!is_retryable_embedding_status(
8085            reqwest::StatusCode::BAD_REQUEST
8086        ));
8087    }
8088
8089    #[test]
8090    fn query_timeout_marker_round_trips_and_classifies() {
8091        // A query-timeout error carries the budget that fired; the budget is
8092        // recoverable and the marker strips cleanly for display.
8093        let marked = format!(
8094            "{}openai compatible request failed: operation timed out",
8095            query_embedding_timeout_marker(3_000)
8096        );
8097        assert_eq!(query_embedding_timeout_budget(&marked), Some(3_000));
8098        let clean = strip_query_embedding_timeout_marker(&marked);
8099        assert!(!clean.contains(QUERY_EMBEDDING_TIMEOUT_MARKER_PREFIX));
8100        assert!(clean.starts_with("openai compatible request failed:"));
8101
8102        // Non-timeout errors carry no marker and no budget — they must not be
8103        // misclassified as timeouts.
8104        for permanent in [
8105            "openai compatible request failed (HTTP 401): Unauthorized",
8106            "failed to embed query: embedding model was not initialized",
8107            "openai compatible request failed: connection refused",
8108        ] {
8109            assert_eq!(
8110                query_embedding_timeout_budget(permanent),
8111                None,
8112                "{permanent:?} must not classify as a query timeout"
8113            );
8114            assert_eq!(
8115                strip_query_embedding_timeout_marker(permanent),
8116                permanent,
8117                "stripping a marker-free string is a no-op"
8118            );
8119        }
8120    }
8121
8122    fn install_test_crypto_provider() {
8123        // Reqwest and the direct test-server dependency enable different rustls
8124        // providers, so select one explicitly before either side builds TLS.
8125        let _ = rustls::crypto::ring::default_provider().install_default();
8126    }
8127
8128    fn start_platform_verifier_tls_server() -> (String, NamedTempFile, thread::JoinHandle<()>) {
8129        install_test_crypto_provider();
8130        let ca_key = rcgen::KeyPair::generate().expect("generate test CA key");
8131        let mut ca_params = rcgen::CertificateParams::default();
8132        ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
8133        ca_params.key_usages = vec![
8134            rcgen::KeyUsagePurpose::KeyCertSign,
8135            rcgen::KeyUsagePurpose::DigitalSignature,
8136        ];
8137        let ca_cert = ca_params
8138            .self_signed(&ca_key)
8139            .expect("generate test CA certificate");
8140
8141        let leaf_key = rcgen::KeyPair::generate().expect("generate test leaf key");
8142        let mut leaf_params = rcgen::CertificateParams::new(vec!["localhost".to_string()])
8143            .expect("generate leaf parameters");
8144        leaf_params.key_usages = vec![rcgen::KeyUsagePurpose::DigitalSignature];
8145        leaf_params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ServerAuth];
8146        let leaf_cert = leaf_params
8147            .signed_by(&leaf_key, &ca_cert, &ca_key)
8148            .expect("sign test leaf certificate");
8149
8150        let mut ca_file = NamedTempFile::new().expect("create test CA file");
8151        ca_file
8152            .write_all(ca_cert.pem().as_bytes())
8153            .expect("write test CA certificate");
8154
8155        let server_config = Arc::new(
8156            rustls::ServerConfig::builder()
8157                .with_no_client_auth()
8158                .with_single_cert(
8159                    vec![rustls::pki_types::CertificateDer::from(
8160                        leaf_cert.der().to_vec(),
8161                    )],
8162                    rustls::pki_types::PrivateKeyDer::Pkcs8(
8163                        rustls::pki_types::PrivatePkcs8KeyDer::from(leaf_key.serialize_der()),
8164                    ),
8165                )
8166                .expect("build test TLS server configuration"),
8167        );
8168        let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind test TLS server");
8169        let address = listener.local_addr().expect("read test TLS server address");
8170        let url = format!("https://localhost:{}/v1/embeddings", address.port());
8171        let handle = thread::spawn(move || {
8172            // Linux exercises both trust paths: the first handshake fails with
8173            // UnknownIssuer, and the second succeeds after SSL_CERT_FILE supplies
8174            // the throwaway CA. Other platforms only exercise the failure path;
8175            // their platform verifiers do not consult SSL_CERT_FILE.
8176            let expected_connections = if cfg!(target_os = "linux") { 2 } else { 1 };
8177            for _ in 0..expected_connections {
8178                let (stream, _) = listener.accept().expect("accept test TLS connection");
8179                stream
8180                    .set_read_timeout(Some(Duration::from_secs(10)))
8181                    .expect("set test TLS read timeout");
8182                let connection = rustls::ServerConnection::new(server_config.clone())
8183                    .expect("create test TLS server connection");
8184                let mut tls_stream = rustls::StreamOwned::new(connection, stream);
8185                let mut request = [0_u8; 4096];
8186                if tls_stream.read(&mut request).is_ok() {
8187                    let body = r#"{"data":[],"model":"test","object":"list"}"#;
8188                    let response = format!(
8189                        "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
8190                        body.len(), body
8191                    );
8192                    let _ = tls_stream.write_all(response.as_bytes());
8193                    tls_stream.conn.send_close_notify();
8194                    let _ = tls_stream.flush();
8195                }
8196            }
8197        });
8198
8199        (url, ca_file, handle)
8200    }
8201
8202    fn run_platform_verifier_tls_child() {
8203        install_test_crypto_provider();
8204        let url = env::var("AFT_PLATFORM_VERIFIER_TLS_URL").expect("test TLS URL");
8205        let tls_config = crate::platform_tls::client_config().expect("build platform TLS config");
8206        // This test asserts the ERROR CLASS (certificate trust failure), not
8207        // latency, so the budget must be unreachable by keychain slowness: on
8208        // macOS the first evaluation of an untrusted chain walks user trust
8209        // settings (trustd), which a pathological keychain entry plus machine
8210        // load has stretched past 120s — at which point the request surfaces a
8211        // transient "operation timed out" BEFORE the certificate verdict
8212        // exists and the assertion fails on the wrong error class. 120s was
8213        // tried twice and breached twice (485s observed once under load ~100).
8214        // Clean keychains answer in milliseconds; this budget only ever costs
8215        // time on machines with hostile trust settings, where a slow correct
8216        // verdict beats a fast wrong one.
8217        let client = Client::builder()
8218            .timeout(Duration::from_secs(600))
8219            .use_preconfigured_tls(tls_config)
8220            .build()
8221            .expect("build test embedding client");
8222        let result = send_embedding_request(
8223            || client.post(&url).body("{}"),
8224            "openai compatible",
8225            EmbeddingRequestPolicy::Query(QueryBudget {
8226                timeout_ms: 600_000,
8227            }),
8228        );
8229
8230        #[cfg(target_os = "linux")]
8231        if env::var_os("SSL_CERT_FILE").is_some() {
8232            let body = result.expect("SSL_CERT_FILE should make the private CA trusted");
8233            assert!(
8234                body.contains("\"data\""),
8235                "unexpected embedding response: {body}"
8236            );
8237            return;
8238        }
8239
8240        let error = result.expect_err("the private CA must not be trusted on this path");
8241        let lower = error.to_ascii_lowercase();
8242        assert!(
8243            ["certificate", "unknownissuer", "unknown issuer", "trust"]
8244                .iter()
8245                .any(|marker| lower.contains(marker)),
8246            "the rendered source chain must include a certificate trust failure: {error}"
8247        );
8248        assert!(
8249            !embedding_failure_is_transient(&error),
8250            "certificate trust failures must not be retried: {error}"
8251        );
8252    }
8253
8254    #[test]
8255    fn platform_verifier_tls_client_subprocess() {
8256        if env::var_os("AFT_PLATFORM_VERIFIER_TLS_CHILD").is_some() {
8257            run_platform_verifier_tls_child();
8258            return;
8259        }
8260
8261        // Run each trust configuration in a fresh process because the
8262        // TLS/platform-verifier configuration caches CA settings; SSL_CERT_FILE
8263        // must be set before that configuration is initialized for Linux CA
8264        // discovery to use it. The process-env lock prevents this test from
8265        // racing other tests that modify environment variables. macOS and Windows
8266        // exercise only the untrusted path because their platform verifiers do
8267        // not consult SSL_CERT_FILE.
8268        let _env_lock = crate::test_env::process_env_lock();
8269        let (url, _ca_file, server_handle) = start_platform_verifier_tls_server();
8270        let test_name = "semantic_index::tests::platform_verifier_tls_client_subprocess";
8271        #[cfg(target_os = "linux")]
8272        let ca_paths: &[Option<&Path>] = &[None, Some(_ca_file.path())];
8273        #[cfg(not(target_os = "linux"))]
8274        let ca_paths: &[Option<&Path>] = &[None];
8275
8276        for ca_path in ca_paths {
8277            let mut command = Command::new(env::current_exe().expect("test executable"));
8278            command
8279                .args(["--exact", test_name, "--nocapture"])
8280                .env("AFT_PLATFORM_VERIFIER_TLS_CHILD", "1")
8281                .env("AFT_PLATFORM_VERIFIER_TLS_URL", &url)
8282                .env_remove("SSL_CERT_FILE")
8283                .env_remove("SSL_CERT_DIR");
8284            if let Some(ca_path) = ca_path {
8285                command.env("SSL_CERT_FILE", ca_path);
8286            }
8287            let output = command.output().expect("run TLS child test");
8288            // Name the exit status and any terminating signal in the failure:
8289            // under heavy machine load this child has died with EMPTY output,
8290            // and a blind "child failed" leaves nothing to diagnose with.
8291            #[cfg(unix)]
8292            let signal = std::os::unix::process::ExitStatusExt::signal(&output.status);
8293            #[cfg(not(unix))]
8294            let signal: Option<i32> = None;
8295            assert!(
8296                output.status.success(),
8297                "TLS child failed: status={:?} code={:?} signal={:?}\nstdout:\n{}\nstderr:\n{}",
8298                output.status,
8299                output.status.code(),
8300                signal,
8301                String::from_utf8_lossy(&output.stdout),
8302                String::from_utf8_lossy(&output.stderr)
8303            );
8304        }
8305
8306        server_handle.join().expect("join test TLS server");
8307    }
8308
8309    #[test]
8310    fn local_backend_model_loading_body_is_transient() {
8311        // LM Studio / Ollama return a 4xx with a loading/unloaded message while
8312        // the model swaps; these must classify transient so the build self-heals.
8313        for body in [
8314            r#"{"error":"Model was unloaded while the request was still in queue.."}"#,
8315            r#"{"error":"model is loading, please wait"}"#,
8316            r#"{"error":"Model not loaded"}"#,
8317            "Loading model into memory",
8318        ] {
8319            assert!(
8320                embedding_response_body_is_transient(reqwest::StatusCode::BAD_REQUEST, body),
8321                "{body:?} should be body-transient"
8322            );
8323        }
8324
8325        // A genuine 4xx misconfiguration body must NOT be treated as transient,
8326        // even when it happens to contain generic words from the old broad
8327        // substring matcher.
8328        for body in [
8329            r#"{"error":"invalid api key"}"#,
8330            r#"{"error":"model 'foo' not found"}"#,
8331            "Bad Request: unknown field",
8332            "Bad Request: invalid loading model option",
8333            r#"{"error":"unauthorized while model is being loaded by another account"}"#,
8334        ] {
8335            assert!(
8336                !embedding_response_body_is_transient(reqwest::StatusCode::BAD_REQUEST, body),
8337                "{body:?} must not be body-transient"
8338            );
8339        }
8340
8341        assert!(
8342            !embedding_response_body_is_transient(
8343                reqwest::StatusCode::UNAUTHORIZED,
8344                r#"{"error":"model is loading, please wait"}"#
8345            ),
8346            "permanent auth failures must not become transient because of body text"
8347        );
8348    }
8349
8350    #[test]
8351    fn context_overflow_body_classification_is_narrow_and_extracts_counts() {
8352        let fixtures = [
8353            (
8354                r#"{"error":{"type":"exceed_context_size_error","message":"input is too large to process","n_prompt_tokens":518,"n_ctx":512}}"#,
8355                Some(512),
8356                Some(518),
8357            ),
8358            (
8359                r#"{"error":{"type":"exceed_context_size_error","message":"input is too large to process"}}"#,
8360                None,
8361                None,
8362            ),
8363            (
8364                r#"{"error":{"message":"maximum context length is 8192 tokens; you requested 9000 tokens"}}"#,
8365                Some(8192),
8366                Some(9000),
8367            ),
8368            (
8369                r#"{"error":{"message":"This model's maximum context length is 4096 tokens. Your input resulted in 5000 tokens"}}"#,
8370                Some(4096),
8371                Some(5000),
8372            ),
8373            (
8374                r#"{"error":"input length exceeds model context"}"#,
8375                None,
8376                None,
8377            ),
8378        ];
8379
8380        for (body, expected_limit, expected_actual) in fixtures {
8381            let details = embedding_response_row_too_long(reqwest::StatusCode::BAD_REQUEST, body)
8382                .unwrap_or_else(|| panic!("overflow fixture was not classified: {body}"));
8383            assert_eq!(details.limit_tokens, expected_limit, "body={body}");
8384            assert_eq!(details.actual_tokens, expected_actual, "body={body}");
8385        }
8386
8387        assert_eq!(
8388            embedding_response_row_too_long(
8389                reqwest::StatusCode::BAD_REQUEST,
8390                r#"{"error":"model not found"}"#,
8391            ),
8392            None,
8393        );
8394        assert_eq!(
8395            embedding_response_row_too_long(
8396                reqwest::StatusCode::INTERNAL_SERVER_ERROR,
8397                r#"{"error":"input length exceeds model context"}"#,
8398            ),
8399            None,
8400        );
8401
8402        let text = "name:dense file:src/dense.rs kind:function name:dense signature:fn dense() body:abcdefghij";
8403        assert_eq!(
8404            shrink_embed_text(
8405                text,
8406                RowTooLongDetails {
8407                    limit_tokens: None,
8408                    actual_tokens: None,
8409                },
8410            )
8411            .as_deref(),
8412            Some("name:dense file:src/dense.rs kind:function name:dense signature:fn dense() body:abcde"),
8413        );
8414        assert!(shrink_embed_text(
8415            "header-free-base64",
8416            RowTooLongDetails {
8417                limit_tokens: None,
8418                actual_tokens: None,
8419            },
8420        )
8421        .is_none());
8422    }
8423
8424    fn start_slow_embedding_server(
8425        expected_requests: usize,
8426        response_delay: Duration,
8427    ) -> (String, Arc<AtomicUsize>, thread::JoinHandle<()>) {
8428        let listener = TcpListener::bind("127.0.0.1:0").expect("bind slow embedding server");
8429        listener
8430            .set_nonblocking(true)
8431            .expect("set slow server nonblocking");
8432        let addr = listener.local_addr().expect("slow embedding server addr");
8433        let requests = Arc::new(AtomicUsize::new(0));
8434        let requests_for_thread = Arc::clone(&requests);
8435        let handle = thread::spawn(move || {
8436            let deadline = Instant::now() + Duration::from_secs(10);
8437            let mut handlers = Vec::new();
8438            while requests_for_thread.load(Ordering::SeqCst) < expected_requests
8439                && Instant::now() < deadline
8440            {
8441                match listener.accept() {
8442                    Ok((mut stream, _)) => {
8443                        requests_for_thread.fetch_add(1, Ordering::SeqCst);
8444                        handlers.push(thread::spawn(move || {
8445                            let mut request = [0u8; 4096];
8446                            let _ = stream.read(&mut request);
8447                            thread::sleep(response_delay);
8448                            let body =
8449                                r#"{"data":[{"embedding":[0.1,0.2,0.3],"index":0}]}"#;
8450                            let response = format!(
8451                                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
8452                                body.len(),
8453                                body
8454                            );
8455                            let _ = stream.write_all(response.as_bytes());
8456                        }));
8457                    }
8458                    Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
8459                        thread::sleep(Duration::from_millis(5));
8460                    }
8461                    Err(error) => panic!("accept slow embedding request: {error}"),
8462                }
8463            }
8464            for handler in handlers {
8465                handler.join().expect("slow embedding handler");
8466            }
8467        });
8468
8469        (format!("http://{addr}"), requests, handle)
8470    }
8471
8472    struct ProgrammableEmbeddingServer {
8473        base_url: String,
8474        per_item_delay_ms: Arc<AtomicU64>,
8475        never_answer: Arc<AtomicBool>,
8476        requests: Arc<Mutex<Vec<usize>>>,
8477        completed: Arc<Mutex<Vec<usize>>>,
8478        shutdown: Arc<AtomicBool>,
8479        handle: Option<thread::JoinHandle<()>>,
8480    }
8481
8482    impl ProgrammableEmbeddingServer {
8483        fn start(per_item_delay: Duration) -> Self {
8484            let listener = TcpListener::bind("127.0.0.1:0").expect("bind programmable server");
8485            listener
8486                .set_nonblocking(true)
8487                .expect("set programmable server nonblocking");
8488            let addr = listener.local_addr().expect("programmable server addr");
8489            let per_item_delay_ms = Arc::new(AtomicU64::new(
8490                per_item_delay.as_millis().min(u128::from(u64::MAX)) as u64,
8491            ));
8492            let never_answer = Arc::new(AtomicBool::new(false));
8493            let requests = Arc::new(Mutex::new(Vec::new()));
8494            let completed = Arc::new(Mutex::new(Vec::new()));
8495            let shutdown = Arc::new(AtomicBool::new(false));
8496            let thread_delay = Arc::clone(&per_item_delay_ms);
8497            let thread_never = Arc::clone(&never_answer);
8498            let thread_requests = Arc::clone(&requests);
8499            let thread_completed = Arc::clone(&completed);
8500            let thread_shutdown = Arc::clone(&shutdown);
8501            let handle = thread::spawn(move || {
8502                let mut handlers = Vec::new();
8503                while !thread_shutdown.load(Ordering::SeqCst) {
8504                    match listener.accept() {
8505                        Ok((stream, _)) => {
8506                            let delay = Arc::clone(&thread_delay);
8507                            let never = Arc::clone(&thread_never);
8508                            let requests = Arc::clone(&thread_requests);
8509                            let completed = Arc::clone(&thread_completed);
8510                            let shutdown = Arc::clone(&thread_shutdown);
8511                            handlers.push(thread::spawn(move || {
8512                                handle_programmable_embedding_request(
8513                                    stream, delay, never, requests, completed, shutdown,
8514                                );
8515                            }));
8516                        }
8517                        Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
8518                            thread::sleep(Duration::from_millis(2));
8519                        }
8520                        Err(error) => panic!("accept programmable embedding request: {error}"),
8521                    }
8522                }
8523                for handler in handlers {
8524                    handler.join().expect("programmable embedding handler");
8525                }
8526            });
8527
8528            Self {
8529                base_url: format!("http://{addr}"),
8530                per_item_delay_ms,
8531                never_answer,
8532                requests,
8533                completed,
8534                shutdown,
8535                handle: Some(handle),
8536            }
8537        }
8538
8539        fn set_per_item_delay(&self, delay: Duration) {
8540            self.per_item_delay_ms.store(
8541                delay.as_millis().min(u128::from(u64::MAX)) as u64,
8542                Ordering::SeqCst,
8543            );
8544        }
8545
8546        fn set_never_answer(&self, never_answer: bool) {
8547            self.never_answer.store(never_answer, Ordering::SeqCst);
8548        }
8549
8550        fn request_sizes(&self) -> Vec<usize> {
8551            self.requests.lock().unwrap().clone()
8552        }
8553
8554        fn completed_sizes(&self) -> Vec<usize> {
8555            self.completed.lock().unwrap().clone()
8556        }
8557    }
8558
8559    impl Drop for ProgrammableEmbeddingServer {
8560        fn drop(&mut self) {
8561            self.shutdown.store(true, Ordering::SeqCst);
8562            if let Some(handle) = self.handle.take() {
8563                handle.join().expect("programmable embedding server");
8564            }
8565        }
8566    }
8567
8568    fn handle_programmable_embedding_request(
8569        mut stream: TcpStream,
8570        per_item_delay_ms: Arc<AtomicU64>,
8571        never_answer: Arc<AtomicBool>,
8572        requests: Arc<Mutex<Vec<usize>>>,
8573        completed: Arc<Mutex<Vec<usize>>>,
8574        shutdown: Arc<AtomicBool>,
8575    ) {
8576        let mut buf = Vec::new();
8577        let mut chunk = [0u8; 4096];
8578        let mut header_end = None;
8579        let mut content_length = 0usize;
8580        loop {
8581            let count = match stream.read(&mut chunk) {
8582                Ok(count) => count,
8583                Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
8584                    thread::sleep(Duration::from_millis(1));
8585                    continue;
8586                }
8587                Err(error) => panic!("read programmable request: {error}"),
8588            };
8589            if count == 0 {
8590                return;
8591            }
8592            buf.extend_from_slice(&chunk[..count]);
8593            if header_end.is_none() {
8594                if let Some(position) = buf.windows(4).position(|window| window == b"\r\n\r\n") {
8595                    header_end = Some(position + 4);
8596                    for line in String::from_utf8_lossy(&buf[..position + 4]).lines() {
8597                        if line.to_ascii_lowercase().starts_with("content-length:") {
8598                            content_length = line
8599                                .split_once(':')
8600                                .and_then(|(_, value)| value.trim().parse().ok())
8601                                .unwrap_or(0);
8602                        }
8603                    }
8604                }
8605            }
8606            if header_end.is_some_and(|end| buf.len() >= end + content_length) {
8607                break;
8608            }
8609        }
8610        let body_start = header_end.expect("programmable request headers");
8611        let body: serde_json::Value =
8612            serde_json::from_slice(&buf[body_start..body_start + content_length])
8613                .expect("programmable request JSON");
8614        let input_count = body["input"]
8615            .as_array()
8616            .expect("embedding input array")
8617            .len();
8618        requests.lock().unwrap().push(input_count);
8619
8620        if never_answer.load(Ordering::SeqCst) {
8621            while !shutdown.load(Ordering::SeqCst) {
8622                thread::sleep(Duration::from_millis(2));
8623            }
8624            return;
8625        }
8626
8627        thread::sleep(Duration::from_millis(
8628            per_item_delay_ms
8629                .load(Ordering::SeqCst)
8630                .saturating_mul(input_count as u64),
8631        ));
8632        let data = (0..input_count)
8633            .map(|index| serde_json::json!({"embedding": [0.1, 0.2, 0.3], "index": index}))
8634            .collect::<Vec<_>>();
8635        let response_body = serde_json::json!({"data": data}).to_string();
8636        let response = format!(
8637            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
8638            response_body.len(),
8639            response_body,
8640        );
8641        if stream.write_all(response.as_bytes()).is_ok() {
8642            completed.lock().unwrap().push(input_count);
8643        }
8644    }
8645
8646    enum TestEmbeddingRejection {
8647        Oversize { max_bytes: usize },
8648        Always { body: String },
8649    }
8650
8651    struct OverflowEmbeddingServer {
8652        base_url: String,
8653        requests: Arc<Mutex<Vec<Vec<String>>>>,
8654        shutdown: Arc<AtomicBool>,
8655        handle: Option<thread::JoinHandle<()>>,
8656    }
8657
8658    impl OverflowEmbeddingServer {
8659        fn rejecting_oversize(max_bytes: usize) -> Self {
8660            Self::start(TestEmbeddingRejection::Oversize { max_bytes })
8661        }
8662
8663        fn rejecting_all(body: impl Into<String>) -> Self {
8664            Self::start(TestEmbeddingRejection::Always { body: body.into() })
8665        }
8666
8667        fn start(rejection: TestEmbeddingRejection) -> Self {
8668            let listener = TcpListener::bind("127.0.0.1:0").expect("bind overflow server");
8669            listener
8670                .set_nonblocking(true)
8671                .expect("set overflow server nonblocking");
8672            let addr = listener.local_addr().expect("overflow server addr");
8673            let requests = Arc::new(Mutex::new(Vec::new()));
8674            let requests_for_thread = Arc::clone(&requests);
8675            let shutdown = Arc::new(AtomicBool::new(false));
8676            let shutdown_for_thread = Arc::clone(&shutdown);
8677            let handle = thread::spawn(move || {
8678                while !shutdown_for_thread.load(Ordering::SeqCst) {
8679                    match listener.accept() {
8680                        Ok((stream, _)) => handle_overflow_embedding_request(
8681                            stream,
8682                            &rejection,
8683                            &requests_for_thread,
8684                        ),
8685                        Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
8686                            thread::sleep(Duration::from_millis(1));
8687                        }
8688                        Err(error) => panic!("accept overflow request: {error}"),
8689                    }
8690                }
8691            });
8692            Self {
8693                base_url: format!("http://{addr}"),
8694                requests,
8695                shutdown,
8696                handle: Some(handle),
8697            }
8698        }
8699
8700        fn requests(&self) -> Vec<Vec<String>> {
8701            self.requests.lock().unwrap().clone()
8702        }
8703    }
8704
8705    impl Drop for OverflowEmbeddingServer {
8706        fn drop(&mut self) {
8707            self.shutdown.store(true, Ordering::SeqCst);
8708            if let Some(handle) = self.handle.take() {
8709                handle.join().expect("overflow embedding server");
8710            }
8711        }
8712    }
8713
8714    fn handle_overflow_embedding_request(
8715        mut stream: TcpStream,
8716        rejection: &TestEmbeddingRejection,
8717        requests: &Arc<Mutex<Vec<Vec<String>>>>,
8718    ) {
8719        let mut buf = Vec::new();
8720        let mut chunk = [0u8; 4096];
8721        let mut header_end = None;
8722        let mut content_length = 0usize;
8723        loop {
8724            let Ok(count) = stream.read(&mut chunk) else {
8725                return;
8726            };
8727            if count == 0 {
8728                return;
8729            }
8730            buf.extend_from_slice(&chunk[..count]);
8731            if header_end.is_none() {
8732                if let Some(position) = buf.windows(4).position(|window| window == b"\r\n\r\n") {
8733                    header_end = Some(position + 4);
8734                    for line in String::from_utf8_lossy(&buf[..position + 4]).lines() {
8735                        if line.to_ascii_lowercase().starts_with("content-length:") {
8736                            content_length = line
8737                                .split_once(':')
8738                                .and_then(|(_, value)| value.trim().parse().ok())
8739                                .unwrap_or(0);
8740                        }
8741                    }
8742                }
8743            }
8744            if header_end.is_some_and(|end| buf.len() >= end + content_length) {
8745                break;
8746            }
8747        }
8748
8749        let body_start = header_end.expect("overflow request headers");
8750        let body: serde_json::Value =
8751            serde_json::from_slice(&buf[body_start..body_start + content_length])
8752                .expect("overflow request JSON");
8753        let inputs = body["input"]
8754            .as_array()
8755            .expect("embedding input array")
8756            .iter()
8757            .map(|value| value.as_str().expect("embedding input text").to_string())
8758            .collect::<Vec<_>>();
8759        requests.lock().unwrap().push(inputs.clone());
8760
8761        let rejected = match rejection {
8762            TestEmbeddingRejection::Oversize { max_bytes } => inputs
8763                .iter()
8764                .map(String::len)
8765                .max()
8766                .filter(|actual| actual > max_bytes)
8767                .map(|actual| {
8768                    serde_json::json!({
8769                        "error": {
8770                            "type": "exceed_context_size_error",
8771                            "message": "input is too large to process",
8772                            "n_prompt_tokens": actual,
8773                            "n_ctx": max_bytes,
8774                        }
8775                    })
8776                    .to_string()
8777                }),
8778            TestEmbeddingRejection::Always { body } => Some(body.clone()),
8779        };
8780
8781        let (status, response_body) = if let Some(body) = rejected {
8782            ("400 Bad Request", body)
8783        } else {
8784            let data = inputs
8785                .iter()
8786                .enumerate()
8787                .map(|(index, text)| {
8788                    serde_json::json!({
8789                        "embedding": [text.len() as f32, 1.0, 0.5],
8790                        "index": index,
8791                    })
8792                })
8793                .collect::<Vec<_>>();
8794            ("200 OK", serde_json::json!({"data": data}).to_string())
8795        };
8796        let response = format!(
8797            "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
8798            response_body.len(),
8799            response_body,
8800        );
8801        let _ = stream.write_all(response.as_bytes());
8802    }
8803
8804    fn start_recording_embedding_server(
8805        expected_requests: usize,
8806    ) -> (String, Arc<Mutex<Vec<String>>>, thread::JoinHandle<()>) {
8807        let listener = TcpListener::bind("127.0.0.1:0").expect("bind recording server");
8808        let addr = listener.local_addr().expect("recording server addr");
8809        let inputs = Arc::new(Mutex::new(Vec::new()));
8810        let inputs_for_thread = Arc::clone(&inputs);
8811        let handle = thread::spawn(move || {
8812            for _ in 0..expected_requests {
8813                let (mut stream, _) = listener.accept().expect("accept recording request");
8814                let mut buf = Vec::new();
8815                let mut chunk = [0u8; 4096];
8816                let mut header_end = None;
8817                let mut content_length = 0usize;
8818                loop {
8819                    let count = stream.read(&mut chunk).expect("read recording request");
8820                    if count == 0 {
8821                        break;
8822                    }
8823                    buf.extend_from_slice(&chunk[..count]);
8824                    if header_end.is_none() {
8825                        if let Some(position) =
8826                            buf.windows(4).position(|window| window == b"\r\n\r\n")
8827                        {
8828                            header_end = Some(position + 4);
8829                            for line in String::from_utf8_lossy(&buf[..position + 4]).lines() {
8830                                if line.to_ascii_lowercase().starts_with("content-length:") {
8831                                    content_length = line
8832                                        .split_once(':')
8833                                        .map(|(_, value)| value.trim().parse().unwrap_or(0))
8834                                        .unwrap_or(0);
8835                                }
8836                            }
8837                        }
8838                    }
8839                    if header_end.is_some_and(|end| buf.len() >= end + content_length) {
8840                        break;
8841                    }
8842                }
8843                let body_start = header_end.expect("recording request headers");
8844                let body: serde_json::Value =
8845                    serde_json::from_slice(&buf[body_start..body_start + content_length])
8846                        .expect("recording request JSON");
8847                let input = body["input"][0]
8848                    .as_str()
8849                    .expect("single string embedding input")
8850                    .to_string();
8851                inputs_for_thread.lock().unwrap().push(input);
8852                let response_body = r#"{"data":[{"embedding":[0.1,0.2,0.3],"index":0}]}"#;
8853                let response = format!(
8854                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
8855                    response_body.len(),
8856                    response_body
8857                );
8858                stream
8859                    .write_all(response.as_bytes())
8860                    .expect("write recording response");
8861            }
8862        });
8863        (format!("http://{addr}"), inputs, handle)
8864    }
8865
8866    fn start_mock_http_server<F>(handler: F) -> (String, thread::JoinHandle<()>)
8867    where
8868        F: Fn(String, String, String) -> String + Send + 'static,
8869    {
8870        let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
8871        let addr = listener.local_addr().expect("local addr");
8872        let handle = thread::spawn(move || {
8873            let (mut stream, _) = listener.accept().expect("accept request");
8874            let mut buf = Vec::new();
8875            let mut chunk = [0u8; 4096];
8876            let mut header_end = None;
8877            let mut content_length = 0usize;
8878            loop {
8879                let n = stream.read(&mut chunk).expect("read request");
8880                if n == 0 {
8881                    break;
8882                }
8883                buf.extend_from_slice(&chunk[..n]);
8884                if header_end.is_none() {
8885                    if let Some(pos) = buf.windows(4).position(|window| window == b"\r\n\r\n") {
8886                        header_end = Some(pos + 4);
8887                        let headers = String::from_utf8_lossy(&buf[..pos + 4]);
8888                        for line in headers.lines() {
8889                            if let Some(value) = line.strip_prefix("Content-Length:") {
8890                                content_length = value.trim().parse::<usize>().unwrap_or(0);
8891                            }
8892                        }
8893                    }
8894                }
8895                if let Some(end) = header_end {
8896                    if buf.len() >= end + content_length {
8897                        break;
8898                    }
8899                }
8900            }
8901
8902            let end = header_end.expect("header terminator");
8903            let request = String::from_utf8_lossy(&buf[..end]).to_string();
8904            let body = String::from_utf8_lossy(&buf[end..end + content_length]).to_string();
8905            let mut lines = request.lines();
8906            let request_line = lines.next().expect("request line").to_string();
8907            let path = request_line
8908                .split_whitespace()
8909                .nth(1)
8910                .expect("request path")
8911                .to_string();
8912            let response_body = handler(request_line, path, body);
8913            let response = format!(
8914                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
8915                response_body.len(),
8916                response_body
8917            );
8918            stream
8919                .write_all(response.as_bytes())
8920                .expect("write response");
8921        });
8922
8923        (format!("http://{}", addr), handle)
8924    }
8925
8926    fn start_truncated_body_server(attempts: usize) -> (String, thread::JoinHandle<()>) {
8927        let listener = TcpListener::bind("127.0.0.1:0").expect("bind truncated test server");
8928        listener
8929            .set_nonblocking(true)
8930            .expect("nonblocking listener");
8931        let addr = listener.local_addr().expect("local addr");
8932        let handle = thread::spawn(move || {
8933            // The deadline is only a hang-backstop for the case where the client
8934            // makes FEWER than `attempts` connections. It MUST comfortably exceed
8935            // the client's full retry budget (3 attempts: 3x250ms read-timeouts +
8936            // 500ms + 1000ms backoffs ~= 2.25s) so the last connect is always
8937            // accepted — otherwise the 3rd connect lands after a too-short
8938            // deadline, the server thread is already gone, and the client gets a
8939            // connect error ("request failed") instead of the body-read error the
8940            // test asserts. Under loaded CI (esp. Windows) thread scheduling
8941            // drifts the connects later, so this needs generous headroom.
8942            let deadline = std::time::Instant::now() + Duration::from_secs(30);
8943            let mut accepted = 0usize;
8944            while accepted < attempts && std::time::Instant::now() < deadline {
8945                match listener.accept() {
8946                    Ok((mut stream, _)) => {
8947                        accepted += 1;
8948                        let mut buf = [0u8; 4096];
8949                        // The client (under test) uses a 250ms timeout and drops
8950                        // the connection when the truncated body never completes.
8951                        // On Windows that disconnect surfaces as a hard socket
8952                        // error (WSAECONNRESET) on these read/write calls, where
8953                        // Unix returns a clean EOF. Tolerate both: the mock does
8954                        // not need the request bytes, and a write to an
8955                        // already-hung-up client is expected.
8956                        let _ = stream.read(&mut buf);
8957                        let response = "HTTP/1.1 200 OK
8958Content-Type: application/json
8959Content-Length: 128
8960Connection: close
8961
8962{";
8963                        let _ = stream.write_all(response.as_bytes());
8964                    }
8965                    Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
8966                        thread::sleep(Duration::from_millis(10));
8967                    }
8968                    Err(error) => panic!("accept request: {error}"),
8969                }
8970            }
8971        });
8972
8973        (format!("http://{}", addr), handle)
8974    }
8975
8976    #[test]
8977    fn response_body_read_failures_are_marked_transient() {
8978        let (url, handle) = start_truncated_body_server(EMBEDDING_REQUEST_MAX_ATTEMPTS);
8979        // Generous client timeout: this test classifies BODY-TRUNCATION errors,
8980        // and a tight budget flips the failure into a connect/send timeout on a
8981        // loaded machine, changing which error string the assertions see.
8982        let client = Client::builder()
8983            .timeout(Duration::from_secs(5))
8984            .build()
8985            .expect("client");
8986
8987        let error = send_embedding_request(
8988            || client.post(&url).body("{}"),
8989            "test backend",
8990            EmbeddingRequestPolicy::Build(BuildRequestBudget {
8991                batch_size: 1,
8992                deadline_ms: 250,
8993            }),
8994        )
8995        .expect_err("truncated body should fail");
8996
8997        handle.join().unwrap();
8998        assert!(
8999            embedding_failure_is_transient(&error),
9000            "body read failures should be transient-marked: {error}"
9001        );
9002        // The mock closes the socket after writing a truncated body. Whether
9003        // the client observes that as a body-read EOF, as a send-stage
9004        // connection reset, or as hyper's UnexpectedMessage (the partial reply
9005        // arrived while the request was still being written) is an OS-level
9006        // race (Windows sends RST when the socket closes with unread request
9007        // bytes, and under load the mock's single read can return early). All
9008        // shapes are the backend dying mid-exchange and all must carry the
9009        // transient marker; the message prefix differs by stage.
9010        assert!(
9011            error.contains("response read failed") || error.contains("request failed"),
9012            "unexpected error shape: {error}"
9013        );
9014    }
9015
9016    fn test_vector_for_texts(texts: Vec<String>) -> Result<Vec<Vec<f32>>, String> {
9017        Ok(texts.iter().map(|_| vec![1.0, 0.0, 0.0]).collect())
9018    }
9019
9020    fn write_rust_file(path: &Path, function_name: &str) {
9021        fs::write(
9022            path,
9023            format!("pub fn {function_name}() -> bool {{\n    true\n}}\n"),
9024        )
9025        .unwrap();
9026    }
9027
9028    fn build_test_index(project_root: &Path, files: &[PathBuf]) -> SemanticIndex {
9029        let mut embed = test_vector_for_texts;
9030        SemanticIndex::build(project_root, files, &mut embed, 8).unwrap()
9031    }
9032
9033    fn test_project_root() -> PathBuf {
9034        std::env::current_dir().unwrap()
9035    }
9036
9037    #[test]
9038    fn empty_snapshot_replaces_nonempty_and_loads_as_valid_tombstone() {
9039        let project = tempfile::tempdir().expect("create project");
9040        let storage = tempfile::tempdir().expect("create storage");
9041        let source = project.path().join("lib.rs");
9042        write_rust_file(&source, "persisted_symbol");
9043        let populated = build_test_index(project.path(), std::slice::from_ref(&source));
9044        assert!(populated.write_to_disk(storage.path(), "project"));
9045
9046        let data_path = storage.path().join("semantic/project/semantic.bin");
9047        let populated_bytes = fs::read(&data_path).expect("read populated snapshot");
9048        let empty = SemanticIndex::new(project.path().to_path_buf(), populated.dimension());
9049        assert!(empty.write_to_disk(storage.path(), "project"));
9050        let empty_bytes = fs::read(&data_path).expect("read explicit empty snapshot");
9051        assert_ne!(empty_bytes, populated_bytes);
9052        let decoded = SemanticIndex::from_bytes(&empty_bytes, project.path())
9053            .expect("decode explicit empty snapshot");
9054        assert_eq!(decoded.entry_count(), 0);
9055        for _ in 0..2 {
9056            let loaded = SemanticIndex::read_from_disk(
9057                storage.path(),
9058                "project",
9059                project.path(),
9060                false,
9061                None,
9062            )
9063            .expect("explicit empty snapshot remains loadable");
9064            assert_eq!(loaded.entry_count(), 0);
9065        }
9066    }
9067
9068    #[test]
9069    fn persistence_failure_is_reported_to_caller() {
9070        let project = tempfile::tempdir().expect("create project");
9071        let storage_parent = tempfile::tempdir().expect("create storage parent");
9072        let storage_file = storage_parent.path().join("not-a-directory");
9073        fs::write(&storage_file, b"occupied").expect("create blocking file");
9074        let empty = SemanticIndex::new(project.path().to_path_buf(), 3);
9075
9076        assert!(!empty.write_to_disk(&storage_file, "project"));
9077    }
9078
9079    #[test]
9080    fn semantic_memory_estimate_is_zero_when_empty_and_scales_with_entries() {
9081        let root = test_project_root();
9082        let mut index = SemanticIndex::new(root.clone(), 3);
9083        assert_eq!(index.estimated_memory().estimated_bytes, Some(0));
9084
9085        let entry = |name: &str| EmbeddingEntry {
9086            chunk: SemanticChunk {
9087                file: root.join(format!("{name}.rs")),
9088                name: name.to_string(),
9089                qualified_name: Some(format!("module::{name}")),
9090                kind: SymbolKind::Function,
9091                start_line: 0,
9092                end_line: 1,
9093                exported: true,
9094                embed_text: format!("function {name} body"),
9095                snippet: format!("fn {name}() {{}}"),
9096            },
9097            norm: vector_norm(&[1.0, 2.0, 3.0]),
9098            vector: vec![1.0, 2.0, 3.0],
9099        };
9100        index.entries.push(entry("one"));
9101        let one_entry = index.estimated_memory().estimated_bytes.unwrap();
9102        assert!(one_entry > 0);
9103        index.entries.push(entry("two"));
9104        let two_entries = index.estimated_memory().estimated_bytes.unwrap();
9105        assert!(two_entries > one_entry);
9106    }
9107
9108    fn set_file_metadata(index: &mut SemanticIndex, file: &Path, mtime: SystemTime, size: u64) {
9109        index.file_mtimes.insert(file.to_path_buf(), mtime);
9110        index.file_sizes.insert(file.to_path_buf(), size);
9111        index
9112            .file_hashes
9113            .insert(file.to_path_buf(), cache_freshness::zero_hash());
9114    }
9115
9116    fn legacy_semantic_index_bytes(index: &SemanticIndex) -> Vec<u8> {
9117        let mut buf = Vec::new();
9118        let fingerprint_bytes = index.fingerprint.as_ref().and_then(|fingerprint| {
9119            let encoded = fingerprint.as_string();
9120            if encoded.is_empty() {
9121                None
9122            } else {
9123                Some(encoded.into_bytes())
9124            }
9125        });
9126        let file_mtimes: Vec<_> = index
9127            .file_mtimes
9128            .iter()
9129            .filter_map(|(path, mtime)| {
9130                cache_relative_path(&index.project_root, path)
9131                    .map(|relative| (relative, path, mtime))
9132            })
9133            .collect();
9134        let entries: Vec<_> = index
9135            .entries
9136            .iter()
9137            .filter_map(|entry| {
9138                cache_relative_path(&index.project_root, &entry.chunk.file)
9139                    .map(|relative| (relative, entry))
9140            })
9141            .collect();
9142
9143        buf.push(SEMANTIC_INDEX_VERSION_V6);
9144        buf.extend_from_slice(&(index.dimension as u32).to_le_bytes());
9145        buf.extend_from_slice(&(entries.len() as u32).to_le_bytes());
9146        let fp_bytes_ref: &[u8] = fingerprint_bytes.as_deref().unwrap_or(&[]);
9147        buf.extend_from_slice(&(fp_bytes_ref.len() as u32).to_le_bytes());
9148        buf.extend_from_slice(fp_bytes_ref);
9149
9150        buf.extend_from_slice(&(file_mtimes.len() as u32).to_le_bytes());
9151        for (relative, path, mtime) in &file_mtimes {
9152            let path_bytes = relative.to_string_lossy().as_bytes().to_vec();
9153            buf.extend_from_slice(&(path_bytes.len() as u32).to_le_bytes());
9154            buf.extend_from_slice(&path_bytes);
9155            let duration = mtime
9156                .duration_since(SystemTime::UNIX_EPOCH)
9157                .unwrap_or_default();
9158            buf.extend_from_slice(&duration.as_secs().to_le_bytes());
9159            buf.extend_from_slice(&duration.subsec_nanos().to_le_bytes());
9160            let size = index.file_sizes.get(*path).copied().unwrap_or_default();
9161            buf.extend_from_slice(&size.to_le_bytes());
9162            let hash = index
9163                .file_hashes
9164                .get(*path)
9165                .copied()
9166                .unwrap_or_else(cache_freshness::zero_hash);
9167            buf.extend_from_slice(hash.as_bytes());
9168        }
9169
9170        for (relative, entry) in &entries {
9171            let c = &entry.chunk;
9172            let file_bytes = relative.to_string_lossy().as_bytes().to_vec();
9173            buf.extend_from_slice(&(file_bytes.len() as u32).to_le_bytes());
9174            buf.extend_from_slice(&file_bytes);
9175
9176            let name_bytes = c.name.as_bytes();
9177            buf.extend_from_slice(&(name_bytes.len() as u32).to_le_bytes());
9178            buf.extend_from_slice(name_bytes);
9179
9180            buf.push(symbol_kind_to_u8(&c.kind));
9181            buf.extend_from_slice(&(c.start_line as u32).to_le_bytes());
9182            buf.extend_from_slice(&(c.end_line as u32).to_le_bytes());
9183            buf.push(c.exported as u8);
9184
9185            let snippet_bytes = c.snippet.as_bytes();
9186            buf.extend_from_slice(&(snippet_bytes.len() as u32).to_le_bytes());
9187            buf.extend_from_slice(snippet_bytes);
9188
9189            let embed_bytes = c.embed_text.as_bytes();
9190            buf.extend_from_slice(&(embed_bytes.len() as u32).to_le_bytes());
9191            buf.extend_from_slice(embed_bytes);
9192
9193            for &val in &entry.vector {
9194                buf.extend_from_slice(&val.to_le_bytes());
9195            }
9196        }
9197
9198        buf
9199    }
9200
9201    #[derive(Default)]
9202    struct RecordingEmbedder {
9203        calls: Vec<Vec<String>>,
9204    }
9205
9206    impl RecordingEmbedder {
9207        fn embed(&mut self, texts: Vec<String>) -> Result<Vec<Vec<f32>>, String> {
9208            let vectors = texts
9209                .iter()
9210                .map(|text| deterministic_test_vector(text))
9211                .collect();
9212            self.calls.push(texts);
9213            Ok(vectors)
9214        }
9215
9216        fn total_embedded_texts(&self) -> usize {
9217            self.calls.iter().map(Vec::len).sum()
9218        }
9219
9220        fn embedded_texts(&self) -> Vec<&str> {
9221            self.calls
9222                .iter()
9223                .flat_map(|batch| batch.iter().map(String::as_str))
9224                .collect()
9225        }
9226    }
9227
9228    fn deterministic_test_vector(text: &str) -> Vec<f32> {
9229        let hash = blake3::hash(text.as_bytes());
9230        let bytes = hash.as_bytes();
9231        vec![
9232            1.0,
9233            bytes[0] as f32 / 255.0,
9234            bytes[1] as f32 / 255.0,
9235            bytes[2] as f32 / 255.0,
9236        ]
9237    }
9238
9239    fn build_recorded_test_index(project_root: &Path, files: &[PathBuf]) -> SemanticIndex {
9240        let mut embedder = RecordingEmbedder::default();
9241        let mut embed = |texts: Vec<String>| embedder.embed(texts);
9242        SemanticIndex::build(project_root, files, &mut embed, 16).unwrap()
9243    }
9244
9245    fn force_stale(index: &mut SemanticIndex, file: &Path) {
9246        set_file_metadata(index, file, SystemTime::UNIX_EPOCH, 0);
9247    }
9248
9249    fn write_source(path: &Path, source: &str) {
9250        if let Some(parent) = path.parent() {
9251            fs::create_dir_all(parent).unwrap();
9252        }
9253        fs::write(path, source).unwrap();
9254    }
9255
9256    fn entries_for_file<'a>(index: &'a SemanticIndex, file: &Path) -> Vec<&'a EmbeddingEntry> {
9257        index
9258            .entries
9259            .iter()
9260            .filter(|entry| entry.chunk.file == file)
9261            .collect()
9262    }
9263
9264    fn entry_by_name<'a>(index: &'a SemanticIndex, file: &Path, name: &str) -> &'a EmbeddingEntry {
9265        index
9266            .entries
9267            .iter()
9268            .find(|entry| entry.chunk.file == file && entry.chunk.name == name)
9269            .unwrap_or_else(|| panic!("missing semantic entry {name} in {}", file.display()))
9270    }
9271
9272    fn file_summary_entry<'a>(index: &'a SemanticIndex, file: &Path) -> &'a EmbeddingEntry {
9273        index
9274            .entries
9275            .iter()
9276            .find(|entry| entry.chunk.file == file && entry.chunk.kind == SymbolKind::FileSummary)
9277            .unwrap_or_else(|| panic!("missing file-summary entry in {}", file.display()))
9278    }
9279
9280    #[test]
9281    fn borrowed_snapshots_deserialize_once_share_memory_and_drop_with_last_holder() {
9282        let owner = tempfile::tempdir().unwrap();
9283        let storage = tempfile::tempdir().unwrap();
9284        let borrower_a = tempfile::tempdir().unwrap();
9285        let borrower_b = tempfile::tempdir().unwrap();
9286        let relative = Path::new("src/lib.rs");
9287        for root in [owner.path(), borrower_a.path(), borrower_b.path()] {
9288            let file = root.join(relative);
9289            fs::create_dir_all(file.parent().unwrap()).unwrap();
9290            fs::write(&file, "pub fn shared_symbol() -> bool { true }\n").unwrap();
9291        }
9292        let owner_file = owner.path().join(relative);
9293        let metadata = fs::metadata(&owner_file).unwrap();
9294        let mut index = SemanticIndex::new(owner.path().to_path_buf(), 3);
9295        index.entries.push(EmbeddingEntry {
9296            chunk: SemanticChunk {
9297                file: owner_file.clone(),
9298                name: "shared_symbol".to_string(),
9299                qualified_name: None,
9300                kind: SymbolKind::Function,
9301                start_line: 0,
9302                end_line: 0,
9303                exported: true,
9304                embed_text: "shared symbol".to_string(),
9305                snippet: "pub fn shared_symbol() -> bool { true }".to_string(),
9306            },
9307            norm: vector_norm(&[1.0, 0.0, 0.0]),
9308            vector: vec![1.0, 0.0, 0.0],
9309        });
9310        index.file_mtimes.insert(
9311            owner_file.clone(),
9312            metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
9313        );
9314        index.file_sizes.insert(owner_file.clone(), metadata.len());
9315        index.file_hashes.insert(
9316            owner_file,
9317            blake3::hash(b"pub fn shared_symbol() -> bool { true }\n"),
9318        );
9319        index.set_fingerprint(SemanticIndexFingerprint {
9320            backend: "test".to_string(),
9321            model: "shared-base".to_string(),
9322            base_url: FALLBACK_BACKEND.to_string(),
9323            dimension: 3,
9324            chunking_version: default_chunking_version(),
9325            ..Default::default()
9326        });
9327        assert!(index.shared_base.is_none(), "owner indexes stay private");
9328
9329        let project_key = format!(
9330            "shared-base-{}",
9331            blake3::hash(owner.path().as_os_str().as_encoded_bytes()).to_hex()
9332        );
9333        let dir = storage.path().join("semantic").join(&project_key);
9334        fs::create_dir_all(&dir).unwrap();
9335        fs::write(dir.join("semantic.bin"), index.to_bytes()).unwrap();
9336        let loads_before = SHARED_SEMANTIC_BASE_LOADS.load(Ordering::Relaxed);
9337        let hits_before = SHARED_SEMANTIC_BASE_HITS.load(Ordering::Relaxed);
9338        let a = SemanticIndex::read_from_disk_borrow_tolerant(
9339            storage.path(),
9340            &project_key,
9341            borrower_a.path(),
9342        )
9343        .unwrap();
9344        let b = SemanticIndex::read_from_disk_borrow_tolerant(
9345            storage.path(),
9346            &project_key,
9347            borrower_b.path(),
9348        )
9349        .unwrap();
9350        let a_base = a.shared_base.as_ref().unwrap();
9351        let b_base = b.shared_base.as_ref().unwrap();
9352        assert!(Arc::ptr_eq(a_base, b_base));
9353        assert!(SHARED_SEMANTIC_BASE_LOADS.load(Ordering::Relaxed) > loads_before);
9354        assert!(SHARED_SEMANTIC_BASE_HITS.load(Ordering::Relaxed) > hits_before);
9355        assert_eq!(
9356            a.search(&[1.0, 0.0, 0.0], 1)[0].file,
9357            borrower_a.path().join(relative)
9358        );
9359        assert_eq!(
9360            b.search(&[1.0, 0.0, 0.0], 1)[0].file,
9361            borrower_b.path().join(relative)
9362        );
9363        assert_eq!(a.estimated_memory().estimated_bytes, Some(0));
9364        assert!(shared_semantic_bases_memory().estimated_bytes.unwrap_or(0) > 0);
9365
9366        let weak = Arc::downgrade(a_base);
9367        let ctx = crate::context::AppContext::new(
9368            Box::new(crate::parser::TreeSitterProvider::new()),
9369            crate::config::Config {
9370                project_root: Some(borrower_a.path().to_path_buf()),
9371                ..crate::config::Config::default()
9372            },
9373        );
9374        *ctx.semantic_index()
9375            .write()
9376            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(a);
9377        assert!(ctx.evict_idle_artifacts());
9378        assert!(
9379            weak.upgrade().is_some(),
9380            "the second borrower keeps the base live"
9381        );
9382        drop(b);
9383        assert!(
9384            weak.upgrade().is_none(),
9385            "the last borrower releases the base"
9386        );
9387    }
9388
9389    #[test]
9390    fn borrowed_snapshot_hash_change_falls_back_to_private_copy() {
9391        let owner = tempfile::tempdir().unwrap();
9392        let storage = tempfile::tempdir().unwrap();
9393        let borrower_a = tempfile::tempdir().unwrap();
9394        let borrower_b = tempfile::tempdir().unwrap();
9395        let relative = Path::new("src/lib.rs");
9396        for root in [owner.path(), borrower_a.path(), borrower_b.path()] {
9397            let file = root.join(relative);
9398            fs::create_dir_all(file.parent().unwrap()).unwrap();
9399            fs::write(&file, "pub fn hash_guard() {}\n").unwrap();
9400        }
9401        let owner_file = owner.path().join(relative);
9402        let metadata = fs::metadata(&owner_file).unwrap();
9403        let mut index = SemanticIndex::new(owner.path().to_path_buf(), 2);
9404        index.entries.push(EmbeddingEntry {
9405            chunk: SemanticChunk {
9406                file: owner_file.clone(),
9407                name: "hash_guard".to_string(),
9408                qualified_name: None,
9409                kind: SymbolKind::Function,
9410                start_line: 0,
9411                end_line: 0,
9412                exported: true,
9413                embed_text: "hash guard".to_string(),
9414                snippet: "pub fn hash_guard() {}".to_string(),
9415            },
9416            norm: vector_norm(&[1.0, 0.0]),
9417            vector: vec![1.0, 0.0],
9418        });
9419        index.file_mtimes.insert(
9420            owner_file.clone(),
9421            metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
9422        );
9423        index.file_sizes.insert(owner_file.clone(), metadata.len());
9424        index
9425            .file_hashes
9426            .insert(owner_file, blake3::hash(b"pub fn hash_guard() {}\n"));
9427        index.set_fingerprint(SemanticIndexFingerprint {
9428            backend: "test".to_string(),
9429            model: "hash-guard".to_string(),
9430            base_url: FALLBACK_BACKEND.to_string(),
9431            dimension: 2,
9432            chunking_version: default_chunking_version(),
9433            ..Default::default()
9434        });
9435        let project_key = format!(
9436            "hash-fallback-{}",
9437            blake3::hash(owner.path().as_os_str().as_encoded_bytes()).to_hex()
9438        );
9439        let dir = storage.path().join("semantic").join(&project_key);
9440        fs::create_dir_all(&dir).unwrap();
9441        fs::write(dir.join("semantic.bin"), index.to_bytes()).unwrap();
9442        let shared = SemanticIndex::read_from_disk_borrow_tolerant(
9443            storage.path(),
9444            &project_key,
9445            borrower_a.path(),
9446        )
9447        .unwrap();
9448        assert!(shared.shared_base.is_some());
9449
9450        let changed_vector = vec![0.0, 1.0];
9451        index.entries[0].norm = vector_norm(&changed_vector);
9452        index.entries[0].vector = changed_vector;
9453        fs::write(dir.join("semantic.bin"), index.to_bytes()).unwrap();
9454        let fallback = SemanticIndex::read_from_disk_borrow_tolerant(
9455            storage.path(),
9456            &project_key,
9457            borrower_b.path(),
9458        )
9459        .unwrap();
9460        assert!(
9461            fallback.shared_base.is_none(),
9462            "a different byte identity must not join the live shared generation"
9463        );
9464        drop(shared);
9465    }
9466
9467    #[test]
9468    fn borrow_only_root_skips_semantic_lock_and_persist() {
9469        let project = tempfile::tempdir().expect("project");
9470        let source = project.path().join("lib.rs");
9471        write_rust_file(&source, "borrow_only_symbol");
9472        let project_key = "shared-artifact-key".to_string();
9473        let storage = tempfile::tempdir().expect("storage");
9474        crate::root_cache::configure_artifact_access(project.path(), &project_key, true);
9475
9476        let _lock = SemanticIndexLock::acquire(storage.path(), &project_key, project.path())
9477            .expect("borrow-only lock downgrade");
9478        let cache_dir = storage.path().join("semantic").join(&project_key);
9479        assert!(!cache_dir.join("cache.lock").exists());
9480
9481        let index = build_test_index(project.path(), &[source]);
9482        index.write_to_disk(storage.path(), &project_key);
9483
9484        assert!(!cache_dir.join("semantic.bin").exists());
9485        assert!(!cache_dir.exists());
9486    }
9487
9488    #[test]
9489    fn corpus_refresh_failure_reports_exact_file_set_for_recovery() {
9490        let temp = tempfile::tempdir().unwrap();
9491        let root = std::fs::canonicalize(temp.path()).unwrap();
9492        let changed = root.join("changed.rs");
9493        let deleted = root.join("deleted.rs");
9494        let unchanged = root.join("unchanged.rs");
9495        write_rust_file(&changed, "changed_before");
9496        write_rust_file(&deleted, "deleted");
9497        write_rust_file(&unchanged, "unchanged");
9498        let mut index = build_test_index(
9499            &root,
9500            &[changed.clone(), deleted.clone(), unchanged.clone()],
9501        );
9502
9503        write_rust_file(&changed, "changed_after_with_a_longer_name");
9504        force_stale(&mut index, &changed);
9505        std::fs::remove_file(&deleted).unwrap();
9506        let added = root.join("added.rs");
9507        write_rust_file(&added, "added");
9508        let current_files = vec![changed.clone(), unchanged, added.clone()];
9509        let mut recovery_paths = Vec::new();
9510        let mut embed = |_texts: Vec<String>| -> Result<Vec<Vec<f32>>, String> {
9511            Err(format!("{TRANSIENT_EMBEDDING_MARKER}backend timeout"))
9512        };
9513        let mut progress = |_done: usize, _total: usize| {};
9514
9515        let result = index.refresh_stale_files_with_strategy_and_blob_reuse(
9516            &root,
9517            &current_files,
9518            &mut embed,
9519            64,
9520            &mut progress,
9521            cache_freshness::VerifyStrategy::Strict,
9522            &mut |_| None,
9523            Some(&mut recovery_paths),
9524        );
9525
9526        assert!(result.is_err());
9527        let mut expected = vec![added, changed, deleted];
9528        expected.sort();
9529        assert_eq!(recovery_paths, expected);
9530    }
9531
9532    #[test]
9533    fn refresh_stale_line_shift_reuses_all_chunks_and_retains_entries() {
9534        let temp = tempfile::tempdir().unwrap();
9535        let project_root = temp.path();
9536        let file = project_root.join("src/lib.rs");
9537        let original = "pub fn alpha() -> i32 {\n    1\n}\n\npub fn beta() -> i32 {\n    2\n}\n";
9538        write_source(&file, original);
9539
9540        let mut index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
9541        let original_entry_count = index.entries.len();
9542        let original_alpha_vector = entry_by_name(&index, &file, "alpha").vector.clone();
9543
9544        write_source(&file, &format!("\n{original}"));
9545        force_stale(&mut index, &file);
9546
9547        let mut embedder = RecordingEmbedder::default();
9548        let mut embed = |texts: Vec<String>| embedder.embed(texts);
9549        let mut progress = |_done: usize, _total: usize| {};
9550        let summary = index
9551            .refresh_stale_files(
9552                project_root,
9553                std::slice::from_ref(&file),
9554                &mut embed,
9555                16,
9556                &mut progress,
9557            )
9558            .unwrap();
9559
9560        assert_eq!(summary.changed, 1);
9561        assert_eq!(embedder.total_embedded_texts(), 0);
9562        assert_eq!(index.entries.len(), original_entry_count);
9563        let shifted_alpha = entry_by_name(&index, &file, "alpha");
9564        assert_eq!(shifted_alpha.chunk.start_line, 1);
9565        assert_eq!(shifted_alpha.vector, original_alpha_vector);
9566    }
9567
9568    #[test]
9569    fn refresh_invalidated_line_shift_emits_full_replacement_delta_for_apply() {
9570        let temp = tempfile::tempdir().unwrap();
9571        let project_root = temp.path();
9572        let file = project_root.join("src/lib.rs");
9573        let original = "pub fn alpha() -> i32 {\n    1\n}\n\npub fn beta() -> i32 {\n    2\n}\n";
9574        write_source(&file, original);
9575
9576        let mut worker_index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
9577        let mut serving_index = worker_index.clone();
9578        let original_entry_count = worker_index.entries.len();
9579
9580        write_source(&file, &format!("\n{original}"));
9581
9582        let mut embedder = RecordingEmbedder::default();
9583        let mut embed = |texts: Vec<String>| embedder.embed(texts);
9584        let mut progress = |_done: usize, _total: usize| {};
9585        let update = worker_index
9586            .refresh_invalidated_files(
9587                project_root,
9588                std::slice::from_ref(&file),
9589                &mut embed,
9590                16,
9591                100,
9592                &mut progress,
9593            )
9594            .unwrap();
9595
9596        assert_eq!(embedder.total_embedded_texts(), 0);
9597        assert_eq!(update.added_entries.len(), original_entry_count);
9598        assert_eq!(worker_index.entries.len(), original_entry_count);
9599
9600        serving_index.apply_refresh_update(
9601            update.added_entries,
9602            update.updated_metadata,
9603            &update.completed_paths,
9604        );
9605
9606        assert_eq!(serving_index.entries.len(), original_entry_count);
9607        assert_eq!(
9608            entries_for_file(&serving_index, &file).len(),
9609            original_entry_count
9610        );
9611        assert_eq!(
9612            entry_by_name(&serving_index, &file, "alpha")
9613                .chunk
9614                .start_line,
9615            1
9616        );
9617    }
9618
9619    #[test]
9620    fn refresh_invalidated_one_symbol_edit_embeds_only_changed_symbol() {
9621        let temp = tempfile::tempdir().unwrap();
9622        let project_root = temp.path();
9623        let file = project_root.join("src/lib.rs");
9624        write_source(
9625            &file,
9626            "pub fn alpha() -> i32 {\n    1\n}\n\npub fn beta() -> i32 {\n    2\n}\n",
9627        );
9628
9629        let mut index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
9630        let original_entry_count = index.entries.len();
9631        let beta_vector = entry_by_name(&index, &file, "beta").vector.clone();
9632
9633        write_source(
9634            &file,
9635            "pub fn alpha() -> i32 {\n    10\n}\n\npub fn beta() -> i32 {\n    2\n}\n",
9636        );
9637
9638        let mut embedder = RecordingEmbedder::default();
9639        let mut embed = |texts: Vec<String>| embedder.embed(texts);
9640        let mut progress = |_done: usize, _total: usize| {};
9641        let update = index
9642            .refresh_invalidated_files(
9643                project_root,
9644                std::slice::from_ref(&file),
9645                &mut embed,
9646                16,
9647                100,
9648                &mut progress,
9649            )
9650            .unwrap();
9651
9652        assert_eq!(embedder.total_embedded_texts(), 1);
9653        assert!(embedder.embedded_texts()[0].contains("name:alpha"));
9654        assert_eq!(update.added_entries.len(), original_entry_count);
9655        assert_eq!(entry_by_name(&index, &file, "beta").vector, beta_vector);
9656    }
9657
9658    #[test]
9659    fn refresh_reuses_one_old_vector_for_two_byte_identical_symbols() {
9660        let temp = tempfile::tempdir().unwrap();
9661        let project_root = temp.path();
9662        let file = project_root.join("src/dupe.js");
9663        let one_duplicate = "function duplicate() {\n  return 1;\n}\n";
9664        write_source(&file, one_duplicate);
9665
9666        let mut index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
9667        let original_vector = entry_by_name(&index, &file, "duplicate").vector.clone();
9668
9669        write_source(&file, &format!("{one_duplicate}\n{one_duplicate}"));
9670
9671        let mut embedder = RecordingEmbedder::default();
9672        let mut embed = |texts: Vec<String>| embedder.embed(texts);
9673        let mut progress = |_done: usize, _total: usize| {};
9674        index
9675            .refresh_invalidated_files(
9676                project_root,
9677                std::slice::from_ref(&file),
9678                &mut embed,
9679                16,
9680                100,
9681                &mut progress,
9682            )
9683            .unwrap();
9684
9685        let duplicate_entries = index
9686            .entries
9687            .iter()
9688            .filter(|entry| entry.chunk.file == file && entry.chunk.name == "duplicate")
9689            .collect::<Vec<_>>();
9690        assert_eq!(duplicate_entries.len(), 2);
9691        assert_eq!(embedder.total_embedded_texts(), 0);
9692        assert_eq!(duplicate_entries[0].vector, original_vector);
9693        assert_eq!(duplicate_entries[1].vector, original_vector);
9694    }
9695
9696    #[test]
9697    fn file_summary_reuses_on_body_edit_and_misses_on_leading_doc_edit() {
9698        let temp = tempfile::tempdir().unwrap();
9699        let project_root = temp.path();
9700        let file = project_root.join("src/lib.rs");
9701        write_source(
9702            &file,
9703            "//! module docs v1\n\npub fn alpha() -> i32 {\n    1\n}\n",
9704        );
9705
9706        let mut index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
9707        let summary_before = file_summary_entry(&index, &file).vector.clone();
9708
9709        write_source(
9710            &file,
9711            "//! module docs v1\n\npub fn alpha() -> i32 {\n    2\n}\n",
9712        );
9713        let mut body_embedder = RecordingEmbedder::default();
9714        let mut body_embed = |texts: Vec<String>| body_embedder.embed(texts);
9715        let mut progress = |_done: usize, _total: usize| {};
9716        index
9717            .refresh_invalidated_files(
9718                project_root,
9719                std::slice::from_ref(&file),
9720                &mut body_embed,
9721                16,
9722                100,
9723                &mut progress,
9724            )
9725            .unwrap();
9726        assert_eq!(body_embedder.total_embedded_texts(), 1);
9727        assert!(body_embedder.embedded_texts()[0].contains("name:alpha"));
9728        assert_eq!(file_summary_entry(&index, &file).vector, summary_before);
9729
9730        write_source(
9731            &file,
9732            "//! module docs v2\n\npub fn alpha() -> i32 {\n    2\n}\n",
9733        );
9734        let mut doc_embedder = RecordingEmbedder::default();
9735        let mut doc_embed = |texts: Vec<String>| doc_embedder.embed(texts);
9736        index
9737            .refresh_invalidated_files(
9738                project_root,
9739                std::slice::from_ref(&file),
9740                &mut doc_embed,
9741                16,
9742                100,
9743                &mut progress,
9744            )
9745            .unwrap();
9746
9747        assert_eq!(doc_embedder.total_embedded_texts(), 1);
9748        assert!(doc_embedder.embedded_texts()[0].contains("kind:file-summary"));
9749        assert_ne!(file_summary_entry(&index, &file).vector, summary_before);
9750    }
9751
9752    #[test]
9753    fn refresh_invalidated_deleted_file_drops_entries_without_embedding() {
9754        let temp = tempfile::tempdir().unwrap();
9755        let project_root = temp.path();
9756        let file = project_root.join("src/lib.rs");
9757        write_source(&file, "pub fn alpha() -> i32 {\n    1\n}\n");
9758
9759        let mut worker_index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
9760        let mut serving_index = worker_index.clone();
9761        fs::remove_file(&file).unwrap();
9762
9763        let mut embedder = RecordingEmbedder::default();
9764        let mut embed = |texts: Vec<String>| embedder.embed(texts);
9765        let mut progress = |_done: usize, _total: usize| {};
9766        let update = worker_index
9767            .refresh_invalidated_files(
9768                project_root,
9769                std::slice::from_ref(&file),
9770                &mut embed,
9771                16,
9772                100,
9773                &mut progress,
9774            )
9775            .unwrap();
9776
9777        assert_eq!(update.summary.deleted, 1);
9778        assert_eq!(embedder.total_embedded_texts(), 0);
9779        assert!(worker_index.entries.is_empty());
9780
9781        serving_index.apply_refresh_update(
9782            update.added_entries,
9783            update.updated_metadata,
9784            &update.completed_paths,
9785        );
9786        assert!(serving_index.entries.is_empty());
9787    }
9788
9789    #[test]
9790    fn watcher_collect_failure_does_not_resurrect_stale_entries() {
9791        let temp = tempfile::tempdir().unwrap();
9792        let project_root = temp.path();
9793        let file = project_root.join("src/lib.rs");
9794        write_source(&file, "pub fn alpha() -> i32 {\n    1\n}\n");
9795
9796        let mut worker_index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
9797        let mut serving_index = worker_index.clone();
9798        fs::write(&file, [0xff, 0xfe, 0xfd]).unwrap();
9799
9800        let mut embedder = RecordingEmbedder::default();
9801        let mut embed = |texts: Vec<String>| embedder.embed(texts);
9802        let mut progress = |_done: usize, _total: usize| {};
9803        let update = worker_index
9804            .refresh_invalidated_files(
9805                project_root,
9806                std::slice::from_ref(&file),
9807                &mut embed,
9808                16,
9809                100,
9810                &mut progress,
9811            )
9812            .unwrap();
9813
9814        assert_eq!(embedder.total_embedded_texts(), 0);
9815        assert!(update.added_entries.is_empty());
9816        assert!(worker_index.entries.is_empty());
9817        assert!(!worker_index.file_mtimes.contains_key(&file));
9818
9819        serving_index.apply_refresh_update(
9820            update.added_entries,
9821            update.updated_metadata,
9822            &update.completed_paths,
9823        );
9824        assert!(serving_index.entries.is_empty());
9825        assert!(!serving_index.file_mtimes.contains_key(&file));
9826    }
9827
9828    #[test]
9829    fn refresh_invalidated_cap_deferral_remains_file_count_based() {
9830        let temp = tempfile::tempdir().unwrap();
9831        let project_root = temp.path();
9832        let indexed = project_root.join("src/a.rs");
9833        let deferred = project_root.join("src/b.rs");
9834        write_source(&indexed, "pub fn alpha() -> i32 {\n    1\n}\n");
9835        write_source(&deferred, "pub fn beta() -> i32 {\n    2\n}\n");
9836
9837        let mut index = build_recorded_test_index(project_root, std::slice::from_ref(&indexed));
9838        let mut embedder = RecordingEmbedder::default();
9839        let mut embed = |texts: Vec<String>| embedder.embed(texts);
9840        let mut progress = |_done: usize, _total: usize| {};
9841        let update = index
9842            .refresh_invalidated_files(
9843                project_root,
9844                std::slice::from_ref(&deferred),
9845                &mut embed,
9846                16,
9847                1,
9848                &mut progress,
9849            )
9850            .unwrap();
9851
9852        assert_eq!(update.summary.total_processed, 1);
9853        assert_eq!(update.summary.added, 0);
9854        assert_eq!(embedder.total_embedded_texts(), 0);
9855        assert_eq!(index.indexed_file_count(), 1);
9856        assert!(index.deferred_files.contains(&deferred));
9857        assert!(entries_for_file(&index, &deferred).is_empty());
9858    }
9859
9860    #[test]
9861    fn semantic_cache_serialization_skips_paths_outside_project_root() {
9862        let dir = tempfile::tempdir().expect("create temp dir");
9863        let project = fs::canonicalize(dir.path()).expect("canonical project");
9864        let outside = project.join("..").join("outside.rs");
9865        let mut index = SemanticIndex::new(project.clone(), 3);
9866        index
9867            .file_mtimes
9868            .insert(outside.clone(), SystemTime::UNIX_EPOCH);
9869        index.file_sizes.insert(outside.clone(), 1);
9870        index
9871            .file_hashes
9872            .insert(outside.clone(), cache_freshness::zero_hash());
9873        index.entries.push(EmbeddingEntry {
9874            chunk: SemanticChunk {
9875                file: outside,
9876                name: "outside".to_string(),
9877                qualified_name: None,
9878                kind: SymbolKind::Function,
9879                start_line: 0,
9880                end_line: 0,
9881                exported: false,
9882                embed_text: "outside".to_string(),
9883                snippet: "outside".to_string(),
9884            },
9885            norm: vector_norm(&[1.0, 0.0, 0.0]),
9886            vector: vec![1.0, 0.0, 0.0],
9887        });
9888
9889        let bytes = index.to_bytes();
9890        let loaded = SemanticIndex::from_bytes(&bytes, &project).expect("load serialized index");
9891        assert_eq!(loaded.entries.len(), 0);
9892        assert!(loaded.file_mtimes.is_empty());
9893    }
9894
9895    #[test]
9896    fn semantic_search_bounded_top_k_matches_reference_full_sort() {
9897        let project_root = test_project_root();
9898        let file = project_root.join("src/lib.rs");
9899        let mut index = SemanticIndex::new(project_root, 2);
9900        let entries = [
9901            ("alpha", vec![2.0, 0.0], false),
9902            ("beta", vec![0.0, 3.0], false),
9903            ("gamma", vec![4.0, 0.0], false),
9904            ("delta", vec![1.0, 1.0], true),
9905            ("epsilon", vec![-5.0, 0.0], false),
9906        ];
9907        for (line, (name, vector, exported)) in entries.into_iter().enumerate() {
9908            index.entries.push(EmbeddingEntry {
9909                chunk: SemanticChunk {
9910                    file: file.clone(),
9911                    name: name.to_string(),
9912                    qualified_name: None,
9913                    kind: SymbolKind::Function,
9914                    start_line: line as u32 + 1,
9915                    end_line: line as u32 + 1,
9916                    exported,
9917                    embed_text: name.to_string(),
9918                    snippet: format!("fn {name}() {{}}"),
9919                },
9920                norm: vector_norm(&vector),
9921                vector,
9922            });
9923        }
9924
9925        let query = vec![2.0, 0.0];
9926        let top_k = 4;
9927        let mut reference: Vec<(f32, usize)> = index
9928            .entries
9929            .iter()
9930            .enumerate()
9931            .map(|(idx, entry)| {
9932                // Recompute both norms for every entry as the reference
9933                // implementation, so cached norms cannot change ranking or scores.
9934                let mut dot = 0.0f32;
9935                let mut query_squared_norm = 0.0f32;
9936                let mut entry_squared_norm = 0.0f32;
9937                for i in 0..query.len() {
9938                    dot += query[i] * entry.vector[i];
9939                    query_squared_norm += query[i] * query[i];
9940                    entry_squared_norm += entry.vector[i] * entry.vector[i];
9941                }
9942                let denom = query_squared_norm.sqrt() * entry_squared_norm.sqrt();
9943                let mut score = if denom == 0.0 { 0.0 } else { dot / denom };
9944                if entry.chunk.exported {
9945                    score *= 1.1;
9946                }
9947                (score, idx)
9948            })
9949            .collect();
9950        reference.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
9951        let expected: Vec<(String, f32)> = reference
9952            .into_iter()
9953            .take(top_k)
9954            .map(|(score, idx)| (index.entries[idx].chunk.name.clone(), score))
9955            .collect();
9956
9957        let actual: Vec<(String, f32)> = index
9958            .search(&query, top_k)
9959            .into_iter()
9960            .map(|result| (result.name, result.score))
9961            .collect();
9962
9963        assert_eq!(
9964            actual.iter().map(|(name, _)| name).collect::<Vec<_>>(),
9965            expected.iter().map(|(name, _)| name).collect::<Vec<_>>()
9966        );
9967        for ((_, actual_score), (_, expected_score)) in actual.iter().zip(expected.iter()) {
9968            assert!((actual_score - expected_score).abs() < 1e-6);
9969        }
9970        assert_eq!(actual[0].0, "alpha");
9971        assert_eq!(actual[1].0, "gamma", "equal scores keep insertion order");
9972        assert!(index.search(&query, 0).is_empty());
9973    }
9974
9975    #[test]
9976    fn test_cosine_similarity_identical() {
9977        let a = vec![1.0, 0.0, 0.0];
9978        let b = vec![1.0, 0.0, 0.0];
9979        assert!((cosine_similarity(&a, &b) - 1.0).abs() < 0.001);
9980    }
9981
9982    #[test]
9983    fn test_cosine_similarity_orthogonal() {
9984        let a = vec![1.0, 0.0, 0.0];
9985        let b = vec![0.0, 1.0, 0.0];
9986        assert!(cosine_similarity(&a, &b).abs() < 0.001);
9987    }
9988
9989    #[test]
9990    fn test_cosine_similarity_opposite() {
9991        let a = vec![1.0, 0.0, 0.0];
9992        let b = vec![-1.0, 0.0, 0.0];
9993        assert!((cosine_similarity(&a, &b) + 1.0).abs() < 0.001);
9994    }
9995
9996    #[test]
9997    fn test_serialization_roundtrip() {
9998        let project_root = test_project_root();
9999        let file = project_root.join("src/main.rs");
10000        let mut index = SemanticIndex::new(project_root.clone(), DEFAULT_DIMENSION);
10001        index.entries.push(EmbeddingEntry {
10002            chunk: SemanticChunk {
10003                file: file.clone(),
10004                name: "handle_request".to_string(),
10005                qualified_name: None,
10006                kind: SymbolKind::Function,
10007                start_line: 10,
10008                end_line: 25,
10009                exported: true,
10010                embed_text: "file:src/main.rs kind:function name:handle_request".to_string(),
10011                snippet: "fn handle_request() {\n  // ...\n}".to_string(),
10012            },
10013            norm: vector_norm(&[0.1, 0.2, 0.3, 0.4]),
10014            vector: vec![0.1, 0.2, 0.3, 0.4],
10015        });
10016        index.dimension = 4;
10017        index
10018            .file_mtimes
10019            .insert(file.clone(), SystemTime::UNIX_EPOCH);
10020        index.file_sizes.insert(file, 0);
10021        index.set_fingerprint(SemanticIndexFingerprint {
10022            backend: "fastembed".to_string(),
10023            model: "all-MiniLM-L6-v2".to_string(),
10024            base_url: FALLBACK_BACKEND.to_string(),
10025            dimension: 4,
10026            chunking_version: default_chunking_version(),
10027            ..Default::default()
10028        });
10029
10030        let bytes = index.to_bytes();
10031        let restored = SemanticIndex::from_bytes(&bytes, &project_root).unwrap();
10032
10033        assert_eq!(restored.entries.len(), 1);
10034        assert_eq!(restored.entries[0].chunk.name, "handle_request");
10035        assert_eq!(restored.entries[0].vector, vec![0.1, 0.2, 0.3, 0.4]);
10036        assert_eq!(
10037            restored.entries[0].norm,
10038            vector_norm(&restored.entries[0].vector)
10039        );
10040        assert_eq!(restored.dimension, 4);
10041        assert_eq!(restored.backend_label(), Some("fastembed"));
10042        assert_eq!(restored.model_label(), Some("all-MiniLM-L6-v2"));
10043    }
10044
10045    #[test]
10046    fn semantic_cache_v6_loads_and_v7_round_trips_qualified_names() {
10047        let storage = tempfile::tempdir().expect("create storage dir");
10048        let project = storage.path().join("project");
10049        fs::create_dir_all(project.join("src")).expect("create project src");
10050        let file = project.join("src/lib.rs");
10051        fs::write(&file, "pub fn alpha() {}\npub fn beta() {}\n").expect("write source");
10052        let project_root = fs::canonicalize(&project).expect("canonical project");
10053        let file = fs::canonicalize(&file).expect("canonical file");
10054
10055        let mut index = SemanticIndex::new(project_root.clone(), 3);
10056        let mtime = SystemTime::UNIX_EPOCH + Duration::new(123, 456);
10057        index.file_mtimes.insert(file.clone(), mtime);
10058        index.file_sizes.insert(file.clone(), 42);
10059        index
10060            .file_hashes
10061            .insert(file.clone(), cache_freshness::zero_hash());
10062        index.entries.push(EmbeddingEntry {
10063            chunk: SemanticChunk {
10064                file: file.clone(),
10065                name: "alpha".to_string(),
10066                qualified_name: Some("Service.alpha".to_string()),
10067                kind: SymbolKind::Function,
10068                start_line: 0,
10069                end_line: 0,
10070                exported: true,
10071                embed_text: "file:src/lib.rs kind:function name:alpha".to_string(),
10072                snippet: "pub fn alpha() {}".to_string(),
10073            },
10074            norm: vector_norm(&[0.1, 0.2, 0.3]),
10075            vector: vec![0.1, 0.2, 0.3],
10076        });
10077        index.entries.push(EmbeddingEntry {
10078            chunk: SemanticChunk {
10079                file: file.clone(),
10080                name: "beta".to_string(),
10081                qualified_name: Some("Service.beta".to_string()),
10082                kind: SymbolKind::Function,
10083                start_line: 1,
10084                end_line: 1,
10085                exported: true,
10086                embed_text: "file:src/lib.rs kind:function name:beta".to_string(),
10087                snippet: "pub fn beta() {}".to_string(),
10088            },
10089            norm: vector_norm(&[0.4, 0.5, 0.6]),
10090            vector: vec![0.4, 0.5, 0.6],
10091        });
10092        let fingerprint = SemanticIndexFingerprint {
10093            backend: "fastembed".to_string(),
10094            model: "all-MiniLM-L6-v2".to_string(),
10095            base_url: FALLBACK_BACKEND.to_string(),
10096            dimension: 3,
10097            chunking_version: default_chunking_version(),
10098            ..Default::default()
10099        };
10100        let fingerprint_before = fingerprint.as_string();
10101        index.set_fingerprint(fingerprint.clone());
10102
10103        let legacy_bytes = legacy_semantic_index_bytes(&index);
10104        assert_eq!(legacy_bytes[0], SEMANTIC_INDEX_VERSION_V6);
10105        let legacy_dir = storage.path().join("semantic/legacy-proj");
10106        fs::create_dir_all(&legacy_dir).expect("create legacy semantic dir");
10107        let legacy_path = legacy_dir.join("semantic.bin");
10108        fs::write(&legacy_path, &legacy_bytes).expect("write legacy semantic.bin");
10109        let legacy_loaded = SemanticIndex::read_from_disk(
10110            storage.path(),
10111            "legacy-proj",
10112            &project_root,
10113            false,
10114            Some(&fingerprint_before),
10115        )
10116        .expect("load v6 semantic index");
10117        assert!(
10118            legacy_path.exists(),
10119            "compatible V6 cache must not be deleted"
10120        );
10121        assert!(legacy_loaded
10122            .entries
10123            .iter()
10124            .all(|entry| entry.chunk.qualified_name.is_none()));
10125        assert_eq!(
10126            legacy_loaded.fingerprint().unwrap().as_string(),
10127            fingerprint_before
10128        );
10129
10130        let v7_bytes = index.to_bytes();
10131        assert_eq!(v7_bytes[0], SEMANTIC_INDEX_VERSION_V7);
10132        assert_ne!(v7_bytes, legacy_bytes);
10133        let restored = SemanticIndex::from_bytes(&v7_bytes, &project_root).unwrap();
10134        assert_eq!(
10135            restored.entries[0].chunk.qualified_name.as_deref(),
10136            Some("Service.alpha")
10137        );
10138        assert_eq!(
10139            restored.entries[1].chunk.qualified_name.as_deref(),
10140            Some("Service.beta")
10141        );
10142        assert_eq!(
10143            restored.fingerprint().unwrap().as_string(),
10144            fingerprint_before
10145        );
10146
10147        index.write_to_disk(storage.path(), "proj");
10148        let data_path = storage.path().join("semantic/proj/semantic.bin");
10149        let persisted = fs::read(&data_path).expect("read semantic.bin");
10150        assert_eq!(persisted[0], SEMANTIC_INDEX_VERSION_V7);
10151
10152        let loaded = SemanticIndex::read_from_disk(
10153            storage.path(),
10154            "proj",
10155            &project_root,
10156            false,
10157            Some(&fingerprint_before),
10158        )
10159        .expect("load semantic index");
10160        assert_eq!(loaded.entries.len(), index.entries.len());
10161        assert_eq!(loaded.dimension, index.dimension);
10162        assert_eq!(
10163            loaded.fingerprint().unwrap().as_string(),
10164            fingerprint_before
10165        );
10166        assert_eq!(loaded.file_mtimes.get(&file), Some(&mtime));
10167        assert_eq!(loaded.file_sizes.get(&file), Some(&42));
10168        assert_eq!(
10169            loaded.file_hashes.get(&file),
10170            Some(&cache_freshness::zero_hash())
10171        );
10172        for (actual, expected) in loaded.entries.iter().zip(index.entries.iter()) {
10173            assert_eq!(actual.chunk.file, expected.chunk.file);
10174            assert_eq!(actual.chunk.name, expected.chunk.name);
10175            assert_eq!(actual.chunk.qualified_name, expected.chunk.qualified_name);
10176            assert_eq!(actual.chunk.kind, expected.chunk.kind);
10177            assert_eq!(actual.chunk.start_line, expected.chunk.start_line);
10178            assert_eq!(actual.chunk.end_line, expected.chunk.end_line);
10179            assert_eq!(actual.chunk.exported, expected.chunk.exported);
10180            assert_eq!(actual.chunk.embed_text, expected.chunk.embed_text);
10181            assert_eq!(actual.chunk.snippet, expected.chunk.snippet);
10182            assert_eq!(actual.vector, expected.vector);
10183        }
10184        assert_eq!(loaded.to_bytes(), persisted);
10185        assert_eq!(fingerprint.as_string(), fingerprint_before);
10186    }
10187
10188    #[test]
10189    fn symbol_kind_serialization_roundtrip_includes_file_summary_variant() {
10190        let cases = [
10191            (SymbolKind::Function, 0),
10192            (SymbolKind::Class, 1),
10193            (SymbolKind::Method, 2),
10194            (SymbolKind::Struct, 3),
10195            (SymbolKind::Interface, 4),
10196            (SymbolKind::Enum, 5),
10197            (SymbolKind::TypeAlias, 6),
10198            (SymbolKind::Variable, 7),
10199            (SymbolKind::Heading, 8),
10200            (SymbolKind::FileSummary, 9),
10201        ];
10202
10203        for (kind, encoded) in cases {
10204            assert_eq!(symbol_kind_to_u8(&kind), encoded);
10205            assert_eq!(u8_to_symbol_kind(encoded), kind);
10206        }
10207    }
10208
10209    #[test]
10210    fn test_search_top_k() {
10211        let mut index = SemanticIndex::new(test_project_root(), DEFAULT_DIMENSION);
10212        index.dimension = 3;
10213
10214        // Add entries with known vectors
10215        for (i, name) in ["auth", "database", "handler"].iter().enumerate() {
10216            let mut vec = vec![0.0f32; 3];
10217            vec[i] = 1.0; // orthogonal vectors
10218            index.entries.push(EmbeddingEntry {
10219                chunk: SemanticChunk {
10220                    file: PathBuf::from("/src/lib.rs"),
10221                    name: name.to_string(),
10222                    qualified_name: None,
10223                    kind: SymbolKind::Function,
10224                    start_line: (i * 10 + 1) as u32,
10225                    end_line: (i * 10 + 5) as u32,
10226                    exported: true,
10227                    embed_text: format!("kind:function name:{}", name),
10228                    snippet: format!("fn {}() {{}}", name),
10229                },
10230                norm: vector_norm(&vec),
10231                vector: vec,
10232            });
10233        }
10234
10235        // Query aligned with "auth" (index 0)
10236        let query = vec![0.9, 0.1, 0.0];
10237        let results = index.search(&query, 2);
10238
10239        assert_eq!(results.len(), 2);
10240        assert_eq!(results[0].name, "auth"); // highest score
10241        assert!(results[0].score > results[1].score);
10242    }
10243
10244    #[test]
10245    fn test_empty_index_search() {
10246        let index = SemanticIndex::new(test_project_root(), DEFAULT_DIMENSION);
10247        let results = index.search(&[0.1, 0.2, 0.3], 10);
10248        assert!(results.is_empty());
10249    }
10250
10251    #[test]
10252    fn single_line_symbol_builds_non_empty_snippet() {
10253        let symbol = Symbol {
10254            name: "answer".to_string(),
10255            kind: SymbolKind::Variable,
10256            range: crate::symbols::Range {
10257                start_line: 0,
10258                start_col: 0,
10259                end_line: 0,
10260                end_col: 24,
10261            },
10262            signature: Some("const answer = 42".to_string()),
10263            scope_chain: Vec::new(),
10264            exported: true,
10265            parent: None,
10266        };
10267        let source = "export const answer = 42;\n";
10268
10269        let snippet = build_snippet(&symbol, source);
10270
10271        assert_eq!(snippet, "export const answer = 42;");
10272    }
10273
10274    #[test]
10275    fn metal_chunk_collection_uses_shader_function_boundaries() {
10276        let project_root = Path::new("/project");
10277        let file = project_root.join("sample.metal");
10278        let source = include_str!("../tests/fixtures/sample.metal");
10279        let chunks = collect_file_chunks_from_source(
10280            project_root,
10281            &file,
10282            crate::parser::LangId::Metal,
10283            source,
10284        )
10285        .expect("collect Metal chunks");
10286
10287        let helper = chunks
10288            .iter()
10289            .find(|chunk| chunk.name == "brighten")
10290            .expect("helper chunk");
10291        assert_eq!((helper.start_line, helper.end_line), (3, 5));
10292        assert!(!helper.snippet.contains("brighten_buffer"));
10293
10294        let shader = chunks
10295            .iter()
10296            .find(|chunk| chunk.name == "brighten_buffer")
10297            .expect("shader chunk");
10298        assert_eq!(shader.kind, SymbolKind::Function);
10299        assert_eq!((shader.start_line, shader.end_line), (7, 9));
10300        assert!(shader.snippet.starts_with("kernel void brighten_buffer"));
10301        assert!(shader.snippet.contains("brighten(values[id])"));
10302    }
10303
10304    #[test]
10305    fn cuda_chunk_collection_uses_function_boundaries() {
10306        let project_root = Path::new("/project");
10307        let file = project_root.join("sample.cu");
10308        let source = include_str!("../tests/fixtures/sample.cu");
10309        let chunks = collect_file_chunks_from_source(
10310            project_root,
10311            &file,
10312            crate::parser::LangId::Cuda,
10313            source,
10314        )
10315        .expect("collect CUDA chunks");
10316
10317        let kernel = chunks
10318            .iter()
10319            .find(|chunk| chunk.name == "transform")
10320            .expect("kernel chunk");
10321        assert_eq!(kernel.kind, SymbolKind::Kernel);
10322        assert_eq!((kernel.start_line, kernel.end_line), (4, 7));
10323        assert!(kernel.snippet.contains("scale(data[index])"));
10324        assert!(!kernel.snippet.contains("launch_transform"));
10325
10326        let host = chunks
10327            .iter()
10328            .find(|chunk| chunk.name == "launch_transform")
10329            .expect("host function chunk");
10330        assert_eq!((host.start_line, host.end_line), (9, 11));
10331        assert!(host.snippet.contains("transform<<<grid, block>>>(data)"));
10332    }
10333
10334    #[test]
10335    fn toml_chunk_collection_uses_table_and_key_boundaries() {
10336        let project_root = Path::new("/project");
10337        let file = project_root.join("Cargo.toml");
10338        let source = "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n\n[dependencies.foo]\nversion = \"1\"\n";
10339        let chunks = collect_file_chunks_from_source(
10340            project_root,
10341            &file,
10342            crate::parser::LangId::Toml,
10343            source,
10344        )
10345        .expect("collect TOML chunks");
10346
10347        let package = chunks
10348            .iter()
10349            .find(|chunk| chunk.name == "package")
10350            .expect("package table chunk");
10351        assert_eq!((package.start_line, package.end_line), (0, 2));
10352        assert!(package.snippet.contains("name = \"demo\""));
10353        assert!(!package.snippet.contains("dependencies.foo"));
10354
10355        let name = chunks
10356            .iter()
10357            .find(|chunk| chunk.qualified_name.as_deref() == Some("package.name"))
10358            .expect("nested package.name key chunk");
10359        assert_eq!((name.start_line, name.end_line), (1, 1));
10360        assert_eq!(name.snippet, "name = \"demo\"");
10361
10362        let dependency = chunks
10363            .iter()
10364            .find(|chunk| chunk.name == "dependencies.foo")
10365            .expect("dependency table chunk");
10366        assert_eq!((dependency.start_line, dependency.end_line), (4, 5));
10367    }
10368
10369    #[test]
10370    fn optimized_file_chunk_collection_matches_file_parser_path() {
10371        let project_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
10372        let file = project_root.join("src/semantic_index.rs");
10373        let source = std::fs::read_to_string(&file).unwrap();
10374
10375        let mut legacy_parser = FileParser::new();
10376        let legacy_symbols = legacy_parser.extract_symbols(&file).unwrap();
10377        let legacy_chunks = symbols_to_chunks(&file, &legacy_symbols, &source, &project_root);
10378
10379        let optimized_chunks = collect_file_chunks(&project_root, &file).unwrap();
10380
10381        assert_eq!(
10382            chunk_fingerprint(&optimized_chunks),
10383            chunk_fingerprint(&legacy_chunks)
10384        );
10385    }
10386
10387    #[test]
10388    fn collect_file_chunks_indexes_java_symbols() {
10389        let dir = tempfile::tempdir().unwrap();
10390        let file = dir.path().join("Greeter.java");
10391        std::fs::write(
10392            &file,
10393            r#"package example;
10394
10395public class Greeter {
10396    public String greet(String name) {
10397        return "Hello, " + name;
10398    }
10399}
10400"#,
10401        )
10402        .unwrap();
10403
10404        let chunks = collect_file_chunks(dir.path(), &file).unwrap();
10405
10406        assert!(
10407            !chunks.is_empty(),
10408            "Java file should produce semantic chunks"
10409        );
10410        assert!(
10411            chunks
10412                .iter()
10413                .any(|chunk| chunk.name == "Greeter" && chunk.kind == SymbolKind::Class),
10414            "Java class symbol should be chunked: {chunks:?}"
10415        );
10416        assert!(
10417            chunks
10418                .iter()
10419                .any(|chunk| chunk.name == "greet" && chunk.kind == SymbolKind::Method),
10420            "Java method symbol should be chunked: {chunks:?}"
10421        );
10422    }
10423
10424    fn chunk_fingerprint(
10425        chunks: &[SemanticChunk],
10426    ) -> Vec<(String, SymbolKind, u32, u32, bool, String, String)> {
10427        chunks
10428            .iter()
10429            .map(|chunk| {
10430                (
10431                    chunk.name.clone(),
10432                    chunk.kind.clone(),
10433                    chunk.start_line,
10434                    chunk.end_line,
10435                    chunk.exported,
10436                    chunk.embed_text.clone(),
10437                    chunk.snippet.clone(),
10438                )
10439            })
10440            .collect()
10441    }
10442
10443    #[test]
10444    fn collect_file_chunks_skips_oversized_file() {
10445        let dir = tempfile::tempdir().unwrap();
10446        let big = dir.path().join("huge.ts");
10447        // Just over the cap: a valid TS file that would otherwise yield chunks.
10448        let filler = "export const x = 1;\n"
10449            .repeat(((MAX_SEMANTIC_FILE_BYTES as usize) / "export const x = 1;\n".len()) + 16);
10450        std::fs::write(&big, &filler).unwrap();
10451        assert!(big.metadata().unwrap().len() > MAX_SEMANTIC_FILE_BYTES);
10452
10453        // Oversized → tracked with zero chunks, NOT an error (so the caller keeps
10454        // the file in metadata and freshness skips re-reading it).
10455        let chunks = collect_file_chunks(dir.path(), &big).unwrap();
10456        assert!(chunks.is_empty(), "oversized file must yield no chunks");
10457
10458        // A small file of the same language still produces chunks.
10459        let small = dir.path().join("small.ts");
10460        std::fs::write(&small, "export function foo() { return 1; }\n").unwrap();
10461        let small_chunks = collect_file_chunks(dir.path(), &small).unwrap();
10462        assert!(!small_chunks.is_empty(), "small file should still chunk");
10463    }
10464
10465    #[test]
10466    fn rejects_oversized_dimension_during_deserialization() {
10467        let mut bytes = Vec::new();
10468        bytes.push(1u8);
10469        bytes.extend_from_slice(&((MAX_DIMENSION as u32) + 1).to_le_bytes());
10470        bytes.extend_from_slice(&0u32.to_le_bytes());
10471        bytes.extend_from_slice(&0u32.to_le_bytes());
10472
10473        assert!(SemanticIndex::from_bytes(&bytes, &test_project_root()).is_err());
10474    }
10475
10476    #[test]
10477    fn rejects_oversized_entry_count_during_deserialization() {
10478        let mut bytes = Vec::new();
10479        bytes.push(1u8);
10480        bytes.extend_from_slice(&(DEFAULT_DIMENSION as u32).to_le_bytes());
10481        bytes.extend_from_slice(&((MAX_ENTRIES as u32) + 1).to_le_bytes());
10482        bytes.extend_from_slice(&0u32.to_le_bytes());
10483
10484        assert!(SemanticIndex::from_bytes(&bytes, &test_project_root()).is_err());
10485    }
10486
10487    fn add_invalidation_fixture_entry(index: &mut SemanticIndex, file: PathBuf, ordinal: u64) {
10488        index.entries.push(EmbeddingEntry::new(
10489            SemanticChunk {
10490                file: file.clone(),
10491                name: format!("symbol_{ordinal}"),
10492                qualified_name: None,
10493                kind: SymbolKind::Function,
10494                start_line: ordinal as u32,
10495                end_line: ordinal as u32 + 1,
10496                exported: false,
10497                embed_text: format!("symbol {ordinal}"),
10498                snippet: format!("fn symbol_{ordinal}() {{}}"),
10499            },
10500            vec![ordinal as f32 + 1.0, 1.0],
10501        ));
10502        let mtime = SystemTime::UNIX_EPOCH + Duration::from_secs(ordinal + 1);
10503        index.file_mtimes.insert(file.clone(), mtime);
10504        index.file_sizes.insert(file.clone(), ordinal + 10);
10505        index
10506            .file_hashes
10507            .insert(file, blake3::hash(&ordinal.to_le_bytes()));
10508    }
10509
10510    #[test]
10511    fn freezing_declines_instead_of_panicking_when_a_dirty_path_is_outside_the_root() {
10512        // A delta path outside the root once turned the freeze into a panic:
10513        // the shareability check covered entries, metadata maps and deferred
10514        // files but not the dirty-path set, and the move then hit an expect.
10515        // Under the daemon that panic is a fatal actor exit (exit 4 three
10516        // times on 2026-09-14).
10517        let temp = tempfile::tempdir().unwrap();
10518        let project_root = temp
10519            .path()
10520            .join("owner")
10521            .canonicalize()
10522            .unwrap_or_else(|_| {
10523                fs::create_dir_all(temp.path().join("owner")).unwrap();
10524                temp.path().join("owner").canonicalize().unwrap()
10525            });
10526        let borrower = temp.path().join("borrower");
10527        fs::create_dir_all(&borrower).unwrap();
10528        let mut index = SemanticIndex::new(project_root.clone(), 2);
10529        let file = project_root.join("file_0.rs");
10530        fs::write(&file, "fn symbol_0() {}\n").unwrap();
10531        add_invalidation_fixture_entry(&mut index, file, 0);
10532        let config = SemanticBackendConfig::default();
10533        index.set_fingerprint(SemanticIndexFingerprint::for_config_dimension(&config, 2));
10534        // A fresh index carries no delta set (None means "structural diff");
10535        // seed one the way a refresh does so the stray path is really carried.
10536        index.set_dirty_paths(Some(BTreeSet::from([temp
10537            .path()
10538            .join("elsewhere")
10539            .join("stray.rs")])));
10540        let entries_before = index.entries.len();
10541
10542        let adopted = index.adopt_frozen_base_for_root(&borrower, &config);
10543
10544        assert!(adopted.is_none(), "an unshareable index must stay private");
10545        assert!(index.shared_base.is_none());
10546        assert_eq!(
10547            index.entries.len(),
10548            entries_before,
10549            "the private index survives intact"
10550        );
10551
10552        // The same index with an in-root dirty path freezes normally.
10553        let mut shareable = SemanticIndex::new(project_root.clone(), 2);
10554        let file = project_root.join("file_1.rs");
10555        fs::write(&file, "fn symbol_1() {}\n").unwrap();
10556        add_invalidation_fixture_entry(&mut shareable, file.clone(), 1);
10557        shareable.set_fingerprint(SemanticIndexFingerprint::for_config_dimension(&config, 2));
10558        shareable.set_dirty_paths(Some(BTreeSet::from([file])));
10559        assert!(shareable
10560            .adopt_frozen_base_for_root(&borrower, &config)
10561            .is_some());
10562        assert!(shareable.shared_base.is_some());
10563    }
10564
10565    #[test]
10566    fn batch_invalidation_matches_sequential_calls_with_one_retain_pass() {
10567        let temp = tempfile::tempdir().unwrap();
10568        let project_root = temp.path().canonicalize().unwrap();
10569        let mut source = SemanticIndex::new(project_root.clone(), 2);
10570        let files = (0..8)
10571            .map(|ordinal| {
10572                let file = project_root.join(format!("file_{ordinal}.rs"));
10573                fs::write(&file, format!("fn symbol_{ordinal}() {{}}\n")).unwrap();
10574                add_invalidation_fixture_entry(&mut source, file.clone(), ordinal);
10575                file
10576            })
10577            .collect::<Vec<_>>();
10578        let invalidated = vec![files[1].clone(), files[3].clone(), files[6].clone()];
10579
10580        let shared = Arc::new(source.into_shared_base().ok().unwrap());
10581        let mut shared_batched =
10582            SemanticIndex::from_shared_base(project_root.clone(), Arc::clone(&shared));
10583        shared_batched.invalidate_files(&invalidated);
10584        let mut source = SemanticIndex::from_shared_base(project_root, shared);
10585        source.materialize_shared_base();
10586        let mut sequential = source.clone();
10587        let mut batched = source;
10588        for file in &invalidated {
10589            sequential.invalidate_file(file);
10590        }
10591        batched.invalidate_files(&invalidated);
10592
10593        assert!(sequential.shared_base.is_none());
10594        assert!(batched.shared_base.is_none());
10595        assert!(shared_batched.shared_base.is_none());
10596        assert_eq!(batched.to_bytes(), sequential.to_bytes());
10597        assert_eq!(shared_batched.file_mtimes, batched.file_mtimes);
10598        assert_eq!(shared_batched.file_sizes, batched.file_sizes);
10599        assert_eq!(shared_batched.file_hashes, batched.file_hashes);
10600        assert_eq!(
10601            format!("{:?}", shared_batched.entries),
10602            format!("{:?}", batched.entries)
10603        );
10604        assert_eq!(
10605            sequential.removal_retain_passes_for_test(),
10606            invalidated.len()
10607        );
10608        assert_eq!(batched.removal_retain_passes_for_test(), 1);
10609        assert_eq!(shared_batched.removal_retain_passes_for_test(), 1);
10610    }
10611
10612    #[cfg(unix)]
10613    #[test]
10614    fn batch_invalidation_removes_raw_and_canonical_alias_metadata() {
10615        use std::os::unix::fs::symlink;
10616
10617        let temp = tempfile::tempdir().unwrap();
10618        let project_root = temp.path().canonicalize().unwrap();
10619        let real_dir = project_root.join("real");
10620        let alias_dir = project_root.join("alias");
10621        fs::create_dir(&real_dir).unwrap();
10622        symlink(&real_dir, &alias_dir).unwrap();
10623        let real_file = real_dir.join("lib.rs");
10624        let alias_file = alias_dir.join("lib.rs");
10625        let untouched = project_root.join("untouched.rs");
10626        fs::write(&real_file, "fn aliased() {}\n").unwrap();
10627        fs::write(&untouched, "fn untouched() {}\n").unwrap();
10628        assert_eq!(fs::canonicalize(&alias_file).unwrap(), real_file);
10629
10630        let mut index = SemanticIndex::new(project_root, 2);
10631        add_invalidation_fixture_entry(&mut index, alias_file.clone(), 1);
10632        add_invalidation_fixture_entry(&mut index, real_file.clone(), 2);
10633        add_invalidation_fixture_entry(&mut index, untouched.clone(), 3);
10634        let mut sequential = index.clone();
10635        sequential.invalidate_file(&alias_file);
10636        index.invalidate_files(std::slice::from_ref(&alias_file));
10637
10638        assert_eq!(index.to_bytes(), sequential.to_bytes());
10639        assert!(index
10640            .entries
10641            .iter()
10642            .all(|entry| entry.chunk.file != alias_file && entry.chunk.file != real_file));
10643        assert!(!index.file_mtimes.contains_key(&alias_file));
10644        assert!(!index.file_mtimes.contains_key(&real_file));
10645        assert!(index.file_mtimes.contains_key(&untouched));
10646        assert!(!index.file_sizes.contains_key(&alias_file));
10647        assert!(!index.file_sizes.contains_key(&real_file));
10648        assert!(index.file_sizes.contains_key(&untouched));
10649        assert!(!index.file_hashes.contains_key(&alias_file));
10650        assert!(!index.file_hashes.contains_key(&real_file));
10651        assert!(index.file_hashes.contains_key(&untouched));
10652        assert_eq!(index.removal_retain_passes_for_test(), 1);
10653    }
10654
10655    #[test]
10656    fn invalidate_file_removes_entries_and_mtime() {
10657        let target = PathBuf::from("/src/main.rs");
10658        let mut index = SemanticIndex::new(test_project_root(), DEFAULT_DIMENSION);
10659        index.entries.push(EmbeddingEntry {
10660            chunk: SemanticChunk {
10661                file: target.clone(),
10662                name: "main".to_string(),
10663                qualified_name: None,
10664                kind: SymbolKind::Function,
10665                start_line: 0,
10666                end_line: 1,
10667                exported: false,
10668                embed_text: "main".to_string(),
10669                snippet: "fn main() {}".to_string(),
10670            },
10671            norm: vector_norm(&[1.0; DEFAULT_DIMENSION]),
10672            vector: vec![1.0; DEFAULT_DIMENSION],
10673        });
10674        index
10675            .file_mtimes
10676            .insert(target.clone(), SystemTime::UNIX_EPOCH);
10677        index.file_sizes.insert(target.clone(), 0);
10678
10679        index.invalidate_file(&target);
10680
10681        assert!(index.entries.is_empty());
10682        assert!(!index.file_mtimes.contains_key(&target));
10683        assert!(!index.file_sizes.contains_key(&target));
10684    }
10685
10686    #[test]
10687    fn refresh_missing_changed_file_is_purged_after_collect() {
10688        let temp = tempfile::tempdir().unwrap();
10689        let project_root = temp.path();
10690        let file = project_root.join("src/lib.rs");
10691        fs::create_dir_all(file.parent().unwrap()).unwrap();
10692        write_rust_file(&file, "vanished_symbol");
10693
10694        let mut index = build_test_index(project_root, std::slice::from_ref(&file));
10695        let original_size = *index.file_sizes.get(&file).unwrap();
10696        set_file_metadata(&mut index, &file, SystemTime::UNIX_EPOCH, original_size + 1);
10697        fs::remove_file(&file).unwrap();
10698
10699        let mut embed = test_vector_for_texts;
10700        let mut progress = |_done: usize, _total: usize| {};
10701        let summary = index
10702            .refresh_stale_files(
10703                project_root,
10704                std::slice::from_ref(&file),
10705                &mut embed,
10706                8,
10707                &mut progress,
10708            )
10709            .unwrap();
10710
10711        assert_eq!(summary.changed, 0);
10712        assert_eq!(summary.added, 0);
10713        assert_eq!(summary.deleted, 1);
10714        assert!(index.entries.is_empty());
10715        assert!(!index.file_mtimes.contains_key(&file));
10716        assert!(!index.file_sizes.contains_key(&file));
10717        assert!(!index.file_hashes.contains_key(&file));
10718    }
10719
10720    #[test]
10721    fn refresh_collect_error_for_existing_path_preserves_cached_entry() {
10722        let temp = tempfile::tempdir().unwrap();
10723        let project_root = temp.path();
10724        let file = project_root.join("src/lib.rs");
10725        fs::create_dir_all(file.parent().unwrap()).unwrap();
10726        write_rust_file(&file, "kept_symbol");
10727
10728        let mut index = build_test_index(project_root, std::slice::from_ref(&file));
10729        let original_entry_count = index.entries.len();
10730        let original_mtime = *index.file_mtimes.get(&file).unwrap();
10731        let original_size = *index.file_sizes.get(&file).unwrap();
10732
10733        let stale_mtime = SystemTime::UNIX_EPOCH;
10734        set_file_metadata(&mut index, &file, stale_mtime, original_size + 1);
10735        fs::remove_file(&file).unwrap();
10736        fs::create_dir(&file).unwrap();
10737
10738        let mut embed = test_vector_for_texts;
10739        let mut progress = |_done: usize, _total: usize| {};
10740        let summary = index
10741            .refresh_stale_files(
10742                project_root,
10743                std::slice::from_ref(&file),
10744                &mut embed,
10745                8,
10746                &mut progress,
10747            )
10748            .unwrap();
10749
10750        assert_eq!(summary.changed, 0);
10751        assert_eq!(summary.added, 0);
10752        assert_eq!(summary.deleted, 0);
10753        assert_eq!(index.entries.len(), original_entry_count);
10754        assert!(index
10755            .entries
10756            .iter()
10757            .any(|entry| entry.chunk.name == "kept_symbol"));
10758        assert_eq!(index.file_mtimes.get(&file), Some(&stale_mtime));
10759        assert_ne!(index.file_mtimes.get(&file), Some(&original_mtime));
10760        assert_eq!(index.file_sizes.get(&file), Some(&(original_size + 1)));
10761    }
10762
10763    #[test]
10764    fn refresh_never_indexed_file_error_does_not_record_mtime() {
10765        let temp = tempfile::tempdir().unwrap();
10766        let project_root = temp.path();
10767        let missing = project_root.join("src/missing.rs");
10768        fs::create_dir_all(missing.parent().unwrap()).unwrap();
10769
10770        let mut index = SemanticIndex::new(test_project_root(), DEFAULT_DIMENSION);
10771        let mut embed = test_vector_for_texts;
10772        let mut progress = |_done: usize, _total: usize| {};
10773        let summary = index
10774            .refresh_stale_files(
10775                project_root,
10776                std::slice::from_ref(&missing),
10777                &mut embed,
10778                8,
10779                &mut progress,
10780            )
10781            .unwrap();
10782
10783        assert_eq!(summary.added, 0);
10784        assert_eq!(summary.changed, 0);
10785        assert_eq!(summary.deleted, 0);
10786        assert!(!index.file_mtimes.contains_key(&missing));
10787        assert!(!index.file_sizes.contains_key(&missing));
10788        assert!(index.entries.is_empty());
10789    }
10790
10791    #[test]
10792    fn refresh_reports_added_for_new_files() {
10793        let temp = tempfile::tempdir().unwrap();
10794        let project_root = temp.path();
10795        let existing = project_root.join("src/lib.rs");
10796        let added = project_root.join("src/new.rs");
10797        fs::create_dir_all(existing.parent().unwrap()).unwrap();
10798        write_rust_file(&existing, "existing_symbol");
10799        write_rust_file(&added, "added_symbol");
10800
10801        let mut index = build_test_index(project_root, std::slice::from_ref(&existing));
10802        let mut embed = test_vector_for_texts;
10803        let mut progress = |_done: usize, _total: usize| {};
10804        let summary = index
10805            .refresh_stale_files(
10806                project_root,
10807                &[existing.clone(), added.clone()],
10808                &mut embed,
10809                8,
10810                &mut progress,
10811            )
10812            .unwrap();
10813
10814        assert_eq!(summary.added, 1);
10815        assert_eq!(summary.changed, 0);
10816        assert_eq!(summary.deleted, 0);
10817        assert_eq!(summary.total_processed, 2);
10818        assert!(index.file_mtimes.contains_key(&added));
10819        assert!(index.entries.iter().any(|entry| entry.chunk.file == added));
10820    }
10821
10822    #[test]
10823    fn refresh_reports_deleted_for_removed_files() {
10824        let temp = tempfile::tempdir().unwrap();
10825        let project_root = temp.path();
10826        let deleted = project_root.join("src/deleted.rs");
10827        fs::create_dir_all(deleted.parent().unwrap()).unwrap();
10828        write_rust_file(&deleted, "deleted_symbol");
10829
10830        let mut index = build_test_index(project_root, std::slice::from_ref(&deleted));
10831        fs::remove_file(&deleted).unwrap();
10832
10833        let mut embed = test_vector_for_texts;
10834        let mut progress = |_done: usize, _total: usize| {};
10835        let summary = index
10836            .refresh_stale_files(project_root, &[], &mut embed, 8, &mut progress)
10837            .unwrap();
10838
10839        assert_eq!(summary.deleted, 1);
10840        assert_eq!(summary.changed, 0);
10841        assert_eq!(summary.added, 0);
10842        assert_eq!(summary.total_processed, 1);
10843        assert!(!index.file_mtimes.contains_key(&deleted));
10844        assert!(index.entries.is_empty());
10845    }
10846
10847    #[test]
10848    fn refresh_reports_changed_for_modified_files() {
10849        let temp = tempfile::tempdir().unwrap();
10850        let project_root = temp.path();
10851        let file = project_root.join("src/lib.rs");
10852        fs::create_dir_all(file.parent().unwrap()).unwrap();
10853        write_rust_file(&file, "old_symbol");
10854
10855        let mut index = build_test_index(project_root, std::slice::from_ref(&file));
10856        set_file_metadata(&mut index, &file, SystemTime::UNIX_EPOCH, 0);
10857        write_rust_file(&file, "new_symbol");
10858
10859        let mut embed = test_vector_for_texts;
10860        let mut progress = |_done: usize, _total: usize| {};
10861        let summary = index
10862            .refresh_stale_files(
10863                project_root,
10864                std::slice::from_ref(&file),
10865                &mut embed,
10866                8,
10867                &mut progress,
10868            )
10869            .unwrap();
10870
10871        assert_eq!(summary.changed, 1);
10872        assert_eq!(summary.added, 0);
10873        assert_eq!(summary.deleted, 0);
10874        assert_eq!(summary.total_processed, 1);
10875        assert!(index
10876            .entries
10877            .iter()
10878            .any(|entry| entry.chunk.name == "new_symbol"));
10879        assert!(!index
10880            .entries
10881            .iter()
10882            .any(|entry| entry.chunk.name == "old_symbol"));
10883    }
10884
10885    #[test]
10886    fn refresh_all_clean_reports_zero_counts_and_no_embedding_work() {
10887        let temp = tempfile::tempdir().unwrap();
10888        let project_root = temp.path();
10889        let file = project_root.join("src/lib.rs");
10890        fs::create_dir_all(file.parent().unwrap()).unwrap();
10891        write_rust_file(&file, "clean_symbol");
10892
10893        let mut index = build_test_index(project_root, std::slice::from_ref(&file));
10894        let original_entries = index.entries.len();
10895        let mut embed_called = false;
10896        let mut embed = |texts: Vec<String>| {
10897            embed_called = true;
10898            test_vector_for_texts(texts)
10899        };
10900        let mut progress = |_done: usize, _total: usize| {};
10901        let summary = index
10902            .refresh_stale_files(
10903                project_root,
10904                std::slice::from_ref(&file),
10905                &mut embed,
10906                8,
10907                &mut progress,
10908            )
10909            .unwrap();
10910
10911        assert!(summary.is_noop());
10912        assert_eq!(summary.total_processed, 1);
10913        assert!(!embed_called);
10914        assert_eq!(index.entries.len(), original_entries);
10915    }
10916
10917    #[test]
10918    fn detects_missing_onnx_runtime_from_dynamic_load_error() {
10919        let message = "Failed to load ONNX Runtime shared library libonnxruntime.dylib via dlopen: no such file";
10920
10921        assert!(is_onnx_runtime_unavailable(message));
10922    }
10923
10924    #[test]
10925    fn formats_missing_onnx_runtime_with_install_hint() {
10926        let message = format_embedding_init_error(
10927            "Failed to load ONNX Runtime shared library libonnxruntime.so via dlopen: no such file",
10928        );
10929
10930        assert!(message.starts_with("ONNX Runtime not found. Install via:"));
10931        assert!(message.contains("Original error:"));
10932    }
10933
10934    #[test]
10935    fn qwen_query_request_uses_documented_instruction_shape() {
10936        assert_eq!(
10937            query_embedding_text(
10938                "where is authentication handled",
10939                Some(crate::config::QWEN3_EMBEDDING_MODEL_CARD_RETRIEVAL_TASK),
10940            ),
10941            "Instruct: Given a web search query, retrieve relevant passages that answer the query\nQuery: where is authentication handled"
10942        );
10943        assert_eq!(
10944            query_embedding_text("where is authentication handled", None),
10945            "where is authentication handled"
10946        );
10947    }
10948
10949    #[test]
10950    fn query_instruction_does_not_change_index_fingerprint() {
10951        let mut config = SemanticBackendConfig {
10952            backend: SemanticBackend::OpenAiCompatible,
10953            model: "text-embedding-qwen3-embedding-0.6b".to_string(),
10954            base_url: Some("http://127.0.0.1:1234/v1".to_string()),
10955            ..SemanticBackendConfig::default()
10956        };
10957        let automatic = SemanticIndexFingerprint::for_config_dimension(&config, 1024);
10958        config.query_instruction = "off".to_string();
10959        let off = SemanticIndexFingerprint::for_config_dimension(&config, 1024);
10960        config.query_instruction = crate::config::QWEN3_EMBEDDING_CODE_SEARCH_TASK.to_string();
10961        let literal = SemanticIndexFingerprint::for_config_dimension(&config, 1024);
10962
10963        assert_eq!(automatic.as_string(), off.as_string());
10964        assert_eq!(off.as_string(), literal.as_string());
10965        assert!(automatic.matches(&off));
10966        assert!(off.matches(&literal));
10967    }
10968
10969    #[test]
10970    fn query_embedding_cache_keys_the_text_sent_to_the_server() {
10971        let (base_url, inputs, handle) = start_recording_embedding_server(2);
10972        let config = SemanticBackendConfig {
10973            backend: SemanticBackend::OpenAiCompatible,
10974            model: "text-embedding-qwen3-embedding-0.6b".to_string(),
10975            base_url: Some(base_url),
10976            query_instruction: crate::config::QWEN3_EMBEDDING_CODE_SEARCH_TASK.to_string(),
10977            ..SemanticBackendConfig::default()
10978        };
10979        let budget = QueryBudget::from_config(&config);
10980        let mut model = SemanticEmbeddingModel::from_config(&config).unwrap();
10981
10982        model.embed_query_cached("cache probe", budget).unwrap();
10983        model.query_instruction = None;
10984        model.embed_query_cached("cache probe", budget).unwrap();
10985        model.embed_query_cached("cache probe", budget).unwrap();
10986        handle.join().unwrap();
10987
10988        assert_eq!(model.query_embedding_cache_stats(), (1, 2, 2));
10989        assert_eq!(
10990            *inputs.lock().unwrap(),
10991            vec![
10992                format!(
10993                    "Instruct: {}\nQuery: cache probe",
10994                    crate::config::QWEN3_EMBEDDING_CODE_SEARCH_TASK
10995                ),
10996                "cache probe".to_string(),
10997            ]
10998        );
10999    }
11000
11001    #[test]
11002    fn interactive_query_budget_is_independent_from_build_timeout() {
11003        let mut config = SemanticBackendConfig {
11004            backend: SemanticBackend::OpenAiCompatible,
11005            model: "test-embedding".to_string(),
11006            base_url: Some("http://127.0.0.1:9".to_string()),
11007            api_key_env: None,
11008            timeout_ms: 0,
11009            query_timeout_ms: 0,
11010            max_batch_size: 64,
11011            max_files: 20_000,
11012            ..Default::default()
11013        };
11014
11015        let build_model = SemanticEmbeddingModel::from_config(&config).unwrap();
11016        let query_model = SemanticEmbeddingModel::from_config_for_query(&config).unwrap();
11017        assert_eq!(
11018            build_model.timeout_ms(),
11019            DEFAULT_OPENAI_EMBEDDING_TIMEOUT_MS,
11020            "background build keeps the longer default embedding timeout"
11021        );
11022        assert_eq!(
11023            query_model.timeout_ms(),
11024            DEFAULT_OPENAI_EMBEDDING_TIMEOUT_MS,
11025            "a query-created model remains safe for later background build reuse"
11026        );
11027        assert_eq!(
11028            QueryBudget::from_config(&config).timeout_ms(),
11029            DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS
11030        );
11031
11032        config.timeout_ms = 60_000;
11033        assert_eq!(
11034            QueryBudget::from_config(&config).timeout_ms(),
11035            DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS,
11036            "the build timeout must not affect interactive requests"
11037        );
11038
11039        config.query_timeout_ms = 700;
11040        assert_eq!(QueryBudget::from_config(&config).timeout_ms(), 700);
11041    }
11042
11043    #[test]
11044    fn single_item_build_timeout_is_dead_evidence_without_same_batch_retry() {
11045        let (base_url, requests, handle) =
11046            start_slow_embedding_server(1, Duration::from_millis(300));
11047        let config = SemanticBackendConfig {
11048            backend: SemanticBackend::OpenAiCompatible,
11049            model: "test-embedding".to_string(),
11050            base_url: Some(base_url),
11051            api_key_env: None,
11052            timeout_ms: 100,
11053            query_timeout_ms: DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS,
11054            max_batch_size: 64,
11055            max_files: 20_000,
11056            ..Default::default()
11057        };
11058        let mut model = SemanticEmbeddingModel::from_config(&config).unwrap();
11059
11060        let error = model
11061            .embed(vec!["slow build batch".to_string()])
11062            .expect_err("a single item exceeding the base deadline is dead evidence");
11063        handle.join().expect("slow embedding server");
11064
11065        assert!(embedding_failure_is_transient(&error), "error: {error}");
11066        assert!(
11067            error.contains("single-item request timed out at 100 ms: treating as down"),
11068            "error: {error}"
11069        );
11070        assert_eq!(
11071            requests.load(Ordering::SeqCst),
11072            1,
11073            "a timeout must shrink or terminate rather than retrying the same batch"
11074        );
11075    }
11076
11077    fn programmable_http_config(server: &ProgrammableEmbeddingServer) -> SemanticBackendConfig {
11078        SemanticBackendConfig {
11079            backend: SemanticBackend::OpenAiCompatible,
11080            model: "test-embedding".to_string(),
11081            base_url: Some(server.base_url.clone()),
11082            api_key_env: None,
11083            // The floor leaves a single item on a loaded CI runner (HTTP setup
11084            // plus scheduling is tens of ms there) far below the base deadline:
11085            // a one-item timeout is the "down" verdict, and this suite must
11086            // reach it only from the never-answer arm, never from contention.
11087            timeout_ms: 300,
11088            query_timeout_ms: DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS,
11089            max_batch_size: 64,
11090            max_files: 20_000,
11091            ..Default::default()
11092        }
11093    }
11094
11095    fn embedding_inputs(count: usize) -> Vec<String> {
11096        (0..count).map(|index| format!("chunk {index}")).collect()
11097    }
11098
11099    fn overflow_http_config(server: &OverflowEmbeddingServer) -> SemanticBackendConfig {
11100        SemanticBackendConfig {
11101            backend: SemanticBackend::OpenAiCompatible,
11102            model: "overflow-test-embedding".to_string(),
11103            base_url: Some(server.base_url.clone()),
11104            api_key_env: None,
11105            timeout_ms: 2_000,
11106            query_timeout_ms: DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS,
11107            max_batch_size: 64,
11108            max_files: 20_000,
11109            ..Default::default()
11110        }
11111    }
11112
11113    fn overflow_test_chunk(root: &Path, index: usize, embed_text: String) -> SemanticChunk {
11114        SemanticChunk {
11115            file: root.join(format!("src/file_{index}.rs")),
11116            name: format!("symbol_{index}"),
11117            qualified_name: None,
11118            kind: SymbolKind::Function,
11119            start_line: index as u32,
11120            end_line: index as u32 + 1,
11121            exported: false,
11122            embed_text,
11123            snippet: format!("fn symbol_{index}() {{}}"),
11124        }
11125    }
11126
11127    fn build_chunks_with_model(
11128        root: &Path,
11129        chunks: Vec<SemanticChunk>,
11130        model: &mut SemanticEmbeddingModel,
11131    ) -> Result<SemanticIndex, String> {
11132        let file_metadata = chunks
11133            .iter()
11134            .map(|chunk| {
11135                (
11136                    chunk.file.clone(),
11137                    IndexedFileMetadata {
11138                        mtime: SystemTime::UNIX_EPOCH,
11139                        size: 0,
11140                        content_hash: blake3::hash(b""),
11141                    },
11142                )
11143            })
11144            .collect();
11145        let mut embed = |texts: Vec<String>| model.embed(texts);
11146        let mut should_continue = || true;
11147        SemanticIndex::build_from_chunks(
11148            root,
11149            chunks,
11150            file_metadata,
11151            &mut embed,
11152            64,
11153            Option::<&mut fn(usize, usize)>::None,
11154            &mut should_continue,
11155        )
11156    }
11157
11158    #[test]
11159    fn overflow_build_bisects_and_shrinks_only_rejected_rows() {
11160        let root = tempfile::tempdir().expect("project root");
11161        let server = OverflowEmbeddingServer::rejecting_oversize(240);
11162        let config = overflow_http_config(&server);
11163        let mut model = SemanticEmbeddingModel::from_config(&config).unwrap();
11164        let oversize_indices = HashSet::from([7usize, 31, 58]);
11165        let chunks = (0..64)
11166            .map(|index| {
11167                let body = if oversize_indices.contains(&index) {
11168                    "dense-token ".repeat(80)
11169                } else {
11170                    format!("return value_{index};")
11171                };
11172                overflow_test_chunk(
11173                    root.path(),
11174                    index,
11175                    format!(
11176                        "name:symbol_{index} file:src/file_{index}.rs kind:function name:symbol_{index} signature:fn symbol_{index}() body:{body}"
11177                    ),
11178                )
11179            })
11180            .collect::<Vec<_>>();
11181        let originals = chunks
11182            .iter()
11183            .map(|chunk| (chunk.name.clone(), chunk.embed_text.clone()))
11184            .collect::<HashMap<_, _>>();
11185
11186        let (result, events) = crate::logging::capture_index_events(|| {
11187            let (_guard, scope, mut failure_guard) = begin_semantic_index_build(root.path());
11188            let result = build_chunks_with_model(root.path(), chunks, &mut model);
11189            finish_semantic_index_build(&scope, &mut failure_guard, &result);
11190            result
11191        });
11192        let index = result.unwrap();
11193
11194        assert_eq!(index.entry_count(), 64);
11195        assert_eq!(index.skipped_rows(), 0);
11196        assert!(
11197            events
11198                .iter()
11199                .any(|line| line.contains("kind=build_ready") && line.contains("skipped_rows=0")),
11200            "semantic ready event must disclose zero skipped rows: {events:?}",
11201        );
11202        let mut full_rows = 0usize;
11203        let mut shrunk_rows = 0usize;
11204        for entry in &index.entries {
11205            let original = originals.get(&entry.chunk.name).unwrap();
11206            let index = entry
11207                .chunk
11208                .name
11209                .strip_prefix("symbol_")
11210                .unwrap()
11211                .parse::<usize>()
11212                .unwrap();
11213            if oversize_indices.contains(&index) {
11214                assert!(entry.chunk.embed_text.len() < original.len());
11215                assert!(entry.chunk.embed_text.contains("name:symbol_"));
11216                shrunk_rows += 1;
11217            } else {
11218                assert_eq!(&entry.chunk.embed_text, original);
11219                full_rows += 1;
11220            }
11221            assert_eq!(entry.vector[0], entry.chunk.embed_text.len() as f32);
11222        }
11223        assert_eq!((full_rows, shrunk_rows), (61, 3));
11224        let restored = SemanticIndex::from_bytes(&index.to_bytes(), root.path()).unwrap();
11225        for entry in &restored.entries {
11226            assert_eq!(entry.vector[0], entry.chunk.embed_text.len() as f32);
11227            assert_eq!(
11228                entry.chunk.embed_text,
11229                index
11230                    .entries
11231                    .iter()
11232                    .find(|candidate| candidate.chunk.name == entry.chunk.name)
11233                    .unwrap()
11234                    .chunk
11235                    .embed_text,
11236            );
11237        }
11238
11239        let request_count = server.requests().len();
11240        println!("overflow bisection request_count={request_count}");
11241        assert!(
11242            (8..40).contains(&request_count),
11243            "expected logarithmic bisection rather than 64 single-row probes; request_count={request_count}, request_sizes={:?}",
11244            server
11245                .requests()
11246                .iter()
11247                .map(Vec::len)
11248                .collect::<Vec<_>>(),
11249        );
11250    }
11251
11252    #[test]
11253    fn overflow_build_skips_unshrinkable_row_and_reports_file() {
11254        let root = tempfile::tempdir().expect("project root");
11255        let server = OverflowEmbeddingServer::rejecting_oversize(240);
11256        let config = overflow_http_config(&server);
11257        let mut model = SemanticEmbeddingModel::from_config(&config).unwrap();
11258        let normal = overflow_test_chunk(
11259            root.path(),
11260            0,
11261            "name:normal file:src/normal.rs kind:function name:normal body:return one;".to_string(),
11262        );
11263        let unshrinkable = overflow_test_chunk(root.path(), 1, "A".repeat(3_000));
11264        let skipped_file = unshrinkable.file.display().to_string();
11265        take_test_skipped_row_warnings();
11266
11267        let (result, events) = crate::logging::capture_index_events(|| {
11268            let (_guard, scope, mut failure_guard) = begin_semantic_index_build(root.path());
11269            let result =
11270                build_chunks_with_model(root.path(), vec![normal, unshrinkable], &mut model);
11271            finish_semantic_index_build(&scope, &mut failure_guard, &result);
11272            result
11273        });
11274        let index = result.unwrap();
11275
11276        assert_eq!(index.entry_count(), 1);
11277        assert_eq!(index.skipped_rows(), 1);
11278        assert!(index
11279            .entries
11280            .iter()
11281            .all(|entry| entry.chunk.name != "symbol_1"));
11282        let warnings = take_test_skipped_row_warnings();
11283        assert_eq!(warnings.len(), 1, "warnings={warnings:?}");
11284        assert!(warnings[0].contains("semantic embed skipped row:"));
11285        assert!(warnings[0].contains(&format!("file={skipped_file}")));
11286        assert!(warnings[0].contains("symbol=symbol_1"));
11287        assert!(
11288            events
11289                .iter()
11290                .any(|line| line.contains("kind=build_ready") && line.contains("skipped_rows=1")),
11291            "semantic ready event must disclose skipped rows: {events:?}",
11292        );
11293    }
11294
11295    #[test]
11296    fn unknown_4xx_still_aborts_semantic_build() {
11297        let root = tempfile::tempdir().expect("project root");
11298        let server = OverflowEmbeddingServer::rejecting_all(r#"{"error":"model not found"}"#);
11299        let config = overflow_http_config(&server);
11300        let mut model = SemanticEmbeddingModel::from_config(&config).unwrap();
11301        let chunk = overflow_test_chunk(
11302            root.path(),
11303            0,
11304            format!(
11305                "name:unknown file:src/unknown.rs kind:function name:unknown body:{}",
11306                "payload ".repeat(80)
11307            ),
11308        );
11309
11310        let error = build_chunks_with_model(root.path(), vec![chunk], &mut model).unwrap_err();
11311
11312        assert!(error.contains("HTTP 400 Bad Request"), "{error}");
11313        assert!(error.contains("model not found"), "{error}");
11314        assert!(!error.contains(ROW_TOO_LONG_MARKER_PREFIX), "{error}");
11315    }
11316
11317    #[test]
11318    fn slow_backend_converges_without_being_marked_down() {
11319        // The reporter's shape (2 s/item against a 25 s floor) scaled so the
11320        // test exercises real HTTP deadlines in seconds, not minutes: a 64-item
11321        // batch cannot fit the initial deadline, a 4-item batch can.
11322        let server = ProgrammableEmbeddingServer::start(Duration::from_millis(50));
11323        let config = programmable_http_config(&server);
11324        let mut model = SemanticEmbeddingModel::from_config(&config).unwrap();
11325
11326        for _ in 0..3 {
11327            assert_eq!(model.embed(embedding_inputs(64)).unwrap().len(), 64);
11328        }
11329
11330        assert_eq!(model.adaptive_build_batch_size, config.max_batch_size);
11331        assert!(
11332            server.completed_sizes().contains(&64),
11333            "EMA-scaled deadlines must eventually let a recovered 64-item batch finish; requests={:?}, completed={:?}",
11334            server.request_sizes(),
11335            server.completed_sizes(),
11336        );
11337    }
11338
11339    #[test]
11340    fn never_answering_backend_is_declared_down_within_eleven_base_deadlines() {
11341        let server = ProgrammableEmbeddingServer::start(Duration::ZERO);
11342        server.set_never_answer(true);
11343        let config = programmable_http_config(&server);
11344        let mut model = SemanticEmbeddingModel::from_config(&config).unwrap();
11345        let started = Instant::now();
11346
11347        let error = model
11348            .embed(embedding_inputs(64))
11349            .expect_err("a backend that never answers must reach the singleton dead check");
11350        let elapsed = started.elapsed();
11351
11352        assert!(
11353            error.contains("single-item request timed out at 300 ms: treating as down"),
11354            "error: {error}"
11355        );
11356        assert_eq!(server.request_sizes(), vec![64, 32, 16, 8, 4, 2, 1]);
11357        let protocol_bound = Duration::from_millis(config.timeout_ms * 11);
11358        assert!(
11359            elapsed <= protocol_bound + Duration::from_secs(1),
11360            "never-answer ladder exceeded 11 base deadlines plus scheduler allowance: elapsed={elapsed:?}, protocol_bound={protocol_bound:?}"
11361        );
11362    }
11363
11364    #[test]
11365    fn refused_connection_is_an_immediate_honest_transient_failure() {
11366        let listener = TcpListener::bind("127.0.0.1:0").expect("reserve refused port");
11367        let addr = listener.local_addr().expect("refused port address");
11368        drop(listener);
11369        let config = SemanticBackendConfig {
11370            backend: SemanticBackend::OpenAiCompatible,
11371            model: "test-embedding".to_string(),
11372            base_url: Some(format!("http://{addr}")),
11373            api_key_env: None,
11374            timeout_ms: 500,
11375            max_batch_size: 64,
11376            ..Default::default()
11377        };
11378        let mut model = SemanticEmbeddingModel::from_config(&config).unwrap();
11379        let started = Instant::now();
11380
11381        let error = model
11382            .embed(vec!["connection probe".to_string()])
11383            .expect_err("closed listener must refuse the request");
11384
11385        assert!(embedding_failure_is_transient(&error), "error: {error}");
11386        // Unix answers a closed loopback port with RST, so the request fails at
11387        // connect. Windows Filtering Platform stealth mode drops the SYN instead,
11388        // so the same probe is a connect timeout at the base floor - which the
11389        // one-item rule already reads as down. Either arm is one deadline at most
11390        // and never the same-batch retry ladder.
11391        if cfg!(windows) {
11392            assert!(
11393                error.contains(
11394                    "embedding backend unreachable (connection refused or connect failure)"
11395                ) || error.contains("single-item request timed out at 500 ms: treating as down"),
11396                "error: {error}"
11397            );
11398            assert!(
11399                started.elapsed() < Duration::from_millis(500 * 2),
11400                "a dropped SYN must be judged within one base deadline, not a ladder"
11401            );
11402        } else {
11403            assert!(
11404                error.contains(
11405                    "embedding backend unreachable (connection refused or connect failure)"
11406                ),
11407                "error: {error}"
11408            );
11409            assert!(
11410                started.elapsed() < Duration::from_millis(500),
11411                "connection refusal should not wait through a retry ladder"
11412            );
11413        }
11414    }
11415
11416    #[test]
11417    fn aimd_grows_back_to_configured_max_after_backend_speeds_up() {
11418        let server = ProgrammableEmbeddingServer::start(Duration::from_millis(60));
11419        let config = programmable_http_config(&server);
11420        let mut model = SemanticEmbeddingModel::from_config(&config).unwrap();
11421
11422        assert_eq!(model.embed(embedding_inputs(64)).unwrap().len(), 64);
11423        assert!(
11424            model.adaptive_build_batch_size < config.max_batch_size,
11425            "the initial slowdown should reduce the active batch size"
11426        );
11427
11428        server.set_per_item_delay(Duration::from_millis(2));
11429        for _ in 0..4 {
11430            assert_eq!(model.embed(embedding_inputs(64)).unwrap().len(), 64);
11431            if model.adaptive_build_batch_size == config.max_batch_size {
11432                break;
11433            }
11434        }
11435
11436        assert_eq!(model.adaptive_build_batch_size, config.max_batch_size);
11437    }
11438
11439    #[test]
11440    fn openai_compatible_backend_embeds_with_mock_server() {
11441        let (base_url, handle) = start_mock_http_server(|request_line, path, _body| {
11442            assert!(request_line.starts_with("POST "));
11443            assert_eq!(path, "/v1/embeddings");
11444            "{\"data\":[{\"embedding\":[0.1,0.2,0.3],\"index\":0},{\"embedding\":[0.4,0.5,0.6],\"index\":1}]}".to_string()
11445        });
11446
11447        let config = SemanticBackendConfig {
11448            backend: SemanticBackend::OpenAiCompatible,
11449            model: "test-embedding".to_string(),
11450            base_url: Some(base_url),
11451            api_key_env: None,
11452            timeout_ms: 5_000,
11453            query_timeout_ms: DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS,
11454            max_batch_size: 64,
11455            max_files: 20_000,
11456            ..Default::default()
11457        };
11458
11459        let mut model = SemanticEmbeddingModel::from_config(&config).unwrap();
11460        let vectors = model
11461            .embed(vec!["hello".to_string(), "world".to_string()])
11462            .unwrap();
11463
11464        assert_eq!(vectors, vec![vec![0.1, 0.2, 0.3], vec![0.4, 0.5, 0.6]]);
11465        handle.join().unwrap();
11466    }
11467
11468    /// Regression for issue #36: AFT was sending TWO Content-Type headers
11469    /// on the OpenAI embeddings request — once implicitly via `.json(&body)`
11470    /// and again explicitly via `.header("Content-Type", "application/json")`.
11471    /// reqwest's `.header()` calls `HeaderMap::append`, which produces two
11472    /// headers on the wire. OpenAI's /v1/embeddings endpoint rejects that
11473    /// with `HTTP 400 "you must provide a model parameter"` even though the
11474    /// body actually contains `model`. The fix is to drop the explicit
11475    /// `.header("Content-Type", ...)` call. This test pins that we send
11476    /// exactly one Content-Type header.
11477    #[test]
11478    fn openai_compatible_request_has_single_content_type_header() {
11479        use std::sync::{Arc, Mutex};
11480        let captured: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
11481        let captured_for_thread = Arc::clone(&captured);
11482
11483        let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
11484        let addr = listener.local_addr().expect("local addr");
11485        let handle = thread::spawn(move || {
11486            let (mut stream, _) = listener.accept().expect("accept");
11487            let mut buf = Vec::new();
11488            let mut chunk = [0u8; 4096];
11489            let mut header_end = None;
11490            let mut content_length = 0usize;
11491            loop {
11492                let n = stream.read(&mut chunk).expect("read");
11493                if n == 0 {
11494                    break;
11495                }
11496                buf.extend_from_slice(&chunk[..n]);
11497                if header_end.is_none() {
11498                    if let Some(pos) = buf.windows(4).position(|window| window == b"\r\n\r\n") {
11499                        header_end = Some(pos + 4);
11500                        for line in String::from_utf8_lossy(&buf[..pos + 4]).lines() {
11501                            if let Some(value) = line.strip_prefix("Content-Length:") {
11502                                content_length = value.trim().parse::<usize>().unwrap_or(0);
11503                            }
11504                        }
11505                    }
11506                }
11507                if let Some(end) = header_end {
11508                    if buf.len() >= end + content_length {
11509                        break;
11510                    }
11511                }
11512            }
11513            *captured_for_thread.lock().unwrap() = buf;
11514            let body = "{\"data\":[{\"embedding\":[0.1,0.2,0.3],\"index\":0}]}";
11515            let response = format!(
11516                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
11517                body.len(),
11518                body
11519            );
11520            let _ = stream.write_all(response.as_bytes());
11521        });
11522
11523        let config = SemanticBackendConfig {
11524            backend: SemanticBackend::OpenAiCompatible,
11525            model: "text-embedding-3-small".to_string(),
11526            base_url: Some(format!("http://{}", addr)),
11527            api_key_env: None,
11528            timeout_ms: 5_000,
11529            query_timeout_ms: DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS,
11530            max_batch_size: 64,
11531            max_files: 20_000,
11532            ..Default::default()
11533        };
11534        let mut model = SemanticEmbeddingModel::from_config(&config).unwrap();
11535        let _ = model.embed(vec!["probe".to_string()]).unwrap();
11536        handle.join().unwrap();
11537
11538        let bytes = captured.lock().unwrap().clone();
11539        let request = String::from_utf8_lossy(&bytes);
11540
11541        // Lowercase line counts because HTTP headers are case-insensitive
11542        // and reqwest may emit `content-type` in lowercase under HTTP/2.
11543        let content_type_lines = request
11544            .lines()
11545            .filter(|line| {
11546                let lower = line.to_ascii_lowercase();
11547                lower.starts_with("content-type:")
11548            })
11549            .count();
11550        assert_eq!(
11551            content_type_lines, 1,
11552            "expected exactly one Content-Type header but found {content_type_lines}; full request:\n{request}",
11553        );
11554
11555        // The body must still include the model field — pin this so a future
11556        // change can't accidentally drop `model` while fixing duplicate headers.
11557        assert!(
11558            request.contains(r#""model":"text-embedding-3-small""#),
11559            "request body should contain model field; full request:\n{request}",
11560        );
11561    }
11562
11563    #[test]
11564    fn ollama_backend_embeds_with_mock_server() {
11565        let (base_url, handle) = start_mock_http_server(|request_line, path, _body| {
11566            assert!(request_line.starts_with("POST "));
11567            assert_eq!(path, "/api/embed");
11568            "{\"embeddings\":[[0.7,0.8,0.9],[1.0,1.1,1.2]]}".to_string()
11569        });
11570
11571        let config = SemanticBackendConfig {
11572            backend: SemanticBackend::Ollama,
11573            model: "embeddinggemma".to_string(),
11574            base_url: Some(base_url),
11575            api_key_env: None,
11576            timeout_ms: 5_000,
11577            query_timeout_ms: DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS,
11578            max_batch_size: 64,
11579            max_files: 20_000,
11580            ..Default::default()
11581        };
11582
11583        let mut model = SemanticEmbeddingModel::from_config(&config).unwrap();
11584        let vectors = model
11585            .embed(vec!["hello".to_string(), "world".to_string()])
11586            .unwrap();
11587
11588        assert_eq!(vectors, vec![vec![0.7, 0.8, 0.9], vec![1.0, 1.1, 1.2]]);
11589        handle.join().unwrap();
11590    }
11591
11592    #[test]
11593    fn read_from_disk_rejects_fingerprint_mismatch() {
11594        let storage = tempfile::tempdir().unwrap();
11595        let project_key = "proj";
11596
11597        let project_root = test_project_root();
11598        let file = project_root.join("src/main.rs");
11599        let mut index = SemanticIndex::new(project_root.clone(), DEFAULT_DIMENSION);
11600        index.entries.push(EmbeddingEntry {
11601            chunk: SemanticChunk {
11602                file: file.clone(),
11603                name: "handle_request".to_string(),
11604                qualified_name: None,
11605                kind: SymbolKind::Function,
11606                start_line: 10,
11607                end_line: 25,
11608                exported: true,
11609                embed_text: "file:src/main.rs kind:function name:handle_request".to_string(),
11610                snippet: "fn handle_request() {}".to_string(),
11611            },
11612            norm: vector_norm(&[0.1, 0.2, 0.3]),
11613            vector: vec![0.1, 0.2, 0.3],
11614        });
11615        index.dimension = 3;
11616        index
11617            .file_mtimes
11618            .insert(file.clone(), SystemTime::UNIX_EPOCH);
11619        index.file_sizes.insert(file, 0);
11620        index.set_fingerprint(SemanticIndexFingerprint {
11621            backend: "openai_compatible".to_string(),
11622            model: "test-embedding".to_string(),
11623            base_url: "http://127.0.0.1:1234/v1".to_string(),
11624            dimension: 3,
11625            chunking_version: default_chunking_version(),
11626            ..Default::default()
11627        });
11628        index.write_to_disk(storage.path(), project_key);
11629
11630        let data_path = storage
11631            .path()
11632            .join("semantic")
11633            .join(project_key)
11634            .join("semantic.bin");
11635        let before = fs::read(&data_path).unwrap();
11636
11637        let matching = index.fingerprint().unwrap().as_string();
11638        assert!(SemanticIndex::read_from_disk(
11639            storage.path(),
11640            project_key,
11641            &project_root,
11642            false,
11643            Some(&matching),
11644        )
11645        .is_some());
11646
11647        let mismatched = SemanticIndexFingerprint {
11648            backend: "ollama".to_string(),
11649            model: "embeddinggemma".to_string(),
11650            base_url: "http://127.0.0.1:11434".to_string(),
11651            dimension: 3,
11652            chunking_version: default_chunking_version(),
11653            ..Default::default()
11654        }
11655        .as_string();
11656        assert!(SemanticIndex::read_from_disk(
11657            storage.path(),
11658            project_key,
11659            &project_root,
11660            false,
11661            Some(&mismatched),
11662        )
11663        .is_none());
11664        assert_eq!(fs::read(&data_path).unwrap(), before);
11665    }
11666
11667    #[test]
11668    fn synapse_fingerprint_pin_matches_only_equivalent_alias_at_same_epoch() {
11669        let cached = SemanticIndexFingerprint {
11670            backend: "synapse".to_string(),
11671            model: "configured-model".to_string(),
11672            dimension: 768,
11673            chunking_version: 2,
11674            synapse_fingerprint: Some("fp-old".to_string()),
11675            synapse_table_epoch: Some(9),
11676            ..Default::default()
11677        };
11678        let mut served = cached.clone();
11679        served.synapse_fingerprint = Some("fp-current".to_string());
11680        served.synapse_equivalent_to = vec!["fp-old".to_string()];
11681        assert!(cached.matches_expected(&served.as_string()));
11682
11683        served.synapse_table_epoch = Some(10);
11684        assert!(!cached.matches_expected(&served.as_string()));
11685    }
11686
11687    #[test]
11688    fn fingerprint_mismatch_details_redact_base_url_and_list_changed_fields() {
11689        let cached = SemanticIndexFingerprint {
11690            backend: "openai_compatible".to_string(),
11691            model: "cached-model".to_string(),
11692            base_url: "https://user:secret@example.com/v1/embeddings".to_string(),
11693            dimension: 3,
11694            chunking_version: 2,
11695            ..Default::default()
11696        };
11697        let current = SemanticIndexFingerprint {
11698            backend: "ollama".to_string(),
11699            model: "current-model".to_string(),
11700            base_url: "https://example.org/api/embed".to_string(),
11701            dimension: 4,
11702            chunking_version: 3,
11703            ..Default::default()
11704        };
11705
11706        let details = format_fingerprint_mismatch_details(Some(&cached), &current);
11707
11708        assert!(details.contains("backend kind cached=openai_compatible current=ollama"));
11709        assert!(details.contains("model cached=cached-model current=current-model"));
11710        assert!(details.contains("base_url host cached=example.com current=example.org"));
11711        assert!(details.contains("dimension cached=3 current=4"));
11712        assert!(details.contains("chunking version cached=2 current=3"));
11713        assert!(!details.contains("secret"));
11714        assert!(!details.contains("/v1/embeddings"));
11715        assert!(!details.contains("/api/embed"));
11716    }
11717
11718    #[test]
11719    fn read_from_disk_rejects_v3_cache_for_snippet_rebuild() {
11720        let storage = tempfile::tempdir().unwrap();
11721        let project_key = "proj-v3";
11722        let dir = storage.path().join("semantic").join(project_key);
11723        fs::create_dir_all(&dir).unwrap();
11724
11725        let mut index = SemanticIndex::new(test_project_root(), DEFAULT_DIMENSION);
11726        index.entries.push(EmbeddingEntry {
11727            chunk: SemanticChunk {
11728                file: PathBuf::from("/src/main.rs"),
11729                name: "handle_request".to_string(),
11730                qualified_name: None,
11731                kind: SymbolKind::Function,
11732                start_line: 0,
11733                end_line: 0,
11734                exported: true,
11735                embed_text: "file:src/main.rs kind:function name:handle_request".to_string(),
11736                snippet: "fn handle_request() {}".to_string(),
11737            },
11738            norm: vector_norm(&[0.1, 0.2, 0.3]),
11739            vector: vec![0.1, 0.2, 0.3],
11740        });
11741        index.dimension = 3;
11742        index
11743            .file_mtimes
11744            .insert(PathBuf::from("/src/main.rs"), SystemTime::UNIX_EPOCH);
11745        index.file_sizes.insert(PathBuf::from("/src/main.rs"), 0);
11746        let fingerprint = SemanticIndexFingerprint {
11747            backend: "fastembed".to_string(),
11748            model: "test".to_string(),
11749            base_url: FALLBACK_BACKEND.to_string(),
11750            dimension: 3,
11751            chunking_version: default_chunking_version(),
11752            ..Default::default()
11753        };
11754        index.set_fingerprint(fingerprint.clone());
11755
11756        let mut bytes = index.to_bytes();
11757        bytes[0] = SEMANTIC_INDEX_VERSION_V3;
11758        let data_path = dir.join("semantic.bin");
11759        fs::write(&data_path, &bytes).unwrap();
11760
11761        assert!(SemanticIndex::read_from_disk(
11762            storage.path(),
11763            project_key,
11764            &test_project_root(),
11765            false,
11766            Some(&fingerprint.as_string())
11767        )
11768        .is_none());
11769        assert_eq!(fs::read(&data_path).unwrap(), bytes);
11770    }
11771
11772    fn make_symbol(kind: SymbolKind, name: &str, start: u32, end: u32) -> crate::symbols::Symbol {
11773        crate::symbols::Symbol {
11774            name: name.to_string(),
11775            kind,
11776            range: crate::symbols::Range {
11777                start_line: start,
11778                start_col: 0,
11779                end_line: end,
11780                end_col: 0,
11781            },
11782            signature: None,
11783            scope_chain: Vec::new(),
11784            exported: false,
11785            parent: None,
11786        }
11787    }
11788
11789    #[test]
11790    fn symbols_to_chunks_sets_qualified_name_without_changing_embed_text() {
11791        let project_root = PathBuf::from("/proj");
11792        let file = project_root.join("src/engine.ts");
11793        let source = "class Index {\n}\n";
11794        let mut symbol = make_symbol(SymbolKind::Class, "Index", 0, 1);
11795        symbol.scope_chain = vec!["Engine".to_string()];
11796        symbol.signature = Some("class Index".to_string());
11797        let embed_text = build_embed_text(&symbol, source, &file, &project_root);
11798
11799        let chunks = symbols_to_chunks(&file, &[symbol], source, &project_root);
11800        let chunk = chunks
11801            .iter()
11802            .find(|chunk| chunk.name == "Index")
11803            .expect("class chunk");
11804
11805        assert_eq!(chunk.name, "Index");
11806        assert_eq!(chunk.qualified_name.as_deref(), Some("Engine.Index"));
11807        assert_eq!(chunk.embed_text, embed_text);
11808        assert!(!chunk.embed_text.contains("Engine.Index"));
11809    }
11810
11811    /// Heading symbols (Markdown / HTML headings) must NOT be indexed —
11812    /// they overwhelmingly dominated semantic results even on code-shaped
11813    /// queries because heading prose embeds far more strongly than code
11814    /// chunks. Skipping headings keeps aft_search a code-finder.
11815    #[test]
11816    fn symbols_to_chunks_skips_heading_symbols() {
11817        let project_root = PathBuf::from("/proj");
11818        let file = project_root.join("README.md");
11819        let source = "# Title\n\nbody text\n\n## Section\n\nmore text\n";
11820
11821        let symbols = vec![
11822            make_symbol(SymbolKind::Heading, "Title", 0, 2),
11823            make_symbol(SymbolKind::Heading, "Section", 4, 6),
11824        ];
11825
11826        let chunks = symbols_to_chunks(&file, &symbols, source, &project_root);
11827        assert!(
11828            chunks.is_empty(),
11829            "Heading symbols must be filtered out before embedding; got {} chunk(s)",
11830            chunks.len()
11831        );
11832    }
11833
11834    /// A symbol with an enormous signature (e.g. a YAML/Kubernetes CronJob
11835    /// whose inline `command:` script is parsed into the signature) must not
11836    /// produce an embed_text that overflows the embedding backend's physical
11837    /// batch. Before the clamp, the unbounded `signature:` append created a
11838    /// multi-KB input that aborted the whole index build and degraded every
11839    /// search to lexical-only.
11840    #[test]
11841    fn build_embed_text_clamps_oversized_signature() {
11842        let project_root = PathBuf::from("/proj");
11843        let file = project_root.join("cronjob.yaml");
11844        let huge_sig = "kubectl ".repeat(2000); // ~16 KB
11845        let source = "apiVersion: batch/v1\nkind: CronJob\n";
11846
11847        let mut symbol = make_symbol(SymbolKind::Class, "cluster-janitor", 0, 1);
11848        symbol.signature = Some(huge_sig);
11849
11850        let text = build_embed_text(&symbol, source, &file, &project_root);
11851        assert!(
11852            text.chars().count() <= MAX_EMBED_TEXT_CHARS,
11853            "embed_text must be clamped to {} chars, got {}",
11854            MAX_EMBED_TEXT_CHARS,
11855            text.chars().count()
11856        );
11857    }
11858
11859    #[test]
11860    fn embed_text_caps_resolve_per_backend_without_changing_defaults() {
11861        let defaults = EmbedTextCaps::default();
11862
11863        let mut local = SemanticBackendConfig {
11864            max_input_tokens: Some(960),
11865            ..SemanticBackendConfig::default()
11866        };
11867        assert_eq!(EmbedTextCaps::from_config(&local), defaults);
11868
11869        local.backend = SemanticBackend::OpenAiCompatible;
11870        local.base_url = Some("http://127.0.0.1:1234/v1".to_string());
11871        local.max_input_tokens = None;
11872        assert_eq!(EmbedTextCaps::from_config(&local), defaults);
11873
11874        local.max_input_tokens = Some(531);
11875        let expanded = EmbedTextCaps::from_config(&local);
11876        assert_eq!(expanded.signature_chars, 400);
11877        assert_eq!(expanded.body_lines, usize::MAX);
11878        assert_eq!(expanded.body_chars, 1001);
11879        assert_eq!(expanded.total_chars, 1858);
11880
11881        for backend in [SemanticBackend::Ollama, SemanticBackend::Synapse] {
11882            local.backend = backend;
11883            assert_eq!(EmbedTextCaps::from_config(&local), expanded);
11884        }
11885    }
11886
11887    #[test]
11888    fn semantic_fingerprint_changes_when_embed_text_caps_change() {
11889        let mut config = SemanticBackendConfig {
11890            backend: SemanticBackend::OpenAiCompatible,
11891            model: "test-embedding".to_string(),
11892            base_url: Some("http://127.0.0.1:1234/v1".to_string()),
11893            ..SemanticBackendConfig::default()
11894        };
11895        let legacy = SemanticIndexFingerprint::for_config_dimension(&config, 1024);
11896
11897        config.max_input_tokens = Some(531);
11898        let expanded = SemanticIndexFingerprint::for_config_dimension(&config, 1024);
11899
11900        assert_ne!(legacy.embed_text_caps, expanded.embed_text_caps);
11901        assert_ne!(legacy.as_string(), expanded.as_string());
11902        assert!(!legacy.matches(&expanded));
11903    }
11904
11905    #[test]
11906    fn file_summary_embed_text_is_independent_of_symbol_caps() {
11907        let project_root = PathBuf::from("/proj");
11908        let file = project_root.join("src/long.rs");
11909        let source = "//! module docs\npub fn exported() {}\n";
11910        let mut symbol = make_symbol(SymbolKind::Function, "exported", 1, 1);
11911        symbol.exported = true;
11912        symbol.signature = Some("pub fn exported()".to_string());
11913
11914        let legacy = symbols_to_chunks_with_caps(
11915            &file,
11916            std::slice::from_ref(&symbol),
11917            source,
11918            &project_root,
11919            EmbedTextCaps::default(),
11920        );
11921        let expanded = symbols_to_chunks_with_caps(
11922            &file,
11923            &[symbol],
11924            source,
11925            &project_root,
11926            EmbedTextCaps {
11927                signature_chars: 400,
11928                body_lines: usize::MAX,
11929                body_chars: 2500,
11930                total_chars: 3357,
11931            },
11932        );
11933
11934        let legacy_summary = legacy
11935            .iter()
11936            .find(|chunk| chunk.kind == SymbolKind::FileSummary)
11937            .expect("legacy file summary");
11938        let expanded_summary = expanded
11939            .iter()
11940            .find(|chunk| chunk.kind == SymbolKind::FileSummary)
11941            .expect("expanded file summary");
11942        assert_eq!(legacy_summary.embed_text, expanded_summary.embed_text);
11943    }
11944
11945    #[test]
11946    fn unbounded_chunk_caps_preserve_full_signature_and_body() {
11947        let project_root = PathBuf::from("/proj");
11948        let file = project_root.join("long.rs");
11949        let source = (0..20)
11950            .map(|line| format!("line_{line:02}_{}", "body".repeat(20)))
11951            .collect::<Vec<_>>()
11952            .join("\n");
11953        let mut symbol = make_symbol(SymbolKind::Function, "long_function", 0, 19);
11954        symbol.signature = Some(format!(
11955            "fn long_function({}) SIGNATURE_END",
11956            "x".repeat(500)
11957        ));
11958        let line_cache = SourceLineCache::new(&source);
11959
11960        let today = build_embed_text_with_lines_and_caps(
11961            &symbol,
11962            &line_cache,
11963            &file,
11964            &project_root,
11965            EmbedTextCaps::default(),
11966        );
11967        let full = build_embed_text_with_lines_and_caps(
11968            &symbol,
11969            &line_cache,
11970            &file,
11971            &project_root,
11972            EmbedTextCaps {
11973                signature_chars: usize::MAX,
11974                body_lines: usize::MAX,
11975                body_chars: usize::MAX,
11976                total_chars: usize::MAX,
11977            },
11978        );
11979
11980        assert!(!today.contains("SIGNATURE_END"));
11981        assert!(!today.contains("line_19"));
11982        assert!(full.contains("SIGNATURE_END"));
11983        assert!(full.contains("line_19"));
11984        assert!(full.len() > today.len());
11985    }
11986
11987    /// Code symbols (functions, classes, methods, structs, etc.) must still
11988    /// be indexed alongside the heading skip — otherwise we'd starve the
11989    /// index entirely.
11990    #[test]
11991    fn symbols_to_chunks_keeps_code_symbols_alongside_skipped_headings() {
11992        let project_root = PathBuf::from("/proj");
11993        let file = project_root.join("src/lib.rs");
11994        let source = "pub fn handle_request() -> bool {\n    true\n}\n";
11995
11996        let symbols = vec![
11997            // A heading mixed in (e.g. from a doc comment block elsewhere).
11998            make_symbol(SymbolKind::Heading, "doc heading", 0, 1),
11999            make_symbol(SymbolKind::Function, "handle_request", 0, 2),
12000            make_symbol(SymbolKind::Struct, "AuthService", 4, 6),
12001        ];
12002
12003        let chunks = symbols_to_chunks(&file, &symbols, source, &project_root);
12004        assert_eq!(
12005            chunks.len(),
12006            3,
12007            "Expected file-summary + 2 code chunks (Function + Struct), got {}",
12008            chunks.len()
12009        );
12010        let names: Vec<&str> = chunks.iter().map(|c| c.name.as_str()).collect();
12011        assert!(chunks
12012            .iter()
12013            .any(|chunk| matches!(chunk.kind, SymbolKind::FileSummary)));
12014        assert!(names.contains(&"handle_request"));
12015        assert!(names.contains(&"AuthService"));
12016        assert!(
12017            !names.contains(&"doc heading"),
12018            "Heading symbol leaked into chunks: {names:?}"
12019        );
12020    }
12021
12022    #[test]
12023    fn validate_ssrf_allows_loopback_hostnames() {
12024        // Loopback hostnames are explicitly allowed so self-hosted backends
12025        // (Ollama at http://localhost:11434) work at their default config.
12026        for host in &[
12027            "http://localhost",
12028            "http://localhost:8080",
12029            "http://localhost:11434", // Ollama default
12030            "http://localhost.localdomain",
12031            "http://foo.localhost",
12032        ] {
12033            assert!(
12034                validate_base_url_no_ssrf(host).is_ok(),
12035                "Expected {host} to be allowed (loopback), got: {:?}",
12036                validate_base_url_no_ssrf(host)
12037            );
12038        }
12039    }
12040
12041    #[test]
12042    fn validate_ssrf_allows_loopback_ips() {
12043        // 127.0.0.0/8 is loopback — by definition same-machine and not an
12044        // SSRF target. Allow it so Ollama at http://127.0.0.1:11434 works.
12045        for url in &[
12046            "http://127.0.0.1",
12047            "http://127.0.0.1:11434", // Ollama default
12048            "http://127.0.0.1:8080",
12049            "http://127.1.2.3",
12050        ] {
12051            let result = validate_base_url_no_ssrf(url);
12052            assert!(
12053                result.is_ok(),
12054                "Expected {url} to be allowed (loopback), got: {:?}",
12055                result
12056            );
12057        }
12058    }
12059
12060    #[test]
12061    fn validate_ssrf_rejects_private_non_loopback_ips() {
12062        // Non-loopback private/reserved IPs remain rejected — homelab/intranet
12063        // services on LAN IPs are real SSRF targets even though the user
12064        // configured them. Users who want this can opt in by binding the
12065        // service to a public-routable address.
12066        for url in &[
12067            "http://192.168.1.1",
12068            "http://10.0.0.1",
12069            "http://172.16.0.1",
12070            "http://169.254.169.254",
12071            "http://100.64.0.1",
12072        ] {
12073            let result = validate_base_url_no_ssrf(url);
12074            assert!(
12075                result.is_err(),
12076                "Expected {url} to be rejected (non-loopback private), got: {:?}",
12077                result
12078            );
12079        }
12080    }
12081
12082    #[test]
12083    fn validate_ssrf_rejects_mdns_local_hostnames() {
12084        // mDNS .local hostnames typically resolve to LAN devices, not
12085        // loopback. Rejecting them before DNS lookup gives a clearer error.
12086        for host in &[
12087            "http://printer.local",
12088            "http://nas.local:8080",
12089            "http://homelab.local",
12090        ] {
12091            let result = validate_base_url_no_ssrf(host);
12092            assert!(
12093                result.is_err(),
12094                "Expected {host} to be rejected (mDNS), got: {:?}",
12095                result
12096            );
12097        }
12098    }
12099
12100    #[test]
12101    fn normalize_base_url_allows_localhost_for_tests() {
12102        // normalize_base_url itself should NOT block localhost — only
12103        // validate_base_url_no_ssrf does. Tests construct backends directly.
12104        assert!(normalize_base_url("http://127.0.0.1:9999").is_ok());
12105        assert!(normalize_base_url("http://localhost:8080").is_ok());
12106    }
12107
12108    #[test]
12109    fn ssrf_guard_blocks_reserved_ranges_but_allows_loopback() {
12110        use std::net::IpAddr;
12111        let blocked = |s: &str| is_private_non_loopback_ip(&s.parse::<IpAddr>().unwrap());
12112
12113        // Private / link-local / CGNAT — blocked (unchanged behavior).
12114        assert!(blocked("10.0.0.1"));
12115        assert!(blocked("192.168.1.1"));
12116        assert!(blocked("169.254.0.1"));
12117        assert!(blocked("100.64.0.1"));
12118        // Newly covered by delegating to url_fetch's complete list:
12119        assert!(
12120            blocked("198.18.0.1"),
12121            "RFC2544 benchmark range must be blocked"
12122        );
12123        assert!(blocked("224.0.0.1"), "multicast must be blocked");
12124        assert!(blocked("fc00::1"), "IPv6 ULA must be blocked");
12125        assert!(blocked("fe80::1"), "IPv6 link-local must be blocked");
12126
12127        // Loopback — allowed (local Ollama endpoint), incl. IPv4-mapped form.
12128        assert!(!blocked("127.0.0.1"), "loopback must stay allowed");
12129        assert!(!blocked("::1"), "IPv6 loopback must stay allowed");
12130        assert!(
12131            !blocked("::ffff:127.0.0.1"),
12132            "IPv4-mapped loopback must stay allowed (matches prior carve-out)"
12133        );
12134
12135        // A public address must NOT be flagged.
12136        assert!(!blocked("8.8.8.8"));
12137    }
12138
12139    /// Pin the user-facing wording of the ONNX version-mismatch error.
12140    /// The auto-fix path MUST be listed first because it's the only safe
12141    /// option that doesn't require sudo or risk breaking other apps that
12142    /// link the system library. Regression of any of these strings would
12143    /// either mislead users (system rm before auto-fix) or break the
12144    /// `aft doctor --fix` discovery path.
12145    #[test]
12146    fn ort_mismatch_message_recommends_auto_fix_first() {
12147        let msg =
12148            format_ort_version_mismatch("1.9.0", "/usr/lib/x86_64-linux-gnu/libonnxruntime.so");
12149
12150        // The reported version and path must appear verbatim.
12151        assert!(
12152            msg.contains("v1.9.0"),
12153            "should report detected version: {msg}"
12154        );
12155        assert!(
12156            msg.contains("/usr/lib/x86_64-linux-gnu/libonnxruntime.so"),
12157            "should report system path: {msg}"
12158        );
12159        assert!(msg.contains("v1.20+"), "should state requirement: {msg}");
12160
12161        // Solution ordering: auto-fix is #1, system rm is #2, install is #3.
12162        let auto_fix_pos = msg
12163            .find("Auto-fix")
12164            .expect("Auto-fix solution missing — users won't discover --fix");
12165        let remove_pos = msg
12166            .find("Remove the old library")
12167            .expect("system-rm solution missing");
12168        assert!(
12169            auto_fix_pos < remove_pos,
12170            "Auto-fix must come before manual rm — see PR comment thread"
12171        );
12172
12173        // The auto-fix command must be runnable as-is on a fresh system.
12174        assert!(
12175            msg.contains("npx @cortexkit/aft doctor --fix"),
12176            "auto-fix command must be present and copy-pasteable: {msg}"
12177        );
12178    }
12179
12180    #[cfg(any(target_os = "linux", target_os = "macos"))]
12181    #[test]
12182    fn loaded_ort_version_detection_prefers_actual_loaded_library_path() {
12183        let requested = "libonnxruntime.so";
12184        let actual = "/usr/local/lib/libonnxruntime.so.1.19.0";
12185
12186        assert_eq!(detect_ort_version_from_path(requested), None);
12187        let (version, source) =
12188            detect_ort_version_from_resolved_or_requested(Some(actual.to_string()), requested);
12189
12190        assert_eq!(version, Some("1.19.0".to_string()));
12191        assert_eq!(source, actual);
12192
12193        let msg = format_ort_version_mismatch(&version.unwrap(), &source);
12194        assert!(msg.contains("v1.19.0"));
12195        assert!(msg.contains(actual));
12196    }
12197
12198    /// macOS dylib paths must not produce a malformed message when the
12199    /// system path lacks a trailing slash. This is a regression guard
12200    /// for the "{}\n{}" format string contract.
12201    #[test]
12202    fn ort_mismatch_message_handles_macos_dylib_path() {
12203        let msg = format_ort_version_mismatch("1.9.0", "/opt/homebrew/lib/libonnxruntime.dylib");
12204        assert!(msg.contains("v1.9.0"));
12205        assert!(msg.contains("/opt/homebrew/lib/libonnxruntime.dylib"));
12206        // The dylib path must appear in the auto-fix paragraph (single
12207        // quotes around it) AND in the manual-rm paragraph; verify
12208        // both placements survived the format string.
12209        assert!(
12210            msg.contains("'/opt/homebrew/lib/libonnxruntime.dylib'"),
12211            "system path should be quoted in the auto-fix sentence: {msg}"
12212        );
12213    }
12214
12215    // ── managed ONNX Runtime resolver tests ──────────────────────────────────
12216
12217    /// Build a fake `<storage>/onnxruntime/<version>/<libname>` tree. Returns
12218    /// the storage root. `lib_name` is the platform library filename the
12219    /// resolver looks for.
12220    fn fake_managed_ort_tree(storage: &std::path::Path, lib_name: &str, versions: &[(&str, bool)]) {
12221        for (version, has_lib) in versions {
12222            let dir = storage.join("onnxruntime").join(version);
12223            std::fs::create_dir_all(&dir).unwrap();
12224            if *has_lib {
12225                std::fs::write(dir.join(lib_name), b"fake-ort").unwrap();
12226            }
12227        }
12228    }
12229
12230    #[test]
12231    fn managed_ort_resolver_picks_highest_compatible_version() {
12232        let _env_lock = crate::test_env::process_env_lock();
12233        let storage = tempfile::tempdir().unwrap();
12234        fake_managed_ort_tree(
12235            storage.path(),
12236            MANAGED_ORT_LIB_NAME,
12237            &[
12238                ("1.19.0", true), // below the 1.20 floor — must be ignored
12239                ("1.20.1", true),
12240                ("1.24.4", true), // highest compatible — must win
12241                ("1.23.0", true),
12242            ],
12243        );
12244        let found = find_managed_onnx_runtime(storage.path()).expect("resolver finds a runtime");
12245        assert_eq!(
12246            found,
12247            storage
12248                .path()
12249                .join("onnxruntime")
12250                .join("1.24.4")
12251                .join(MANAGED_ORT_LIB_NAME)
12252        );
12253    }
12254
12255    #[test]
12256    fn managed_ort_resolver_ignores_non_version_and_pre_120_dirs() {
12257        let _env_lock = crate::test_env::process_env_lock();
12258        let storage = tempfile::tempdir().unwrap();
12259        fake_managed_ort_tree(
12260            storage.path(),
12261            MANAGED_ORT_LIB_NAME,
12262            &[
12263                ("1.19.0", true),     // pre-1.20 — ignored
12264                ("1.24.4.tmp", true), // not a parseable version — ignored
12265                ("latest", true),     // not a version — ignored
12266                ("1.24.4", false),    // compatible but no library file — ignored
12267            ],
12268        );
12269        assert_eq!(
12270            find_managed_onnx_runtime(storage.path()),
12271            None,
12272            "no compatible version with a library file should resolve"
12273        );
12274    }
12275
12276    #[test]
12277    fn empty_onnx_runtime_override_is_unset_with_an_injected_lookup() {
12278        assert!(!onnx_runtime_override_configured_with(|key| {
12279            assert_eq!(key, "ORT_DYLIB_PATH");
12280            Some(std::ffi::OsString::new())
12281        }));
12282        assert!(onnx_runtime_override_configured_with(|_| Some(
12283            std::ffi::OsString::from("/runtime/libonnxruntime.so")
12284        )));
12285    }
12286
12287    #[test]
12288    fn managed_ort_resolver_absent_tree_falls_through() {
12289        let _env_lock = crate::test_env::process_env_lock();
12290        let storage = tempfile::tempdir().unwrap();
12291        // No onnxruntime/ dir at all.
12292        assert_eq!(find_managed_onnx_runtime(storage.path()), None);
12293        // Empty onnxruntime/ dir.
12294        std::fs::create_dir_all(storage.path().join("onnxruntime")).unwrap();
12295        assert_eq!(find_managed_onnx_runtime(storage.path()), None);
12296    }
12297
12298    #[test]
12299    fn managed_ort_resolver_prefers_version_root_over_lib_subdir() {
12300        let _env_lock = crate::test_env::process_env_lock();
12301        let storage = tempfile::tempdir().unwrap();
12302        let version_dir = storage.path().join("onnxruntime").join("1.24.4");
12303        std::fs::create_dir_all(version_dir.join("lib")).unwrap();
12304        // Both the version root and the lib/ subdir hold the library; the root
12305        // must win (mirrors resolveCachedOnnxRuntimeDir).
12306        std::fs::write(version_dir.join(MANAGED_ORT_LIB_NAME), b"root").unwrap();
12307        std::fs::write(version_dir.join("lib").join(MANAGED_ORT_LIB_NAME), b"lib").unwrap();
12308        let found = find_managed_onnx_runtime(storage.path()).expect("resolver finds a runtime");
12309        assert_eq!(found, version_dir.join(MANAGED_ORT_LIB_NAME));
12310    }
12311
12312    #[test]
12313    fn managed_ort_resolver_accepts_lib_subdir_only() {
12314        let _env_lock = crate::test_env::process_env_lock();
12315        let storage = tempfile::tempdir().unwrap();
12316        let version_dir = storage.path().join("onnxruntime").join("1.24.4");
12317        std::fs::create_dir_all(version_dir.join("lib")).unwrap();
12318        // Library only under lib/ (manual Microsoft-archive install, #71).
12319        std::fs::write(version_dir.join("lib").join(MANAGED_ORT_LIB_NAME), b"lib").unwrap();
12320        let found = find_managed_onnx_runtime(storage.path()).expect("resolver finds a runtime");
12321        assert_eq!(found, version_dir.join("lib").join(MANAGED_ORT_LIB_NAME));
12322    }
12323
12324    #[test]
12325    fn managed_ort_resolver_pre_set_env_short_circuits_without_reading_tree() {
12326        let _env_lock = crate::test_env::process_env_lock();
12327        let storage = tempfile::tempdir().unwrap();
12328        // Plant a poison dir that would panic the resolver if it were read:
12329        // a version dir whose name is a valid version but whose library file is
12330        // a directory (so `is_file()` would be false) — harmless, but the point
12331        // is the resolver must never even look.
12332        let poison = storage.path().join("onnxruntime").join("1.24.4");
12333        std::fs::create_dir_all(poison.join(MANAGED_ORT_LIB_NAME)).unwrap();
12334
12335        let before = MANAGED_ORT_PROBE_READS.load(Ordering::Relaxed);
12336        // Pre-set ORT_DYLIB_PATH — the resolver must not run at all.
12337        std::env::set_var("ORT_DYLIB_PATH", "/explicit/override/libonnxruntime.so");
12338        resolve_managed_onnx_runtime(storage.path());
12339        std::env::remove_var("ORT_DYLIB_PATH");
12340        assert_eq!(
12341            MANAGED_ORT_PROBE_READS.load(Ordering::Relaxed),
12342            before,
12343            "resolver must not read the storage tree when ORT_DYLIB_PATH is pre-set"
12344        );
12345    }
12346
12347    #[test]
12348    fn cancelled_build_stops_before_the_next_embed_batch() {
12349        let project = tempfile::tempdir().expect("project directory");
12350        let files = (0..16)
12351            .map(|index| {
12352                let path = project.path().join(format!("batch_{index}.rs"));
12353                std::fs::write(&path, format!("pub fn batch_symbol_{index}() {{}}\n"))
12354                    .expect("write source");
12355                path
12356            })
12357            .collect::<Vec<_>>();
12358        let cancelled = std::sync::atomic::AtomicBool::new(false);
12359        let embed_calls = AtomicUsize::new(0);
12360        let total_chunks = AtomicUsize::new(0);
12361        let mut embed = |texts: Vec<String>| {
12362            let call = embed_calls.fetch_add(1, Ordering::SeqCst) + 1;
12363            assert_eq!(texts.len(), 1, "one chunk per mocked batch");
12364            if call == 1 {
12365                cancelled.store(true, Ordering::SeqCst);
12366            }
12367            Ok(vec![vec![1.0, 2.0, 3.0]])
12368        };
12369        let mut progress = |done: usize, total: usize| {
12370            assert!(done <= total);
12371            total_chunks.store(total, Ordering::SeqCst);
12372        };
12373        let mut should_continue = || !cancelled.load(Ordering::SeqCst);
12374
12375        let error = SemanticIndex::build_with_progress_and_cancellation(
12376            project.path(),
12377            &files,
12378            &mut embed,
12379            1,
12380            &mut progress,
12381            &mut should_continue,
12382        )
12383        .expect_err("the second batch boundary observes cancellation");
12384
12385        let total_chunks = total_chunks.load(Ordering::SeqCst);
12386        assert!(error.contains("semantic build superseded"));
12387        assert_eq!(embed_calls.load(Ordering::SeqCst), 1);
12388        assert!(
12389            total_chunks > 4,
12390            "fixture must contain enough chunks to demonstrate an early stop, got {total_chunks}"
12391        );
12392    }
12393
12394    #[test]
12395    fn managed_ort_resolver_sets_env_when_found() {
12396        let _env_lock = crate::test_env::process_env_lock();
12397        let storage = tempfile::tempdir().unwrap();
12398        fake_managed_ort_tree(storage.path(), MANAGED_ORT_LIB_NAME, &[("1.24.4", true)]);
12399        std::env::remove_var("ORT_DYLIB_PATH");
12400        resolve_managed_onnx_runtime(storage.path());
12401        let set = std::env::var_os("ORT_DYLIB_PATH").expect("resolver sets ORT_DYLIB_PATH");
12402        assert_eq!(
12403            PathBuf::from(set),
12404            storage
12405                .path()
12406                .join("onnxruntime")
12407                .join("1.24.4")
12408                .join(MANAGED_ORT_LIB_NAME)
12409        );
12410        std::env::remove_var("ORT_DYLIB_PATH");
12411    }
12412}