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::collections::{BTreeSet, HashMap, HashSet, VecDeque};
18use std::env;
19use std::error::Error;
20use std::fmt::Display;
21use std::fs::{self, OpenOptions};
22use std::io::{self, BufReader, BufWriter, Cursor, Read, Seek, SeekFrom, Write};
23use std::path::{Path, PathBuf};
24use std::sync::atomic::{AtomicUsize, Ordering};
25use std::sync::{Arc, Mutex, OnceLock, Weak};
26use std::time::{Duration, Instant, SystemTime};
27use url::Url;
28
29const DEFAULT_DIMENSION: usize = 384;
30const MAX_ENTRIES: usize = 1_000_000;
31// Covers high-dimensional backends such as OpenAI text-embedding-3-large (3072)
32// and common local models (4096) while keeping a bounded supported shape.
33const MAX_DIMENSION: usize = 4096;
34const F32_BYTES: usize = std::mem::size_of::<f32>();
35const HEADER_BYTES_V1: usize = 9;
36const HEADER_BYTES_V2: usize = 13;
37fn begin_semantic_index_build(
38    project_root: &Path,
39) -> (
40    Option<crate::logging::IndexBuildGuard>,
41    crate::logging::IndexBuildScope,
42    crate::logging::IndexBuildFailureGuard,
43) {
44    if let Some(scope) = crate::logging::current_index_build() {
45        if scope.plane == crate::logging::IndexPlane::Semantic {
46            return (None, scope, crate::logging::IndexBuildFailureGuard::new());
47        }
48    }
49    let key = crate::search_index::artifact_cache_key(project_root);
50    let scope = crate::logging::IndexBuildScope::new(
51        crate::logging::IndexPlane::Semantic,
52        project_root,
53        key,
54    );
55    let guard = crate::logging::install_index_build(scope.clone());
56    crate::logging::log_index_event(crate::logging::IndexEvent::from_scope(
57        crate::logging::IndexEventKind::BuildStarted,
58        &scope,
59    ));
60    (
61        Some(guard),
62        scope,
63        crate::logging::IndexBuildFailureGuard::new(),
64    )
65}
66
67fn finish_semantic_index_build(
68    scope: &crate::logging::IndexBuildScope,
69    failure_guard: &mut crate::logging::IndexBuildFailureGuard,
70    result: &Result<SemanticIndex, String>,
71) {
72    match result {
73        Ok(index) => {
74            crate::logging::log_index_event(
75                crate::logging::IndexEvent::from_scope(
76                    crate::logging::IndexEventKind::BuildReady,
77                    scope,
78                )
79                .field("elapsed_ms", scope.elapsed_ms())
80                .field("files", index.file_mtimes.len())
81                .field("chunks", index.entries.len()),
82            );
83            failure_guard.disarm();
84        }
85        Err(error) if error.contains("superseded") => {
86            crate::logging::log_index_event(
87                crate::logging::IndexEvent::from_scope(
88                    crate::logging::IndexEventKind::BuildSuperseded,
89                    scope,
90                )
91                .field("stage", "embed"),
92            );
93            failure_guard.disarm();
94        }
95        Err(error) => {
96            crate::logging::log_index_event(
97                crate::logging::IndexEvent::from_scope(
98                    crate::logging::IndexEventKind::BuildFailed,
99                    scope,
100                )
101                .field("reason", error),
102            );
103            failure_guard.disarm();
104        }
105    }
106}
107
108const ONNX_RUNTIME_INSTALL_HINT: &str =
109    "ONNX Runtime not found. Install via: brew install onnxruntime (macOS), \
110     apt install libonnxruntime (Linux), or place onnxruntime.dll in your PATH (Windows). \
111     AFT can auto-download ONNX Runtime — run `npx @cortexkit/aft doctor` to diagnose.";
112
113const SEMANTIC_INDEX_VERSION_V1: u8 = 1;
114const SEMANTIC_INDEX_VERSION_V2: u8 = 2;
115/// V3 adds subsec_nanos to the file-mtime table so staleness detection survives
116/// restart round-trips on filesystems with subsecond mtime precision (APFS,
117/// ext4 with nsec, NTFS). V1/V2 persisted whole-second mtimes only, which
118/// caused every restart to flag ~99% of files as stale and re-embed them.
119const SEMANTIC_INDEX_VERSION_V3: u8 = 3;
120/// V4 keeps the V3 on-disk layout but rebuilds persisted snippets once after
121/// fixing symbol ranges that were incorrectly treated as 1-based.
122const SEMANTIC_INDEX_VERSION_V4: u8 = 4;
123/// V5 adds file sizes to the file metadata table so incremental staleness
124/// detection can catch content changes even when mtime precision misses them.
125const SEMANTIC_INDEX_VERSION_V5: u8 = 5;
126/// V6 stores paths relative to project_root and adds content hashes.
127const SEMANTIC_INDEX_VERSION_V6: u8 = 6;
128/// V7 adds qualified symbol names for ranking metadata without changing embeddings.
129const SEMANTIC_INDEX_VERSION_V7: u8 = 7;
130/// A V6/V7 base snapshot may be followed by these checksummed delta frames.
131/// The base stays independently readable, so an incomplete final frame can be discarded.
132const SEMANTIC_SEGMENT_MAGIC: &[u8; 8] = b"AFTSEG01";
133const SEMANTIC_SEGMENT_VERSION: u8 = 1;
134const SEMANTIC_SEGMENT_FRAME_HEADER_BYTES: usize = 8 + 8 + 32;
135const SEMANTIC_COMPACT_SEGMENT_LIMIT: usize = 64;
136const SEMANTIC_COMPACT_BYTE_RATIO_DENOMINATOR: u64 = 4;
137const SEMANTIC_PERSIST_LOCK_MIN_WAIT: Duration = Duration::from_secs(5);
138const SEMANTIC_PERSIST_LOCK_BYTES_PER_SECOND: u64 = 32 * 1024 * 1024;
139const DEFAULT_OPENAI_EMBEDDING_PATH: &str = "/embeddings";
140const DEFAULT_OLLAMA_EMBEDDING_PATH: &str = "/api/embed";
141// Build/refresh embedding requests keep a larger budget because they run on
142// background workers and often batch many texts through a cold local backend.
143const DEFAULT_OPENAI_EMBEDDING_TIMEOUT_MS: u64 = 25_000;
144const DEFAULT_MAX_BATCH_SIZE: usize = 64;
145const QUERY_EMBEDDING_CACHE_CAP: usize = 1_000;
146const FALLBACK_BACKEND: &str = "none";
147const EMBEDDING_REQUEST_MAX_ATTEMPTS: usize = 3;
148const EMBEDDING_REQUEST_BACKOFF_MS: [u64; 2] = [500, 1_000];
149const BUILD_EMBEDDING_TIMEOUT_MARKER_PREFIX: &str = "[build-timeout:";
150const BUILD_EMBEDDING_TIMEOUT_MARKER_SUFFIX: &str = "]";
151const BUILD_PER_ITEM_EMA_ALPHA: f64 = 0.25;
152const BUILD_PER_ITEM_SAFETY_FACTOR: f64 = 2.0;
153const BUILD_INITIAL_BATCH_DIVISOR: u64 = 16;
154const BUILD_BATCH_GROWTH_SUCCESSES: usize = 2;
155static SEMANTIC_LOCK_ACQUIRE_MUTEX: Mutex<()> = Mutex::new(());
156
157/// Test-only probe counter for the managed-ONNX resolver (see
158/// `find_managed_onnx_runtime`). Counts storage-tree reads so a negative-control
159/// test can assert a pre-set ORT_DYLIB_PATH short-circuits the resolver.
160#[cfg(test)]
161static MANAGED_ORT_PROBE_READS: AtomicUsize = AtomicUsize::new(0);
162
163/// Per-query request policy kept separate from the background build timeout.
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
165pub struct QueryBudget {
166    timeout_ms: u64,
167}
168
169impl QueryBudget {
170    pub fn from_config(config: &SemanticBackendConfig) -> Self {
171        let configured = if config.query_timeout_ms == 0 {
172            DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS
173        } else {
174            config.query_timeout_ms
175        };
176        Self {
177            timeout_ms: configured
178                .clamp(MIN_SEMANTIC_QUERY_TIMEOUT_MS, MAX_SEMANTIC_QUERY_TIMEOUT_MS),
179        }
180    }
181
182    #[cfg(test)]
183    fn timeout_ms(self) -> u64 {
184        self.timeout_ms
185    }
186}
187
188#[derive(Debug, Clone, Copy)]
189struct BuildRequestBudget {
190    batch_size: usize,
191    deadline_ms: u64,
192}
193
194#[derive(Debug, Clone, Copy)]
195enum EmbeddingRequestPolicy {
196    Build(BuildRequestBudget),
197    Query(QueryBudget),
198}
199
200impl EmbeddingRequestPolicy {
201    fn max_attempts(self) -> usize {
202        match self {
203            Self::Build(_) => EMBEDDING_REQUEST_MAX_ATTEMPTS,
204            Self::Query(_) => 1,
205        }
206    }
207
208    fn request_timeout(self) -> Duration {
209        match self {
210            Self::Build(budget) => Duration::from_millis(budget.deadline_ms),
211            Self::Query(budget) => Duration::from_millis(budget.timeout_ms),
212        }
213    }
214}
215
216pub struct SemanticIndexLock {
217    _guard: Option<fs_lock::LockGuard>,
218}
219
220impl SemanticIndexLock {
221    pub fn acquire(
222        storage_dir: &Path,
223        project_key: &str,
224        project_root: &Path,
225    ) -> std::io::Result<Self> {
226        let dir = storage_dir.join("semantic").join(project_key);
227        let path = dir.join("cache.lock");
228        let access = crate::root_cache::ArtifactAccess::for_root(project_root);
229        if !access.allows_write(project_key, &path) {
230            return Ok(Self { _guard: None });
231        }
232        fs::create_dir_all(&dir)?;
233        let _acquire_guard = SEMANTIC_LOCK_ACQUIRE_MUTEX
234            .lock()
235            .map_err(|_| std::io::Error::other("semantic cache lock acquisition mutex poisoned"))?;
236        fs_lock::try_acquire(&path, Duration::from_secs(2))
237            .map(|guard| Self {
238                _guard: Some(guard),
239            })
240            .map_err(|error| match error {
241                fs_lock::AcquireError::Timeout => {
242                    std::io::Error::other("timed out acquiring semantic cache lock")
243                }
244                fs_lock::AcquireError::Io(error) => error,
245            })
246    }
247}
248
249#[derive(Debug, Clone, Default, Serialize, Deserialize)]
250pub struct SemanticIndexFingerprint {
251    pub backend: String,
252    pub model: String,
253    #[serde(default)]
254    pub base_url: String,
255    pub dimension: usize,
256    #[serde(default = "default_chunking_version")]
257    pub chunking_version: u32,
258    /// Exact caps used to construct symbol embedding rows. Including them in the
259    /// fingerprint prevents cache reuse across incompatible chunk shapes.
260    #[serde(default)]
261    pub embed_text_caps: EmbedTextCaps,
262    /// The Synapse fingerprint and table epoch identify the served vector space
263    /// so indexes built against incompatible embeddings are rejected.
264    #[serde(default, skip_serializing_if = "Option::is_none")]
265    pub synapse_fingerprint: Option<String>,
266    #[serde(default, skip_serializing_if = "Option::is_none")]
267    pub synapse_table_epoch: Option<u64>,
268    /// Alternative fingerprints that Synapse explicitly declares equivalent to
269    /// this index's fingerprint, allowing those versions to pass compatibility checks.
270    #[serde(default, skip_serializing_if = "Vec::is_empty")]
271    pub synapse_equivalent_to: Vec<String>,
272}
273
274fn default_chunking_version() -> u32 {
275    2
276}
277
278impl SemanticIndexFingerprint {
279    fn from_config(config: &SemanticBackendConfig, dimension: usize) -> Self {
280        // Use normalized URL for fingerprinting so cosmetic differences
281        // (e.g. "http://host/v1" vs "http://host/v1/") don't cause rebuilds.
282        let base_url = config
283            .base_url
284            .as_ref()
285            .and_then(|u| normalize_base_url(u).ok())
286            .unwrap_or_else(|| FALLBACK_BACKEND.to_string());
287        Self {
288            backend: config.backend.as_str().to_string(),
289            model: config.model.clone(),
290            base_url,
291            dimension,
292            chunking_version: default_chunking_version(),
293            embed_text_caps: EmbedTextCaps::from_config(config),
294            synapse_fingerprint: None,
295            synapse_table_epoch: None,
296            synapse_equivalent_to: Vec::new(),
297        }
298    }
299
300    pub fn as_string(&self) -> String {
301        serde_json::to_string(self).unwrap_or_else(|_| String::new())
302    }
303
304    pub(crate) fn for_config_dimension(config: &SemanticBackendConfig, dimension: usize) -> Self {
305        Self::from_config(config, dimension)
306    }
307
308    fn matches_expected(&self, expected: &str) -> bool {
309        let Ok(current) = serde_json::from_str::<Self>(expected) else {
310            return false;
311        };
312        self.matches(&current)
313    }
314
315    fn matches(&self, current: &Self) -> bool {
316        if self.backend != current.backend
317            || self.model != current.model
318            || self.base_url != current.base_url
319            || self.dimension != current.dimension
320            || self.chunking_version != current.chunking_version
321            || self.embed_text_caps != current.embed_text_caps
322            || self.synapse_table_epoch != current.synapse_table_epoch
323        {
324            return false;
325        }
326        match (&self.synapse_fingerprint, &current.synapse_fingerprint) {
327            (None, None) => true,
328            (Some(cached), Some(served)) => {
329                cached == served
330                    || current
331                        .synapse_equivalent_to
332                        .iter()
333                        .any(|alias| alias == cached)
334                    || self
335                        .synapse_equivalent_to
336                        .iter()
337                        .any(|alias| alias == served)
338            }
339            _ => false,
340        }
341    }
342}
343
344fn redacted_base_url_host(base_url: &str) -> String {
345    if base_url.is_empty() {
346        return "<empty>".to_string();
347    }
348    if base_url == FALLBACK_BACKEND {
349        return FALLBACK_BACKEND.to_string();
350    }
351
352    match Url::parse(base_url) {
353        Ok(parsed) => {
354            let host = parsed.host_str().unwrap_or("<missing-host>");
355            match parsed.port() {
356                Some(port) => format!("{host}:{port}"),
357                None => host.to_string(),
358            }
359        }
360        Err(_) => "<invalid>".to_string(),
361    }
362}
363
364fn format_fingerprint_mismatch_details(
365    cached: Option<&SemanticIndexFingerprint>,
366    current: &SemanticIndexFingerprint,
367) -> String {
368    let Some(cached) = cached else {
369        return format!(
370            "cached fingerprint missing; current backend kind={}, model={}, base_url host={}, dimension={}, chunking version={}",
371            current.backend,
372            current.model,
373            redacted_base_url_host(&current.base_url),
374            current.dimension,
375            current.chunking_version,
376        );
377    };
378
379    let mut diffs = Vec::new();
380    if cached.backend != current.backend {
381        diffs.push(format!(
382            "backend kind cached={} current={}",
383            cached.backend, current.backend
384        ));
385    }
386    if cached.model != current.model {
387        diffs.push(format!(
388            "model cached={} current={}",
389            cached.model, current.model
390        ));
391    }
392    if cached.base_url != current.base_url {
393        let cached_host = redacted_base_url_host(&cached.base_url);
394        let current_host = redacted_base_url_host(&current.base_url);
395        if cached_host == current_host {
396            diffs.push(format!(
397                "base_url host cached={} current={} (credentials/path redacted)",
398                cached_host, current_host
399            ));
400        } else {
401            diffs.push(format!(
402                "base_url host cached={} current={}",
403                cached_host, current_host
404            ));
405        }
406    }
407    if cached.dimension != current.dimension {
408        diffs.push(format!(
409            "dimension cached={} current={}",
410            cached.dimension, current.dimension
411        ));
412    }
413    if cached.chunking_version != current.chunking_version {
414        diffs.push(format!(
415            "chunking version cached={} current={}",
416            cached.chunking_version, current.chunking_version
417        ));
418    }
419    if cached.embed_text_caps != current.embed_text_caps {
420        diffs.push(format!(
421            "embed text caps cached={:?} current={:?}",
422            cached.embed_text_caps, current.embed_text_caps
423        ));
424    }
425    if cached.synapse_table_epoch != current.synapse_table_epoch {
426        diffs.push(format!(
427            "synapse table_epoch cached={:?} current={:?}",
428            cached.synapse_table_epoch, current.synapse_table_epoch
429        ));
430    }
431    if !cached.matches(current)
432        && (cached.synapse_fingerprint.is_some() || current.synapse_fingerprint.is_some())
433    {
434        diffs.push(format!(
435            "synapse fingerprint cached={} current={} (equivalence class checked)",
436            cached.synapse_fingerprint.as_deref().unwrap_or("<missing>"),
437            current
438                .synapse_fingerprint
439                .as_deref()
440                .unwrap_or("<missing>")
441        ));
442    }
443
444    if diffs.is_empty() {
445        "fingerprint strings differ but parsed fields match".to_string()
446    } else {
447        diffs.join("; ")
448    }
449}
450
451fn log_fingerprint_mismatch(cached: Option<&SemanticIndexFingerprint>, expected: &str) {
452    match serde_json::from_str::<SemanticIndexFingerprint>(expected) {
453        Ok(current) => slog_warn!(
454            "cached semantic index fingerprint mismatch, rebuilding without deleting the shared artifact: {}",
455            format_fingerprint_mismatch_details(cached, &current)
456        ),
457        Err(error) => slog_warn!(
458            "cached semantic index fingerprint mismatch, rebuilding without deleting the shared artifact: could not parse current fingerprint: {}",
459            error
460        ),
461    }
462}
463
464enum SemanticEmbeddingEngine {
465    /// Local ONNX embedder (all-MiniLM-L6-v2 via raw `ort`). The config-facing
466    /// backend string stays "fastembed" for index-fingerprint compatibility.
467    Local(LocalEmbedder),
468    OpenAiCompatible {
469        client: Client,
470        model: String,
471        base_url: String,
472        api_key: Option<String>,
473    },
474    Ollama {
475        client: Client,
476        model: String,
477        base_url: String,
478    },
479    Synapse(SynapseEmbeddingClient),
480}
481
482pub struct SemanticEmbeddingModel {
483    backend: SemanticBackend,
484    model: String,
485    base_url: Option<String>,
486    timeout_ms: u64,
487    max_batch_size: usize,
488    adaptive_build_batch_size: usize,
489    successful_build_batches_at_size: usize,
490    per_item_ema_ms: Option<f64>,
491    dimension: Option<usize>,
492    engine: SemanticEmbeddingEngine,
493    query_embedding_cache: HashMap<String, Vec<f32>>,
494    query_embedding_cache_order: VecDeque<String>,
495    query_embedding_cache_hits: u64,
496    query_embedding_cache_misses: u64,
497    query_instruction: Option<String>,
498    query_instruction_logged: bool,
499    query_instruction_root: Option<PathBuf>,
500}
501
502pub type EmbeddingModel = SemanticEmbeddingModel;
503
504fn validate_embedding_batch(
505    vectors: &[Vec<f32>],
506    expected_count: usize,
507    context: &str,
508) -> Result<(), String> {
509    if expected_count > 0 && vectors.is_empty() {
510        return Err(format!(
511            "{context} returned no vectors for {expected_count} inputs"
512        ));
513    }
514
515    if vectors.len() != expected_count {
516        return Err(format!(
517            "{context} returned {} vectors for {} inputs",
518            vectors.len(),
519            expected_count
520        ));
521    }
522
523    let Some(first_vector) = vectors.first() else {
524        return Ok(());
525    };
526    let expected_dimension = first_vector.len();
527    validate_embedding_dimension(expected_dimension)
528        .map_err(|error| format!("{context} returned {error}"))?;
529    for (index, vector) in vectors.iter().enumerate() {
530        if vector.len() != expected_dimension {
531            return Err(format!(
532                "{context} returned inconsistent embedding dimensions: vector 0 has length {expected_dimension}, vector {index} has length {}",
533                vector.len()
534            ));
535        }
536    }
537
538    Ok(())
539}
540
541fn validate_embedding_dimension(dimension: usize) -> Result<(), String> {
542    if dimension == 0 || dimension > MAX_DIMENSION {
543        return Err(format!(
544            "invalid embedding dimension: {dimension}; supported range is 1..={MAX_DIMENSION}"
545        ));
546    }
547
548    Ok(())
549}
550
551/// Normalize a base URL: validate scheme and strip trailing slash.
552/// Does NOT perform SSRF/private-IP validation — call
553/// `validate_base_url_no_ssrf` separately when processing user-supplied config.
554fn normalize_base_url(raw: &str) -> Result<String, String> {
555    let parsed = Url::parse(raw).map_err(|error| format!("invalid base_url '{raw}': {error}"))?;
556    let scheme = parsed.scheme();
557    if scheme != "http" && scheme != "https" {
558        return Err(format!(
559            "unsupported URL scheme '{}' — only http:// and https:// are allowed",
560            scheme
561        ));
562    }
563    Ok(parsed.to_string().trim_end_matches('/').to_string())
564}
565
566/// Validate that a base URL does not point to a private/loopback address.
567/// Call this on user-supplied config (at configure time) to prevent SSRF.
568/// Not called for programmatically constructed configs (e.g. tests).
569///
570/// **Loopback is allowed.** Self-hosted embedding backends (e.g. Ollama at
571/// `http://127.0.0.1:11434`) are a primary use case for `aft_search`. Loopback
572/// addresses by definition cannot be exploited as SSRF targets — they only
573/// reach services on the same machine. Allowing loopback unblocks Ollama at its
574/// default config without opening up SSRF to LAN/intranet services, which
575/// remain rejected.
576///
577/// **mDNS `.local` is rejected.** mDNS hostnames typically resolve to LAN
578/// devices (printers, homelab servers); rejecting them before DNS lookup keeps
579/// the SSRF guard meaningful for non-loopback private networks.
580pub fn validate_base_url_no_ssrf(raw: &str) -> Result<(), String> {
581    use std::net::{IpAddr, ToSocketAddrs};
582
583    let parsed = Url::parse(raw).map_err(|error| format!("invalid base_url '{raw}': {error}"))?;
584
585    let host = parsed.host_str().unwrap_or("");
586
587    // Loopback hostnames are explicitly allowed. RFC 6761 mandates that
588    // `localhost` and `*.localhost` resolve to loopback;
589    // `localhost.localdomain` is a historical alias used on some Linux
590    // distros. Self-hosted backends like Ollama use these by default.
591    let is_loopback_host =
592        host == "localhost" || host == "localhost.localdomain" || host.ends_with(".localhost");
593    if is_loopback_host {
594        return Ok(());
595    }
596
597    // mDNS hostnames are typically LAN devices, not loopback. Reject before
598    // DNS lookup so users get a clear error rather than a private-IP error.
599    if host.ends_with(".local") {
600        return Err(format!(
601            "base_url host '{host}' is an mDNS name — only loopback (localhost / 127.0.0.1) and public endpoints are allowed"
602        ));
603    }
604
605    // Resolve the hostname. Reject private/link-local/CGNAT IPs but NOT
606    // loopback (which is by definition same-machine and not an SSRF target).
607    let port = parsed.port_or_known_default().unwrap_or(443);
608    let addr_str = format!("{host}:{port}");
609    let addrs: Vec<IpAddr> = addr_str
610        .to_socket_addrs()
611        .map(|iter| iter.map(|sa| sa.ip()).collect())
612        .unwrap_or_default();
613    for ip in &addrs {
614        if is_private_non_loopback_ip(ip) {
615            return Err(format!(
616                "base_url '{raw}' resolves to a private/reserved IP — only loopback (127.0.0.1) and public endpoints are allowed"
617            ));
618        }
619    }
620
621    Ok(())
622}
623
624/// Returns true for IPv4/IPv6 addresses in private/link-local/CGNAT/benchmark/
625/// multicast/reserved ranges, EXCLUDING loopback (127.0.0.0/8 and ::1). Loopback
626/// is considered safe for SSRF purposes (same-machine, e.g. a local Ollama
627/// endpoint) — see [`validate_base_url_no_ssrf`] for rationale.
628///
629/// Delegates to [`crate::url_fetch::is_private_or_reserved_ip`] so there is one
630/// authoritative reserved-range list (the url_fetch copy is the maintained one;
631/// this used to be a drifting subset that missed e.g. 198.18.0.0/15 and the
632/// multicast/reserved blocks). We only re-add the loopback carve-out the
633/// url_fetch guard deliberately does not make.
634fn is_private_non_loopback_ip(ip: &std::net::IpAddr) -> bool {
635    // Canonicalize so an IPv4-mapped loopback (`::ffff:127.0.0.1`) is also
636    // recognized as loopback, matching the prior carve-out.
637    if ip.to_canonical().is_loopback() {
638        return false;
639    }
640    crate::url_fetch::is_private_or_reserved_ip(*ip)
641}
642
643fn build_openai_embeddings_endpoint(base_url: &str) -> String {
644    if base_url.ends_with("/v1") {
645        format!("{base_url}{DEFAULT_OPENAI_EMBEDDING_PATH}")
646    } else {
647        format!("{base_url}/v1{}", DEFAULT_OPENAI_EMBEDDING_PATH)
648    }
649}
650
651fn build_ollama_embeddings_endpoint(base_url: &str) -> String {
652    if base_url.ends_with("/api") {
653        format!("{base_url}/embed")
654    } else {
655        format!("{base_url}{DEFAULT_OLLAMA_EMBEDDING_PATH}")
656    }
657}
658
659fn normalize_api_key(value: Option<String>) -> Option<String> {
660    value.and_then(|token| {
661        let token = token.trim();
662        if token.is_empty() {
663            None
664        } else {
665            Some(token.to_string())
666        }
667    })
668}
669
670fn is_retryable_embedding_status(status: reqwest::StatusCode) -> bool {
671    status.is_server_error() || status == reqwest::StatusCode::TOO_MANY_REQUESTS
672}
673
674/// Local backends (LM Studio, Ollama, llama.cpp) can return a 4xx — usually
675/// 400/409 — while a model is loading or was just unloaded. Only narrowly known
676/// local-backend loading/unloaded payloads are classified transient; generic
677/// 4xx bodies that merely mention phrases like "loading model" remain
678/// permanent so misconfigurations do not retry forever.
679fn embedding_response_body_is_transient(status: reqwest::StatusCode, raw: &str) -> bool {
680    if !matches!(
681        status,
682        reqwest::StatusCode::BAD_REQUEST
683            | reqwest::StatusCode::CONFLICT
684            | reqwest::StatusCode::REQUEST_TIMEOUT
685            | reqwest::StatusCode::LOCKED
686            | reqwest::StatusCode::TOO_EARLY
687    ) {
688        return false;
689    }
690
691    let lower = raw.to_ascii_lowercase();
692    let normalized = lower.trim();
693
694    normalized.contains("model was unloaded while the request was still in queue")
695        || normalized == "model is loading"
696        || normalized.starts_with("model is loading,")
697        || normalized.contains(r#""error":"model is loading"#)
698        || normalized.contains(r#""message":"model is loading"#)
699        || normalized == "model not loaded"
700        || normalized.contains(r#""error":"model not loaded""#)
701        || normalized.contains(r#""message":"model not loaded""#)
702        || normalized == "loading model into memory"
703        || normalized.contains(r#""error":"loading model into memory""#)
704        || normalized.contains(r#""message":"loading model into memory""#)
705        || normalized == "model is being loaded"
706        || normalized.contains(r#""error":"model is being loaded""#)
707        || normalized.contains(r#""message":"model is being loaded""#)
708        || normalized == "model is currently loading"
709        || normalized.contains(r#""error":"model is currently loading""#)
710        || normalized.contains(r#""message":"model is currently loading""#)
711}
712
713fn is_retryable_embedding_error(error: &reqwest::Error) -> bool {
714    // Retryable == transient-at-send-stage: a backend that refused, timed
715    // out, or died mid-exchange deserves the same in-request retry ladder.
716    embedding_send_error_is_transient(error)
717}
718
719/// Whether a send-time error means the backend is *unreachable or temporarily
720/// failing* (vs. a real misconfiguration). Build requests retry both connection
721/// failures and timeouts; query requests use the same classification but have a
722/// one-attempt policy.
723fn embedding_send_error_is_transient(error: &reqwest::Error) -> bool {
724    // TLS trust failures are reported by reqwest as connect errors, but they
725    // cannot recover by retrying. Check the source chain before the broad
726    // connect/timeout classification so private-CA failures become terminal.
727    if embedding_error_is_certificate_trust_failure(error) {
728        return false;
729    }
730    if error.is_connect() || error.is_timeout() {
731        return true;
732    }
733    // A connection reset/abort mid-request is the backend dying between
734    // accept and response (local backends do this when they crash or restart
735    // under load) — the same "temporarily failing" class as a refused
736    // connection, just later in the exchange. reqwest surfaces it as a plain
737    // send error. Classify from the io source chain where one exists; hyper
738    // errors like IncompleteMessage ("connection closed before message
739    // completed") and UnexpectedMessage ("received unexpected message from
740    // connection" — the peer wrote a partial reply and closed while the
741    // request was still being sent, observed on Windows CI where the socket
742    // closes with unread request bytes) carry no io source, so fall back to
743    // known phrases in the chain's rendered messages.
744    let mut source = std::error::Error::source(error);
745    while let Some(inner) = source {
746        if let Some(io) = inner.downcast_ref::<std::io::Error>() {
747            if matches!(
748                io.kind(),
749                std::io::ErrorKind::ConnectionReset
750                    | std::io::ErrorKind::ConnectionAborted
751                    | std::io::ErrorKind::BrokenPipe
752                    | std::io::ErrorKind::UnexpectedEof
753            ) {
754                return true;
755            }
756        }
757        let rendered = inner.to_string().to_ascii_lowercase();
758        if rendered.contains("connection reset")
759            || rendered.contains("connection aborted")
760            || rendered.contains("connection closed")
761            || rendered.contains("broken pipe")
762            || rendered.contains("unexpected end of file")
763            || rendered.contains("unexpected message from connection")
764        {
765            return true;
766        }
767        source = std::error::Error::source(inner);
768    }
769    false
770}
771
772fn render_error_source_chain(error: &dyn Error) -> String {
773    let mut rendered = error.to_string();
774    let mut source = error.source();
775    while let Some(cause) = source {
776        rendered.push_str(": ");
777        rendered.push_str(&cause.to_string());
778        source = cause.source();
779    }
780    rendered
781}
782
783fn embedding_error_is_certificate_trust_failure(error: &reqwest::Error) -> bool {
784    let rendered = render_error_source_chain(error).to_ascii_lowercase();
785    [
786        "unknownissuer",
787        "unknown issuer",
788        "invalid peer certificate",
789        "certificate verify failed",
790        "certificate validation failed",
791        "certificate error",
792    ]
793    .iter()
794    .any(|marker| rendered.contains(marker))
795}
796
797fn embedding_response_read_error_is_transient(error: &reqwest::Error) -> bool {
798    embedding_send_error_is_transient(error) || error.is_body() || error.is_decode()
799}
800
801/// Returns the query-timeout marker for a request error when the active policy
802/// is a `Query(budget)` and reqwest classifies the error as a timeout. Returns
803/// an empty string otherwise — build-policy timeouts and non-timeout query
804/// errors carry no marker. This is the single site that decides whether a
805/// failure is "the configured query budget fired", so the fallback message can
806/// name the knob (`semantic.query_timeout_ms`) without re-parsing reqwest text.
807fn query_timeout_marker_for_error(
808    error: &reqwest::Error,
809    policy: EmbeddingRequestPolicy,
810) -> String {
811    match policy {
812        EmbeddingRequestPolicy::Query(budget) if error.is_timeout() => {
813            query_embedding_timeout_marker(budget.timeout_ms)
814        }
815        _ => String::new(),
816    }
817}
818
819/// Stable machine marker prefixed onto embedding error strings whose root cause
820/// is transient — the backend is down, timing out, or returning 5xx/429, not
821/// misconfigured. The build and corpus-refresh layers key retry-vs-give-up on
822/// this marker (see [`embedding_failure_is_transient`]) instead of re-parsing
823/// error text, so transience stays authoritative at the one site that knows it.
824/// Stripped before any user-facing display via [`strip_transient_embedding_marker`].
825pub const TRANSIENT_EMBEDDING_MARKER: &str = "[transient] ";
826
827/// True when an embedding error carries the transient marker — i.e. retrying
828/// once the backend recovers is the right move, not surfacing a hard failure.
829pub fn embedding_failure_is_transient(error: &str) -> bool {
830    error.contains(TRANSIENT_EMBEDDING_MARKER)
831}
832
833/// Remove the machine transient marker so the message is clean for display.
834pub fn strip_transient_embedding_marker(error: &str) -> String {
835    error.replace(TRANSIENT_EMBEDDING_MARKER, "")
836}
837
838#[derive(Debug, Clone, Copy, PartialEq, Eq)]
839struct BuildTimeoutDetails {
840    batch_size: usize,
841    deadline_ms: u64,
842    attempts: usize,
843}
844
845fn build_embedding_timeout_marker(details: BuildTimeoutDetails) -> String {
846    format!(
847        "{BUILD_EMBEDDING_TIMEOUT_MARKER_PREFIX}{}:{}:{}{BUILD_EMBEDDING_TIMEOUT_MARKER_SUFFIX}",
848        details.batch_size, details.deadline_ms, details.attempts
849    )
850}
851
852fn build_embedding_timeout_details(error: &str) -> Option<BuildTimeoutDetails> {
853    let start = error.find(BUILD_EMBEDDING_TIMEOUT_MARKER_PREFIX)?
854        + BUILD_EMBEDDING_TIMEOUT_MARKER_PREFIX.len();
855    let end = error[start..].find(BUILD_EMBEDDING_TIMEOUT_MARKER_SUFFIX)? + start;
856    let mut fields = error[start..end].split(':');
857    let details = BuildTimeoutDetails {
858        batch_size: fields.next()?.parse().ok()?,
859        deadline_ms: fields.next()?.parse().ok()?,
860        attempts: fields.next()?.parse().ok()?,
861    };
862    fields.next().is_none().then_some(details)
863}
864
865/// Stable machine marker prefixed onto a *query* embedding error string when
866/// the failure was a request timeout — i.e. reqwest's `is_timeout()` fired
867/// while running under a `Query(budget)` policy. The marker carries the budget
868/// that fired (`[query-timeout:{ms}]`) so the consumer can name both the
869/// mechanism and the knob (`semantic.query_timeout_ms`) without re-parsing
870/// reqwest's rendered error text, which varies by backend and locale.
871///
872/// Classification lives here — next to the one site that knows both the policy
873/// (Query with a budget) and the typed reqwest error — so it cannot drift from
874/// the error shape. Stripped before user-facing display via
875/// [`strip_query_embedding_timeout_marker`]; the budget is recovered via
876/// [`query_embedding_timeout_budget`].
877pub const QUERY_EMBEDDING_TIMEOUT_MARKER_PREFIX: &str = "[query-timeout:";
878pub const QUERY_EMBEDDING_TIMEOUT_MARKER_SUFFIX: &str = "]";
879
880/// Build the timeout marker for a given query budget. Kept here so the format
881/// and the parser below stay in lockstep. `pub(crate)` so the classification
882/// test in `semantic_search` can construct a marked error without duplicating
883/// the format string.
884pub(crate) fn query_embedding_timeout_marker(timeout_ms: u64) -> String {
885    format!("{QUERY_EMBEDDING_TIMEOUT_MARKER_PREFIX}{timeout_ms}{QUERY_EMBEDDING_TIMEOUT_MARKER_SUFFIX}")
886}
887
888/// Recover the timeout budget (ms) a query embedding error carries, or `None`
889/// when the failure was not a query timeout. This is the single authoritative
890/// way to detect the timeout case — never substring-match on reqwest's text.
891pub fn query_embedding_timeout_budget(error: &str) -> Option<u64> {
892    let start = error.find(QUERY_EMBEDDING_TIMEOUT_MARKER_PREFIX)?;
893    let rest = &error[start + QUERY_EMBEDDING_TIMEOUT_MARKER_PREFIX.len()..];
894    let end = rest.find(QUERY_EMBEDDING_TIMEOUT_MARKER_SUFFIX)?;
895    rest[..end].parse::<u64>().ok()
896}
897
898/// Remove the query-timeout marker so the message is clean for display. The
899/// budget is recovered separately via [`query_embedding_timeout_budget`] before
900/// stripping.
901pub fn strip_query_embedding_timeout_marker(error: &str) -> String {
902    if let (Some(start), Some(budget)) = (
903        error.find(QUERY_EMBEDDING_TIMEOUT_MARKER_PREFIX),
904        query_embedding_timeout_budget(error),
905    ) {
906        let marker = query_embedding_timeout_marker(budget);
907        let end = start + marker.len();
908        let mut cleaned = error.to_string();
909        cleaned.replace_range(start..end, "");
910        cleaned
911    } else {
912        error.to_string()
913    }
914}
915
916fn sleep_before_embedding_retry(attempt_index: usize) {
917    if let Some(delay_ms) = EMBEDDING_REQUEST_BACKOFF_MS.get(attempt_index) {
918        std::thread::sleep(Duration::from_millis(*delay_ms));
919    }
920}
921
922const QUERY_EMBEDDING_CANCELLED_MARKER: &str = "__AFT_QUERY_EMBEDDING_CANCELLED__";
923const QUERY_EMBEDDING_CANCEL_POLL: Duration = Duration::from_millis(10);
924
925enum EmbeddingExchange {
926    SendFailed(reqwest::Error),
927    Response {
928        status: reqwest::StatusCode,
929        body: Result<String, reqwest::Error>,
930    },
931}
932
933fn execute_embedding_exchange(request: reqwest::blocking::RequestBuilder) -> EmbeddingExchange {
934    match request.send() {
935        Ok(response) => EmbeddingExchange::Response {
936            status: response.status(),
937            body: response.text(),
938        },
939        Err(error) => EmbeddingExchange::SendFailed(error),
940    }
941}
942
943fn execute_query_embedding_exchange(
944    request: reqwest::blocking::RequestBuilder,
945) -> Result<EmbeddingExchange, String> {
946    let Some(cancellation) = crate::executor::current_job_cancellation() else {
947        return Ok(execute_embedding_exchange(request));
948    };
949    if cancellation.cancel_requested_before_commit() {
950        return Err(QUERY_EMBEDDING_CANCELLED_MARKER.to_string());
951    }
952
953    let (tx, rx) = crossbeam_channel::bounded(1);
954    std::thread::spawn(move || {
955        let _ = tx.send(execute_embedding_exchange(request));
956    });
957    loop {
958        match rx.try_recv() {
959            Ok(exchange) => {
960                if cancellation.cancel_requested_before_commit() {
961                    return Err(QUERY_EMBEDDING_CANCELLED_MARKER.to_string());
962                }
963                return Ok(exchange);
964            }
965            Err(crossbeam_channel::TryRecvError::Disconnected) => {
966                return Err("embedding request worker disconnected".to_string());
967            }
968            Err(crossbeam_channel::TryRecvError::Empty) => {}
969        }
970        if cancellation.wait_for_cancellation(QUERY_EMBEDDING_CANCEL_POLL) {
971            return Err(QUERY_EMBEDDING_CANCELLED_MARKER.to_string());
972        }
973    }
974}
975
976fn send_embedding_request<F>(
977    mut make_request: F,
978    backend_label: &str,
979    policy: EmbeddingRequestPolicy,
980) -> Result<String, String>
981where
982    F: FnMut() -> reqwest::blocking::RequestBuilder,
983{
984    let max_attempts = policy.max_attempts();
985    for attempt_index in 0..max_attempts {
986        let last_attempt = attempt_index + 1 == max_attempts;
987        let request = make_request().timeout(policy.request_timeout());
988
989        let exchange = match policy {
990            EmbeddingRequestPolicy::Build(_) => execute_embedding_exchange(request),
991            EmbeddingRequestPolicy::Query(_) => execute_query_embedding_exchange(request)?,
992        };
993        let (status, raw) = match exchange {
994            EmbeddingExchange::SendFailed(error) => {
995                if let EmbeddingRequestPolicy::Build(budget) = policy {
996                    if error.is_timeout() {
997                        let details = BuildTimeoutDetails {
998                            batch_size: budget.batch_size,
999                            deadline_ms: budget.deadline_ms,
1000                            attempts: attempt_index + 1,
1001                        };
1002                        return Err(format!(
1003                            "{TRANSIENT_EMBEDDING_MARKER}{}{} request timed out: {}",
1004                            build_embedding_timeout_marker(details),
1005                            backend_label,
1006                            render_error_source_chain(&error),
1007                        ));
1008                    }
1009                }
1010                // A refused connection is already conclusive unreachable evidence;
1011                // retrying the same socket target only delays the circuit breaker.
1012                if error.is_connect() && embedding_send_error_is_transient(&error) {
1013                    return Err(format!(
1014                        "{TRANSIENT_EMBEDDING_MARKER}embedding backend unreachable (connection refused or connect failure): {}",
1015                        render_error_source_chain(&error),
1016                    ));
1017                }
1018                if !last_attempt && is_retryable_embedding_error(&error) {
1019                    sleep_before_embedding_retry(attempt_index);
1020                    continue;
1021                }
1022                let marker = if embedding_send_error_is_transient(&error) {
1023                    TRANSIENT_EMBEDDING_MARKER
1024                } else {
1025                    ""
1026                };
1027                // A query-timeout is a distinct, actionable failure: the
1028                // configured `semantic.query_timeout_ms` budget fired. Tag it
1029                // here — the only site that has both the typed reqwest error
1030                // and the Query budget — so the fallback can name the knob
1031                // without guessing at reqwest's rendered text.
1032                let timeout_marker = query_timeout_marker_for_error(&error, policy);
1033                return Err(format!(
1034                    "{timeout_marker}{marker}{backend_label} request failed: {}",
1035                    render_error_source_chain(&error)
1036                ));
1037            }
1038            EmbeddingExchange::Response {
1039                status,
1040                body: Ok(raw),
1041            } => (status, raw),
1042            EmbeddingExchange::Response {
1043                status: _,
1044                body: Err(error),
1045            } => {
1046                if let EmbeddingRequestPolicy::Build(budget) = policy {
1047                    if error.is_timeout() {
1048                        let details = BuildTimeoutDetails {
1049                            batch_size: budget.batch_size,
1050                            deadline_ms: budget.deadline_ms,
1051                            attempts: attempt_index + 1,
1052                        };
1053                        return Err(format!(
1054                            "{TRANSIENT_EMBEDDING_MARKER}{}{} response timed out: {}",
1055                            build_embedding_timeout_marker(details),
1056                            backend_label,
1057                            render_error_source_chain(&error),
1058                        ));
1059                    }
1060                }
1061                if !last_attempt && embedding_response_read_error_is_transient(&error) {
1062                    sleep_before_embedding_retry(attempt_index);
1063                    continue;
1064                }
1065                let marker = if embedding_response_read_error_is_transient(&error) {
1066                    TRANSIENT_EMBEDDING_MARKER
1067                } else {
1068                    ""
1069                };
1070                // A body-read timeout under a Query policy is the same budget
1071                // firing mid-exchange; tag it identically to the send case.
1072                let timeout_marker = query_timeout_marker_for_error(&error, policy);
1073                return Err(format!(
1074                    "{timeout_marker}{marker}{backend_label} response read failed: {}",
1075                    render_error_source_chain(&error)
1076                ));
1077            }
1078        };
1079
1080        if status.is_success() {
1081            return Ok(raw);
1082        }
1083
1084        // A 4xx whose body says the model is loading/unloaded is transient on
1085        // local backends (LM Studio/Ollama), so treat it like a retryable
1086        // status: ride it out at both the in-request and build-retry layers.
1087        let body_transient = embedding_response_body_is_transient(status, &raw);
1088        if !last_attempt && (is_retryable_embedding_status(status) || body_transient) {
1089            sleep_before_embedding_retry(attempt_index);
1090            continue;
1091        }
1092
1093        // 5xx / 429 are server-side and transient — the backend is overloaded
1094        // or briefly unavailable, not misconfigured. A 4xx whose body indicates
1095        // the model is (un)loading is also transient (local backend mid-swap).
1096        // Other 4xx (auth, bad request, model-not-found) is a real error the
1097        // user must fix; no marker.
1098        let marker = if is_retryable_embedding_status(status) || body_transient {
1099            TRANSIENT_EMBEDDING_MARKER
1100        } else {
1101            ""
1102        };
1103        return Err(format!(
1104            "{marker}{backend_label} request failed (HTTP {}): {}",
1105            status, raw
1106        ));
1107    }
1108
1109    unreachable!("embedding request retries exhausted without returning")
1110}
1111
1112fn configured_embedding_timeout_ms(config: &SemanticBackendConfig) -> u64 {
1113    if config.timeout_ms == 0 {
1114        DEFAULT_OPENAI_EMBEDDING_TIMEOUT_MS
1115    } else {
1116        config.timeout_ms
1117    }
1118}
1119
1120fn query_embedding_text(query: &str, instruction: Option<&str>) -> String {
1121    match instruction {
1122        Some(task) => format!("Instruct: {task}\nQuery: {query}"),
1123        None => query.to_string(),
1124    }
1125}
1126
1127impl SemanticEmbeddingModel {
1128    pub fn from_config(config: &SemanticBackendConfig) -> Result<Self, String> {
1129        Self::from_config_with_timeout_ms(config, configured_embedding_timeout_ms(config))
1130    }
1131
1132    pub fn from_config_for_query(config: &SemanticBackendConfig) -> Result<Self, String> {
1133        // The model may later be reused by a background build, so retain the build
1134        // client's timeout. QueryBudget overrides each interactive HTTP request.
1135        Self::from_config(config)
1136    }
1137
1138    fn from_config_with_timeout_ms(
1139        config: &SemanticBackendConfig,
1140        timeout_ms: u64,
1141    ) -> Result<Self, String> {
1142        let max_batch_size = if config.max_batch_size == 0 {
1143            DEFAULT_MAX_BATCH_SIZE
1144        } else {
1145            config.max_batch_size
1146        };
1147
1148        let api_key_env = normalize_api_key(config.api_key_env.clone());
1149        let model = config.model.clone();
1150
1151        let tls_config = crate::platform_tls::client_config()
1152            .map_err(|error| format!("failed to configure embedding client TLS: {error}"))?;
1153        let client = Client::builder()
1154            .timeout(Duration::from_millis(timeout_ms))
1155            .redirect(reqwest::redirect::Policy::none())
1156            .use_preconfigured_tls(tls_config)
1157            .build()
1158            .map_err(|error| format!("failed to configure embedding client: {error}"))?;
1159
1160        let engine = match config.backend {
1161            SemanticBackend::Fastembed => {
1162                SemanticEmbeddingEngine::Local(LocalEmbedder::new(&model)?)
1163            }
1164            SemanticBackend::OpenAiCompatible => {
1165                let raw = config.base_url.as_ref().ok_or_else(|| {
1166                    "base_url is required for openai_compatible backend".to_string()
1167                })?;
1168                let base_url = normalize_base_url(raw)?;
1169
1170                let api_key = match api_key_env {
1171                    Some(var_name) => Some(env::var(&var_name).map_err(|_| {
1172                        format!("missing api_key_env '{var_name}' for openai_compatible backend")
1173                    })?),
1174                    None => None,
1175                };
1176
1177                SemanticEmbeddingEngine::OpenAiCompatible {
1178                    client,
1179                    model,
1180                    base_url,
1181                    api_key,
1182                }
1183            }
1184            SemanticBackend::Ollama => {
1185                let raw = config
1186                    .base_url
1187                    .as_ref()
1188                    .ok_or_else(|| "base_url is required for ollama backend".to_string())?;
1189                let base_url = normalize_base_url(raw)?;
1190
1191                SemanticEmbeddingEngine::Ollama {
1192                    client,
1193                    model,
1194                    base_url,
1195                }
1196            }
1197            SemanticBackend::Synapse => SemanticEmbeddingEngine::Synapse(
1198                SynapseEmbeddingClient::from_config(config).map_err(|error| error.to_string())?,
1199            ),
1200        };
1201        let max_batch_size = match &engine {
1202            SemanticEmbeddingEngine::Synapse(client) => client.metadata().recommended_rows,
1203            _ => max_batch_size,
1204        };
1205
1206        Ok(Self {
1207            backend: config.backend,
1208            model: config.model.clone(),
1209            base_url: config.base_url.clone(),
1210            timeout_ms,
1211            max_batch_size,
1212            adaptive_build_batch_size: max_batch_size,
1213            successful_build_batches_at_size: 0,
1214            per_item_ema_ms: None,
1215            dimension: None,
1216            engine,
1217            query_embedding_cache: HashMap::new(),
1218            query_embedding_cache_order: VecDeque::new(),
1219            query_embedding_cache_hits: 0,
1220            query_embedding_cache_misses: 0,
1221            query_instruction: config.resolved_query_instruction().map(str::to_string),
1222            query_instruction_logged: false,
1223            query_instruction_root: config.route_project_root.clone(),
1224        })
1225    }
1226
1227    pub fn backend(&self) -> SemanticBackend {
1228        self.backend
1229    }
1230
1231    pub fn model(&self) -> &str {
1232        &self.model
1233    }
1234
1235    pub fn base_url(&self) -> Option<&str> {
1236        self.base_url.as_deref()
1237    }
1238
1239    pub fn max_batch_size(&self) -> usize {
1240        self.max_batch_size
1241    }
1242
1243    pub fn timeout_ms(&self) -> u64 {
1244        self.timeout_ms
1245    }
1246
1247    pub fn fingerprint(
1248        &mut self,
1249        config: &SemanticBackendConfig,
1250    ) -> Result<SemanticIndexFingerprint, String> {
1251        let dimension = self.dimension()?;
1252        let mut fingerprint = SemanticIndexFingerprint::from_config(config, dimension);
1253        if let SemanticEmbeddingEngine::Synapse(client) = &self.engine {
1254            let identity = client.identity();
1255            fingerprint.synapse_fingerprint = Some(identity.fingerprint.clone());
1256            fingerprint.synapse_table_epoch = Some(identity.table_epoch);
1257            fingerprint.synapse_equivalent_to = identity.equivalent_to.clone();
1258        }
1259        Ok(fingerprint)
1260    }
1261
1262    fn uses_http_embedding_backend(&self) -> bool {
1263        matches!(
1264            &self.engine,
1265            SemanticEmbeddingEngine::OpenAiCompatible { .. }
1266                | SemanticEmbeddingEngine::Ollama { .. }
1267        )
1268    }
1269
1270    fn build_request_deadline_ms(&self, batch_size: usize) -> u64 {
1271        let batch_size = batch_size.max(1);
1272        match self.per_item_ema_ms {
1273            Some(per_item_ms) => {
1274                let scaled =
1275                    (per_item_ms * batch_size as f64 * BUILD_PER_ITEM_SAFETY_FACTOR).ceil();
1276                let scaled = if scaled.is_finite() {
1277                    scaled.min(u64::MAX as f64) as u64
1278                } else {
1279                    u64::MAX
1280                };
1281                self.timeout_ms.max(scaled)
1282            }
1283            None => self.timeout_ms.max(
1284                self.timeout_ms
1285                    .saturating_mul(batch_size as u64)
1286                    .div_ceil(BUILD_INITIAL_BATCH_DIVISOR),
1287            ),
1288        }
1289    }
1290
1291    fn build_request_budget(&self, batch_size: usize) -> BuildRequestBudget {
1292        BuildRequestBudget {
1293            batch_size,
1294            deadline_ms: self.build_request_deadline_ms(batch_size),
1295        }
1296    }
1297
1298    fn note_successful_build_batch(&mut self, batch_size: usize, elapsed: Duration) {
1299        let measured_per_item_ms = elapsed.as_secs_f64() * 1_000.0 / batch_size.max(1) as f64;
1300        self.per_item_ema_ms = Some(match self.per_item_ema_ms {
1301            Some(previous) => {
1302                previous * (1.0 - BUILD_PER_ITEM_EMA_ALPHA)
1303                    + measured_per_item_ms * BUILD_PER_ITEM_EMA_ALPHA
1304            }
1305            None => measured_per_item_ms,
1306        });
1307
1308        if batch_size != self.adaptive_build_batch_size {
1309            return;
1310        }
1311        self.successful_build_batches_at_size =
1312            self.successful_build_batches_at_size.saturating_add(1);
1313        if self.successful_build_batches_at_size < BUILD_BATCH_GROWTH_SUCCESSES
1314            || self.adaptive_build_batch_size >= self.max_batch_size
1315        {
1316            return;
1317        }
1318
1319        let old_size = self.adaptive_build_batch_size;
1320        self.adaptive_build_batch_size = old_size.saturating_mul(2).min(self.max_batch_size);
1321        self.successful_build_batches_at_size = 0;
1322        slog_info!(
1323            "semantic embed batch size {} -> {} after successful batches (per_item_ms={:.0})",
1324            old_size,
1325            self.adaptive_build_batch_size,
1326            self.per_item_ema_ms.unwrap_or(measured_per_item_ms),
1327        );
1328    }
1329
1330    fn embed_build_http_adaptive(&mut self, texts: Vec<String>) -> Result<Vec<Vec<f32>>, String> {
1331        let mut vectors = Vec::with_capacity(texts.len());
1332        let mut cursor = 0usize;
1333
1334        while cursor < texts.len() {
1335            let batch_size = self
1336                .adaptive_build_batch_size
1337                .max(1)
1338                .min(texts.len() - cursor);
1339            let budget = self.build_request_budget(batch_size);
1340            let batch = texts[cursor..cursor + batch_size].to_vec();
1341            let started = Instant::now();
1342            match self.embed_texts(batch, EmbeddingRequestPolicy::Build(budget)) {
1343                Ok(mut batch_vectors) => {
1344                    self.note_successful_build_batch(batch_size, started.elapsed());
1345                    vectors.append(&mut batch_vectors);
1346                    cursor += batch_size;
1347                }
1348                Err(error) => {
1349                    let Some(timeout) = build_embedding_timeout_details(&error) else {
1350                        return Err(error);
1351                    };
1352                    if timeout.batch_size == 1 {
1353                        return Err(format!(
1354                            "{TRANSIENT_EMBEDDING_MARKER}single-item request timed out at {} ms: treating as down ({} attempt(s))",
1355                            self.timeout_ms, timeout.attempts,
1356                        ));
1357                    }
1358
1359                    let new_size = timeout.batch_size.div_ceil(2).max(1);
1360                    self.adaptive_build_batch_size = new_size;
1361                    self.successful_build_batches_at_size = 0;
1362                    let per_item_ms = self
1363                        .per_item_ema_ms
1364                        .unwrap_or(self.timeout_ms as f64 / BUILD_INITIAL_BATCH_DIVISOR as f64);
1365                    slog_info!(
1366                        "semantic embed batch size {} -> {} after timeout (per_item_ms={:.0}, deadline_ms={})",
1367                        timeout.batch_size,
1368                        new_size,
1369                        per_item_ms,
1370                        timeout.deadline_ms,
1371                    );
1372                }
1373            }
1374        }
1375
1376        Ok(vectors)
1377    }
1378
1379    pub fn dimension(&mut self) -> Result<usize, String> {
1380        if let Some(dimension) = self.dimension {
1381            return Ok(dimension);
1382        }
1383
1384        let dimension = if self.uses_http_embedding_backend() {
1385            let vectors = self.embed(vec!["semantic index fingerprint probe".to_string()])?;
1386            vectors
1387                .first()
1388                .map(|v| v.len())
1389                .ok_or_else(|| "embedding backend returned no vectors".to_string())?
1390        } else {
1391            match &mut self.engine {
1392                SemanticEmbeddingEngine::Local(model) => {
1393                    let vectors = model.embed(&["semantic index fingerprint probe".to_string()])?;
1394                    vectors
1395                        .first()
1396                        .map(|v| v.len())
1397                        .ok_or_else(|| "embedding backend returned no vectors".to_string())?
1398                }
1399                SemanticEmbeddingEngine::Synapse(client) => client
1400                    .probe_dimension(Duration::from_millis(self.timeout_ms))
1401                    .map_err(|error| error.to_string())?,
1402                SemanticEmbeddingEngine::OpenAiCompatible { .. }
1403                | SemanticEmbeddingEngine::Ollama { .. } => {
1404                    unreachable!("HTTP backends are handled above")
1405                }
1406            }
1407        };
1408
1409        self.dimension = Some(dimension);
1410        Ok(dimension)
1411    }
1412
1413    pub fn embed(&mut self, texts: Vec<String>) -> Result<Vec<Vec<f32>>, String> {
1414        if self.uses_http_embedding_backend() {
1415            self.embed_build_http_adaptive(texts)
1416        } else {
1417            let budget = self.build_request_budget(texts.len());
1418            self.embed_texts(texts, EmbeddingRequestPolicy::Build(budget))
1419        }
1420    }
1421
1422    pub fn embed_query_cached(
1423        &mut self,
1424        query: &str,
1425        budget: QueryBudget,
1426    ) -> Result<Vec<f32>, String> {
1427        if !self.query_instruction_logged {
1428            let root = self
1429                .query_instruction_root
1430                .as_deref()
1431                .map(|path| path.display().to_string())
1432                .unwrap_or_else(|| "<unscoped>".to_string());
1433            match self.query_instruction.as_deref() {
1434                Some(instruction) => slog_info!(
1435                    "semantic query instruction for root {} model {}: {:?}",
1436                    root,
1437                    self.model,
1438                    instruction
1439                ),
1440                None => slog_info!(
1441                    "semantic query instruction for root {} model {}: none",
1442                    root,
1443                    self.model
1444                ),
1445            }
1446            self.query_instruction_logged = true;
1447        }
1448        let query_text = query_embedding_text(query, self.query_instruction.as_deref());
1449        self.embed_texts(vec![query_text], EmbeddingRequestPolicy::Query(budget))?
1450            .into_iter()
1451            .next()
1452            .ok_or_else(|| "embedding model returned no query vector".to_string())
1453    }
1454
1455    pub fn query_embedding_cache_stats(&self) -> (u64, u64, usize) {
1456        (
1457            self.query_embedding_cache_hits,
1458            self.query_embedding_cache_misses,
1459            self.query_embedding_cache.len(),
1460        )
1461    }
1462
1463    fn embed_texts(
1464        &mut self,
1465        texts: Vec<String>,
1466        policy: EmbeddingRequestPolicy,
1467    ) -> Result<Vec<Vec<f32>>, String> {
1468        let query_cache_key = match policy {
1469            EmbeddingRequestPolicy::Build(_) => None,
1470            EmbeddingRequestPolicy::Query(_) => texts.first().cloned(),
1471        };
1472        let cached_vectors = query_cache_key.as_ref().and_then(|query| {
1473            self.query_embedding_cache.get(query).map(|vector| {
1474                self.query_embedding_cache_hits += 1;
1475                vec![vector.clone()]
1476            })
1477        });
1478        let cache_hit = u64::from(cached_vectors.is_some());
1479        if query_cache_key.is_some() && cached_vectors.is_none() {
1480            self.query_embedding_cache_misses += 1;
1481        }
1482        let requested = if query_cache_key.is_some() && cached_vectors.is_none() {
1483            texts.len() as u64
1484        } else {
1485            0
1486        };
1487        let live_calls = u64::from(requested > 0 && self.is_live_query_provider());
1488        crate::search_b2::embed_counter::record(crate::search_b2::embed_counter::EmbedCounts {
1489            requested,
1490            cache_hits: cache_hit,
1491            live_calls,
1492        });
1493        if let Some(vectors) = cached_vectors {
1494            return Ok(vectors);
1495        }
1496
1497        let result = match &mut self.engine {
1498            SemanticEmbeddingEngine::Local(model) => model
1499                .embed(&texts)
1500                .map_err(|error| format!("failed to embed batch: {error}")),
1501            SemanticEmbeddingEngine::OpenAiCompatible {
1502                client,
1503                model,
1504                base_url,
1505                api_key,
1506            } => {
1507                let expected_text_count = texts.len();
1508                let endpoint = build_openai_embeddings_endpoint(base_url);
1509                let body = serde_json::json!({
1510                    "input": texts,
1511                    "model": model,
1512                });
1513
1514                let raw = send_embedding_request(
1515                    || {
1516                        // `.json(&body)` sets Content-Type: application/json
1517                        // automatically. Do NOT add `.header("Content-Type",
1518                        // "application/json")` afterwards — RequestBuilder::header()
1519                        // calls HeaderMap::append, which produces TWO Content-Type
1520                        // headers on the wire. OpenAI's /v1/embeddings endpoint
1521                        // treats duplicate Content-Type as malformed and rejects
1522                        // the body with 400 "you must provide a model parameter"
1523                        // even when `model` is set. Verified end-to-end against
1524                        // api.openai.com. See issue #36.
1525                        let mut request = client.post(&endpoint).json(&body);
1526
1527                        if let Some(api_key) = api_key {
1528                            request = request.header("Authorization", format!("Bearer {api_key}"));
1529                        }
1530
1531                        request
1532                    },
1533                    "openai compatible",
1534                    policy,
1535                )?;
1536
1537                #[derive(Deserialize)]
1538                struct OpenAiResponse {
1539                    data: Vec<OpenAiEmbeddingResult>,
1540                }
1541
1542                #[derive(Deserialize)]
1543                struct OpenAiEmbeddingResult {
1544                    embedding: Vec<f32>,
1545                    index: Option<u32>,
1546                }
1547
1548                let parsed: OpenAiResponse = serde_json::from_str(&raw)
1549                    .map_err(|error| format!("invalid openai compatible response: {error}"))?;
1550                if parsed.data.len() != expected_text_count {
1551                    return Err(format!(
1552                        "openai compatible response returned {} embeddings for {} inputs",
1553                        parsed.data.len(),
1554                        expected_text_count
1555                    ));
1556                }
1557
1558                let mut vectors = vec![Vec::new(); parsed.data.len()];
1559                for (i, item) in parsed.data.into_iter().enumerate() {
1560                    let index = item.index.unwrap_or(i as u32) as usize;
1561                    if index >= vectors.len() {
1562                        return Err(
1563                            "openai compatible response contains invalid vector index".to_string()
1564                        );
1565                    }
1566                    vectors[index] = item.embedding;
1567                }
1568
1569                for vector in &vectors {
1570                    if vector.is_empty() {
1571                        return Err(
1572                            "openai compatible response contained missing vectors".to_string()
1573                        );
1574                    }
1575                }
1576
1577                self.dimension = vectors.first().map(Vec::len);
1578                Ok(vectors)
1579            }
1580            SemanticEmbeddingEngine::Ollama {
1581                client,
1582                model,
1583                base_url,
1584            } => {
1585                let expected_text_count = texts.len();
1586                let endpoint = build_ollama_embeddings_endpoint(base_url);
1587
1588                #[derive(Serialize)]
1589                struct OllamaPayload<'a> {
1590                    model: &'a str,
1591                    input: Vec<String>,
1592                }
1593
1594                let payload = OllamaPayload {
1595                    model,
1596                    input: texts,
1597                };
1598
1599                let raw = send_embedding_request(
1600                    || {
1601                        // `.json(&payload)` sets Content-Type automatically.
1602                        // Same duplicate-header trap as the OpenAI branch above
1603                        // — most Ollama servers tolerate it, but the
1604                        // single-Content-Type form is the correct one.
1605                        client.post(&endpoint).json(&payload)
1606                    },
1607                    "ollama",
1608                    policy,
1609                )?;
1610
1611                #[derive(Deserialize)]
1612                struct OllamaResponse {
1613                    embeddings: Vec<Vec<f32>>,
1614                }
1615
1616                let parsed: OllamaResponse = serde_json::from_str(&raw)
1617                    .map_err(|error| format!("invalid ollama response: {error}"))?;
1618                if parsed.embeddings.is_empty() {
1619                    return Err("ollama response returned no embeddings".to_string());
1620                }
1621                if parsed.embeddings.len() != expected_text_count {
1622                    return Err(format!(
1623                        "ollama response returned {} embeddings for {} inputs",
1624                        parsed.embeddings.len(),
1625                        expected_text_count
1626                    ));
1627                }
1628
1629                let vectors = parsed.embeddings;
1630                for vector in &vectors {
1631                    if vector.is_empty() {
1632                        return Err("ollama response contained empty embeddings".to_string());
1633                    }
1634                }
1635
1636                self.dimension = vectors.first().map(Vec::len);
1637                Ok(vectors)
1638            }
1639            SemanticEmbeddingEngine::Synapse(client) => {
1640                let vectors = match policy {
1641                    EmbeddingRequestPolicy::Build(_) => client
1642                        .embed_batch(&texts)
1643                        .map_err(|error| error.to_string())?,
1644                    EmbeddingRequestPolicy::Query(budget) => {
1645                        let timeout = Duration::from_millis(budget.timeout_ms);
1646                        texts
1647                            .iter()
1648                            .map(|text| client.embed_query(text, timeout))
1649                            .collect::<Result<Vec<_>, _>>()
1650                            .map_err(|error| error.to_string())?
1651                    }
1652                };
1653                self.dimension = vectors.first().map(Vec::len);
1654                Ok(vectors)
1655            }
1656        };
1657
1658        if let (Some(query), Ok(vectors)) = (query_cache_key, &result) {
1659            if let Some(vector) = vectors.first() {
1660                if self.query_embedding_cache.len() >= QUERY_EMBEDDING_CACHE_CAP {
1661                    if let Some(oldest) = self.query_embedding_cache_order.pop_front() {
1662                        self.query_embedding_cache.remove(&oldest);
1663                    }
1664                }
1665                self.query_embedding_cache
1666                    .insert(query.clone(), vector.clone());
1667                self.query_embedding_cache_order.push_back(query);
1668            }
1669        }
1670
1671        result
1672    }
1673
1674    fn is_live_query_provider(&self) -> bool {
1675        match &self.engine {
1676            // The offline fixture serves checked-in vectors over HTTP, so crossing
1677            // that socket is observable work but not a live model invocation.
1678            SemanticEmbeddingEngine::OpenAiCompatible { model, .. } => {
1679                model != crate::search_b2::embed_counter::FIXTURE_PROVIDER_MODEL
1680            }
1681            SemanticEmbeddingEngine::Local(_)
1682            | SemanticEmbeddingEngine::Ollama { .. }
1683            | SemanticEmbeddingEngine::Synapse(_) => true,
1684        }
1685    }
1686}
1687
1688/// Platform library filename for the plugin-managed ONNX Runtime.
1689///
1690/// Mirrors `ORT_PLATFORM_MAP` in packages/aft-bridge/src/onnx-runtime.ts. A
1691/// layout change on either side must update both — the plugin downloads the
1692/// runtime into `<storage_dir>/onnxruntime/<version>/` and this resolver must
1693/// find it at the same path.
1694#[cfg(target_os = "linux")]
1695const MANAGED_ORT_LIB_NAME: &str = "libonnxruntime.so";
1696#[cfg(target_os = "macos")]
1697const MANAGED_ORT_LIB_NAME: &str = "libonnxruntime.dylib";
1698#[cfg(target_os = "windows")]
1699const MANAGED_ORT_LIB_NAME: &str = "onnxruntime.dll";
1700
1701/// Minimum managed ONNX Runtime minor version this resolver will accept.
1702///
1703/// Mirrors the `REQUIRED_ORT_MIN_MINOR` floor in onnx-runtime.ts and the 1.20
1704/// floor `pre_validate_onnx_runtime` enforces. A managed install below this
1705/// would be handed to ort and rejected there, so the resolver must skip it.
1706const MANAGED_ORT_MIN_MINOR: u32 = 20;
1707
1708/// Resolve the plugin-managed ONNX Runtime under the ACTIVE storage dir and
1709/// export it as `ORT_DYLIB_PATH` for the process.
1710///
1711/// The plugin (packages/aft-bridge/src/onnx-runtime.ts) downloads the runtime
1712/// to `<storage_dir>/onnxruntime/<version>/<libname>` and exports ORT_DYLIB_PATH
1713/// into the child env. A bare `aft` binary has no such step: without this
1714/// resolver, `pre_validate_onnx_runtime` dlopens the bare soname, which only
1715/// works with a system-installed runtime. This makes the standalone binary pick
1716/// up the runtime the plugin already downloaded.
1717///
1718/// Resolution order:
1719///   1. If `ORT_DYLIB_PATH` is non-empty (an explicit user override, or the
1720///      plugin already exported it), do nothing — the caller's choice wins and
1721///      the resolver must not run at all.
1722///   2. Enumerate `<storage_dir>/onnxruntime/` version directories, keep only
1723///      parseable `1.x.y` with x >= 20, pick the highest, and if its library
1724///      file exists set `ORT_DYLIB_PATH` to it.
1725///   3. Otherwise leave the env untouched; `pre_validate_onnx_runtime` falls
1726///      back to the bare soname + doctor hint as before.
1727///
1728/// # Process-global env mutation
1729/// This sets a process-wide env var and must run ONCE at startup, before any
1730/// worker threads spawn (the warmup CLI main and the standalone main's semantic
1731/// init path). Setting it lazily from a worker thread would race ort's own
1732/// dlopen and other threads reading the env. The function is idempotent: once
1733/// `ORT_DYLIB_PATH` is set, subsequent calls short-circuit.
1734pub fn resolve_managed_onnx_runtime(storage_dir: &Path) {
1735    if onnx_runtime_override_configured_with(|name| std::env::var_os(name)) {
1736        return;
1737    }
1738    let Some(lib_path) = find_managed_onnx_runtime(storage_dir) else {
1739        return;
1740    };
1741    std::env::set_var("ORT_DYLIB_PATH", &lib_path);
1742    slog_info!(
1743        "using plugin-managed ONNX Runtime at {}",
1744        lib_path.display()
1745    );
1746}
1747
1748fn onnx_runtime_override_configured_with(
1749    lookup: impl FnOnce(&str) -> Option<std::ffi::OsString>,
1750) -> bool {
1751    lookup("ORT_DYLIB_PATH").is_some_and(|value| !value.is_empty())
1752}
1753
1754/// Find the highest compatible managed ONNX Runtime library under
1755/// `<storage_dir>/onnxruntime/`, or None when absent/incompatible.
1756///
1757/// Mirrors the plugin's `resolveCachedOnnxRuntimeDir`: the library may live at
1758/// the version root (the plugin's own flattened install) or under a `lib/`
1759/// subdir (manual Microsoft-archive installs, issue #71).
1760fn find_managed_onnx_runtime(storage_dir: &Path) -> Option<PathBuf> {
1761    let base = storage_dir.join("onnxruntime");
1762    let entries = std::fs::read_dir(&base).ok()?;
1763    #[cfg(test)]
1764    {
1765        // Test-only probe: counts how many times the resolver actually reads
1766        // the storage tree. Lets a negative-control test assert that a pre-set
1767        // ORT_DYLIB_PATH short-circuits the resolver without touching the tree.
1768        MANAGED_ORT_PROBE_READS.fetch_add(1, Ordering::Relaxed);
1769    }
1770    let mut best: Option<(u32, u32, PathBuf)> = None;
1771    for entry in entries.flatten() {
1772        let path = entry.path();
1773        if !path.is_dir() {
1774            continue;
1775        }
1776        let Some((major, minor)) = parse_managed_ort_version(&entry.file_name().to_string_lossy())
1777        else {
1778            continue;
1779        };
1780        if major != 1 || minor < MANAGED_ORT_MIN_MINOR {
1781            continue;
1782        }
1783        let Some(lib_path) = managed_ort_lib_in_version_dir(&path) else {
1784            continue;
1785        };
1786        if best
1787            .as_ref()
1788            .is_none_or(|(best_major, best_minor, _)| (major, minor) > (*best_major, *best_minor))
1789        {
1790            best = Some((major, minor, lib_path));
1791        }
1792    }
1793    best.map(|(_, _, path)| path)
1794}
1795
1796/// Locate the library file inside one `<version>` directory, preferring the
1797/// version root over a `lib/` subdir (mirrors `resolveCachedOnnxRuntimeDir`).
1798fn managed_ort_lib_in_version_dir(version_dir: &Path) -> Option<PathBuf> {
1799    let root = version_dir.join(MANAGED_ORT_LIB_NAME);
1800    if root.is_file() {
1801        return Some(root);
1802    }
1803    let lib_subdir = version_dir.join("lib").join(MANAGED_ORT_LIB_NAME);
1804    if lib_subdir.is_file() {
1805        return Some(lib_subdir);
1806    }
1807    None
1808}
1809
1810/// Parse a `major.minor.patch` triple from a version directory name. Returns
1811/// None for anything that is not exactly a three-part numeric version (so
1812/// non-version dirs and malformed names are ignored).
1813fn parse_managed_ort_version(name: &str) -> Option<(u32, u32)> {
1814    let mut parts = name.split('.');
1815    let major = parts.next()?.parse::<u32>().ok()?;
1816    let minor = parts.next()?.parse::<u32>().ok()?;
1817    let _patch = parts.next()?.parse::<u32>().ok()?;
1818    // Reject trailing junk like "1.24.4.tmp" or "1.24.4.5".
1819    if parts.next().is_some() {
1820        return None;
1821    }
1822    Some((major, minor))
1823}
1824
1825/// Pre-validate ONNX Runtime by attempting a raw dlopen before ort touches it.
1826/// This catches broken/incompatible .so files without risking a panic in the ort crate.
1827/// Also checks the runtime version via OrtGetApiBase if available.
1828pub fn pre_validate_onnx_runtime() -> Result<(), String> {
1829    let dylib_path = std::env::var("ORT_DYLIB_PATH").ok();
1830
1831    #[cfg(any(target_os = "linux", target_os = "macos"))]
1832    {
1833        #[cfg(target_os = "linux")]
1834        let default_name = "libonnxruntime.so";
1835        #[cfg(target_os = "macos")]
1836        let default_name = "libonnxruntime.dylib";
1837
1838        let lib_name = dylib_path.as_deref().unwrap_or(default_name);
1839
1840        unsafe {
1841            let c_name = std::ffi::CString::new(lib_name)
1842                .map_err(|e| format!("invalid library path: {}", e))?;
1843            let handle = libc::dlopen(c_name.as_ptr(), libc::RTLD_NOW);
1844            if handle.is_null() {
1845                let err = libc::dlerror();
1846                let msg = if err.is_null() {
1847                    "unknown dlopen error".to_string()
1848                } else {
1849                    std::ffi::CStr::from_ptr(err).to_string_lossy().into_owned()
1850                };
1851                return Err(format!(
1852                    "ONNX Runtime not found. dlopen('{}') failed: {}. \
1853                     Run `npx @cortexkit/aft doctor` to diagnose.",
1854                    lib_name, msg
1855                ));
1856            }
1857
1858            // Try to detect the runtime version from the actual loaded library
1859            // path first. A bare dlopen("libonnxruntime.so") may resolve to an
1860            // older system ORT through loader search paths; checking only the
1861            // caller-supplied soname would miss that and let ort fail opaquely.
1862            let (detected_version, version_source) =
1863                detect_ort_version_from_loaded_library(handle, lib_name);
1864
1865            libc::dlclose(handle);
1866
1867            // Check version compatibility — we need 1.20+.
1868            if let Some(ref version) = detected_version {
1869                let parts: Vec<&str> = version.split('.').collect();
1870                if let (Some(major), Some(minor)) = (
1871                    parts.first().and_then(|s| s.parse::<u32>().ok()),
1872                    parts.get(1).and_then(|s| s.parse::<u32>().ok()),
1873                ) {
1874                    if major != 1 || minor < 20 {
1875                        return Err(format_ort_version_mismatch(version, &version_source));
1876                    }
1877                }
1878            }
1879        }
1880    }
1881
1882    #[cfg(target_os = "windows")]
1883    {
1884        // Validate ONNX Runtime availability on Windows by loading the DLL
1885        // via LoadLibraryExW before the ort crate attempts its own LoadLibrary.
1886        // This way we can produce a friendly error (with installation hints)
1887        // instead of a raw LoadLibrary failure from deep inside fastembed.
1888        let lib_name = dylib_path.as_deref().unwrap_or("onnxruntime.dll");
1889
1890        // Use kernel32 LoadLibraryExW for the validation — built-in, no
1891        // crate dependency required. GetModuleFileNameW resolves the loaded
1892        // DLL path for version probing via the version.dll API.
1893        #[link(name = "kernel32")]
1894        extern "system" {
1895            fn LoadLibraryExW(
1896                lpLibFileName: *const u16,
1897                hFile: *mut std::ffi::c_void,
1898                dwFlags: u32,
1899            ) -> *mut std::ffi::c_void;
1900            fn FreeLibrary(hLibModule: *mut std::ffi::c_void) -> i32;
1901            fn GetModuleFileNameW(
1902                hModule: *mut std::ffi::c_void,
1903                lpFilename: *mut u16,
1904                nSize: u32,
1905            ) -> u32;
1906        }
1907
1908        #[link(name = "version")]
1909        extern "system" {
1910            fn GetFileVersionInfoSizeW(lptstrFilename: *const u16, lpdwHandle: *mut u32) -> u32;
1911            fn GetFileVersionInfoW(
1912                lptstrFilename: *const u16,
1913                dwHandle: u32,
1914                dwLen: u32,
1915                lpData: *mut std::ffi::c_void,
1916            ) -> i32;
1917            fn VerQueryValueW(
1918                pBlock: *mut std::ffi::c_void,
1919                lpSubBlock: *const u16,
1920                lplpBuffer: *mut *mut std::ffi::c_void,
1921                puLen: *mut u32,
1922            ) -> i32;
1923        }
1924
1925        #[repr(C)]
1926        struct VS_FIXEDFILEINFO {
1927            dw_signature: u32,
1928            dw_struc_version: u32,
1929            dw_file_version_ms: u32, // HIWORD major, LOWORD minor
1930            dw_file_version_ls: u32, // HIWORD build, LOWORD revision
1931            dw_product_version_ms: u32,
1932            dw_product_version_ls: u32,
1933            dw_file_flags_mask: u32,
1934            dw_file_flags: u32,
1935            dw_file_os: u32,
1936            dw_file_type: u32,
1937            dw_file_subtype: u32,
1938            dw_file_date_ms: u32,
1939            dw_file_date_ls: u32,
1940        }
1941
1942        unsafe {
1943            use std::os::windows::ffi::OsStrExt;
1944            let wide: Vec<u16> = std::ffi::OsStr::new(lib_name)
1945                .encode_wide()
1946                .chain(std::iter::once(0))
1947                .collect();
1948
1949            let handle = LoadLibraryExW(wide.as_ptr(), std::ptr::null_mut(), 0);
1950            if handle.is_null() {
1951                let err = std::io::Error::last_os_error();
1952                return Err(format!(
1953                    "ONNX Runtime not found. LoadLibraryExW('{}') failed: {}. \
1954                     Run `npx @cortexkit/aft doctor` to diagnose.",
1955                    lib_name, err
1956                ));
1957            }
1958
1959            // Probe the file version from PE resources so we can reject
1960            // outdated DLLs (e.g. v1.9.x) before the ort crate panics.
1961            let mut detected_major: u32 = 0;
1962            let mut detected_minor: u32 = 0;
1963            // Use MAX_UNICODEPATH (32767) so deeply nested ORT paths (e.g.
1964            // long NuGet package paths under %USERPROFILE%) never truncate.
1965            // GetModuleFileNameW truncates silently when the buffer is too
1966            // small, which causes version probing to fail and the version
1967            // check to be bypassed — better to allocate generously.
1968            let mut path_buf = [0u16; 32767];
1969            let path_len = GetModuleFileNameW(handle, path_buf.as_mut_ptr(), 32767);
1970            if path_len > 0 {
1971                let mut dummy_handle: u32 = 0;
1972                let info_size = GetFileVersionInfoSizeW(path_buf.as_ptr(), &mut dummy_handle);
1973                if info_size > 0 {
1974                    let mut info = vec![0u8; info_size as usize];
1975                    if GetFileVersionInfoW(
1976                        path_buf.as_ptr(),
1977                        0,
1978                        info_size,
1979                        info.as_mut_ptr() as *mut std::ffi::c_void,
1980                    ) != 0
1981                    {
1982                        let sub_block = "\\\0".encode_utf16().collect::<Vec<u16>>();
1983                        let mut vs_info: *mut std::ffi::c_void = std::ptr::null_mut();
1984                        let mut vs_len: u32 = 0;
1985                        if VerQueryValueW(
1986                            info.as_mut_ptr() as *mut std::ffi::c_void,
1987                            sub_block.as_ptr(),
1988                            &mut vs_info,
1989                            &mut vs_len,
1990                        ) != 0
1991                            && !vs_info.is_null()
1992                        {
1993                            let fixed = vs_info as *const VS_FIXEDFILEINFO;
1994                            detected_major = (*fixed).dw_file_version_ms >> 16;
1995                            detected_minor = (*fixed).dw_file_version_ms & 0xFFFF;
1996                        }
1997                    }
1998                }
1999            }
2000
2001            FreeLibrary(handle);
2002
2003            // Version compatibility check (mirrors the Linux/macOS path).
2004            // If version could not be detected (detected_major == 0) we let
2005            // the load succeed — the ort crate will diagnose further.
2006            if detected_major != 0 && (detected_major != 1 || detected_minor < 20) {
2007                let ver = format!("{}.{}", detected_major, detected_minor);
2008                return Err(format_ort_version_mismatch(&ver, lib_name));
2009            }
2010        }
2011    }
2012
2013    Ok(())
2014}
2015
2016#[cfg(any(target_os = "linux", target_os = "macos"))]
2017unsafe fn loaded_library_path_from_handle(handle: *mut std::ffi::c_void) -> Option<String> {
2018    let symbol_name = std::ffi::CString::new("OrtGetApiBase").ok()?;
2019    let symbol = unsafe { libc::dlsym(handle, symbol_name.as_ptr()) };
2020    if symbol.is_null() {
2021        return None;
2022    }
2023
2024    let mut info = std::mem::MaybeUninit::<libc::Dl_info>::uninit();
2025    if unsafe { libc::dladdr(symbol, info.as_mut_ptr()) } == 0 {
2026        return None;
2027    }
2028
2029    let info = unsafe { info.assume_init() };
2030    if info.dli_fname.is_null() {
2031        return None;
2032    }
2033
2034    Some(
2035        unsafe { std::ffi::CStr::from_ptr(info.dli_fname) }
2036            .to_string_lossy()
2037            .into_owned(),
2038    )
2039}
2040
2041#[cfg(any(target_os = "linux", target_os = "macos"))]
2042fn detect_ort_version_from_resolved_or_requested(
2043    resolved_path: Option<String>,
2044    requested_lib_name: &str,
2045) -> (Option<String>, String) {
2046    if let Some(path) = resolved_path {
2047        if let Some(version) = detect_ort_version_from_path(&path) {
2048            return (Some(version), path);
2049        }
2050        return (detect_ort_version_from_path(requested_lib_name), path);
2051    }
2052
2053    (
2054        detect_ort_version_from_path(requested_lib_name),
2055        requested_lib_name.to_string(),
2056    )
2057}
2058
2059#[cfg(any(target_os = "linux", target_os = "macos"))]
2060fn detect_ort_version_from_loaded_library(
2061    handle: *mut std::ffi::c_void,
2062    requested_lib_name: &str,
2063) -> (Option<String>, String) {
2064    detect_ort_version_from_resolved_or_requested(
2065        unsafe { loaded_library_path_from_handle(handle) },
2066        requested_lib_name,
2067    )
2068}
2069
2070/// Try to extract the ORT version from the library filename or resolved symlink.
2071/// Examples: "libonnxruntime.so.1.19.0" → "1.19.0", "libonnxruntime.1.24.4.dylib" → "1.24.4"
2072#[cfg(any(target_os = "linux", target_os = "macos"))]
2073fn detect_ort_version_from_path(lib_path: &str) -> Option<String> {
2074    let path = std::path::Path::new(lib_path);
2075
2076    // Try the path as given, then follow symlinks
2077    for candidate in [Some(path.to_path_buf()), std::fs::canonicalize(path).ok()]
2078        .into_iter()
2079        .flatten()
2080    {
2081        if let Some(name) = candidate.file_name().and_then(|n| n.to_str()) {
2082            if let Some(version) = extract_version_from_filename(name) {
2083                return Some(version);
2084            }
2085        }
2086    }
2087
2088    // Also check for versioned siblings in the same directory
2089    if let Some(parent) = path.parent() {
2090        if let Ok(entries) = std::fs::read_dir(parent) {
2091            for entry in entries.flatten() {
2092                if let Some(name) = entry.file_name().to_str() {
2093                    if name.starts_with("libonnxruntime") {
2094                        if let Some(version) = extract_version_from_filename(name) {
2095                            return Some(version);
2096                        }
2097                    }
2098                }
2099            }
2100        }
2101    }
2102
2103    None
2104}
2105
2106/// Extract version from filenames like "libonnxruntime.so.1.19.0" or "libonnxruntime.1.24.4.dylib"
2107#[cfg(any(target_os = "linux", target_os = "macos"))]
2108fn extract_version_from_filename(name: &str) -> Option<String> {
2109    // Match patterns: .so.X.Y.Z or .X.Y.Z.dylib or .X.Y.Z.so
2110    let re = regex::Regex::new(r"(\d+\.\d+\.\d+)").ok()?;
2111    re.find(name).map(|m| m.as_str().to_string())
2112}
2113
2114fn suggest_removal_command(lib_path: &str) -> String {
2115    if lib_path.starts_with("/usr/local/lib")
2116        || lib_path == "libonnxruntime.so"
2117        || lib_path == "libonnxruntime.dylib"
2118    {
2119        #[cfg(target_os = "linux")]
2120        return "   sudo rm /usr/local/lib/libonnxruntime* && sudo ldconfig".to_string();
2121        #[cfg(target_os = "macos")]
2122        return "   sudo rm /usr/local/lib/libonnxruntime*".to_string();
2123    }
2124    format!("   rm '{}'", lib_path)
2125}
2126
2127/// Build the user-facing error message for an incompatible ONNX Runtime
2128/// install. Extracted as a pure helper so we can unit-test the wording
2129/// stability — the auto-fix recommendation must always come first because
2130/// it's the only safe option, and the system-rm step must remain present
2131/// because some users prefer the system-wide cleanup path.
2132pub(crate) fn format_ort_version_mismatch(version: &str, lib_name: &str) -> String {
2133    format!(
2134        "ONNX Runtime version mismatch: found v{} at '{}', but AFT requires v1.20+. \
2135         Solutions:\n\
2136         1. Auto-fix (recommended): run `npx @cortexkit/aft doctor --fix`. \
2137         This downloads AFT-managed ONNX Runtime v1.24 into AFT's storage and \
2138         configures the bridge to load it instead of the system library — no \
2139         changes to '{}'.\n\
2140         2. Remove the old library and restart (AFT auto-downloads the correct version on next start):\n\
2141         {}\n\
2142         3. Or install ONNX Runtime 1.24 system-wide: https://github.com/microsoft/onnxruntime/releases/tag/v1.24.0\n\
2143         4. Run `npx @cortexkit/aft doctor` for full diagnostics.",
2144        version,
2145        lib_name,
2146        lib_name,
2147        suggest_removal_command(lib_name),
2148    )
2149}
2150
2151pub fn is_onnx_runtime_unavailable(message: &str) -> bool {
2152    if message.trim_start().starts_with("ONNX Runtime not found.") {
2153        return true;
2154    }
2155
2156    let message = message.to_ascii_lowercase();
2157    let mentions_onnx_runtime = ["onnx runtime", "onnxruntime", "libonnxruntime"]
2158        .iter()
2159        .any(|pattern| message.contains(pattern));
2160    let mentions_dynamic_load_failure = [
2161        "shared library",
2162        "dynamic library",
2163        "failed to load",
2164        "could not load",
2165        "unable to load",
2166        "dlopen",
2167        "loadlibrary",
2168        "no such file",
2169        "not found",
2170    ]
2171    .iter()
2172    .any(|pattern| message.contains(pattern));
2173
2174    mentions_onnx_runtime && mentions_dynamic_load_failure
2175}
2176
2177pub fn format_embedding_init_error(error: impl Display) -> String {
2178    let message = error.to_string();
2179
2180    if is_onnx_runtime_unavailable(&message) {
2181        return format!("{ONNX_RUNTIME_INSTALL_HINT} Original error: {message}");
2182    }
2183
2184    format!("failed to initialize semantic embedding model: {message}")
2185}
2186
2187/// A chunk of code ready for embedding — derived from a Symbol with context enrichment
2188#[derive(Debug, Clone)]
2189pub struct SemanticChunk {
2190    /// Absolute file path
2191    pub file: PathBuf,
2192    /// Symbol name
2193    pub name: String,
2194    /// Fully-qualified symbol name, when known from the outline scope chain.
2195    pub qualified_name: Option<String>,
2196    /// Symbol kind (function, class, struct, etc.)
2197    pub kind: SymbolKind,
2198    /// Line range (0-based internally, inclusive)
2199    pub start_line: u32,
2200    pub end_line: u32,
2201    /// Whether the symbol is exported
2202    pub exported: bool,
2203    /// The enriched text that gets embedded (name + file + kind + signature + body snippet)
2204    pub embed_text: String,
2205    /// Short code snippet for display in results
2206    pub snippet: String,
2207}
2208
2209/// A stored embedding entry — chunk metadata + vector
2210#[derive(Debug, Clone)]
2211pub struct EmbeddingEntry {
2212    chunk: SemanticChunk,
2213    vector: Vec<f32>,
2214    /// Cached L2 norm so searches only recompute the query norm. Remote embedding
2215    /// backends do not guarantee unit vectors, so keep the actual norm instead of
2216    /// assuming it is 1.0.
2217    norm: f32,
2218}
2219
2220impl EmbeddingEntry {
2221    fn new(chunk: SemanticChunk, vector: Vec<f32>) -> Self {
2222        let norm = vector_norm(&vector);
2223        Self {
2224            chunk,
2225            vector,
2226            norm,
2227        }
2228    }
2229}
2230
2231#[derive(Debug)]
2232struct SharedSemanticBase {
2233    entries: Vec<EmbeddingEntry>,
2234    file_mtimes: HashMap<PathBuf, SystemTime>,
2235    file_sizes: HashMap<PathBuf, u64>,
2236    any_missing_sizes: bool,
2237    file_hashes: HashMap<PathBuf, blake3::Hash>,
2238    dimension: usize,
2239    fingerprint: Option<SemanticIndexFingerprint>,
2240    deferred_files: HashSet<PathBuf>,
2241    dirty_paths: Arc<Mutex<Option<BTreeSet<PathBuf>>>>,
2242    persistence: Arc<Mutex<Option<SemanticPersistenceState>>>,
2243}
2244
2245#[derive(Debug, Clone, PartialEq, Eq, Hash)]
2246struct SharedSemanticBaseKey {
2247    artifact_cache_key: String,
2248    fingerprint: String,
2249    artifact_content_hash: blake3::Hash,
2250}
2251
2252type SharedSemanticBaseRegistry = HashMap<SharedSemanticBaseKey, Weak<SharedSemanticBase>>;
2253
2254fn shared_semantic_bases() -> &'static Mutex<SharedSemanticBaseRegistry> {
2255    static REGISTRY: OnceLock<Mutex<SharedSemanticBaseRegistry>> = OnceLock::new();
2256    REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
2257}
2258
2259static SHARED_SEMANTIC_BASE_LOADS: AtomicUsize = AtomicUsize::new(0);
2260static SHARED_SEMANTIC_BASE_HITS: AtomicUsize = AtomicUsize::new(0);
2261
2262impl SharedSemanticBase {
2263    fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
2264        let vector_bytes = self.entries.iter().fold(0u64, |bytes, entry| {
2265            bytes.saturating_add(
2266                crate::memory::usize_to_u64(entry.vector.len())
2267                    .saturating_mul(std::mem::size_of::<f32>() as u64),
2268            )
2269        });
2270        let text_bytes = self.entries.iter().fold(0u64, |bytes, entry| {
2271            bytes
2272                .saturating_add(crate::memory::path_bytes(&entry.chunk.file))
2273                .saturating_add(crate::memory::usize_to_u64(entry.chunk.name.len()))
2274                .saturating_add(
2275                    entry
2276                        .chunk
2277                        .qualified_name
2278                        .as_ref()
2279                        .map(|name| crate::memory::usize_to_u64(name.len()))
2280                        .unwrap_or(0),
2281                )
2282                .saturating_add(crate::memory::usize_to_u64(entry.chunk.embed_text.len()))
2283                .saturating_add(crate::memory::usize_to_u64(entry.chunk.snippet.len()))
2284        });
2285        let metadata_bytes = crate::memory::usize_to_u64(self.entries.len())
2286            .saturating_mul(std::mem::size_of::<EmbeddingEntry>() as u64)
2287            .saturating_add(
2288                self.file_mtimes
2289                    .keys()
2290                    .chain(self.file_sizes.keys())
2291                    .chain(self.file_hashes.keys())
2292                    .chain(self.deferred_files.iter())
2293                    .map(|path| crate::memory::path_bytes(path))
2294                    .fold(0u64, u64::saturating_add),
2295            )
2296            .saturating_add(
2297                crate::memory::usize_to_u64(self.file_mtimes.len())
2298                    .saturating_mul(std::mem::size_of::<SystemTime>() as u64),
2299            )
2300            .saturating_add(
2301                crate::memory::usize_to_u64(self.file_sizes.len())
2302                    .saturating_mul(std::mem::size_of::<u64>() as u64),
2303            )
2304            .saturating_add(
2305                crate::memory::usize_to_u64(self.file_hashes.len())
2306                    .saturating_mul(std::mem::size_of::<blake3::Hash>() as u64),
2307            );
2308        crate::memory::MemoryEstimate::estimated(
2309            vector_bytes
2310                .saturating_add(text_bytes)
2311                .saturating_add(metadata_bytes),
2312        )
2313        .count("entries", self.entries.len())
2314        .count("indexed_files", self.file_mtimes.len())
2315        .count_u64("vector_bytes", vector_bytes)
2316        .count_u64("text_bytes", text_bytes)
2317        .count_u64("metadata_bytes", metadata_bytes)
2318    }
2319}
2320
2321pub(crate) fn shared_semantic_bases_memory() -> crate::memory::MemoryEstimate {
2322    let mut registry = shared_semantic_bases()
2323        .lock()
2324        .unwrap_or_else(std::sync::PoisonError::into_inner);
2325    registry.retain(|_, base| base.strong_count() > 0);
2326    let bases = registry
2327        .values()
2328        .filter_map(Weak::upgrade)
2329        .collect::<Vec<_>>();
2330    let estimates = bases
2331        .iter()
2332        .map(|base| base.estimated_memory())
2333        .collect::<Vec<_>>();
2334    let bytes = estimates.iter().fold(0u64, |sum, estimate| {
2335        sum.saturating_add(estimate.estimated_bytes.unwrap_or(0))
2336    });
2337    let count_bytes = |name: &str| {
2338        estimates.iter().fold(0u64, |sum, estimate| {
2339            sum.saturating_add(estimate.counts.get(name).copied().unwrap_or(0))
2340        })
2341    };
2342    crate::memory::MemoryEstimate::estimated(bytes)
2343        .count("bases", bases.len())
2344        .count("entries", bases.iter().map(|base| base.entries.len()).sum())
2345        .count_u64("vector_bytes", count_bytes("vector_bytes"))
2346        .count_u64("text_bytes", count_bytes("text_bytes"))
2347        .count_u64("metadata_bytes", count_bytes("metadata_bytes"))
2348        .count_u64(
2349            "loads",
2350            SHARED_SEMANTIC_BASE_LOADS.load(Ordering::Relaxed) as u64,
2351        )
2352        .count_u64(
2353            "hits",
2354            SHARED_SEMANTIC_BASE_HITS.load(Ordering::Relaxed) as u64,
2355        )
2356}
2357
2358fn borrowed_artifact_identity(data_path: &Path) -> Result<(String, blake3::Hash), String> {
2359    let mut file = fs::File::open(data_path).map_err(|error| error.to_string())?;
2360    let mut hasher = blake3::Hasher::new();
2361    hasher
2362        .update_reader(&mut file)
2363        .map_err(|error| error.to_string())?;
2364    let artifact_content_hash = hasher.finalize();
2365
2366    let mut header = BufReader::new(fs::File::open(data_path).map_err(|error| error.to_string())?);
2367    let mut fixed = [0u8; HEADER_BYTES_V2];
2368    header
2369        .read_exact(&mut fixed)
2370        .map_err(|error| error.to_string())?;
2371    if fixed[0] != SEMANTIC_INDEX_VERSION_V6 && fixed[0] != SEMANTIC_INDEX_VERSION_V7 {
2372        return Err(format!(
2373            "unsupported semantic artifact version {}",
2374            fixed[0]
2375        ));
2376    }
2377    let fingerprint_len = u32::from_le_bytes(fixed[9..13].try_into().unwrap()) as usize;
2378    if fingerprint_len == 0 || fingerprint_len > 64 * 1024 {
2379        return Err("semantic artifact fingerprint is missing or oversized".to_string());
2380    }
2381    let mut fingerprint = vec![0u8; fingerprint_len];
2382    header
2383        .read_exact(&mut fingerprint)
2384        .map_err(|error| error.to_string())?;
2385    let fingerprint = String::from_utf8(fingerprint).map_err(|error| error.to_string())?;
2386    Ok((fingerprint, artifact_content_hash))
2387}
2388
2389/// The semantic index — stores embeddings for all symbols in a project.
2390/// Borrow-only roots retain only a root path plus an Arc to immutable relative data.
2391#[derive(Debug, Clone)]
2392pub struct SemanticIndex {
2393    entries: Vec<EmbeddingEntry>,
2394    /// Track which files are indexed and their mtime for staleness detection
2395    file_mtimes: HashMap<PathBuf, SystemTime>,
2396    /// Track indexed file sizes alongside mtimes for staleness detection
2397    file_sizes: HashMap<PathBuf, u64>,
2398    /// Avoid walking every indexed path on warm refreshes once size metadata is complete.
2399    any_missing_sizes: bool,
2400    file_hashes: HashMap<PathBuf, blake3::Hash>,
2401    /// Embedding dimension (384 for MiniLM-L6-v2)
2402    dimension: usize,
2403    fingerprint: Option<SemanticIndexFingerprint>,
2404    project_root: PathBuf,
2405    deferred_files: HashSet<PathBuf>,
2406    shared_base: Option<Arc<SharedSemanticBase>>,
2407    /// Paths whose complete persisted rows must replace prior rows. `None` is
2408    /// reserved for indexes created by callers that cannot report mutations.
2409    dirty_paths: Arc<Mutex<Option<BTreeSet<PathBuf>>>>,
2410    persistence: Arc<Mutex<Option<SemanticPersistenceState>>>,
2411    last_append_read_bytes: Arc<AtomicUsize>,
2412    #[cfg(test)]
2413    removal_retain_passes: usize,
2414}
2415
2416#[derive(Debug, Clone, Copy)]
2417struct IndexedFileMetadata {
2418    mtime: SystemTime,
2419    size: u64,
2420    content_hash: blake3::Hash,
2421}
2422
2423#[derive(Debug, Default, Clone, Copy)]
2424struct SemanticCollectPhaseTimings {
2425    sched: Duration,
2426    read_hash: Duration,
2427    parse: Duration,
2428    extract: Duration,
2429    build: Duration,
2430}
2431
2432impl SemanticCollectPhaseTimings {
2433    fn add_assign(&mut self, other: Self) {
2434        self.sched += other.sched;
2435        self.read_hash += other.read_hash;
2436        self.parse += other.parse;
2437        self.extract += other.extract;
2438        self.build += other.build;
2439    }
2440}
2441
2442type CollectedSemanticFile = (
2443    PathBuf,
2444    Result<(IndexedFileMetadata, Vec<SemanticChunk>), String>,
2445    SemanticCollectPhaseTimings,
2446);
2447
2448/// Result of an incremental refresh of the semantic index. Counts are file
2449/// counts; `total_processed` is the number of current/deleted files considered.
2450#[derive(Debug, Default, Clone, Copy)]
2451pub struct RefreshSummary {
2452    pub changed: usize,
2453    pub added: usize,
2454    pub deleted: usize,
2455    pub total_processed: usize,
2456}
2457
2458impl RefreshSummary {
2459    /// True when no files were touched.
2460    pub fn is_noop(&self) -> bool {
2461        self.changed == 0 && self.added == 0 && self.deleted == 0
2462    }
2463}
2464
2465#[derive(Debug, Default)]
2466pub struct InvalidatedFilesRefresh {
2467    /// Full replacement entries for `completed_paths`, not just newly embedded
2468    /// chunks. `apply_refresh_update` removes completed paths before extending
2469    /// this set, so reused chunks must travel in this delta too.
2470    pub added_entries: Vec<EmbeddingEntry>,
2471    pub updated_metadata: Vec<(PathBuf, FileFreshness)>,
2472    pub completed_paths: Vec<PathBuf>,
2473    pub summary: RefreshSummary,
2474}
2475
2476#[derive(Debug, Clone)]
2477struct ReusableEmbedding {
2478    embed_text: String,
2479    vector: Vec<f32>,
2480}
2481
2482type ChunkReuseMap = HashMap<PathBuf, HashMap<blake3::Hash, Vec<ReusableEmbedding>>>;
2483
2484const SEMANTIC_BLOB_PAYLOAD_VERSION: u8 = 1;
2485
2486fn extend_reuse_map_from_semantic_blob(
2487    reuse_map: &mut ChunkReuseMap,
2488    file: &Path,
2489    payload: &[u8],
2490    expected_fingerprint: &str,
2491    expected_dimension: usize,
2492) -> Result<(), String> {
2493    let mut reader = CountingReader::with_bytes_read(Cursor::new(payload), 0);
2494    let version = read_u8_stream(&mut reader, "missing semantic blob version")?;
2495    if version != SEMANTIC_BLOB_PAYLOAD_VERSION {
2496        return Err(format!("unsupported semantic blob version {version}"));
2497    }
2498    for (label, expected) in [
2499        ("chunker", crate::blob_store::SEMANTIC_PRODUCER_VERSION),
2500        ("template", crate::blob_store::SEMANTIC_PRODUCER_VERSION),
2501        ("model", expected_fingerprint),
2502    ] {
2503        let actual = read_string_stream(&mut reader, Some(payload.len()))?;
2504        if actual != expected {
2505            return Err(format!("semantic blob {label} fingerprint mismatch"));
2506        }
2507    }
2508    let entry_count = read_u32_stream(&mut reader)? as usize;
2509    if entry_count > MAX_ENTRIES {
2510        return Err(format!("too many semantic blob entries {entry_count}"));
2511    }
2512    let vector_bytes = expected_dimension
2513        .checked_mul(F32_BYTES)
2514        .ok_or_else(|| "semantic blob vector length overflow".to_string())?;
2515    for _ in 0..entry_count {
2516        let _name = read_string_stream(&mut reader, Some(payload.len()))?;
2517        let _qualified_name = read_string_stream(&mut reader, Some(payload.len()))?;
2518        let _kind = read_u8_stream(&mut reader, "missing semantic blob symbol kind")?;
2519        let _start_line = read_u32_stream(&mut reader)?;
2520        let _end_line = read_u32_stream(&mut reader)?;
2521        let _exported = read_u8_stream(&mut reader, "missing semantic blob export flag")?;
2522        let _snippet = read_string_stream(&mut reader, Some(payload.len()))?;
2523        let embed_text = read_string_stream(&mut reader, Some(payload.len()))?;
2524        let raw_vector = read_blob_bytes(&mut reader, payload.len())?;
2525        if raw_vector.len() != vector_bytes {
2526            return Err(format!(
2527                "semantic blob vector has {} bytes, expected {vector_bytes}",
2528                raw_vector.len()
2529            ));
2530        }
2531        let vector = raw_vector
2532            .chunks_exact(F32_BYTES)
2533            .map(|bytes| f32::from_le_bytes(bytes.try_into().expect("four-byte float")))
2534            .collect::<Vec<_>>();
2535        reuse_map
2536            .entry(file.to_path_buf())
2537            .or_default()
2538            .entry(blake3::hash(embed_text.as_bytes()))
2539            .or_default()
2540            .push(ReusableEmbedding { embed_text, vector });
2541    }
2542    if reader.bytes_read() != payload.len() {
2543        return Err("trailing bytes after semantic blob payload".to_string());
2544    }
2545    Ok(())
2546}
2547
2548fn read_blob_bytes<R: Read>(
2549    reader: &mut CountingReader<R>,
2550    total_len: usize,
2551) -> Result<Vec<u8>, String> {
2552    let len = read_u32_stream(reader)? as usize;
2553    if reader.bytes_read().saturating_add(len) > total_len {
2554        return Err("unexpected end of semantic blob bytes".to_string());
2555    }
2556    let mut bytes = vec![0; len];
2557    read_exact_stream(reader, &mut bytes, "unexpected end of semantic blob bytes")?;
2558    Ok(bytes)
2559}
2560
2561/// Search result from a semantic query
2562#[derive(Debug, Clone)]
2563pub struct SemanticResult {
2564    pub file: PathBuf,
2565    pub name: String,
2566    pub qualified_name: Option<String>,
2567    pub kind: SymbolKind,
2568    pub start_line: u32,
2569    pub end_line: u32,
2570    pub exported: bool,
2571    pub snippet: String,
2572    pub score: f32,
2573    pub rank_score: f32,
2574    pub cap_protected: bool,
2575    pub source: &'static str,
2576}
2577
2578fn relativize_semantic_map<T>(
2579    project_root: &Path,
2580    map: HashMap<PathBuf, T>,
2581) -> Option<HashMap<PathBuf, T>> {
2582    map.into_iter()
2583        .map(|(path, value)| cache_relative_path(project_root, &path).map(|path| (path, value)))
2584        .collect()
2585}
2586
2587#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2588struct SemanticArtifactIdentity {
2589    bytes: u64,
2590    modified_nanos: Option<u128>,
2591}
2592
2593#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2594struct SemanticPersistenceState {
2595    identity: SemanticArtifactIdentity,
2596    base_bytes: usize,
2597    segment_count: usize,
2598    segment_bytes: usize,
2599    valid_bytes: usize,
2600}
2601
2602#[derive(Debug, Clone, Copy)]
2603struct SemanticArtifactLayout {
2604    identity: SemanticArtifactIdentity,
2605    base_bytes: usize,
2606    valid_bytes: usize,
2607    segment_count: usize,
2608    segment_bytes: usize,
2609    torn_tail: bool,
2610    bytes_read: usize,
2611}
2612
2613#[derive(Debug)]
2614struct LoadedSemanticArtifact {
2615    index: SemanticIndex,
2616    base_bytes: usize,
2617    valid_bytes: usize,
2618    segment_count: usize,
2619    segment_bytes: usize,
2620    torn_tail: bool,
2621}
2622
2623fn semantic_artifact_identity(path: &Path) -> Option<SemanticArtifactIdentity> {
2624    let metadata = path.metadata().ok()?;
2625    let modified_nanos = metadata
2626        .modified()
2627        .ok()
2628        .and_then(|modified| modified.duration_since(SystemTime::UNIX_EPOCH).ok())
2629        .map(|duration| duration.as_nanos());
2630    Some(SemanticArtifactIdentity {
2631        bytes: metadata.len(),
2632        modified_nanos,
2633    })
2634}
2635
2636fn semantic_persistence_lock_wait(artifact_bytes: u64) -> Duration {
2637    let proportional_seconds = artifact_bytes
2638        .div_ceil(SEMANTIC_PERSIST_LOCK_BYTES_PER_SECOND)
2639        .saturating_add(2);
2640    SEMANTIC_PERSIST_LOCK_MIN_WAIT.max(Duration::from_secs(proportional_seconds))
2641}
2642
2643fn acquire_semantic_persistence_lock(
2644    dir: &Path,
2645    artifact_bytes: u64,
2646) -> io::Result<fs_lock::LockGuard> {
2647    fs_lock::try_acquire(
2648        &dir.join("semantic.persist.lock"),
2649        semantic_persistence_lock_wait(artifact_bytes),
2650    )
2651    .map_err(|error| match error {
2652        fs_lock::AcquireError::Timeout => {
2653            io::Error::other("timed out acquiring semantic persistence lock")
2654        }
2655        fs_lock::AcquireError::Io(error) => error,
2656    })
2657}
2658
2659fn semantic_entry_cmp(left: &&EmbeddingEntry, right: &&EmbeddingEntry) -> std::cmp::Ordering {
2660    let left = *left;
2661    let right = *right;
2662    left.chunk
2663        .file
2664        .cmp(&right.chunk.file)
2665        .then_with(|| left.chunk.name.cmp(&right.chunk.name))
2666        .then_with(|| left.chunk.qualified_name.cmp(&right.chunk.qualified_name))
2667        .then_with(|| {
2668            symbol_kind_to_u8(&left.chunk.kind).cmp(&symbol_kind_to_u8(&right.chunk.kind))
2669        })
2670        .then_with(|| left.chunk.start_line.cmp(&right.chunk.start_line))
2671        .then_with(|| left.chunk.end_line.cmp(&right.chunk.end_line))
2672        .then_with(|| left.chunk.exported.cmp(&right.chunk.exported))
2673        .then_with(|| left.chunk.snippet.cmp(&right.chunk.snippet))
2674        .then_with(|| left.chunk.embed_text.cmp(&right.chunk.embed_text))
2675        .then_with(|| {
2676            left.vector
2677                .iter()
2678                .map(|value| value.to_bits())
2679                .cmp(right.vector.iter().map(|value| value.to_bits()))
2680        })
2681}
2682
2683fn semantic_entry_persistence_eq(left: &EmbeddingEntry, right: &EmbeddingEntry) -> bool {
2684    left.chunk.file == right.chunk.file
2685        && left.chunk.name == right.chunk.name
2686        && left.chunk.qualified_name == right.chunk.qualified_name
2687        && left.chunk.kind == right.chunk.kind
2688        && left.chunk.start_line == right.chunk.start_line
2689        && left.chunk.end_line == right.chunk.end_line
2690        && left.chunk.exported == right.chunk.exported
2691        && left.chunk.snippet == right.chunk.snippet
2692        && left.chunk.embed_text == right.chunk.embed_text
2693        && left.vector.len() == right.vector.len()
2694        && left
2695            .vector
2696            .iter()
2697            .zip(&right.vector)
2698            .all(|(left, right)| left.to_bits() == right.to_bits())
2699}
2700
2701fn semantic_entries_by_file(index: &SemanticIndex) -> HashMap<&Path, Vec<&EmbeddingEntry>> {
2702    let mut by_file: HashMap<&Path, Vec<&EmbeddingEntry>> = HashMap::new();
2703    for entry in &index.entries {
2704        by_file
2705            .entry(entry.chunk.file.as_path())
2706            .or_default()
2707            .push(entry);
2708    }
2709    for entries in by_file.values_mut() {
2710        entries.sort_by(semantic_entry_cmp);
2711    }
2712    by_file
2713}
2714
2715fn semantic_changed_paths(previous: &SemanticIndex, current: &SemanticIndex) -> BTreeSet<PathBuf> {
2716    let previous_entries = semantic_entries_by_file(previous);
2717    let current_entries = semantic_entries_by_file(current);
2718    let mut paths = BTreeSet::new();
2719    paths.extend(previous.file_mtimes.keys().cloned());
2720    paths.extend(current.file_mtimes.keys().cloned());
2721    paths.extend(previous_entries.keys().map(|path| (*path).to_path_buf()));
2722    paths.extend(current_entries.keys().map(|path| (*path).to_path_buf()));
2723    paths
2724        .into_iter()
2725        .filter(|path| {
2726            if previous.file_mtimes.get(path) != current.file_mtimes.get(path)
2727                || previous.file_sizes.get(path) != current.file_sizes.get(path)
2728                || previous.file_hashes.get(path) != current.file_hashes.get(path)
2729            {
2730                return true;
2731            }
2732            let previous = previous_entries
2733                .get(path.as_path())
2734                .map(Vec::as_slice)
2735                .unwrap_or_default();
2736            let current = current_entries
2737                .get(path.as_path())
2738                .map(Vec::as_slice)
2739                .unwrap_or_default();
2740            previous.len() != current.len()
2741                || !previous
2742                    .iter()
2743                    .zip(current)
2744                    .all(|(previous, current)| semantic_entry_persistence_eq(previous, current))
2745        })
2746        .collect()
2747}
2748
2749impl SemanticIndex {
2750    fn from_shared_base(project_root: PathBuf, shared_base: Arc<SharedSemanticBase>) -> Self {
2751        debug_assert!(project_root.is_absolute());
2752        Self {
2753            entries: Vec::new(),
2754            file_mtimes: HashMap::new(),
2755            file_sizes: HashMap::new(),
2756            any_missing_sizes: false,
2757            file_hashes: HashMap::new(),
2758            dimension: shared_base.dimension,
2759            fingerprint: shared_base.fingerprint.clone(),
2760            project_root,
2761            deferred_files: HashSet::new(),
2762            dirty_paths: Arc::clone(&shared_base.dirty_paths),
2763            persistence: Arc::clone(&shared_base.persistence),
2764            last_append_read_bytes: Arc::new(AtomicUsize::new(0)),
2765            shared_base: Some(shared_base),
2766            #[cfg(test)]
2767            removal_retain_passes: 0,
2768        }
2769    }
2770
2771    pub(crate) fn adopt_frozen_base_for_root(
2772        &mut self,
2773        project_root: &Path,
2774        config: &SemanticBackendConfig,
2775    ) -> Option<Self> {
2776        let expected = SemanticIndexFingerprint::for_config_dimension(config, self.dimension());
2777        if !self
2778            .fingerprint()
2779            .is_some_and(|fingerprint| fingerprint.matches(&expected))
2780        {
2781            return None;
2782        }
2783
2784        if let Some(base) = self.shared_base.as_ref() {
2785            return Some(Self::from_shared_base(
2786                project_root.to_path_buf(),
2787                Arc::clone(base),
2788            ));
2789        }
2790
2791        if !self.paths_are_shareable() {
2792            return None;
2793        }
2794
2795        // Move the resident vectors into one immutable relative-path base rather
2796        // than cloning them. The owner and each matching worktree then retain
2797        // only an Arc plus their own root for path projection.
2798        let owner_root = self.project_root.clone();
2799        let placeholder = Self::new(owner_root.clone(), self.dimension());
2800        let private = std::mem::replace(self, placeholder);
2801        let base = match private.into_shared_base() {
2802            Ok(base) => Arc::new(base),
2803            Err(private) => {
2804                // Unreachable after the shareability check (this index is held
2805                // exclusively, so no path can appear between the check and the
2806                // move), but a private index is never worth a process: restore
2807                // it and decline to share.
2808                crate::slog_warn!(
2809                    "semantic index for {} could not be frozen into a shared base; keeping it private",
2810                    owner_root.display()
2811                );
2812                *self = private;
2813                return None;
2814            }
2815        };
2816        *self = Self::from_shared_base(owner_root, Arc::clone(&base));
2817        Some(Self::from_shared_base(project_root.to_path_buf(), base))
2818    }
2819
2820    /// Every path this index carries must be expressible relative to its own
2821    /// root before the index can be frozen into a base shared across roots.
2822    /// The dirty-path set belongs here too: it is persisted with the base, and
2823    /// a delta path outside the root once turned the freeze into a panic.
2824    fn paths_are_shareable(&self) -> bool {
2825        let shareable = |path: &Path| cache_relative_path(&self.project_root, path).is_some();
2826        self.entries
2827            .iter()
2828            .all(|entry| shareable(&entry.chunk.file))
2829            && self
2830                .file_mtimes
2831                .keys()
2832                .chain(self.file_sizes.keys())
2833                .chain(self.file_hashes.keys())
2834                .chain(self.deferred_files.iter())
2835                .all(|path| shareable(path))
2836            && self
2837                .dirty_paths
2838                .lock()
2839                .unwrap_or_else(std::sync::PoisonError::into_inner)
2840                .as_ref()
2841                .is_none_or(|paths| paths.iter().all(|path| shareable(path)))
2842    }
2843
2844    fn into_shared_base(mut self) -> Result<SharedSemanticBase, Self> {
2845        // Relativize every path before moving anything, so a path outside the
2846        // root hands the index back intact instead of leaving a half-moved
2847        // one behind. Only the path strings are copied here; the vectors move.
2848        let root = self.project_root.clone();
2849        let relative = |path: &Path| cache_relative_path(&root, path);
2850        let Some(entry_files) = self
2851            .entries
2852            .iter()
2853            .map(|entry| relative(&entry.chunk.file))
2854            .collect::<Option<Vec<_>>>()
2855        else {
2856            return Err(self);
2857        };
2858        let Some(deferred_files) = self
2859            .deferred_files
2860            .iter()
2861            .map(|path| relative(path))
2862            .collect::<Option<HashSet<_>>>()
2863        else {
2864            return Err(self);
2865        };
2866        let dirty_paths = {
2867            let guard = self
2868                .dirty_paths
2869                .lock()
2870                .unwrap_or_else(std::sync::PoisonError::into_inner);
2871            match guard.as_ref() {
2872                Some(paths) => paths
2873                    .iter()
2874                    .map(|path| relative(path))
2875                    .collect::<Option<BTreeSet<_>>>()
2876                    .map(Some),
2877                None => Some(None),
2878            }
2879        };
2880        let Some(dirty_paths) = dirty_paths else {
2881            return Err(self);
2882        };
2883        let (Some(file_mtimes), Some(file_sizes), Some(file_hashes)) = (
2884            relativize_semantic_map(&root, self.file_mtimes.clone()),
2885            relativize_semantic_map(&root, self.file_sizes.clone()),
2886            relativize_semantic_map(&root, self.file_hashes.clone()),
2887        ) else {
2888            return Err(self);
2889        };
2890        for (entry, file) in self.entries.iter_mut().zip(entry_files) {
2891            entry.chunk.file = file;
2892        }
2893        let persistence = *self
2894            .persistence
2895            .lock()
2896            .unwrap_or_else(std::sync::PoisonError::into_inner);
2897        Ok(SharedSemanticBase {
2898            entries: self.entries,
2899            file_mtimes,
2900            file_sizes,
2901            any_missing_sizes: self.any_missing_sizes,
2902            file_hashes,
2903            dimension: self.dimension,
2904            fingerprint: self.fingerprint,
2905            deferred_files,
2906            dirty_paths: Arc::new(Mutex::new(dirty_paths)),
2907            persistence: Arc::new(Mutex::new(persistence)),
2908        })
2909    }
2910
2911    fn materialize_shared_base(&mut self) {
2912        let Some(base) = self.shared_base.take() else {
2913            return;
2914        };
2915        self.entries = base
2916            .entries
2917            .iter()
2918            .cloned()
2919            .map(|mut entry| {
2920                entry.chunk.file = self.project_root.join(&entry.chunk.file);
2921                entry
2922            })
2923            .collect();
2924        self.file_mtimes = base
2925            .file_mtimes
2926            .iter()
2927            .map(|(path, value)| (self.project_root.join(path), *value))
2928            .collect();
2929        self.file_sizes = base
2930            .file_sizes
2931            .iter()
2932            .map(|(path, value)| (self.project_root.join(path), *value))
2933            .collect();
2934        self.any_missing_sizes = base.any_missing_sizes;
2935        self.file_hashes = base
2936            .file_hashes
2937            .iter()
2938            .map(|(path, value)| (self.project_root.join(path), *value))
2939            .collect();
2940        self.dimension = base.dimension;
2941        self.fingerprint = base.fingerprint.clone();
2942        self.deferred_files = base
2943            .deferred_files
2944            .iter()
2945            .map(|path| self.project_root.join(path))
2946            .collect();
2947        let dirty_paths = base
2948            .dirty_paths
2949            .lock()
2950            .unwrap_or_else(std::sync::PoisonError::into_inner)
2951            .as_ref()
2952            .map(|paths| {
2953                paths
2954                    .iter()
2955                    .map(|path| self.project_root.join(path))
2956                    .collect()
2957            });
2958        let persistence = *base
2959            .persistence
2960            .lock()
2961            .unwrap_or_else(std::sync::PoisonError::into_inner);
2962        self.set_dirty_paths(dirty_paths);
2963        self.set_persistence(persistence);
2964    }
2965
2966    pub fn new(project_root: PathBuf, dimension: usize) -> Self {
2967        debug_assert!(project_root.is_absolute());
2968        Self {
2969            entries: Vec::new(),
2970            file_mtimes: HashMap::new(),
2971            file_sizes: HashMap::new(),
2972            any_missing_sizes: false,
2973            file_hashes: HashMap::new(),
2974            dimension,
2975            fingerprint: None,
2976            project_root,
2977            deferred_files: HashSet::new(),
2978            shared_base: None,
2979            dirty_paths: Arc::new(Mutex::new(None)),
2980            persistence: Arc::new(Mutex::new(None)),
2981            last_append_read_bytes: Arc::new(AtomicUsize::new(0)),
2982            #[cfg(test)]
2983            removal_retain_passes: 0,
2984        }
2985    }
2986
2987    /// Number of embedded symbol entries.
2988    pub fn entry_count(&self) -> usize {
2989        self.shared_base
2990            .as_ref()
2991            .map(|base| base.entries.len())
2992            .unwrap_or_else(|| self.entries.len())
2993    }
2994
2995    /// Estimate resident semantic-index bytes from the vectors and metadata
2996    /// actually held by each entry. This intentionally excludes allocator and
2997    /// hash-table bucket overhead, which are not cheaply observable.
2998    pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
2999        if let Some(base) = &self.shared_base {
3000            return crate::memory::MemoryEstimate::estimated(0)
3001                .count("entries", base.entries.len())
3002                .count("dimensions", base.dimension)
3003                .count("indexed_files", base.file_mtimes.len())
3004                .count("shared_base_entries", base.entries.len())
3005                .count("overlay_entries", 0)
3006                .count_u64("vector_bytes", 0)
3007                .count_u64("text_bytes", 0)
3008                .count_u64("metadata_bytes", 0);
3009        }
3010        if self.entries.is_empty()
3011            && self.file_mtimes.is_empty()
3012            && self.file_sizes.is_empty()
3013            && self.file_hashes.is_empty()
3014            && self.deferred_files.is_empty()
3015        {
3016            return crate::memory::MemoryEstimate::estimated(0)
3017                .count("entries", 0)
3018                .count("dimensions", self.dimension)
3019                .count("indexed_files", 0)
3020                .count_u64("vector_bytes", 0)
3021                .count_u64("text_bytes", 0)
3022                .count_u64("metadata_bytes", 0)
3023                .count_u64("average_text_bytes", 0)
3024                .count_u64("average_metadata_bytes", 0);
3025        }
3026        let vector_bytes = self.entries.iter().fold(0u64, |bytes, entry| {
3027            bytes.saturating_add(
3028                crate::memory::usize_to_u64(entry.vector.len())
3029                    .saturating_mul(std::mem::size_of::<f32>() as u64),
3030            )
3031        });
3032        let text_bytes = self.entries.iter().fold(0u64, |bytes, entry| {
3033            let chunk = &entry.chunk;
3034            bytes
3035                .saturating_add(crate::memory::path_bytes(&chunk.file))
3036                .saturating_add(crate::memory::usize_to_u64(chunk.name.len()))
3037                .saturating_add(
3038                    chunk
3039                        .qualified_name
3040                        .as_ref()
3041                        .map(|name| crate::memory::usize_to_u64(name.len()))
3042                        .unwrap_or(0),
3043                )
3044                .saturating_add(crate::memory::usize_to_u64(chunk.embed_text.len()))
3045                .saturating_add(crate::memory::usize_to_u64(chunk.snippet.len()))
3046        });
3047        let entry_metadata_bytes = crate::memory::usize_to_u64(self.entries.len())
3048            .saturating_mul(std::mem::size_of::<EmbeddingEntry>() as u64);
3049        let file_metadata_bytes = self
3050            .file_mtimes
3051            .keys()
3052            .chain(self.file_sizes.keys())
3053            .chain(self.file_hashes.keys())
3054            .chain(self.deferred_files.iter())
3055            .map(|path| crate::memory::path_bytes(path))
3056            .fold(0u64, u64::saturating_add)
3057            .saturating_add(
3058                crate::memory::usize_to_u64(self.file_mtimes.len())
3059                    .saturating_mul(std::mem::size_of::<SystemTime>() as u64),
3060            )
3061            .saturating_add(
3062                crate::memory::usize_to_u64(self.file_sizes.len())
3063                    .saturating_mul(std::mem::size_of::<u64>() as u64),
3064            )
3065            .saturating_add(
3066                crate::memory::usize_to_u64(self.file_hashes.len())
3067                    .saturating_mul(std::mem::size_of::<blake3::Hash>() as u64),
3068            );
3069        let index_metadata_bytes = crate::memory::path_bytes(&self.project_root).saturating_add(
3070            self.fingerprint
3071                .as_ref()
3072                .map(|fingerprint| {
3073                    crate::memory::usize_to_u64(fingerprint.backend.len())
3074                        .saturating_add(crate::memory::usize_to_u64(fingerprint.model.len()))
3075                        .saturating_add(crate::memory::usize_to_u64(fingerprint.base_url.len()))
3076                })
3077                .unwrap_or(0),
3078        );
3079        let metadata_bytes = entry_metadata_bytes
3080            .saturating_add(file_metadata_bytes)
3081            .saturating_add(index_metadata_bytes);
3082        let entry_count = crate::memory::usize_to_u64(self.entries.len());
3083        crate::memory::MemoryEstimate::estimated(
3084            vector_bytes
3085                .saturating_add(text_bytes)
3086                .saturating_add(metadata_bytes),
3087        )
3088        .count("entries", self.entries.len())
3089        .count("dimensions", self.dimension)
3090        .count("indexed_files", self.file_mtimes.len())
3091        .count_u64("vector_bytes", vector_bytes)
3092        .count_u64("text_bytes", text_bytes)
3093        .count_u64("metadata_bytes", metadata_bytes)
3094        .count_u64(
3095            "average_text_bytes",
3096            text_bytes.checked_div(entry_count).unwrap_or(0),
3097        )
3098        .count_u64(
3099            "average_metadata_bytes",
3100            metadata_bytes.checked_div(entry_count).unwrap_or(0),
3101        )
3102    }
3103
3104    /// Number of files currently tracked by the semantic index.
3105    pub fn indexed_file_count(&self) -> usize {
3106        self.shared_base
3107            .as_ref()
3108            .map(|base| base.file_mtimes.len())
3109            .unwrap_or_else(|| self.file_mtimes.len())
3110    }
3111
3112    /// Human-readable status label for the index.
3113    pub fn status_label(&self) -> &'static str {
3114        if self.entry_count() == 0 {
3115            "empty"
3116        } else {
3117            "ready"
3118        }
3119    }
3120
3121    fn collect_chunks(
3122        project_root: &Path,
3123        files: &[PathBuf],
3124        embed_text_caps: EmbedTextCaps,
3125    ) -> (Vec<SemanticChunk>, HashMap<PathBuf, IndexedFileMetadata>) {
3126        let collect_started = Instant::now();
3127        let collect_one = |file: &Path, sched: Duration| {
3128            let mut phases = SemanticCollectPhaseTimings {
3129                sched,
3130                ..SemanticCollectPhaseTimings::default()
3131            };
3132            let result = collect_semantic_file(project_root, file, embed_text_caps, &mut phases);
3133            (file.to_path_buf(), result, phases)
3134        };
3135        let per_file: Vec<CollectedSemanticFile> = if files.len() <= 2 {
3136            files
3137                .iter()
3138                .map(|file| collect_one(file, Duration::ZERO))
3139                .collect()
3140        } else {
3141            files
3142                .par_iter()
3143                .map(|file| collect_one(file, collect_started.elapsed()))
3144                .collect()
3145        };
3146
3147        let mut chunks: Vec<SemanticChunk> = Vec::new();
3148        let mut file_metadata: HashMap<PathBuf, IndexedFileMetadata> = HashMap::new();
3149        let mut phases = SemanticCollectPhaseTimings::default();
3150
3151        for (file, result, file_phases) in per_file {
3152            phases.add_assign(file_phases);
3153            match result {
3154                Ok((metadata, file_chunks)) => {
3155                    file_metadata.insert(file, metadata);
3156                    chunks.extend(file_chunks);
3157                }
3158                Err(error) => {
3159                    // "unsupported file extension" is expected for non-code files
3160                    // (json, xml, .gitignore, etc.) that get included in the
3161                    // project walk. Pre-fix this was swallowed by .unwrap_or_default();
3162                    // we now skip silently to keep the log clean. Only real read/parse
3163                    // errors are worth surfacing.
3164                    if error == "unsupported file extension" {
3165                        continue;
3166                    }
3167                    slog_warn!(
3168                        "failed to collect semantic chunks for {}: {}",
3169                        file.display(),
3170                        error
3171                    );
3172                }
3173            }
3174        }
3175
3176        let collect_ms = collect_started
3177            .elapsed()
3178            .as_millis()
3179            .min(u128::from(u64::MAX)) as u64;
3180        crate::logging::note_semantic_collect(chunks.len(), file_metadata.len(), collect_ms);
3181        slog_info!(
3182            "semantic collect: {} chunks from {} files in {} ms",
3183            chunks.len(),
3184            file_metadata.len(),
3185            collect_ms
3186        );
3187        if let Some(scope) = crate::logging::current_index_build() {
3188            if scope.plane == crate::logging::IndexPlane::Semantic {
3189                crate::logging::log_index_event(
3190                    crate::logging::IndexEvent::from_scope(
3191                        crate::logging::IndexEventKind::BuildProgress,
3192                        &scope,
3193                    )
3194                    .field("stage", "collect")
3195                    .field("completed", 1)
3196                    .field("total", 1)
3197                    .field("elapsed_ms", scope.elapsed_ms())
3198                    .field("chunks", chunks.len())
3199                    .field("files", file_metadata.len()),
3200                );
3201            }
3202        }
3203        if collect_ms > 50 {
3204            slog_info!(
3205                "semantic collect phases: sched={}ms read_hash={}ms parse={}ms extract={}ms build={}ms",
3206                phases.sched.as_millis(),
3207                phases.read_hash.as_millis(),
3208                phases.parse.as_millis(),
3209                phases.extract.as_millis(),
3210                phases.build.as_millis(),
3211            );
3212        }
3213
3214        (chunks, file_metadata)
3215    }
3216
3217    fn build_chunk_reuse_map(&self, files: &[PathBuf]) -> ChunkReuseMap {
3218        let requested: HashSet<&Path> = files.iter().map(PathBuf::as_path).collect();
3219        let mut reuse_map: ChunkReuseMap = HashMap::new();
3220
3221        for entry in &self.entries {
3222            if !requested.contains(entry.chunk.file.as_path()) {
3223                continue;
3224            }
3225
3226            // `embed_text` is already persisted in the current on-disk format,
3227            // so refresh-time reuse can hash it in memory and confirm the exact
3228            // string without bumping `SEMANTIC_INDEX_VERSION` and forcing every
3229            // user through a full rebuild.
3230            let hash = blake3::hash(entry.chunk.embed_text.as_bytes());
3231            reuse_map
3232                .entry(entry.chunk.file.clone())
3233                .or_default()
3234                .entry(hash)
3235                .or_default()
3236                .push(ReusableEmbedding {
3237                    embed_text: entry.chunk.embed_text.clone(),
3238                    vector: entry.vector.clone(),
3239                });
3240        }
3241
3242        reuse_map
3243    }
3244
3245    fn extend_reuse_map_from_blob_store<R>(
3246        &self,
3247        project_root: &Path,
3248        files: impl IntoIterator<Item = PathBuf>,
3249        reuse_map: &mut ChunkReuseMap,
3250        reuse_blob: &mut R,
3251    ) where
3252        R: FnMut(&Path) -> Option<Vec<u8>>,
3253    {
3254        let Some(fingerprint) = self.fingerprint().map(SemanticIndexFingerprint::as_string) else {
3255            return;
3256        };
3257        let mut reused_files = 0usize;
3258        for file in files {
3259            let Some(payload) = reuse_blob(&file) else {
3260                continue;
3261            };
3262            match extend_reuse_map_from_semantic_blob(
3263                reuse_map,
3264                &file,
3265                &payload,
3266                &fingerprint,
3267                self.dimension,
3268            ) {
3269                Ok(()) => reused_files += 1,
3270                Err(error) => slog_warn!(
3271                    "semantic blob reuse rejected for {}: {}",
3272                    file.display(),
3273                    error
3274                ),
3275            }
3276        }
3277        if reused_files > 0 {
3278            slog_info!(
3279                "semantic refresh reused content-addressed vectors: root={} files={}",
3280                project_root.display(),
3281                reused_files
3282            );
3283        }
3284    }
3285
3286    fn reusable_vector_for_chunk(
3287        reuse_map: &ChunkReuseMap,
3288        chunk: &SemanticChunk,
3289    ) -> Option<Vec<f32>> {
3290        let hash = blake3::hash(chunk.embed_text.as_bytes());
3291        reuse_map
3292            .get(&chunk.file)?
3293            .get(&hash)?
3294            .iter()
3295            .find(|candidate| candidate.embed_text == chunk.embed_text)
3296            .map(|candidate| candidate.vector.clone())
3297    }
3298
3299    fn entries_for_chunks_with_reuse<F, P>(
3300        chunks: Vec<SemanticChunk>,
3301        reuse_map: &ChunkReuseMap,
3302        embed_fn: &mut F,
3303        max_batch_size: usize,
3304        initial_observed_dimension: Option<usize>,
3305        refresh_label: &str,
3306        progress: &mut P,
3307    ) -> Result<(Vec<EmbeddingEntry>, Option<usize>), String>
3308    where
3309        F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
3310        P: FnMut(usize, usize),
3311    {
3312        let total_chunks = chunks.len();
3313        progress(0, total_chunks);
3314
3315        let mut entries_by_chunk: Vec<Option<EmbeddingEntry>> = vec![None; total_chunks];
3316        let mut misses: Vec<(usize, SemanticChunk)> = Vec::new();
3317
3318        for (chunk_index, chunk) in chunks.into_iter().enumerate() {
3319            if let Some(vector) = Self::reusable_vector_for_chunk(reuse_map, &chunk) {
3320                entries_by_chunk[chunk_index] = Some(EmbeddingEntry::new(chunk, vector));
3321            } else {
3322                misses.push((chunk_index, chunk));
3323            }
3324        }
3325
3326        let mut completed = total_chunks.saturating_sub(misses.len());
3327        if completed > 0 {
3328            progress(completed, total_chunks);
3329        }
3330
3331        let batch_size = max_batch_size.max(1);
3332        let mut observed_dimension = initial_observed_dimension;
3333
3334        for batch_start in (0..misses.len()).step_by(batch_size) {
3335            let batch_end = (batch_start + batch_size).min(misses.len());
3336            let batch_texts: Vec<String> = misses[batch_start..batch_end]
3337                .iter()
3338                .map(|(_, chunk)| chunk.embed_text.clone())
3339                .collect();
3340
3341            let vectors = embed_fn(batch_texts)?;
3342            validate_embedding_batch(&vectors, batch_end - batch_start, "embedding backend")?;
3343
3344            if let Some(dim) = vectors.first().map(|vector| vector.len()) {
3345                match observed_dimension {
3346                    None => observed_dimension = Some(dim),
3347                    Some(expected) if dim != expected => {
3348                        return Err(format!(
3349                            "embedding dimension changed during {refresh_label}: \
3350                             cached index uses {expected}, new vectors use {dim}"
3351                        ));
3352                    }
3353                    _ => {}
3354                }
3355            }
3356
3357            for (i, vector) in vectors.into_iter().enumerate() {
3358                let (chunk_index, chunk) = misses[batch_start + i].clone();
3359                entries_by_chunk[chunk_index] = Some(EmbeddingEntry::new(chunk, vector));
3360            }
3361
3362            completed += batch_end - batch_start;
3363            progress(completed, total_chunks);
3364        }
3365
3366        let entries = entries_by_chunk
3367            .into_iter()
3368            .map(|entry| entry.expect("semantic refresh accounted for every chunk"))
3369            .collect();
3370
3371        Ok((entries, observed_dimension))
3372    }
3373
3374    fn build_from_chunks<F, P, C>(
3375        project_root: &Path,
3376        chunks: Vec<SemanticChunk>,
3377        file_metadata: HashMap<PathBuf, IndexedFileMetadata>,
3378        embed_fn: &mut F,
3379        max_batch_size: usize,
3380        mut progress: Option<&mut P>,
3381        should_continue: &mut C,
3382    ) -> Result<Self, String>
3383    where
3384        F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
3385        P: FnMut(usize, usize),
3386        C: FnMut() -> bool,
3387    {
3388        debug_assert!(project_root.is_absolute());
3389        let total_chunks = chunks.len();
3390
3391        if chunks.is_empty() {
3392            return Ok(Self {
3393                entries: Vec::new(),
3394                file_mtimes: file_metadata
3395                    .iter()
3396                    .map(|(path, metadata)| (path.clone(), metadata.mtime))
3397                    .collect(),
3398                file_sizes: file_metadata
3399                    .iter()
3400                    .map(|(path, metadata)| (path.clone(), metadata.size))
3401                    .collect(),
3402                any_missing_sizes: false,
3403                file_hashes: file_metadata
3404                    .into_iter()
3405                    .map(|(path, metadata)| (path, metadata.content_hash))
3406                    .collect(),
3407                dimension: DEFAULT_DIMENSION,
3408                fingerprint: None,
3409                project_root: project_root.to_path_buf(),
3410                deferred_files: HashSet::new(),
3411                dirty_paths: Arc::new(Mutex::new(None)),
3412                persistence: Arc::new(Mutex::new(None)),
3413                last_append_read_bytes: Arc::new(AtomicUsize::new(0)),
3414                shared_base: None,
3415                #[cfg(test)]
3416                removal_retain_passes: 0,
3417            });
3418        }
3419
3420        // Embed in batches
3421        let mut entries: Vec<EmbeddingEntry> = Vec::with_capacity(chunks.len());
3422        let mut expected_dimension: Option<usize> = None;
3423        let batch_size = max_batch_size.max(1);
3424        let embed_started = std::time::Instant::now();
3425        let batch_count = total_chunks.div_ceil(batch_size);
3426        for (batch_index, batch_start) in (0..chunks.len()).step_by(batch_size).enumerate() {
3427            if !should_continue() {
3428                slog_info!(
3429                    "semantic embed superseded, stopping after {}/{} batches",
3430                    batch_index,
3431                    batch_count
3432                );
3433                return Err(format!(
3434                    "semantic build superseded after {batch_index}/{batch_count} batches"
3435                ));
3436            }
3437            let batch_end = (batch_start + batch_size).min(chunks.len());
3438            let batch_texts: Vec<String> = chunks[batch_start..batch_end]
3439                .iter()
3440                .map(|c| c.embed_text.clone())
3441                .collect();
3442
3443            let vectors = embed_fn(batch_texts)?;
3444            validate_embedding_batch(&vectors, batch_end - batch_start, "embedding backend")?;
3445
3446            // Track consistent dimension across all batches
3447            if let Some(dim) = vectors.first().map(|v| v.len()) {
3448                match expected_dimension {
3449                    None => expected_dimension = Some(dim),
3450                    Some(expected) if dim != expected => {
3451                        return Err(format!(
3452                            "embedding dimension changed across batches: expected {expected}, got {dim}"
3453                        ));
3454                    }
3455                    _ => {}
3456                }
3457            }
3458
3459            for (i, vector) in vectors.into_iter().enumerate() {
3460                let chunk_idx = batch_start + i;
3461                entries.push(EmbeddingEntry::new(chunks[chunk_idx].clone(), vector));
3462            }
3463
3464            if let Some(callback) = progress.as_mut() {
3465                callback(entries.len(), total_chunks);
3466            }
3467            if let Some(scope) = crate::logging::current_index_build() {
3468                if scope.plane == crate::logging::IndexPlane::Semantic {
3469                    crate::logging::log_index_event(
3470                        crate::logging::IndexEvent::from_scope(
3471                            crate::logging::IndexEventKind::BuildProgress,
3472                            &scope,
3473                        )
3474                        .field("stage", "embed")
3475                        .field("batch", batch_index + 1)
3476                        .field("total_batches", batch_count)
3477                        .field("chunks_done", entries.len())
3478                        .field("completed", entries.len())
3479                        .field("total", total_chunks)
3480                        .field("elapsed_ms", scope.elapsed_ms()),
3481                    );
3482                }
3483            }
3484            if (batch_index + 1) % 25 == 0 {
3485                slog_info!(
3486                    "semantic embed progress: batch {}/{} ({} / {} chunks)",
3487                    batch_index + 1,
3488                    batch_count,
3489                    entries.len(),
3490                    total_chunks
3491                );
3492            }
3493        }
3494
3495        let embed_ms = embed_started.elapsed().as_millis();
3496        let rate = (total_chunks as u128 * 1000)
3497            .checked_div(embed_ms)
3498            .unwrap_or(0) as u64;
3499        slog_info!(
3500            "semantic embed: {} chunks in {} batches, {} ms ({} chunks/s)",
3501            total_chunks,
3502            batch_count,
3503            embed_ms,
3504            rate
3505        );
3506
3507        let dimension = entries
3508            .first()
3509            .map(|e| e.vector.len())
3510            .unwrap_or(DEFAULT_DIMENSION);
3511
3512        Ok(Self {
3513            entries,
3514            file_mtimes: file_metadata
3515                .iter()
3516                .map(|(path, metadata)| (path.clone(), metadata.mtime))
3517                .collect(),
3518            file_sizes: file_metadata
3519                .iter()
3520                .map(|(path, metadata)| (path.clone(), metadata.size))
3521                .collect(),
3522            any_missing_sizes: false,
3523            file_hashes: file_metadata
3524                .into_iter()
3525                .map(|(path, metadata)| (path, metadata.content_hash))
3526                .collect(),
3527            dimension,
3528            fingerprint: None,
3529            project_root: project_root.to_path_buf(),
3530            deferred_files: HashSet::new(),
3531            shared_base: None,
3532            dirty_paths: Arc::new(Mutex::new(None)),
3533            persistence: Arc::new(Mutex::new(None)),
3534            last_append_read_bytes: Arc::new(AtomicUsize::new(0)),
3535            #[cfg(test)]
3536            removal_retain_passes: 0,
3537        })
3538    }
3539
3540    /// Build the semantic index from a set of files using the provided embedding function.
3541    /// `embed_fn` takes a batch of texts and returns a batch of embedding vectors.
3542    pub fn build<F>(
3543        project_root: &Path,
3544        files: &[PathBuf],
3545        embed_fn: &mut F,
3546        max_batch_size: usize,
3547    ) -> Result<Self, String>
3548    where
3549        F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
3550    {
3551        Self::build_with_caps(
3552            project_root,
3553            files,
3554            embed_fn,
3555            max_batch_size,
3556            EmbedTextCaps::default(),
3557        )
3558    }
3559
3560    /// Build using explicitly resolved symbol-row caps.
3561    pub fn build_with_caps<F>(
3562        project_root: &Path,
3563        files: &[PathBuf],
3564        embed_fn: &mut F,
3565        max_batch_size: usize,
3566        embed_text_caps: EmbedTextCaps,
3567    ) -> Result<Self, String>
3568    where
3569        F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
3570    {
3571        let (_guard, scope, mut failure_guard) = begin_semantic_index_build(project_root);
3572        let (chunks, file_mtimes) = Self::collect_chunks(project_root, files, embed_text_caps);
3573        let mut should_continue = || true;
3574        let result = Self::build_from_chunks(
3575            project_root,
3576            chunks,
3577            file_mtimes,
3578            embed_fn,
3579            max_batch_size,
3580            Option::<&mut fn(usize, usize)>::None,
3581            &mut should_continue,
3582        );
3583        finish_semantic_index_build(&scope, &mut failure_guard, &result);
3584        result
3585    }
3586
3587    /// Build the semantic index and report embedding progress using entry counts.
3588    pub fn build_with_progress<F, P>(
3589        project_root: &Path,
3590        files: &[PathBuf],
3591        embed_fn: &mut F,
3592        max_batch_size: usize,
3593        progress: &mut P,
3594    ) -> Result<Self, String>
3595    where
3596        F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
3597        P: FnMut(usize, usize),
3598    {
3599        let (_guard, scope, mut failure_guard) = begin_semantic_index_build(project_root);
3600        let (chunks, file_mtimes) =
3601            Self::collect_chunks(project_root, files, EmbedTextCaps::default());
3602        let total_chunks = chunks.len();
3603        progress(0, total_chunks);
3604        let mut should_continue = || true;
3605        let result = Self::build_from_chunks(
3606            project_root,
3607            chunks,
3608            file_mtimes,
3609            embed_fn,
3610            max_batch_size,
3611            Some(progress),
3612            &mut should_continue,
3613        );
3614        finish_semantic_index_build(&scope, &mut failure_guard, &result);
3615        result
3616    }
3617
3618    /// Build the semantic index while checking cancellation before every embed
3619    /// batch. A batch already in flight is allowed to finish, then the partial
3620    /// result is discarded before the next request can start.
3621    pub fn build_with_progress_and_cancellation<F, P, C>(
3622        project_root: &Path,
3623        files: &[PathBuf],
3624        embed_fn: &mut F,
3625        max_batch_size: usize,
3626        progress: &mut P,
3627        should_continue: &mut C,
3628    ) -> Result<Self, String>
3629    where
3630        F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
3631        P: FnMut(usize, usize),
3632        C: FnMut() -> bool,
3633    {
3634        Self::build_with_progress_and_cancellation_caps(
3635            project_root,
3636            files,
3637            embed_fn,
3638            max_batch_size,
3639            EmbedTextCaps::default(),
3640            progress,
3641            should_continue,
3642        )
3643    }
3644
3645    pub fn build_with_progress_and_cancellation_caps<F, P, C>(
3646        project_root: &Path,
3647        files: &[PathBuf],
3648        embed_fn: &mut F,
3649        max_batch_size: usize,
3650        embed_text_caps: EmbedTextCaps,
3651        progress: &mut P,
3652        should_continue: &mut C,
3653    ) -> Result<Self, String>
3654    where
3655        F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
3656        P: FnMut(usize, usize),
3657        C: FnMut() -> bool,
3658    {
3659        let (_guard, scope, mut failure_guard) = begin_semantic_index_build(project_root);
3660        let (chunks, file_mtimes) = Self::collect_chunks(project_root, files, embed_text_caps);
3661        let total_chunks = chunks.len();
3662        progress(0, total_chunks);
3663        let result = Self::build_from_chunks(
3664            project_root,
3665            chunks,
3666            file_mtimes,
3667            embed_fn,
3668            max_batch_size,
3669            Some(progress),
3670            should_continue,
3671        );
3672        finish_semantic_index_build(&scope, &mut failure_guard, &result);
3673        result
3674    }
3675
3676    /// Incrementally refresh entries for changed/new files only, preserving cached
3677    /// embeddings for unchanged files. Used when loading the index from disk and
3678    /// finding that a small fraction of files have moved on, deleted, or appeared.
3679    ///
3680    /// Returns `RefreshSummary` describing what changed. On success, `self` is
3681    /// mutated in place and remains a valid index.
3682    ///
3683    /// `current_files` is the full set of files the project considers indexable
3684    /// (typically `walk_project_files(...)`). Files in the cache that are no
3685    /// longer in this set are treated as deleted.
3686    pub fn refresh_stale_files<F, P>(
3687        &mut self,
3688        project_root: &Path,
3689        current_files: &[PathBuf],
3690        embed_fn: &mut F,
3691        max_batch_size: usize,
3692        progress: &mut P,
3693    ) -> Result<RefreshSummary, String>
3694    where
3695        F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
3696        P: FnMut(usize, usize),
3697    {
3698        self.refresh_stale_files_with_strategy(
3699            project_root,
3700            current_files,
3701            embed_fn,
3702            max_batch_size,
3703            progress,
3704            cache_freshness::VerifyStrategy::Strict,
3705        )
3706    }
3707
3708    pub(crate) fn refresh_stale_files_with_strategy<F, P>(
3709        &mut self,
3710        project_root: &Path,
3711        current_files: &[PathBuf],
3712        embed_fn: &mut F,
3713        max_batch_size: usize,
3714        progress: &mut P,
3715        verify_strategy: cache_freshness::VerifyStrategy,
3716    ) -> Result<RefreshSummary, String>
3717    where
3718        F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
3719        P: FnMut(usize, usize),
3720    {
3721        self.refresh_stale_files_with_strategy_and_blob_reuse(
3722            project_root,
3723            current_files,
3724            embed_fn,
3725            max_batch_size,
3726            progress,
3727            verify_strategy,
3728            &mut |_| None,
3729            None,
3730        )
3731    }
3732
3733    pub(crate) fn refresh_stale_files_with_strategy_and_blob_reuse<F, P, R>(
3734        &mut self,
3735        project_root: &Path,
3736        current_files: &[PathBuf],
3737        embed_fn: &mut F,
3738        max_batch_size: usize,
3739        progress: &mut P,
3740        verify_strategy: cache_freshness::VerifyStrategy,
3741        reuse_blob: &mut R,
3742        mut recovery_paths: Option<&mut Vec<PathBuf>>,
3743    ) -> Result<RefreshSummary, String>
3744    where
3745        F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
3746        P: FnMut(usize, usize),
3747        R: FnMut(&Path) -> Option<Vec<u8>>,
3748    {
3749        self.materialize_shared_base();
3750        self.backfill_missing_file_sizes();
3751
3752        // 1. Bucket files into deleted / changed / added.
3753        let current_set: HashSet<&Path> = current_files.iter().map(PathBuf::as_path).collect();
3754        self.deferred_files
3755            .retain(|path| current_set.contains(path.as_path()));
3756        let total_processed = current_set.len() + self.file_mtimes.len()
3757            - self
3758                .file_mtimes
3759                .keys()
3760                .filter(|path| current_set.contains(path.as_path()))
3761                .count();
3762
3763        // Files in cache that disappeared from disk OR are no longer in the
3764        // walked set. Both cases need their entries dropped.
3765        enum IndexedFileCheck {
3766            Deleted(PathBuf),
3767            MissingMetadata(PathBuf),
3768            Verified(PathBuf, FreshnessVerdict),
3769        }
3770
3771        let mut deleted: Vec<PathBuf> = Vec::new();
3772        let mut changed: Vec<PathBuf> = Vec::new();
3773        let indexed_paths: Vec<PathBuf> = self.file_mtimes.keys().cloned().collect();
3774        let mut checks: Vec<Option<IndexedFileCheck>> = Vec::with_capacity(indexed_paths.len());
3775        let mut strict_verify_inputs: Vec<(usize, PathBuf, FileFreshness)> = Vec::new();
3776
3777        for indexed_path in indexed_paths {
3778            let check_index = checks.len();
3779            if !current_set.contains(indexed_path.as_path()) {
3780                checks.push(Some(IndexedFileCheck::Deleted(indexed_path)));
3781                continue;
3782            }
3783            let cached = match (
3784                self.file_mtimes.get(&indexed_path),
3785                self.file_sizes.get(&indexed_path),
3786                self.file_hashes.get(&indexed_path),
3787            ) {
3788                (Some(mtime), Some(size), Some(hash)) => Some(FileFreshness {
3789                    mtime: *mtime,
3790                    size: *size,
3791                    content_hash: *hash,
3792                }),
3793                _ => None,
3794            };
3795            if let Some(freshness) = cached {
3796                strict_verify_inputs.push((check_index, indexed_path, freshness));
3797                checks.push(None);
3798            } else {
3799                checks.push(Some(IndexedFileCheck::MissingMetadata(indexed_path)));
3800            }
3801        }
3802
3803        let verified = match verify_strategy {
3804            cache_freshness::VerifyStrategy::StatFirst => cache_freshness::verify_files_bounded(
3805                strict_verify_inputs,
3806                cache_freshness::VerifyStrategy::StatFirst,
3807            ),
3808            cache_freshness::VerifyStrategy::Strict => {
3809                cache_freshness::verify_files_strict_bounded(strict_verify_inputs)
3810            }
3811        };
3812        for (check_index, path, verdict) in verified {
3813            checks[check_index] = Some(IndexedFileCheck::Verified(path, verdict));
3814        }
3815
3816        for check in checks {
3817            match check.expect("freshness check should be populated") {
3818                IndexedFileCheck::Deleted(path) => deleted.push(path),
3819                IndexedFileCheck::MissingMetadata(path) => changed.push(path),
3820                IndexedFileCheck::Verified(_path, FreshnessVerdict::HotFresh) => {}
3821                IndexedFileCheck::Verified(
3822                    path,
3823                    FreshnessVerdict::ContentFresh {
3824                        new_mtime,
3825                        new_size,
3826                    },
3827                ) => {
3828                    self.file_mtimes.insert(path.clone(), new_mtime);
3829                    self.file_sizes.insert(path, new_size);
3830                }
3831                IndexedFileCheck::Verified(
3832                    path,
3833                    FreshnessVerdict::Stale | FreshnessVerdict::Deleted,
3834                ) => {
3835                    changed.push(path);
3836                }
3837            }
3838        }
3839
3840        // Files in walk that were never indexed.
3841        let mut added: Vec<PathBuf> = Vec::new();
3842        for path in current_files {
3843            if !self.file_mtimes.contains_key(path) {
3844                added.push(path.clone());
3845            }
3846        }
3847
3848        // Fast path: nothing to do.
3849        if deleted.is_empty() && changed.is_empty() && added.is_empty() {
3850            progress(0, 0);
3851            return Ok(RefreshSummary {
3852                total_processed,
3853                ..RefreshSummary::default()
3854            });
3855        }
3856
3857        // 2. Drop entries for deleted files immediately. Changed files are only
3858        //    replaced after successful re-extraction + embedding so transient
3859        //    read/parse errors keep the stale-but-valid cache entry.
3860        if !deleted.is_empty() {
3861            self.remove_indexed_files(&deleted);
3862        }
3863
3864        // 3. Embed the changed + added set, if any.
3865        let mut to_embed: Vec<PathBuf> = Vec::with_capacity(changed.len() + added.len());
3866        to_embed.extend(changed.iter().cloned());
3867        to_embed.extend(added.iter().cloned());
3868        if let Some(paths) = recovery_paths.as_mut() {
3869            paths.clear();
3870            paths.extend(deleted.iter().cloned());
3871            paths.extend(to_embed.iter().cloned());
3872            paths.sort();
3873            paths.dedup();
3874        }
3875
3876        if to_embed.is_empty() {
3877            // Only deletions happened.
3878            progress(0, 0);
3879            return Ok(RefreshSummary {
3880                changed: 0,
3881                added: 0,
3882                deleted: deleted.len(),
3883                total_processed,
3884            });
3885        }
3886
3887        let mut reuse_map = self.build_chunk_reuse_map(&changed);
3888        let embed_text_caps = self
3889            .fingerprint
3890            .as_ref()
3891            .map(|fingerprint| fingerprint.embed_text_caps)
3892            .unwrap_or_default();
3893        let (chunks, fresh_metadata) =
3894            Self::collect_chunks(project_root, &to_embed, embed_text_caps);
3895        self.extend_reuse_map_from_blob_store(
3896            project_root,
3897            fresh_metadata.keys().cloned(),
3898            &mut reuse_map,
3899            reuse_blob,
3900        );
3901        let changed_set: HashSet<&Path> = changed.iter().map(PathBuf::as_path).collect();
3902        let vanished = to_embed
3903            .iter()
3904            .filter(|path| {
3905                changed_set.contains(path.as_path())
3906                    && !fresh_metadata.contains_key(*path)
3907                    && !path.exists()
3908            })
3909            .cloned()
3910            .collect::<Vec<_>>();
3911        if !vanished.is_empty() {
3912            self.remove_indexed_files(&vanished);
3913            deleted.extend(vanished);
3914        }
3915
3916        if chunks.is_empty() {
3917            progress(0, 0);
3918            let successful_files: HashSet<PathBuf> = fresh_metadata.keys().cloned().collect();
3919            for file in &successful_files {
3920                self.deferred_files.remove(file);
3921            }
3922            if !successful_files.is_empty() {
3923                self.entries
3924                    .retain(|entry| !successful_files.contains(&entry.chunk.file));
3925            }
3926            let changed_count = changed
3927                .iter()
3928                .filter(|path| successful_files.contains(*path))
3929                .count();
3930            let added_count = added
3931                .iter()
3932                .filter(|path| successful_files.contains(*path))
3933                .count();
3934            for (file, metadata) in fresh_metadata {
3935                self.file_mtimes.insert(file.clone(), metadata.mtime);
3936                self.file_sizes.insert(file.clone(), metadata.size);
3937                self.file_hashes.insert(file.clone(), metadata.content_hash);
3938            }
3939            self.extend_dirty_paths(successful_files.iter().cloned());
3940            return Ok(RefreshSummary {
3941                changed: changed_count,
3942                added: added_count,
3943                deleted: deleted.len(),
3944                total_processed,
3945            });
3946        }
3947
3948        // 4. Build the full replacement set, reusing cached vectors for chunks
3949        //    whose embed_text is unchanged and embedding only cache misses.
3950        let existing_dimension = if self.entries.is_empty() {
3951            None
3952        } else {
3953            Some(self.dimension)
3954        };
3955        let (new_entries, observed_dimension) = Self::entries_for_chunks_with_reuse(
3956            chunks,
3957            &reuse_map,
3958            embed_fn,
3959            max_batch_size,
3960            existing_dimension,
3961            "incremental refresh",
3962            progress,
3963        )?;
3964
3965        let successful_files: HashSet<PathBuf> = fresh_metadata.keys().cloned().collect();
3966        for file in &successful_files {
3967            self.deferred_files.remove(file);
3968        }
3969        if !successful_files.is_empty() {
3970            self.entries
3971                .retain(|entry| !successful_files.contains(&entry.chunk.file));
3972        }
3973
3974        self.entries.extend(new_entries);
3975        for (file, metadata) in fresh_metadata {
3976            self.file_mtimes.insert(file.clone(), metadata.mtime);
3977            self.file_sizes.insert(file.clone(), metadata.size);
3978            self.file_hashes.insert(file, metadata.content_hash);
3979        }
3980        if let Some(dim) = observed_dimension {
3981            self.dimension = dim;
3982        }
3983        self.extend_dirty_paths(successful_files.iter().cloned());
3984
3985        Ok(RefreshSummary {
3986            changed: changed
3987                .iter()
3988                .filter(|path| successful_files.contains(*path))
3989                .count(),
3990            added: added
3991                .iter()
3992                .filter(|path| successful_files.contains(*path))
3993                .count(),
3994            deleted: deleted.len(),
3995            total_processed,
3996        })
3997    }
3998
3999    /// Refresh exactly the files invalidated by the live watcher, without
4000    /// treating the provided path list as the whole project. This is the
4001    /// watcher-side counterpart to `refresh_stale_files`: it drops any stale
4002    /// entries for the requested paths from this in-memory index, re-extracts
4003    /// whatever still exists on disk, embeds those chunks, and returns the
4004    /// delta needed for another in-memory index to apply the same update.
4005    pub fn refresh_invalidated_files<F, P>(
4006        &mut self,
4007        project_root: &Path,
4008        paths: &[PathBuf],
4009        embed_fn: &mut F,
4010        max_batch_size: usize,
4011        max_files: usize,
4012        progress: &mut P,
4013    ) -> Result<InvalidatedFilesRefresh, String>
4014    where
4015        F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
4016        P: FnMut(usize, usize),
4017    {
4018        self.refresh_invalidated_files_with_blob_reuse(
4019            project_root,
4020            paths,
4021            embed_fn,
4022            max_batch_size,
4023            max_files,
4024            progress,
4025            &mut |_| None,
4026        )
4027    }
4028
4029    pub(crate) fn refresh_invalidated_files_with_blob_reuse<F, P, R>(
4030        &mut self,
4031        project_root: &Path,
4032        paths: &[PathBuf],
4033        embed_fn: &mut F,
4034        max_batch_size: usize,
4035        max_files: usize,
4036        progress: &mut P,
4037        reuse_blob: &mut R,
4038    ) -> Result<InvalidatedFilesRefresh, String>
4039    where
4040        F: FnMut(Vec<String>) -> Result<Vec<Vec<f32>>, String>,
4041        P: FnMut(usize, usize),
4042        R: FnMut(&Path) -> Option<Vec<u8>>,
4043    {
4044        self.materialize_shared_base();
4045        self.backfill_missing_file_sizes();
4046
4047        self.deferred_files.retain(|path| path.exists());
4048        let mut requested_paths = paths.to_vec();
4049        requested_paths.extend(self.deferred_files.iter().cloned());
4050        requested_paths.sort();
4051        requested_paths.dedup();
4052        let total_processed = requested_paths.len();
4053
4054        if requested_paths.is_empty() {
4055            progress(0, 0);
4056            return Ok(InvalidatedFilesRefresh {
4057                summary: RefreshSummary {
4058                    total_processed,
4059                    ..RefreshSummary::default()
4060                },
4061                ..InvalidatedFilesRefresh::default()
4062            });
4063        }
4064
4065        let previously_indexed: HashSet<PathBuf> = requested_paths
4066            .iter()
4067            .filter(|path| self.file_mtimes.contains_key(*path))
4068            .cloned()
4069            .collect();
4070        let mut reuse_map = self.build_chunk_reuse_map(&requested_paths);
4071
4072        // The watcher path has already invalidated these files in the request
4073        // thread's live index. Mirror that behavior here before inserting any
4074        // fresh chunks so parse/read failures do not resurrect stale entries.
4075        self.remove_indexed_files(&requested_paths);
4076
4077        let existing_paths = requested_paths
4078            .iter()
4079            .filter(|path| path.exists())
4080            .cloned()
4081            .collect::<Vec<_>>();
4082        let deleted = requested_paths
4083            .iter()
4084            .filter(|path| !path.exists() && previously_indexed.contains(path.as_path()))
4085            .count();
4086
4087        if existing_paths.is_empty() {
4088            for path in &requested_paths {
4089                if !path.exists() {
4090                    self.deferred_files.remove(path);
4091                }
4092            }
4093            progress(0, 0);
4094            return Ok(InvalidatedFilesRefresh {
4095                completed_paths: requested_paths,
4096                summary: RefreshSummary {
4097                    deleted,
4098                    total_processed,
4099                    ..RefreshSummary::default()
4100                },
4101                ..InvalidatedFilesRefresh::default()
4102            });
4103        }
4104
4105        let embed_text_caps = self
4106            .fingerprint
4107            .as_ref()
4108            .map(|fingerprint| fingerprint.embed_text_caps)
4109            .unwrap_or_default();
4110        let (mut chunks, mut fresh_metadata) =
4111            Self::collect_chunks(project_root, &existing_paths, embed_text_caps);
4112        self.extend_reuse_map_from_blob_store(
4113            project_root,
4114            fresh_metadata.keys().cloned(),
4115            &mut reuse_map,
4116            reuse_blob,
4117        );
4118
4119        let retained_file_count = self.file_mtimes.len();
4120        let changed_successful_count = existing_paths
4121            .iter()
4122            .filter(|path| {
4123                previously_indexed.contains(path.as_path()) && fresh_metadata.contains_key(*path)
4124            })
4125            .count();
4126        let available_new_files =
4127            max_files.saturating_sub(retained_file_count.saturating_add(changed_successful_count));
4128        let new_successful_files = existing_paths
4129            .iter()
4130            .filter(|path| {
4131                !previously_indexed.contains(path.as_path()) && fresh_metadata.contains_key(*path)
4132            })
4133            .cloned()
4134            .collect::<Vec<_>>();
4135        if new_successful_files.len() > available_new_files {
4136            let allowed_new_files = new_successful_files
4137                .iter()
4138                .take(available_new_files)
4139                .cloned()
4140                .collect::<HashSet<_>>();
4141            let deferred_new_files = new_successful_files
4142                .into_iter()
4143                .filter(|path| !allowed_new_files.contains(path))
4144                .collect::<HashSet<_>>();
4145
4146            fresh_metadata.retain(|file, _| {
4147                previously_indexed.contains(file.as_path()) || allowed_new_files.contains(file)
4148            });
4149            chunks.retain(|chunk| !deferred_new_files.contains(&chunk.file));
4150
4151            if !deferred_new_files.is_empty() {
4152                for path in &deferred_new_files {
4153                    self.deferred_files.insert(path.clone());
4154                }
4155                slog_warn!(
4156                    "semantic refresh deferred {} new file(s): indexed-file cap {} is reached",
4157                    deferred_new_files.len(),
4158                    max_files
4159                );
4160            }
4161        }
4162
4163        let successful_files: HashSet<PathBuf> = fresh_metadata.keys().cloned().collect();
4164        for file in &successful_files {
4165            self.deferred_files.remove(file);
4166        }
4167        let changed = successful_files
4168            .iter()
4169            .filter(|path| previously_indexed.contains(path.as_path()))
4170            .count();
4171        let added = successful_files.len().saturating_sub(changed);
4172        let mut updated_metadata = Vec::with_capacity(fresh_metadata.len());
4173
4174        if chunks.is_empty() {
4175            progress(0, 0);
4176            for (file, metadata) in fresh_metadata {
4177                let freshness = FileFreshness {
4178                    mtime: metadata.mtime,
4179                    size: metadata.size,
4180                    content_hash: metadata.content_hash,
4181                };
4182                self.file_mtimes.insert(file.clone(), freshness.mtime);
4183                self.file_sizes.insert(file.clone(), freshness.size);
4184                self.file_hashes
4185                    .insert(file.clone(), freshness.content_hash);
4186                updated_metadata.push((file, freshness));
4187            }
4188
4189            return Ok(InvalidatedFilesRefresh {
4190                updated_metadata,
4191                completed_paths: requested_paths,
4192                summary: RefreshSummary {
4193                    changed,
4194                    added,
4195                    deleted,
4196                    total_processed,
4197                },
4198                ..InvalidatedFilesRefresh::default()
4199            });
4200        }
4201
4202        let initial_observed_dimension = if self.entries.is_empty() && previously_indexed.is_empty()
4203        {
4204            None
4205        } else {
4206            Some(self.dimension)
4207        };
4208        let (new_entries, observed_dimension) = Self::entries_for_chunks_with_reuse(
4209            chunks,
4210            &reuse_map,
4211            embed_fn,
4212            max_batch_size,
4213            initial_observed_dimension,
4214            "invalidated-file refresh",
4215            progress,
4216        )?;
4217
4218        let added_entries = new_entries.clone();
4219        self.entries.extend(new_entries);
4220        for (file, metadata) in fresh_metadata {
4221            let freshness = FileFreshness {
4222                mtime: metadata.mtime,
4223                size: metadata.size,
4224                content_hash: metadata.content_hash,
4225            };
4226            self.file_mtimes.insert(file.clone(), freshness.mtime);
4227            self.file_sizes.insert(file.clone(), freshness.size);
4228            self.file_hashes
4229                .insert(file.clone(), freshness.content_hash);
4230            updated_metadata.push((file, freshness));
4231        }
4232        if let Some(dim) = observed_dimension {
4233            self.dimension = dim;
4234        }
4235
4236        Ok(InvalidatedFilesRefresh {
4237            added_entries,
4238            updated_metadata,
4239            completed_paths: requested_paths,
4240            summary: RefreshSummary {
4241                changed,
4242                added,
4243                deleted,
4244                total_processed,
4245            },
4246        })
4247    }
4248
4249    pub fn apply_refresh_update(
4250        &mut self,
4251        added_entries: Vec<EmbeddingEntry>,
4252        updated_metadata: Vec<(PathBuf, FileFreshness)>,
4253        completed_paths: &[PathBuf],
4254    ) {
4255        self.materialize_shared_base();
4256        // `added_entries` is the complete replacement set for completed paths:
4257        // freshly embedded misses plus reused chunks carrying refreshed metadata.
4258        // Removing first is safe only because producers include both kinds.
4259        self.remove_indexed_files(completed_paths);
4260
4261        let observed_dimension = added_entries.first().map(|entry| entry.vector.len());
4262        self.entries.extend(added_entries);
4263        for (file, freshness) in updated_metadata {
4264            self.file_mtimes.insert(file.clone(), freshness.mtime);
4265            self.file_sizes.insert(file.clone(), freshness.size);
4266            self.file_hashes.insert(file, freshness.content_hash);
4267        }
4268        if let Some(dim) = observed_dimension {
4269            self.dimension = dim;
4270        }
4271    }
4272
4273    fn dirty_paths_snapshot(&self) -> Option<BTreeSet<PathBuf>> {
4274        self.dirty_paths
4275            .lock()
4276            .unwrap_or_else(std::sync::PoisonError::into_inner)
4277            .clone()
4278    }
4279
4280    fn set_dirty_paths(&self, paths: Option<BTreeSet<PathBuf>>) {
4281        *self
4282            .dirty_paths
4283            .lock()
4284            .unwrap_or_else(std::sync::PoisonError::into_inner) = paths;
4285    }
4286
4287    fn persistence_snapshot(&self) -> Option<SemanticPersistenceState> {
4288        *self
4289            .persistence
4290            .lock()
4291            .unwrap_or_else(std::sync::PoisonError::into_inner)
4292    }
4293
4294    fn set_persistence(&self, state: Option<SemanticPersistenceState>) {
4295        *self
4296            .persistence
4297            .lock()
4298            .unwrap_or_else(std::sync::PoisonError::into_inner) = state;
4299    }
4300
4301    fn extend_dirty_paths(&self, paths: impl IntoIterator<Item = PathBuf>) {
4302        if let Some(dirty_paths) = self
4303            .dirty_paths
4304            .lock()
4305            .unwrap_or_else(std::sync::PoisonError::into_inner)
4306            .as_mut()
4307        {
4308            dirty_paths.extend(paths);
4309        }
4310    }
4311
4312    fn mark_all_dirty(&self) {
4313        let mut paths = BTreeSet::new();
4314        paths.extend(self.file_mtimes.keys().cloned());
4315        paths.extend(self.entries.iter().map(|entry| entry.chunk.file.clone()));
4316        self.set_dirty_paths(Some(paths));
4317    }
4318
4319    fn remove_indexed_file_keys(
4320        &mut self,
4321        entry_files: &HashSet<PathBuf>,
4322        metadata_files: &[PathBuf],
4323    ) {
4324        #[cfg(test)]
4325        {
4326            self.removal_retain_passes += 1;
4327        }
4328        self.entries
4329            .retain(|entry| !entry_files.contains(&entry.chunk.file));
4330        for path in metadata_files {
4331            self.file_mtimes.remove(path);
4332            self.file_sizes.remove(path);
4333            self.file_hashes.remove(path);
4334        }
4335        self.extend_dirty_paths(metadata_files.iter().cloned());
4336    }
4337
4338    fn remove_indexed_files(&mut self, files: &[PathBuf]) {
4339        let deleted_set = files.iter().cloned().collect();
4340        self.remove_indexed_file_keys(&deleted_set, files);
4341    }
4342
4343    /// Search the index with a query embedding, returning top-K results sorted by relevance.
4344    pub fn search(&self, query_vector: &[f32], top_k: usize) -> Vec<SemanticResult> {
4345        self.search_filtered(query_vector, top_k, |_| true)
4346    }
4347
4348    /// Search only entries whose resolved source path satisfies `include`.
4349    ///
4350    /// Filtering before top-K selection prevents excluded files from consuming the
4351    /// bounded candidate window and hiding lower-ranked eligible results.
4352    pub(crate) fn search_filtered<F>(
4353        &self,
4354        query_vector: &[f32],
4355        top_k: usize,
4356        include: F,
4357    ) -> Vec<SemanticResult>
4358    where
4359        F: Fn(&Path) -> bool,
4360    {
4361        let (entries, dimension) = self
4362            .shared_base
4363            .as_ref()
4364            .map(|base| (base.entries.as_slice(), base.dimension))
4365            .unwrap_or_else(|| (self.entries.as_slice(), self.dimension));
4366        if entries.is_empty() || query_vector.len() != dimension {
4367            return Vec::new();
4368        }
4369
4370        // Query norms are shared by every entry; entry norms are cached because
4371        // remote embedding backends may return non-normalized vectors.
4372        let query_norm = vector_norm(query_vector);
4373        let cancellation = crate::executor::current_job_cancellation();
4374        let mut scored: Vec<(f32, usize)> = Vec::with_capacity(entries.len());
4375        for (i, entry) in entries.iter().enumerate() {
4376            if i % 64 == 0
4377                && cancellation
4378                    .as_ref()
4379                    .is_some_and(|token| token.cancel_requested_before_commit())
4380            {
4381                break;
4382            }
4383            let included = if self.shared_base.is_some() {
4384                include(&self.project_root.join(&entry.chunk.file))
4385            } else {
4386                include(&entry.chunk.file)
4387            };
4388            if !included {
4389                continue;
4390            }
4391
4392            let dot = if query_vector.len() == entry.vector.len() {
4393                dot_product(query_vector, &entry.vector)
4394            } else {
4395                0.0
4396            };
4397            let denom = query_norm * entry.norm;
4398            let mut score = if denom == 0.0 { 0.0 } else { dot / denom };
4399            if entry.chunk.exported {
4400                score *= 1.1;
4401            }
4402            scored.push((score, i));
4403        }
4404
4405        let keep = top_k.min(scored.len());
4406        if keep == 0 {
4407            return Vec::new();
4408        }
4409
4410        if keep < scored.len() {
4411            scored.select_nth_unstable_by(keep, semantic_score_order);
4412            scored.truncate(keep);
4413        }
4414        scored.sort_by(semantic_score_order);
4415
4416        scored
4417            .into_iter()
4418            // Keep the selected best-first slice mapped without reintroducing the
4419            // old `> 0.0` floor: top_k has already been selected, and zero-score
4420            // tail entries remain observable when requested.
4421            .map(|(score, idx)| {
4422                let entry = &entries[idx];
4423                SemanticResult {
4424                    file: if self.shared_base.is_some() {
4425                        self.project_root.join(&entry.chunk.file)
4426                    } else {
4427                        entry.chunk.file.clone()
4428                    },
4429                    name: entry.chunk.name.clone(),
4430                    qualified_name: entry.chunk.qualified_name.clone(),
4431                    kind: entry.chunk.kind.clone(),
4432                    start_line: entry.chunk.start_line,
4433                    end_line: entry.chunk.end_line,
4434                    exported: entry.chunk.exported,
4435                    snippet: entry.chunk.snippet.clone(),
4436                    score,
4437                    rank_score: score,
4438                    cap_protected: false,
4439                    source: "semantic",
4440                }
4441            })
4442            .collect()
4443    }
4444
4445    /// Number of indexed entries
4446    pub fn len(&self) -> usize {
4447        self.entry_count()
4448    }
4449
4450    /// Check if a file needs re-indexing based on mtime/size
4451    pub fn is_file_stale(&self, file: &Path) -> bool {
4452        let relative;
4453        let (file_mtimes, file_sizes, file_hashes, lookup) = if let Some(base) = &self.shared_base {
4454            relative = file
4455                .strip_prefix(&self.project_root)
4456                .unwrap_or(file)
4457                .to_path_buf();
4458            (
4459                &base.file_mtimes,
4460                &base.file_sizes,
4461                &base.file_hashes,
4462                relative.as_path(),
4463            )
4464        } else {
4465            (&self.file_mtimes, &self.file_sizes, &self.file_hashes, file)
4466        };
4467        let Some(stored_mtime) = file_mtimes.get(lookup) else {
4468            return true;
4469        };
4470        let Some(stored_size) = file_sizes.get(lookup) else {
4471            return true;
4472        };
4473        let Some(stored_hash) = file_hashes.get(lookup) else {
4474            return true;
4475        };
4476        let cached = FileFreshness {
4477            mtime: *stored_mtime,
4478            size: *stored_size,
4479            content_hash: *stored_hash,
4480        };
4481        match cache_freshness::verify_file_strict(file, &cached) {
4482            FreshnessVerdict::HotFresh => false,
4483            FreshnessVerdict::ContentFresh { .. } => false,
4484            FreshnessVerdict::Stale | FreshnessVerdict::Deleted => true,
4485        }
4486    }
4487
4488    fn backfill_missing_file_sizes(&mut self) {
4489        if !self.any_missing_sizes {
4490            return;
4491        }
4492
4493        for path in self.file_mtimes.keys() {
4494            if self.file_sizes.contains_key(path) {
4495                continue;
4496            }
4497            if let Ok(metadata) = fs::metadata(path) {
4498                self.file_sizes.insert(path.clone(), metadata.len());
4499                if let Ok(Some(hash)) = cache_freshness::hash_file_if_small(path, metadata.len()) {
4500                    self.file_hashes.insert(path.clone(), hash);
4501                }
4502            }
4503        }
4504        self.any_missing_sizes = self
4505            .file_mtimes
4506            .keys()
4507            .any(|path| !self.file_sizes.contains_key(path));
4508    }
4509
4510    /// Remove entries for a specific file.
4511    pub fn remove_file(&mut self, file: &Path) {
4512        self.invalidate_file(file);
4513    }
4514
4515    pub fn invalidate_file(&mut self, file: &Path) {
4516        let file = file.to_path_buf();
4517        self.invalidate_files(std::slice::from_ref(&file));
4518    }
4519
4520    pub fn invalidate_files(&mut self, files: &[PathBuf]) {
4521        if files.is_empty() {
4522            return;
4523        }
4524        self.materialize_shared_base();
4525
4526        // Watchers may report a symlinked spelling while persisted metadata uses
4527        // the canonical spelling (or vice versa), so both keys must be removed.
4528        let mut invalidated = HashSet::with_capacity(files.len().saturating_mul(2));
4529        let mut metadata_keys = Vec::with_capacity(files.len().saturating_mul(2));
4530        for file in files {
4531            metadata_keys.push(file.clone());
4532            invalidated.insert(file.clone());
4533            let canonical = canonicalize_existing_or_deleted_path(file);
4534            if canonical != *file {
4535                metadata_keys.push(canonical.clone());
4536                invalidated.insert(canonical);
4537            }
4538        }
4539        self.remove_indexed_file_keys(&invalidated, &metadata_keys);
4540    }
4541
4542    #[cfg(test)]
4543    pub(crate) fn removal_retain_passes_for_test(&self) -> usize {
4544        self.removal_retain_passes
4545    }
4546
4547    #[cfg(test)]
4548    pub(crate) fn uses_shared_base_for_test(&self) -> bool {
4549        self.shared_base.is_some()
4550    }
4551
4552    /// Get the embedding dimension
4553    pub fn dimension(&self) -> usize {
4554        self.shared_base
4555            .as_ref()
4556            .map(|base| base.dimension)
4557            .unwrap_or(self.dimension)
4558    }
4559
4560    pub fn fingerprint(&self) -> Option<&SemanticIndexFingerprint> {
4561        self.shared_base
4562            .as_ref()
4563            .and_then(|base| base.fingerprint.as_ref())
4564            .or(self.fingerprint.as_ref())
4565    }
4566
4567    pub fn backend_label(&self) -> Option<&str> {
4568        self.fingerprint().map(|f| f.backend.as_str())
4569    }
4570
4571    pub fn model_label(&self) -> Option<&str> {
4572        self.fingerprint().map(|f| f.model.as_str())
4573    }
4574
4575    pub fn set_fingerprint(&mut self, fingerprint: SemanticIndexFingerprint) {
4576        self.materialize_shared_base();
4577        self.fingerprint = Some(fingerprint);
4578    }
4579
4580    fn scan_artifact_for_append(
4581        data_path: &Path,
4582        expected: SemanticPersistenceState,
4583        expected_fingerprint: &str,
4584        expected_dimension: usize,
4585    ) -> Result<SemanticArtifactLayout, String> {
4586        let mut file = fs::File::open(data_path).map_err(|error| error.to_string())?;
4587        let identity = semantic_artifact_identity(data_path)
4588            .ok_or_else(|| "semantic artifact identity unavailable".to_string())?;
4589        if identity != expected.identity {
4590            return Err("semantic artifact changed since it was loaded".to_string());
4591        }
4592        let file_len = usize::try_from(identity.bytes)
4593            .map_err(|_| "semantic artifact is too large for this platform".to_string())?;
4594        if expected.base_bytes < HEADER_BYTES_V2 || expected.base_bytes > file_len {
4595            return Err("persisted semantic base boundary is invalid".to_string());
4596        }
4597
4598        let mut fixed = [0_u8; HEADER_BYTES_V2];
4599        file.read_exact(&mut fixed)
4600            .map_err(|error| error.to_string())?;
4601        if fixed[0] != SEMANTIC_INDEX_VERSION_V6 && fixed[0] != SEMANTIC_INDEX_VERSION_V7 {
4602            return Err(format!(
4603                "unsupported on-disk semantic version: {}",
4604                fixed[0]
4605            ));
4606        }
4607        let dimension = u32::from_le_bytes(fixed[1..5].try_into().unwrap()) as usize;
4608        if dimension != expected_dimension {
4609            return Err("semantic artifact dimension changed".to_string());
4610        }
4611        let fingerprint_len = u32::from_le_bytes(fixed[9..13].try_into().unwrap()) as usize;
4612        if fingerprint_len > 64 * 1024 {
4613            return Err("semantic artifact fingerprint is oversized".to_string());
4614        }
4615        let mut fingerprint = vec![0_u8; fingerprint_len];
4616        file.read_exact(&mut fingerprint)
4617            .map_err(|error| error.to_string())?;
4618        if fingerprint != expected_fingerprint.as_bytes() {
4619            return Err("semantic artifact fingerprint changed".to_string());
4620        }
4621
4622        file.seek(SeekFrom::Start(expected.base_bytes as u64))
4623            .map_err(|error| error.to_string())?;
4624        let mut valid_bytes = expected.base_bytes;
4625        let mut segment_count = 0usize;
4626        let mut torn_tail = false;
4627        let mut bytes_read = HEADER_BYTES_V2.saturating_add(fingerprint_len);
4628        while valid_bytes < file_len {
4629            let remaining = file_len.saturating_sub(valid_bytes);
4630            if remaining < SEMANTIC_SEGMENT_FRAME_HEADER_BYTES {
4631                torn_tail = true;
4632                break;
4633            }
4634            let mut header = [0_u8; SEMANTIC_SEGMENT_FRAME_HEADER_BYTES];
4635            file.read_exact(&mut header)
4636                .map_err(|error| error.to_string())?;
4637            bytes_read = bytes_read.saturating_add(header.len());
4638            if &header[..8] != SEMANTIC_SEGMENT_MAGIC {
4639                torn_tail = true;
4640                break;
4641            }
4642            let payload_len =
4643                usize::try_from(u64::from_le_bytes(header[8..16].try_into().unwrap()))
4644                    .map_err(|_| "semantic segment length exceeds this platform".to_string())?;
4645            let frame_len = SEMANTIC_SEGMENT_FRAME_HEADER_BYTES
4646                .checked_add(payload_len)
4647                .ok_or_else(|| "semantic segment frame length overflow".to_string())?;
4648            if frame_len > remaining {
4649                torn_tail = true;
4650                break;
4651            }
4652            let frame_end = valid_bytes.saturating_add(frame_len);
4653            if frame_end == file_len {
4654                let mut payload = vec![0_u8; payload_len];
4655                file.read_exact(&mut payload)
4656                    .map_err(|error| error.to_string())?;
4657                bytes_read = bytes_read.saturating_add(payload_len);
4658                if blake3::hash(&payload).as_bytes()
4659                    != &header[16..SEMANTIC_SEGMENT_FRAME_HEADER_BYTES]
4660                {
4661                    torn_tail = true;
4662                    break;
4663                }
4664            } else {
4665                file.seek(SeekFrom::Current(payload_len as i64))
4666                    .map_err(|error| error.to_string())?;
4667            }
4668            valid_bytes = frame_end;
4669            segment_count = segment_count.saturating_add(1);
4670        }
4671
4672        Ok(SemanticArtifactLayout {
4673            identity,
4674            base_bytes: expected.base_bytes,
4675            valid_bytes,
4676            segment_count,
4677            segment_bytes: valid_bytes.saturating_sub(expected.base_bytes),
4678            torn_tail,
4679            bytes_read,
4680        })
4681    }
4682
4683    fn persistence_state_from_loaded(
4684        data_path: &Path,
4685        loaded: &LoadedSemanticArtifact,
4686    ) -> Option<SemanticPersistenceState> {
4687        Some(SemanticPersistenceState {
4688            identity: semantic_artifact_identity(data_path)?,
4689            base_bytes: loaded.base_bytes,
4690            segment_count: loaded.segment_count,
4691            segment_bytes: loaded.segment_bytes,
4692            valid_bytes: loaded.valid_bytes,
4693        })
4694    }
4695
4696    fn write_full_snapshot_at(
4697        &self,
4698        dir: &Path,
4699        data_path: &Path,
4700        pause_before_swap: bool,
4701    ) -> io::Result<usize> {
4702        let tmp_path = dir.join(format!(
4703            "semantic.bin.tmp.{}.{}",
4704            std::process::id(),
4705            SystemTime::now()
4706                .duration_since(SystemTime::UNIX_EPOCH)
4707                .unwrap_or(Duration::ZERO)
4708                .as_nanos()
4709        ));
4710        let write_result = (|| -> io::Result<usize> {
4711            let file = fs::File::create(&tmp_path)?;
4712            let mut writer = BufWriter::new(file);
4713            let bytes_written = self.write_to_writer(&mut writer)?;
4714            writer.flush()?;
4715            writer.get_ref().sync_all()?;
4716            Ok(bytes_written)
4717        })();
4718        let bytes_written = match write_result {
4719            Ok(bytes_written) => bytes_written,
4720            Err(error) => {
4721                let _ = fs::remove_file(&tmp_path);
4722                return Err(error);
4723            }
4724        };
4725
4726        #[cfg(debug_assertions)]
4727        if pause_before_swap {
4728            if let Some(ready) = env::var_os("AFT_TEST_SEMANTIC_COMPACTION_READY") {
4729                let ready = PathBuf::from(ready);
4730                fs::write(&ready, b"ready")?;
4731                let release = ready.with_extension("release");
4732                let started = Instant::now();
4733                while !release.is_file() {
4734                    if started.elapsed() >= Duration::from_secs(30) {
4735                        let _ = fs::remove_file(&tmp_path);
4736                        return Err(io::Error::new(
4737                            io::ErrorKind::TimedOut,
4738                            "timed out waiting at semantic compaction swap test seam",
4739                        ));
4740                    }
4741                    std::thread::sleep(Duration::from_millis(10));
4742                }
4743            }
4744        }
4745        #[cfg(not(debug_assertions))]
4746        let _ = pause_before_swap;
4747
4748        if let Err(error) = crate::fs_lock::rename_over(&tmp_path, data_path) {
4749            let _ = fs::remove_file(&tmp_path);
4750            return Err(error);
4751        }
4752        crate::fs_lock::sync_parent(data_path);
4753        Ok(bytes_written)
4754    }
4755
4756    fn persistence_identity_matches(&self, previous: &Self) -> bool {
4757        self.dimension == previous.dimension
4758            && self
4759                .fingerprint
4760                .as_ref()
4761                .map(SemanticIndexFingerprint::as_string)
4762                == previous
4763                    .fingerprint
4764                    .as_ref()
4765                    .map(SemanticIndexFingerprint::as_string)
4766    }
4767
4768    fn delta_for_paths(&self, paths: &BTreeSet<PathBuf>) -> Self {
4769        Self {
4770            entries: self
4771                .entries
4772                .iter()
4773                .filter(|entry| paths.contains(&entry.chunk.file))
4774                .cloned()
4775                .collect(),
4776            file_mtimes: self
4777                .file_mtimes
4778                .iter()
4779                .filter(|(path, _)| paths.contains(*path))
4780                .map(|(path, value)| (path.clone(), *value))
4781                .collect(),
4782            file_sizes: self
4783                .file_sizes
4784                .iter()
4785                .filter(|(path, _)| paths.contains(*path))
4786                .map(|(path, value)| (path.clone(), *value))
4787                .collect(),
4788            any_missing_sizes: false,
4789            file_hashes: self
4790                .file_hashes
4791                .iter()
4792                .filter(|(path, _)| paths.contains(*path))
4793                .map(|(path, value)| (path.clone(), *value))
4794                .collect(),
4795            dimension: self.dimension,
4796            fingerprint: self.fingerprint.clone(),
4797            project_root: self.project_root.clone(),
4798            deferred_files: HashSet::new(),
4799            shared_base: None,
4800            dirty_paths: Arc::new(Mutex::new(None)),
4801            persistence: Arc::new(Mutex::new(None)),
4802            last_append_read_bytes: Arc::new(AtomicUsize::new(0)),
4803            #[cfg(test)]
4804            removal_retain_passes: 0,
4805        }
4806    }
4807
4808    fn build_segment_frame(
4809        &self,
4810        sequence: u64,
4811        changed_paths: &BTreeSet<PathBuf>,
4812    ) -> Result<Vec<u8>, String> {
4813        let fingerprint = self
4814            .fingerprint
4815            .as_ref()
4816            .map(SemanticIndexFingerprint::as_string)
4817            .unwrap_or_default();
4818        let mut payload = Vec::new();
4819        payload.push(SEMANTIC_SEGMENT_VERSION);
4820        payload.extend_from_slice(&sequence.to_le_bytes());
4821        payload.extend_from_slice(&(fingerprint.len() as u32).to_le_bytes());
4822        payload.extend_from_slice(fingerprint.as_bytes());
4823        payload.extend_from_slice(&(self.dimension as u32).to_le_bytes());
4824        payload.extend_from_slice(&(changed_paths.len() as u32).to_le_bytes());
4825        for path in changed_paths {
4826            let relative = cache_relative_path(&self.project_root, path).ok_or_else(|| {
4827                format!(
4828                    "semantic segment tombstone escapes project root: {}",
4829                    path.display()
4830                )
4831            })?;
4832            let relative = relative.to_string_lossy();
4833            payload.extend_from_slice(&(relative.len() as u32).to_le_bytes());
4834            payload.extend_from_slice(relative.as_bytes());
4835        }
4836
4837        let delta_bytes = self.delta_for_paths(changed_paths).to_bytes();
4838        payload.extend_from_slice(&(delta_bytes.len() as u64).to_le_bytes());
4839        payload.extend_from_slice(&delta_bytes);
4840
4841        let checksum = blake3::hash(&payload);
4842        let mut frame = Vec::with_capacity(SEMANTIC_SEGMENT_FRAME_HEADER_BYTES + payload.len());
4843        frame.extend_from_slice(SEMANTIC_SEGMENT_MAGIC);
4844        frame.extend_from_slice(&(payload.len() as u64).to_le_bytes());
4845        frame.extend_from_slice(checksum.as_bytes());
4846        frame.extend_from_slice(&payload);
4847        Ok(frame)
4848    }
4849
4850    fn append_segment_frame(data_path: &Path, frame: &[u8]) -> io::Result<()> {
4851        let mut file = OpenOptions::new().append(true).open(data_path)?;
4852
4853        #[cfg(debug_assertions)]
4854        if let Some(ready) = env::var_os("AFT_TEST_SEMANTIC_SEGMENT_TEAR_READY") {
4855            let cut = (frame.len() / 2).max(SEMANTIC_SEGMENT_FRAME_HEADER_BYTES);
4856            file.write_all(&frame[..cut])?;
4857            file.sync_all()?;
4858            fs::write(ready, b"ready")?;
4859            loop {
4860                std::thread::sleep(Duration::from_secs(1));
4861            }
4862        }
4863
4864        file.write_all(frame)?;
4865        file.sync_all()
4866    }
4867
4868    fn compact_path_if_unchanged(
4869        dir: &Path,
4870        data_path: &Path,
4871        project_root: &Path,
4872        expected: SemanticArtifactIdentity,
4873    ) -> bool {
4874        let Ok(_lock) = acquire_semantic_persistence_lock(dir, expected.bytes) else {
4875            return false;
4876        };
4877        if semantic_artifact_identity(data_path) != Some(expected) {
4878            return false;
4879        }
4880        let loaded = match Self::load_artifact_path(data_path, project_root) {
4881            Ok(loaded) if !loaded.torn_tail && loaded.valid_bytes as u64 == expected.bytes => {
4882                loaded
4883            }
4884            Ok(_) => return false,
4885            Err(error) => {
4886                slog_warn!("failed to load semantic index for compaction: {}", error);
4887                return false;
4888            }
4889        };
4890        slog_info!(
4891            "semantic index compaction started: root=\"{}\" segments={} segment_bytes={}",
4892            project_root.display(),
4893            loaded.segment_count,
4894            loaded.segment_bytes
4895        );
4896        let started = Instant::now();
4897        match loaded.index.write_full_snapshot_at(dir, data_path, true) {
4898            Ok(bytes_written) => {
4899                slog_info!(
4900                    "semantic index compaction finished: root=\"{}\" segments={} segment_bytes={} entries={} bytes={} elapsed_ms={}",
4901                    project_root.display(),
4902                    loaded.segment_count,
4903                    loaded.segment_bytes,
4904                    loaded.index.entries.len(),
4905                    bytes_written,
4906                    started.elapsed().as_millis()
4907                );
4908                true
4909            }
4910            Err(error) => {
4911                slog_warn!("failed to compact semantic index: {}", error);
4912                false
4913            }
4914        }
4915    }
4916
4917    fn schedule_compaction_if_needed(
4918        &self,
4919        dir: &Path,
4920        data_path: &Path,
4921        layout: &SemanticArtifactLayout,
4922        appended_bytes: usize,
4923    ) {
4924        let segment_count = layout.segment_count.saturating_add(1);
4925        let segment_bytes = layout.segment_bytes.saturating_add(appended_bytes);
4926        let byte_bound_crossed = (segment_bytes as u64)
4927            > (layout.base_bytes as u64 / SEMANTIC_COMPACT_BYTE_RATIO_DENOMINATOR);
4928        if segment_count <= SEMANTIC_COMPACT_SEGMENT_LIMIT && !byte_bound_crossed {
4929            return;
4930        }
4931        let Some(expected) = semantic_artifact_identity(data_path) else {
4932            return;
4933        };
4934        slog_info!(
4935            "semantic index compaction scheduled: root=\"{}\" segments={} segment_bytes={}",
4936            self.project_root.display(),
4937            segment_count,
4938            segment_bytes
4939        );
4940        let project_root = self.project_root.clone();
4941        let dir = dir.to_path_buf();
4942        let data_path = data_path.to_path_buf();
4943        let _ = std::thread::Builder::new()
4944            .name("semantic-index-compaction".to_string())
4945            .spawn(move || {
4946                Self::compact_path_if_unchanged(&dir, &data_path, &project_root, expected);
4947            });
4948    }
4949
4950    /// Write a cold base snapshot or append one checksummed file-replacement segment.
4951    /// A final partial segment is ignored (and truncated by an owning reader/writer),
4952    /// so SIGKILL during append leaves every previously committed refresh loadable.
4953    pub fn write_to_disk(&self, storage_dir: &Path, project_key: &str) -> bool {
4954        if self.shared_base.is_some() {
4955            let mut private = self.clone();
4956            private.materialize_shared_base();
4957            return private.write_to_disk(storage_dir, project_key);
4958        }
4959        let dir = storage_dir.join("semantic").join(project_key);
4960        let data_path = dir.join("semantic.bin");
4961        let access = crate::root_cache::ArtifactAccess::for_root(&self.project_root);
4962        if !access.allows_write(project_key, &data_path) {
4963            return false;
4964        }
4965        if let Err(error) = fs::create_dir_all(&dir) {
4966            slog_warn!("failed to create semantic cache dir: {}", error);
4967            return false;
4968        }
4969        let artifact_bytes = semantic_artifact_identity(&data_path)
4970            .map(|identity| identity.bytes)
4971            .unwrap_or_default();
4972        let _persistence_lock = match acquire_semantic_persistence_lock(&dir, artifact_bytes) {
4973            Ok(lock) => lock,
4974            Err(error) => {
4975                slog_warn!("failed to acquire semantic persistence lock: {}", error);
4976                return false;
4977            }
4978        };
4979
4980        if data_path.is_file() {
4981            let fingerprint = self
4982                .fingerprint
4983                .as_ref()
4984                .map(SemanticIndexFingerprint::as_string)
4985                .unwrap_or_default();
4986            let layout = self.persistence_snapshot().and_then(|persistence| {
4987                match Self::scan_artifact_for_append(
4988                    &data_path,
4989                    persistence,
4990                    &fingerprint,
4991                    self.dimension,
4992                ) {
4993                    Ok(layout) => Some(layout),
4994                    Err(error) => {
4995                        slog_info!(
4996                            "semantic delta metadata unavailable ({}); using structural fallback",
4997                            error
4998                        );
4999                        None
5000                    }
5001                }
5002            });
5003            let (layout, changed_paths) = if let (Some(layout), Some(dirty_paths)) =
5004                (layout, self.dirty_paths_snapshot())
5005            {
5006                (layout, dirty_paths.clone())
5007            } else {
5008                match Self::load_artifact_path(&data_path, &self.project_root) {
5009                    Ok(loaded) if self.persistence_identity_matches(&loaded.index) => {
5010                        let changed_paths = semantic_changed_paths(&loaded.index, self);
5011                        let identity = match semantic_artifact_identity(&data_path) {
5012                            Some(identity) => identity,
5013                            None => return false,
5014                        };
5015                        let layout = SemanticArtifactLayout {
5016                            identity,
5017                            base_bytes: loaded.base_bytes,
5018                            valid_bytes: loaded.valid_bytes,
5019                            segment_count: loaded.segment_count,
5020                            segment_bytes: loaded.segment_bytes,
5021                            torn_tail: loaded.torn_tail,
5022                            bytes_read: identity.bytes as usize,
5023                        };
5024                        (layout, changed_paths)
5025                    }
5026                    Ok(_) => {
5027                        self.set_persistence(None);
5028                        self.mark_all_dirty();
5029                        return self
5030                            .write_full_snapshot_at(&dir, &data_path, false)
5031                            .is_ok_and(|bytes_written| {
5032                                let Some(identity) = semantic_artifact_identity(&data_path) else {
5033                                    return false;
5034                                };
5035                                self.set_persistence(Some(SemanticPersistenceState {
5036                                    identity,
5037                                    base_bytes: bytes_written,
5038                                    segment_count: 0,
5039                                    segment_bytes: 0,
5040                                    valid_bytes: bytes_written,
5041                                }));
5042                                self.set_dirty_paths(Some(BTreeSet::new()));
5043                                slog_info!(
5044                                    "semantic index persisted: {} entries, {:.1} KB",
5045                                    self.entries.len(),
5046                                    bytes_written as f64 / 1024.0
5047                                );
5048                                true
5049                            });
5050                    }
5051                    Err(error) => {
5052                        slog_warn!(
5053                            "semantic index delta baseline unavailable ({}); replacing base snapshot",
5054                            error
5055                        );
5056                        self.set_persistence(None);
5057                        self.mark_all_dirty();
5058                        return self
5059                            .write_full_snapshot_at(&dir, &data_path, false)
5060                            .is_ok_and(|bytes_written| {
5061                                let Some(identity) = semantic_artifact_identity(&data_path) else {
5062                                    return false;
5063                                };
5064                                self.set_persistence(Some(SemanticPersistenceState {
5065                                    identity,
5066                                    base_bytes: bytes_written,
5067                                    segment_count: 0,
5068                                    segment_bytes: 0,
5069                                    valid_bytes: bytes_written,
5070                                }));
5071                                self.set_dirty_paths(Some(BTreeSet::new()));
5072                                true
5073                            });
5074                    }
5075                }
5076            };
5077
5078            self.last_append_read_bytes
5079                .store(layout.bytes_read, Ordering::Relaxed);
5080            if layout.torn_tail {
5081                match OpenOptions::new()
5082                    .write(true)
5083                    .open(&data_path)
5084                    .and_then(|file| {
5085                        file.set_len(layout.valid_bytes as u64)?;
5086                        file.sync_all()
5087                    }) {
5088                    Ok(()) => {}
5089                    Err(error) => {
5090                        slog_warn!("failed to truncate torn semantic segment: {}", error);
5091                        return false;
5092                    }
5093                }
5094            }
5095            if changed_paths.is_empty() {
5096                self.set_dirty_paths(Some(BTreeSet::new()));
5097                self.set_persistence(Some(SemanticPersistenceState {
5098                    identity: layout.identity,
5099                    base_bytes: layout.base_bytes,
5100                    segment_count: layout.segment_count,
5101                    segment_bytes: layout.segment_bytes,
5102                    valid_bytes: layout.valid_bytes,
5103                }));
5104                return true;
5105            }
5106            let frame = match self.build_segment_frame(
5107                layout.segment_count.saturating_add(1) as u64,
5108                &changed_paths,
5109            ) {
5110                Ok(frame) => frame,
5111                Err(error) => {
5112                    slog_warn!("failed to encode semantic delta: {}", error);
5113                    return false;
5114                }
5115            };
5116            if let Err(error) = Self::append_segment_frame(&data_path, &frame) {
5117                slog_warn!("failed to append semantic delta: {}", error);
5118                return false;
5119            }
5120            let Some(identity) = semantic_artifact_identity(&data_path) else {
5121                return false;
5122            };
5123            self.set_persistence(Some(SemanticPersistenceState {
5124                identity,
5125                base_bytes: layout.base_bytes,
5126                segment_count: layout.segment_count.saturating_add(1),
5127                segment_bytes: layout.segment_bytes.saturating_add(frame.len()),
5128                valid_bytes: layout.valid_bytes.saturating_add(frame.len()),
5129            }));
5130            self.set_dirty_paths(Some(BTreeSet::new()));
5131            slog_info!(
5132                "semantic index delta persisted: {} files, {:.1} KB, artifact_read_bytes={}",
5133                changed_paths.len(),
5134                frame.len() as f64 / 1024.0,
5135                layout.bytes_read
5136            );
5137            self.schedule_compaction_if_needed(&dir, &data_path, &layout, frame.len());
5138            return true;
5139        }
5140
5141        match self.write_full_snapshot_at(&dir, &data_path, false) {
5142            Ok(bytes_written) => {
5143                let Some(identity) = semantic_artifact_identity(&data_path) else {
5144                    return false;
5145                };
5146                self.set_persistence(Some(SemanticPersistenceState {
5147                    identity,
5148                    base_bytes: bytes_written,
5149                    segment_count: 0,
5150                    segment_bytes: 0,
5151                    valid_bytes: bytes_written,
5152                }));
5153                self.set_dirty_paths(Some(BTreeSet::new()));
5154                slog_info!(
5155                    "semantic index persisted: {} entries, {:.1} KB",
5156                    self.entries.len(),
5157                    bytes_written as f64 / 1024.0
5158                );
5159                true
5160            }
5161            Err(error) => {
5162                slog_warn!("failed to write semantic index: {}", error);
5163                false
5164            }
5165        }
5166    }
5167
5168    #[doc(hidden)]
5169    pub fn segment_frames_for_test(
5170        &self,
5171        previous: &Self,
5172        sequence: u64,
5173    ) -> Option<(Vec<u8>, Vec<u8>)> {
5174        let dirty_paths = self.dirty_paths_snapshot()?;
5175        let structural_paths = semantic_changed_paths(previous, self);
5176        Some((
5177            self.build_segment_frame(sequence, &dirty_paths).ok()?,
5178            self.build_segment_frame(sequence, &structural_paths).ok()?,
5179        ))
5180    }
5181
5182    #[doc(hidden)]
5183    pub fn extend_dirty_paths_for_test(&self, paths: impl IntoIterator<Item = PathBuf>) {
5184        self.extend_dirty_paths(paths);
5185    }
5186
5187    #[doc(hidden)]
5188    pub fn last_append_read_bytes_for_test(&self) -> usize {
5189        self.last_append_read_bytes.load(Ordering::Relaxed)
5190    }
5191
5192    #[doc(hidden)]
5193    pub fn append_scan_bytes_for_test(
5194        &self,
5195        storage_dir: &Path,
5196        project_key: &str,
5197    ) -> Option<usize> {
5198        let data_path = storage_dir
5199            .join("semantic")
5200            .join(project_key)
5201            .join("semantic.bin");
5202        let persistence = self.persistence_snapshot()?;
5203        let fingerprint = self
5204            .fingerprint
5205            .as_ref()
5206            .map(SemanticIndexFingerprint::as_string)
5207            .unwrap_or_default();
5208        Self::scan_artifact_for_append(&data_path, persistence, &fingerprint, self.dimension)
5209            .ok()
5210            .map(|layout| layout.bytes_read)
5211    }
5212
5213    #[doc(hidden)]
5214    pub fn compact_to_disk_for_test(&self, storage_dir: &Path, project_key: &str) -> bool {
5215        let dir = storage_dir.join("semantic").join(project_key);
5216        let data_path = dir.join("semantic.bin");
5217        let Some(expected) = semantic_artifact_identity(&data_path) else {
5218            return false;
5219        };
5220        Self::compact_path_if_unchanged(&dir, &data_path, &self.project_root, expected)
5221    }
5222
5223    #[doc(hidden)]
5224    pub fn persistence_stats_for_test(
5225        storage_dir: &Path,
5226        project_key: &str,
5227        project_root: &Path,
5228    ) -> Option<(usize, usize, usize)> {
5229        let data_path = storage_dir
5230            .join("semantic")
5231            .join(project_key)
5232            .join("semantic.bin");
5233        let loaded = Self::load_artifact_path(&data_path, project_root).ok()?;
5234        Some((
5235            loaded.base_bytes,
5236            loaded.segment_count,
5237            loaded.segment_bytes,
5238        ))
5239    }
5240
5241    fn decode_segment_payload(
5242        payload: &[u8],
5243        expected_sequence: u64,
5244        current_canonical_root: &Path,
5245        base_fingerprint: Option<&SemanticIndexFingerprint>,
5246        base_dimension: usize,
5247    ) -> Result<(BTreeSet<PathBuf>, Self), String> {
5248        let mut reader = CountingReader::with_bytes_read(Cursor::new(payload), 0);
5249        let segment_version = read_u8_stream(&mut reader, "semantic segment is empty")?;
5250        if segment_version != SEMANTIC_SEGMENT_VERSION {
5251            return Err(format!(
5252                "unsupported semantic segment version: {segment_version}"
5253            ));
5254        }
5255        let sequence = read_u64_stream(&mut reader)?;
5256        if sequence != expected_sequence {
5257            return Err(format!(
5258                "semantic segment order mismatch: expected {expected_sequence}, found {sequence}"
5259            ));
5260        }
5261        let fingerprint_len = read_u32_stream(&mut reader)? as usize;
5262        if reader.bytes_read().saturating_add(fingerprint_len) > payload.len() {
5263            return Err("unexpected end of semantic segment fingerprint".to_string());
5264        }
5265        let mut fingerprint = vec![0_u8; fingerprint_len];
5266        read_exact_stream(
5267            &mut reader,
5268            &mut fingerprint,
5269            "unexpected end of semantic segment fingerprint",
5270        )?;
5271        let fingerprint = String::from_utf8(fingerprint)
5272            .map_err(|error| format!("invalid semantic segment fingerprint: {error}"))?;
5273        let expected_fingerprint = base_fingerprint
5274            .map(SemanticIndexFingerprint::as_string)
5275            .unwrap_or_default();
5276        if fingerprint != expected_fingerprint {
5277            return Err("semantic segment fingerprint does not match base snapshot".to_string());
5278        }
5279
5280        let dimension = read_u32_stream(&mut reader)? as usize;
5281        if dimension != base_dimension {
5282            return Err(format!(
5283                "semantic segment dimension mismatch: base={base_dimension}, segment={dimension}"
5284            ));
5285        }
5286        let tombstone_count = read_u32_stream(&mut reader)? as usize;
5287        if tombstone_count > MAX_ENTRIES {
5288            return Err(format!(
5289                "too many semantic segment tombstones: {tombstone_count}"
5290            ));
5291        }
5292        let mut tombstones = BTreeSet::new();
5293        for _ in 0..tombstone_count {
5294            let relative = PathBuf::from(read_string_stream(&mut reader, Some(payload.len()))?);
5295            let path = cached_path_under_root(current_canonical_root, &relative)
5296                .ok_or_else(|| "semantic segment tombstone escapes project root".to_string())?;
5297            if !tombstones.insert(path) {
5298                return Err("semantic segment contains a duplicate tombstone".to_string());
5299            }
5300        }
5301
5302        let delta_len = usize::try_from(read_u64_stream(&mut reader)?)
5303            .map_err(|_| "semantic segment delta is too large".to_string())?;
5304        if reader.bytes_read().saturating_add(delta_len) != payload.len() {
5305            return Err("semantic segment delta length does not match payload".to_string());
5306        }
5307        let mut delta_bytes = vec![0_u8; delta_len];
5308        read_exact_stream(
5309            &mut reader,
5310            &mut delta_bytes,
5311            "unexpected end of semantic segment delta",
5312        )?;
5313        let delta = Self::from_bytes(&delta_bytes, current_canonical_root)?;
5314        if delta.dimension != base_dimension
5315            || delta
5316                .fingerprint
5317                .as_ref()
5318                .map(SemanticIndexFingerprint::as_string)
5319                != base_fingerprint.map(SemanticIndexFingerprint::as_string)
5320        {
5321            return Err("semantic segment replacement snapshot identity mismatch".to_string());
5322        }
5323
5324        let replacement_paths = delta
5325            .file_mtimes
5326            .keys()
5327            .chain(delta.file_sizes.keys())
5328            .chain(delta.file_hashes.keys())
5329            .cloned()
5330            .chain(delta.entries.iter().map(|entry| entry.chunk.file.clone()))
5331            .collect::<BTreeSet<_>>();
5332        if !replacement_paths.is_subset(&tombstones) {
5333            return Err(
5334                "semantic segment replacement contains a file without a tombstone".to_string(),
5335            );
5336        }
5337        Ok((tombstones, delta))
5338    }
5339
5340    fn apply_segment_log<R: Read>(
5341        reader: &mut R,
5342        mut index: Self,
5343        total_len: usize,
5344        base_bytes: usize,
5345    ) -> Result<LoadedSemanticArtifact, String> {
5346        let mut valid_bytes = base_bytes;
5347        let mut segment_count = 0usize;
5348        let mut torn_tail = false;
5349
5350        while valid_bytes < total_len {
5351            let remaining = total_len.saturating_sub(valid_bytes);
5352            if remaining < SEMANTIC_SEGMENT_FRAME_HEADER_BYTES {
5353                torn_tail = true;
5354                break;
5355            }
5356            let mut header = [0_u8; SEMANTIC_SEGMENT_FRAME_HEADER_BYTES];
5357            if reader.read_exact(&mut header).is_err() {
5358                torn_tail = true;
5359                break;
5360            }
5361            if &header[..SEMANTIC_SEGMENT_MAGIC.len()] != SEMANTIC_SEGMENT_MAGIC {
5362                torn_tail = true;
5363                break;
5364            }
5365            let payload_len = usize::try_from(u64::from_le_bytes(
5366                header[8..16]
5367                    .try_into()
5368                    .expect("semantic segment length field"),
5369            ))
5370            .map_err(|_| "semantic segment length exceeds this platform".to_string())?;
5371            let frame_len = SEMANTIC_SEGMENT_FRAME_HEADER_BYTES
5372                .checked_add(payload_len)
5373                .ok_or_else(|| "semantic segment frame length overflow".to_string())?;
5374            if frame_len > remaining {
5375                torn_tail = true;
5376                break;
5377            }
5378            let mut payload = vec![0_u8; payload_len];
5379            if reader.read_exact(&mut payload).is_err() {
5380                torn_tail = true;
5381                break;
5382            }
5383            let expected_checksum = &header[16..SEMANTIC_SEGMENT_FRAME_HEADER_BYTES];
5384            if blake3::hash(&payload).as_bytes() != expected_checksum {
5385                torn_tail = true;
5386                break;
5387            }
5388
5389            let expected_sequence = segment_count.saturating_add(1) as u64;
5390            let (tombstones, delta) = Self::decode_segment_payload(
5391                &payload,
5392                expected_sequence,
5393                &index.project_root,
5394                index.fingerprint.as_ref(),
5395                index.dimension,
5396            )?;
5397            let tombstones = tombstones.into_iter().collect::<Vec<_>>();
5398            index.remove_indexed_files(&tombstones);
5399            index.entries.extend(delta.entries);
5400            index.file_mtimes.extend(delta.file_mtimes);
5401            index.file_sizes.extend(delta.file_sizes);
5402            index.file_hashes.extend(delta.file_hashes);
5403            index.any_missing_sizes = index
5404                .file_mtimes
5405                .keys()
5406                .any(|path| !index.file_sizes.contains_key(path));
5407
5408            valid_bytes = valid_bytes.saturating_add(frame_len);
5409            segment_count = segment_count.saturating_add(1);
5410        }
5411
5412        Ok(LoadedSemanticArtifact {
5413            index,
5414            base_bytes,
5415            valid_bytes,
5416            segment_count,
5417            segment_bytes: valid_bytes.saturating_sub(base_bytes),
5418            torn_tail,
5419        })
5420    }
5421
5422    fn load_artifact_path(
5423        data_path: &Path,
5424        current_canonical_root: &Path,
5425    ) -> Result<LoadedSemanticArtifact, String> {
5426        let file = fs::File::open(data_path).map_err(|error| error.to_string())?;
5427        let file_len =
5428            usize::try_from(file.metadata().map_err(|error| error.to_string())?.len())
5429                .map_err(|_| "semantic artifact is too large for this platform".to_string())?;
5430        if file_len < HEADER_BYTES_V1 {
5431            return Err(format!("data too short: {file_len} bytes"));
5432        }
5433        let mut reader = BufReader::new(file);
5434        let mut version_buf = [0_u8; 1];
5435        reader
5436            .read_exact(&mut version_buf)
5437            .map_err(|error| error.to_string())?;
5438        let version = version_buf[0];
5439        if version != SEMANTIC_INDEX_VERSION_V6 && version != SEMANTIC_INDEX_VERSION_V7 {
5440            return Err(format!("unsupported on-disk semantic version: {version}"));
5441        }
5442        let (index, base_bytes) = Self::from_reader_after_version(
5443            &mut reader,
5444            version,
5445            current_canonical_root,
5446            Some(file_len),
5447            1,
5448        )?;
5449        let loaded = Self::apply_segment_log(&mut reader, index, file_len, base_bytes)?;
5450        loaded.index.set_dirty_paths(Some(BTreeSet::new()));
5451        loaded
5452            .index
5453            .set_persistence(Self::persistence_state_from_loaded(data_path, &loaded));
5454        Ok(loaded)
5455    }
5456
5457    /// Read the semantic base snapshot and apply every committed delta in sequence.
5458    pub fn read_from_disk(
5459        storage_dir: &Path,
5460        project_key: &str,
5461        current_canonical_root: &Path,
5462        is_worktree_bridge: bool,
5463        expected_fingerprint: Option<&str>,
5464    ) -> Option<Self> {
5465        debug_assert!(current_canonical_root.is_absolute());
5466        let data_path = storage_dir
5467            .join("semantic")
5468            .join(project_key)
5469            .join("semantic.bin");
5470        let file_len = usize::try_from(data_path.metadata().ok()?.len()).ok()?;
5471        if file_len < HEADER_BYTES_V1 {
5472            slog_warn!(
5473                "corrupt semantic index (too small: {} bytes), removing",
5474                file_len
5475            );
5476            if !is_worktree_bridge {
5477                let _ = fs::remove_file(&data_path);
5478            }
5479            return None;
5480        }
5481        let mut version_buf = [0_u8; 1];
5482        fs::File::open(&data_path)
5483            .ok()?
5484            .read_exact(&mut version_buf)
5485            .ok()?;
5486        let version = version_buf[0];
5487        if version != SEMANTIC_INDEX_VERSION_V6 && version != SEMANTIC_INDEX_VERSION_V7 {
5488            slog_info!(
5489                "cached semantic index version {} is not compatible with {}, rebuilding without deleting the shared artifact",
5490                version,
5491                SEMANTIC_INDEX_VERSION_V7
5492            );
5493            return None;
5494        }
5495
5496        match Self::load_artifact_path(&data_path, current_canonical_root) {
5497            Ok(loaded) => {
5498                if let Some(expected) = expected_fingerprint {
5499                    let matches = loaded
5500                        .index
5501                        .fingerprint()
5502                        .map(|fingerprint| fingerprint.matches_expected(expected))
5503                        .unwrap_or(false);
5504                    if !matches {
5505                        log_fingerprint_mismatch(loaded.index.fingerprint(), expected);
5506                        return None;
5507                    }
5508                }
5509                if loaded.torn_tail {
5510                    slog_warn!(
5511                        "ignoring torn semantic segment tail after {} committed bytes",
5512                        loaded.valid_bytes
5513                    );
5514                    if !is_worktree_bridge {
5515                        let truncate_result = OpenOptions::new()
5516                            .write(true)
5517                            .open(&data_path)
5518                            .and_then(|file| {
5519                                file.set_len(loaded.valid_bytes as u64)?;
5520                                file.sync_all()
5521                            });
5522                        if let Err(error) = truncate_result {
5523                            slog_warn!("failed to truncate torn semantic segment: {}", error);
5524                        }
5525                    }
5526                }
5527                slog_info!(
5528                    "loaded semantic index from disk: {} entries ({} delta segments)",
5529                    loaded.index.entries.len(),
5530                    loaded.segment_count
5531                );
5532                Some(loaded.index)
5533            }
5534            Err(error) => {
5535                slog_warn!("corrupt semantic index, rebuilding: {}", error);
5536                if !is_worktree_bridge {
5537                    let _ = fs::remove_file(&data_path);
5538                }
5539                None
5540            }
5541        }
5542    }
5543
5544    pub(crate) fn read_from_disk_borrow_tolerant(
5545        storage_dir: &Path,
5546        project_key: &str,
5547        current_canonical_root: &Path,
5548    ) -> Option<Self> {
5549        let load_started = Instant::now();
5550        let loaded = Self::read_from_disk_borrow_tolerant_inner(
5551            storage_dir,
5552            project_key,
5553            current_canonical_root,
5554        );
5555        let outcome = if loaded.is_some() { "ready" } else { "denied" };
5556        let build_id = crate::logging::in_flight_build_id(
5557            crate::logging::IndexPlane::Semantic,
5558            current_canonical_root,
5559        )
5560        .unwrap_or_else(crate::logging::mint_index_build_id);
5561        crate::logging::log_index_event(
5562            crate::logging::IndexEvent::new(
5563                crate::logging::IndexEventKind::ArtifactLoaded,
5564                crate::logging::IndexPlane::Semantic,
5565                build_id,
5566                current_canonical_root,
5567                project_key,
5568            )
5569            .field("outcome", outcome)
5570            .field("borrowed", "true")
5571            .field(
5572                "elapsed_ms",
5573                load_started.elapsed().as_millis().min(u64::MAX as u128) as u64,
5574            ),
5575        );
5576        crate::logging::note_tool_call_wait(
5577            crate::run_tool_call::WaitingOn::ArtifactLoad,
5578            None,
5579            load_started.elapsed().as_millis().min(u64::MAX as u128) as u64,
5580        );
5581        loaded
5582    }
5583
5584    fn read_from_disk_borrow_tolerant_inner(
5585        storage_dir: &Path,
5586        project_key: &str,
5587        current_canonical_root: &Path,
5588    ) -> Option<Self> {
5589        let data_path = storage_dir
5590            .join("semantic")
5591            .join(project_key)
5592            .join("semantic.bin");
5593        let (fingerprint, artifact_content_hash) = match borrowed_artifact_identity(&data_path) {
5594            Ok(identity) => identity,
5595            Err(error) => {
5596                slog_warn!(
5597                    "semantic shared-base identity unavailable ({}); loading a private borrowed copy",
5598                    error
5599                );
5600                return Self::read_from_disk(
5601                    storage_dir,
5602                    project_key,
5603                    current_canonical_root,
5604                    true,
5605                    None,
5606                );
5607            }
5608        };
5609        let key = SharedSemanticBaseKey {
5610            artifact_cache_key: project_key.to_string(),
5611            fingerprint,
5612            artifact_content_hash,
5613        };
5614
5615        {
5616            let mut registry = shared_semantic_bases()
5617                .lock()
5618                .unwrap_or_else(std::sync::PoisonError::into_inner);
5619            registry.retain(|_, base| base.strong_count() > 0);
5620            if let Some(base) = registry.get(&key).and_then(Weak::upgrade) {
5621                SHARED_SEMANTIC_BASE_HITS.fetch_add(1, Ordering::Relaxed);
5622                return Some(Self::from_shared_base(
5623                    current_canonical_root.to_path_buf(),
5624                    base,
5625                ));
5626            }
5627            if registry.keys().any(|existing| {
5628                existing.artifact_cache_key == key.artifact_cache_key && existing != &key
5629            }) {
5630                slog_warn!(
5631                    "semantic shared-base fingerprint or artifact hash changed for key {}; loading a private borrowed copy",
5632                    project_key
5633                );
5634                return Self::read_from_disk(
5635                    storage_dir,
5636                    project_key,
5637                    current_canonical_root,
5638                    true,
5639                    None,
5640                );
5641            }
5642        }
5643
5644        let private = Self::read_from_disk(
5645            storage_dir,
5646            project_key,
5647            current_canonical_root,
5648            true,
5649            Some(&key.fingerprint),
5650        )?;
5651        let Ok(base) = private.clone().into_shared_base() else {
5652            slog_warn!(
5653                "semantic shared-base paths could not be normalized for key {}; loading a private borrowed copy",
5654                project_key
5655            );
5656            return Some(private);
5657        };
5658        let base = Arc::new(base);
5659
5660        let mut registry = shared_semantic_bases()
5661            .lock()
5662            .unwrap_or_else(std::sync::PoisonError::into_inner);
5663        registry.retain(|_, base| base.strong_count() > 0);
5664        if let Some(existing) = registry.get(&key).and_then(Weak::upgrade) {
5665            SHARED_SEMANTIC_BASE_HITS.fetch_add(1, Ordering::Relaxed);
5666            return Some(Self::from_shared_base(
5667                current_canonical_root.to_path_buf(),
5668                existing,
5669            ));
5670        }
5671        if registry.keys().any(|existing| {
5672            existing.artifact_cache_key == key.artifact_cache_key && existing != &key
5673        }) {
5674            slog_warn!(
5675                "semantic shared-base identity changed while loading key {}; retaining a private borrowed copy",
5676                project_key
5677            );
5678            return Some(private);
5679        }
5680        registry.insert(key, Arc::downgrade(&base));
5681        SHARED_SEMANTIC_BASE_LOADS.fetch_add(1, Ordering::Relaxed);
5682        Some(Self::from_shared_base(
5683            current_canonical_root.to_path_buf(),
5684            base,
5685        ))
5686    }
5687
5688    /// Serialize the index to bytes for disk persistence
5689    pub fn to_bytes(&self) -> Vec<u8> {
5690        if self.shared_base.is_some() {
5691            let mut private = self.clone();
5692            private.materialize_shared_base();
5693            return private.to_bytes();
5694        }
5695        let mut buf = Vec::new();
5696        self.write_to_writer(&mut buf)
5697            .expect("writing semantic index to Vec cannot fail");
5698        buf
5699    }
5700
5701    fn write_to_writer<W: Write>(&self, writer: &mut W) -> io::Result<usize> {
5702        let mut bytes_written = 0usize;
5703        let fingerprint = self.fingerprint.as_ref().and_then(|fingerprint| {
5704            let encoded = fingerprint.as_string();
5705            if encoded.is_empty() {
5706                None
5707            } else {
5708                Some(encoded)
5709            }
5710        });
5711        let fp_bytes_ref = fingerprint.as_deref().map(str::as_bytes).unwrap_or(&[]);
5712        let mut file_metadata = self
5713            .file_mtimes
5714            .iter()
5715            .filter_map(|(path, mtime)| {
5716                cache_relative_path(&self.project_root, path)
5717                    .map(|relative| (relative, path, mtime))
5718            })
5719            .collect::<Vec<_>>();
5720        file_metadata.sort_by(|left, right| left.0.cmp(&right.0));
5721        let mut persisted_entries = self
5722            .entries
5723            .iter()
5724            .filter_map(|entry| {
5725                cache_relative_path(&self.project_root, &entry.chunk.file)
5726                    .map(|relative| (relative, entry))
5727            })
5728            .collect::<Vec<_>>();
5729        persisted_entries.sort_by(|left, right| {
5730            left.0
5731                .cmp(&right.0)
5732                .then_with(|| semantic_entry_cmp(&left.1, &right.1))
5733        });
5734        let file_mtime_count = file_metadata.len();
5735        let entry_count = persisted_entries.len();
5736
5737        // Header: version(1) + dimension(4) + entry_count(4) + fingerprint_len(4) + fingerprint
5738        //
5739        // V7 is the single write format. Layout extends V6 with per-entry
5740        // qualified_name metadata while preserving the embedding fingerprint:
5741        //   - fingerprint is always represented (absent ⇒ fingerprint_len=0,
5742        //     no bytes follow). Uniform format simplifies the reader.
5743        //   - paths are relative to project_root.
5744        //   - file metadata stored as secs(u64) + subsec_nanos(u32) + size(u64) + blake3(32).
5745        //     Preserves full APFS/ext4/NTFS precision and catches mtime ties.
5746        //
5747        // V1/V2 remain readable for backward compatibility (see from_bytes).
5748        // V3/V4 load as compatible formats but are rejected on disk so snippets
5749        // and file sizes are rebuilt once. V6 remains accepted on disk and
5750        // yields qualified_name=None until the next V7 write.
5751        let version = SEMANTIC_INDEX_VERSION_V7;
5752        write_counted(writer, &[version], &mut bytes_written)?;
5753        write_counted(
5754            writer,
5755            &(self.dimension as u32).to_le_bytes(),
5756            &mut bytes_written,
5757        )?;
5758        write_counted(
5759            writer,
5760            &(entry_count as u32).to_le_bytes(),
5761            &mut bytes_written,
5762        )?;
5763        write_counted(
5764            writer,
5765            &(fp_bytes_ref.len() as u32).to_le_bytes(),
5766            &mut bytes_written,
5767        )?;
5768        write_counted(writer, fp_bytes_ref, &mut bytes_written)?;
5769
5770        // File mtime table: count(4) + entries
5771        // V3 layout per entry: path_len(4) + path + secs(8) + subsec_nanos(4)
5772        write_counted(
5773            writer,
5774            &(file_mtime_count as u32).to_le_bytes(),
5775            &mut bytes_written,
5776        )?;
5777        for (relative, path, mtime) in file_metadata {
5778            let relative = relative.to_string_lossy();
5779            let path_bytes = relative.as_bytes();
5780            write_counted(
5781                writer,
5782                &(path_bytes.len() as u32).to_le_bytes(),
5783                &mut bytes_written,
5784            )?;
5785            write_counted(writer, path_bytes, &mut bytes_written)?;
5786            let duration = mtime
5787                .duration_since(SystemTime::UNIX_EPOCH)
5788                .unwrap_or_default();
5789            write_counted(
5790                writer,
5791                &duration.as_secs().to_le_bytes(),
5792                &mut bytes_written,
5793            )?;
5794            write_counted(
5795                writer,
5796                &duration.subsec_nanos().to_le_bytes(),
5797                &mut bytes_written,
5798            )?;
5799            let size = self.file_sizes.get(path).copied().unwrap_or_default();
5800            write_counted(writer, &size.to_le_bytes(), &mut bytes_written)?;
5801            let hash = self
5802                .file_hashes
5803                .get(path)
5804                .copied()
5805                .unwrap_or_else(cache_freshness::zero_hash);
5806            write_counted(writer, hash.as_bytes(), &mut bytes_written)?;
5807        }
5808
5809        // Entries: each is metadata + vector. Canonical ordering lets parity
5810        // compare structure directly even when HashMap insertion order differs.
5811        for (relative, entry) in persisted_entries {
5812            let c = &entry.chunk;
5813
5814            // File path
5815            let relative = relative.to_string_lossy();
5816            let file_bytes = relative.as_bytes();
5817            write_counted(
5818                writer,
5819                &(file_bytes.len() as u32).to_le_bytes(),
5820                &mut bytes_written,
5821            )?;
5822            write_counted(writer, file_bytes, &mut bytes_written)?;
5823
5824            // Name
5825            let name_bytes = c.name.as_bytes();
5826            write_counted(
5827                writer,
5828                &(name_bytes.len() as u32).to_le_bytes(),
5829                &mut bytes_written,
5830            )?;
5831            write_counted(writer, name_bytes, &mut bytes_written)?;
5832
5833            // Qualified name (V7 metadata; absent is encoded as length 0)
5834            let qualified_name_bytes = c.qualified_name.as_deref().unwrap_or_default().as_bytes();
5835            write_counted(
5836                writer,
5837                &(qualified_name_bytes.len() as u32).to_le_bytes(),
5838                &mut bytes_written,
5839            )?;
5840            write_counted(writer, qualified_name_bytes, &mut bytes_written)?;
5841
5842            // Kind (1 byte)
5843            write_counted(writer, &[symbol_kind_to_u8(&c.kind)], &mut bytes_written)?;
5844
5845            // Lines + exported
5846            write_counted(
5847                writer,
5848                &(c.start_line as u32).to_le_bytes(),
5849                &mut bytes_written,
5850            )?;
5851            write_counted(
5852                writer,
5853                &(c.end_line as u32).to_le_bytes(),
5854                &mut bytes_written,
5855            )?;
5856            write_counted(writer, &[c.exported as u8], &mut bytes_written)?;
5857
5858            // Snippet
5859            let snippet_bytes = c.snippet.as_bytes();
5860            write_counted(
5861                writer,
5862                &(snippet_bytes.len() as u32).to_le_bytes(),
5863                &mut bytes_written,
5864            )?;
5865            write_counted(writer, snippet_bytes, &mut bytes_written)?;
5866
5867            // Embed text
5868            let embed_bytes = c.embed_text.as_bytes();
5869            write_counted(
5870                writer,
5871                &(embed_bytes.len() as u32).to_le_bytes(),
5872                &mut bytes_written,
5873            )?;
5874            write_counted(writer, embed_bytes, &mut bytes_written)?;
5875
5876            // Vector (f32 array)
5877            for &val in &entry.vector {
5878                write_counted(writer, &val.to_le_bytes(), &mut bytes_written)?;
5879            }
5880        }
5881
5882        Ok(bytes_written)
5883    }
5884
5885    /// Deserialize a base snapshot and any committed delta segments.
5886    pub fn from_bytes(data: &[u8], current_canonical_root: &Path) -> Result<Self, String> {
5887        debug_assert!(current_canonical_root.is_absolute());
5888        if data.len() < HEADER_BYTES_V1 {
5889            return Err("data too short".to_string());
5890        }
5891
5892        let mut reader = Cursor::new(&data[1..]);
5893        let (index, base_bytes) = Self::from_reader_after_version(
5894            &mut reader,
5895            data[0],
5896            current_canonical_root,
5897            Some(data.len()),
5898            1,
5899        )?;
5900        Self::apply_segment_log(&mut reader, index, data.len(), base_bytes)
5901            .map(|loaded| loaded.index)
5902    }
5903
5904    fn from_reader_after_version<R: Read>(
5905        reader: R,
5906        version: u8,
5907        current_canonical_root: &Path,
5908        total_len: Option<usize>,
5909        bytes_read: usize,
5910    ) -> Result<(Self, usize), String> {
5911        debug_assert!(current_canonical_root.is_absolute());
5912        let mut reader = CountingReader::with_bytes_read(reader, bytes_read);
5913
5914        if version != SEMANTIC_INDEX_VERSION_V1
5915            && version != SEMANTIC_INDEX_VERSION_V2
5916            && version != SEMANTIC_INDEX_VERSION_V3
5917            && version != SEMANTIC_INDEX_VERSION_V4
5918            && version != SEMANTIC_INDEX_VERSION_V5
5919            && version != SEMANTIC_INDEX_VERSION_V6
5920            && version != SEMANTIC_INDEX_VERSION_V7
5921        {
5922            return Err(format!("unsupported version: {}", version));
5923        }
5924        // V2 and newer share the same header layout (V3/V4/V5 only differ from
5925        // V2 in the per-mtime entry layout): version(1) + dimension(4) +
5926        // entry_count(4) + fingerprint_len(4) + fingerprint bytes.
5927        if (version == SEMANTIC_INDEX_VERSION_V2
5928            || version == SEMANTIC_INDEX_VERSION_V3
5929            || version == SEMANTIC_INDEX_VERSION_V4
5930            || version == SEMANTIC_INDEX_VERSION_V5
5931            || version == SEMANTIC_INDEX_VERSION_V6
5932            || version == SEMANTIC_INDEX_VERSION_V7)
5933            && total_len.is_some_and(|len| len < HEADER_BYTES_V2)
5934        {
5935            return Err("data too short for semantic index v2/v3/v4/v5/v6/v7 header".to_string());
5936        }
5937
5938        let dimension = read_u32_stream(&mut reader)? as usize;
5939        let entry_count = read_u32_stream(&mut reader)? as usize;
5940        validate_embedding_dimension(dimension)?;
5941        if entry_count > MAX_ENTRIES {
5942            return Err(format!("too many semantic index entries: {}", entry_count));
5943        }
5944
5945        // Fingerprint handling:
5946        //   - V1: no fingerprint field at all.
5947        //   - V2: fingerprint_len + fingerprint bytes; always present (writer
5948        //     only emitted V2 when fingerprint was Some).
5949        //   - V3+: fingerprint_len always present; fingerprint_len==0 ⇒ None.
5950        let has_fingerprint_field = version == SEMANTIC_INDEX_VERSION_V2
5951            || version == SEMANTIC_INDEX_VERSION_V3
5952            || version == SEMANTIC_INDEX_VERSION_V4
5953            || version == SEMANTIC_INDEX_VERSION_V5
5954            || version == SEMANTIC_INDEX_VERSION_V6
5955            || version == SEMANTIC_INDEX_VERSION_V7;
5956        let fingerprint = if has_fingerprint_field {
5957            let fingerprint_len = read_u32_stream(&mut reader)? as usize;
5958            if total_len
5959                .is_some_and(|len| reader.bytes_read().saturating_add(fingerprint_len) > len)
5960            {
5961                return Err("unexpected end of data reading fingerprint".to_string());
5962            }
5963            if fingerprint_len == 0 {
5964                None
5965            } else {
5966                let mut raw = vec![0u8; fingerprint_len];
5967                read_exact_stream(
5968                    &mut reader,
5969                    &mut raw,
5970                    "unexpected end of data reading fingerprint",
5971                )?;
5972                let raw = String::from_utf8_lossy(&raw).to_string();
5973                Some(
5974                    serde_json::from_str::<SemanticIndexFingerprint>(&raw)
5975                        .map_err(|error| format!("invalid semantic fingerprint: {error}"))?,
5976                )
5977            }
5978        } else {
5979            None
5980        };
5981
5982        // File mtimes
5983        let mtime_count = read_u32_stream(&mut reader)? as usize;
5984        if mtime_count > MAX_ENTRIES {
5985            return Err(format!("too many semantic file mtimes: {}", mtime_count));
5986        }
5987
5988        let vector_bytes = entry_count
5989            .checked_mul(dimension)
5990            .and_then(|count| count.checked_mul(F32_BYTES))
5991            .ok_or_else(|| "semantic vector allocation overflow".to_string())?;
5992        if total_len.is_some_and(|len| vector_bytes > len.saturating_sub(reader.bytes_read())) {
5993            return Err("semantic index vectors exceed available data".to_string());
5994        }
5995
5996        let mut file_mtimes = HashMap::with_capacity(mtime_count);
5997        let mut file_sizes = HashMap::with_capacity(mtime_count);
5998        let mut file_hashes = HashMap::with_capacity(mtime_count);
5999        for _ in 0..mtime_count {
6000            let path = read_string_stream(&mut reader, total_len)?;
6001            let secs = read_u64_stream(&mut reader)?;
6002            // V3+ persists subsec_nanos alongside secs so staleness checks
6003            // survive restart round-trips. V1/V2 load with 0 nanos, which
6004            // causes one rebuild on upgrade (they never matched live APFS
6005            // mtimes anyway — the bug v0.15.2 fixes). After that rebuild,
6006            // the cache is persisted as V3 and stabilises.
6007            let nanos = if version == SEMANTIC_INDEX_VERSION_V3
6008                || version == SEMANTIC_INDEX_VERSION_V4
6009                || version == SEMANTIC_INDEX_VERSION_V5
6010                || version == SEMANTIC_INDEX_VERSION_V6
6011                || version == SEMANTIC_INDEX_VERSION_V7
6012            {
6013                read_u32_stream(&mut reader)?
6014            } else {
6015                0
6016            };
6017            let size = if version == SEMANTIC_INDEX_VERSION_V5
6018                || version == SEMANTIC_INDEX_VERSION_V6
6019                || version == SEMANTIC_INDEX_VERSION_V7
6020            {
6021                read_u64_stream(&mut reader)?
6022            } else {
6023                0
6024            };
6025            let content_hash =
6026                if version == SEMANTIC_INDEX_VERSION_V6 || version == SEMANTIC_INDEX_VERSION_V7 {
6027                    let mut hash_bytes = [0u8; 32];
6028                    read_exact_stream(
6029                        &mut reader,
6030                        &mut hash_bytes,
6031                        "unexpected end of data reading content hash",
6032                    )?;
6033                    blake3::Hash::from_bytes(hash_bytes)
6034                } else {
6035                    cache_freshness::zero_hash()
6036                };
6037            // Hardening against corrupt / maliciously crafted cache files
6038            // (v0.15.2). `Duration::new(secs, nanos)` can panic when the
6039            // nanosecond carry overflows the second counter, and
6040            // `SystemTime + Duration` can panic on carry past the platform's
6041            // upper bound. Explicit validation keeps a corrupted semantic.bin
6042            // from taking down the whole aft process.
6043            if nanos >= 1_000_000_000 {
6044                return Err(format!(
6045                    "invalid semantic mtime: nanos {} >= 1_000_000_000",
6046                    nanos
6047                ));
6048            }
6049            let duration = std::time::Duration::new(secs, nanos);
6050            let mtime = SystemTime::UNIX_EPOCH
6051                .checked_add(duration)
6052                .ok_or_else(|| {
6053                    format!(
6054                        "invalid semantic mtime: secs={} nanos={} overflows SystemTime",
6055                        secs, nanos
6056                    )
6057                })?;
6058            let path = if version == SEMANTIC_INDEX_VERSION_V6
6059                || version == SEMANTIC_INDEX_VERSION_V7
6060            {
6061                cached_path_under_root(current_canonical_root, &PathBuf::from(path))
6062                    .ok_or_else(|| "cached semantic mtime path escapes project root".to_string())?
6063            } else {
6064                PathBuf::from(path)
6065            };
6066            file_mtimes.insert(path.clone(), mtime);
6067            file_sizes.insert(path.clone(), size);
6068            file_hashes.insert(path, content_hash);
6069        }
6070
6071        // Entries
6072        let mut entries = Vec::with_capacity(entry_count);
6073        for _ in 0..entry_count {
6074            let raw_file = PathBuf::from(read_string_stream(&mut reader, total_len)?);
6075            let file = if version == SEMANTIC_INDEX_VERSION_V6
6076                || version == SEMANTIC_INDEX_VERSION_V7
6077            {
6078                cached_path_under_root(current_canonical_root, &raw_file)
6079                    .ok_or_else(|| "cached semantic entry path escapes project root".to_string())?
6080            } else {
6081                raw_file
6082            };
6083            let name = read_string_stream(&mut reader, total_len)?;
6084            let qualified_name = if version == SEMANTIC_INDEX_VERSION_V7 {
6085                let qualified_name = read_string_stream(&mut reader, total_len)?;
6086                if qualified_name.is_empty() {
6087                    None
6088                } else {
6089                    Some(qualified_name)
6090                }
6091            } else {
6092                None
6093            };
6094
6095            let kind = u8_to_symbol_kind(read_u8_stream(&mut reader, "unexpected end of data")?);
6096
6097            let start_line = read_u32_stream(&mut reader)?;
6098            let end_line = read_u32_stream(&mut reader)?;
6099
6100            let exported = read_u8_stream(&mut reader, "unexpected end of data")? != 0;
6101
6102            let snippet = read_string_stream(&mut reader, total_len)?;
6103            let embed_text = read_string_stream(&mut reader, total_len)?;
6104
6105            // Vector
6106            let vec_bytes = dimension
6107                .checked_mul(F32_BYTES)
6108                .ok_or_else(|| "semantic vector allocation overflow".to_string())?;
6109            if total_len.is_some_and(|len| reader.bytes_read().saturating_add(vec_bytes) > len) {
6110                return Err("unexpected end of data reading vector".to_string());
6111            }
6112            let mut vector = Vec::with_capacity(dimension);
6113            for _ in 0..dimension {
6114                let mut bytes = [0u8; F32_BYTES];
6115                read_exact_stream(
6116                    &mut reader,
6117                    &mut bytes,
6118                    "unexpected end of data reading vector",
6119                )?;
6120                vector.push(f32::from_le_bytes(bytes));
6121            }
6122
6123            entries.push(EmbeddingEntry::new(
6124                SemanticChunk {
6125                    file,
6126                    name,
6127                    qualified_name,
6128                    kind,
6129                    start_line,
6130                    end_line,
6131                    exported,
6132                    embed_text,
6133                    snippet,
6134                },
6135                vector,
6136            ));
6137        }
6138
6139        if entries.len() != entry_count {
6140            return Err(format!(
6141                "semantic cache entry count drift: header={} decoded={}",
6142                entry_count,
6143                entries.len()
6144            ));
6145        }
6146        for entry in &entries {
6147            if !file_mtimes.contains_key(&entry.chunk.file) {
6148                return Err(format!(
6149                    "semantic cache metadata missing for entry file {}",
6150                    entry.chunk.file.display()
6151                ));
6152            }
6153        }
6154
6155        let any_missing_sizes = file_mtimes
6156            .keys()
6157            .any(|path| !file_sizes.contains_key(path));
6158        let bytes_read = reader.bytes_read();
6159        Ok((
6160            Self {
6161                entries,
6162                file_mtimes,
6163                file_sizes,
6164                any_missing_sizes,
6165                file_hashes,
6166                dimension,
6167                fingerprint,
6168                project_root: current_canonical_root.to_path_buf(),
6169                deferred_files: HashSet::new(),
6170                shared_base: None,
6171                dirty_paths: Arc::new(Mutex::new(None)),
6172                persistence: Arc::new(Mutex::new(None)),
6173                last_append_read_bytes: Arc::new(AtomicUsize::new(0)),
6174                #[cfg(test)]
6175                removal_retain_passes: 0,
6176            },
6177            bytes_read,
6178        ))
6179    }
6180}
6181
6182fn write_counted<W: Write>(
6183    writer: &mut W,
6184    bytes: &[u8],
6185    bytes_written: &mut usize,
6186) -> io::Result<()> {
6187    writer.write_all(bytes)?;
6188    *bytes_written = bytes_written.saturating_add(bytes.len());
6189    Ok(())
6190}
6191
6192struct CountingReader<R> {
6193    inner: R,
6194    bytes_read: usize,
6195}
6196
6197impl<R> CountingReader<R> {
6198    fn with_bytes_read(inner: R, bytes_read: usize) -> Self {
6199        Self { inner, bytes_read }
6200    }
6201
6202    fn bytes_read(&self) -> usize {
6203        self.bytes_read
6204    }
6205}
6206
6207impl<R: Read> Read for CountingReader<R> {
6208    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
6209        let read = self.inner.read(buf)?;
6210        self.bytes_read = self.bytes_read.saturating_add(read);
6211        Ok(read)
6212    }
6213}
6214
6215fn read_exact_stream<R: Read>(
6216    reader: &mut CountingReader<R>,
6217    buf: &mut [u8],
6218    eof_message: &'static str,
6219) -> Result<(), String> {
6220    reader.read_exact(buf).map_err(|error| {
6221        if error.kind() == io::ErrorKind::UnexpectedEof {
6222            eof_message.to_string()
6223        } else {
6224            format!("{eof_message}: {error}")
6225        }
6226    })
6227}
6228
6229fn read_u8_stream<R: Read>(
6230    reader: &mut CountingReader<R>,
6231    eof_message: &'static str,
6232) -> Result<u8, String> {
6233    let mut bytes = [0u8; 1];
6234    read_exact_stream(reader, &mut bytes, eof_message)?;
6235    Ok(bytes[0])
6236}
6237
6238fn read_u32_stream<R: Read>(reader: &mut CountingReader<R>) -> Result<u32, String> {
6239    let mut bytes = [0u8; 4];
6240    read_exact_stream(reader, &mut bytes, "unexpected end of data reading u32")?;
6241    Ok(u32::from_le_bytes(bytes))
6242}
6243
6244fn read_u64_stream<R: Read>(reader: &mut CountingReader<R>) -> Result<u64, String> {
6245    let mut bytes = [0u8; 8];
6246    read_exact_stream(reader, &mut bytes, "unexpected end of data reading u64")?;
6247    Ok(u64::from_le_bytes(bytes))
6248}
6249
6250fn read_string_stream<R: Read>(
6251    reader: &mut CountingReader<R>,
6252    total_len: Option<usize>,
6253) -> Result<String, String> {
6254    let len = read_u32_stream(reader)? as usize;
6255    if total_len.is_some_and(|total_len| reader.bytes_read().saturating_add(len) > total_len) {
6256        return Err("unexpected end of data reading string".to_string());
6257    }
6258    let mut bytes = vec![0u8; len];
6259    read_exact_stream(reader, &mut bytes, "unexpected end of data reading string")?;
6260    Ok(String::from_utf8_lossy(&bytes).to_string())
6261}
6262
6263struct SourceLineCache<'a> {
6264    lines: Vec<&'a str>,
6265    line_starts: Vec<usize>,
6266}
6267
6268impl<'a> SourceLineCache<'a> {
6269    fn new(source: &'a str) -> Self {
6270        let lines: Vec<&'a str> = source.lines().collect();
6271        let mut line_starts = Vec::with_capacity(lines.len());
6272        let bytes = source.as_bytes();
6273        let mut offset = 0usize;
6274        for line in &lines {
6275            line_starts.push(offset);
6276            offset += line.len();
6277            if bytes.get(offset) == Some(&b'\r') && bytes.get(offset + 1) == Some(&b'\n') {
6278                offset += 2;
6279            } else if bytes.get(offset) == Some(&b'\n') {
6280                offset += 1;
6281            }
6282        }
6283        Self { lines, line_starts }
6284    }
6285
6286    fn len(&self) -> usize {
6287        debug_assert_eq!(self.lines.len(), self.line_starts.len());
6288        self.line_starts.len()
6289    }
6290}
6291
6292/// Build enriched embedding text from a symbol with cAST-style context.
6293fn build_embed_text_with_lines_and_caps(
6294    symbol: &Symbol,
6295    line_cache: &SourceLineCache<'_>,
6296    file: &Path,
6297    project_root: &Path,
6298    caps: EmbedTextCaps,
6299) -> String {
6300    let relative = file
6301        .strip_prefix(project_root)
6302        .unwrap_or(file)
6303        .to_string_lossy();
6304
6305    let kind_label = match &symbol.kind {
6306        SymbolKind::Function => "function",
6307        SymbolKind::Kernel => "kernel",
6308        SymbolKind::Class => "class",
6309        SymbolKind::Method => "method",
6310        SymbolKind::Struct => "struct",
6311        SymbolKind::Interface => "interface",
6312        SymbolKind::Enum => "enum",
6313        SymbolKind::TypeAlias => "type",
6314        SymbolKind::Variable => "variable",
6315        SymbolKind::Heading => "heading",
6316        SymbolKind::FileSummary => "file-summary",
6317    };
6318
6319    // Build: "file:relative/path kind:function name:validateAuth signature:fn validateAuth(token: &str) -> bool"
6320    let name = &symbol.name;
6321    let mut text = format!(
6322        "name:{name} file:{} kind:{} name:{name}",
6323        relative, kind_label
6324    );
6325
6326    if let Some(sig) = &symbol.signature {
6327        // Cap the signature: structured parsers (e.g. YAML/Kubernetes) pack
6328        // entire inline scripts (CronJob/Job `command:` bodies, multi-KB) into
6329        // the signature. Appending it unbounded produces a single embed_text
6330        // that overflows the embedding backend's physical batch (e.g. a
6331        // llama.cpp server's 512-token cap), aborting the whole index build
6332        // and silently degrading every search to lexical. 400 chars keeps the
6333        // identifying head of the signature without blowing the budget.
6334        text.push_str(&format!(
6335            " signature:{}",
6336            truncate_chars(sig, caps.signature_chars)
6337        ));
6338    }
6339
6340    // Add the leading symbol body within the resolved backend budget.
6341    let start = (symbol.range.start_line as usize).min(line_cache.len());
6342    // range.end_line is inclusive 0-based; +1 makes it an exclusive slice bound.
6343    let end = (symbol.range.end_line as usize + 1).min(line_cache.len());
6344    if start < end {
6345        let body: String = line_cache.lines[start..end]
6346            .iter()
6347            .take(caps.body_lines)
6348            .copied()
6349            .collect::<Vec<&str>>()
6350            .join("\n");
6351        let snippet = if body.len() > caps.body_chars {
6352            format!("{}...", &body[..body.floor_char_boundary(caps.body_chars)])
6353        } else {
6354            body
6355        };
6356        text.push_str(&format!(" body:{}", snippet));
6357    }
6358
6359    // Final defense-in-depth clamp: no single embed_text may exceed the
6360    // resolved backend budget regardless of which field grew.
6361    truncate_chars(&text, caps.total_chars)
6362}
6363
6364#[cfg(test)]
6365fn build_embed_text(symbol: &Symbol, source: &str, file: &Path, project_root: &Path) -> String {
6366    let line_cache = SourceLineCache::new(source);
6367    build_embed_text_with_lines_and_caps(
6368        symbol,
6369        &line_cache,
6370        file,
6371        project_root,
6372        EmbedTextCaps::default(),
6373    )
6374}
6375
6376/// Legacy whole-row character cap retained when no remote token budget is set.
6377const MAX_EMBED_TEXT_CHARS: usize = 1600;
6378const DEFAULT_SIGNATURE_CHARS: usize = 400;
6379const DEFAULT_BODY_LINES: usize = 15;
6380const DEFAULT_BODY_CHARS: usize = 300;
6381/// Maximum `name/file/kind/name` header measured across the six September 2026
6382/// semantic-census corpora. Reserving this many characters keeps the configured
6383/// token budget an upper bound even for the longest observed header.
6384pub const MAX_EMBED_TEXT_HEADER_CHARS: usize = 457;
6385const CHARS_PER_TOKEN_NUMERATOR: usize = 7;
6386const CHARS_PER_TOKEN_DENOMINATOR: usize = 2;
6387
6388#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
6389pub struct EmbedTextCaps {
6390    pub signature_chars: usize,
6391    pub body_lines: usize,
6392    pub body_chars: usize,
6393    pub total_chars: usize,
6394}
6395
6396impl EmbedTextCaps {
6397    pub fn from_config(config: &SemanticBackendConfig) -> Self {
6398        let defaults = Self::default();
6399        if config.backend == SemanticBackend::Fastembed {
6400            return defaults;
6401        }
6402        let Some(max_input_tokens) = config.max_input_tokens else {
6403            return defaults;
6404        };
6405
6406        let total_chars = max_input_tokens.saturating_mul(CHARS_PER_TOKEN_NUMERATOR)
6407            / CHARS_PER_TOKEN_DENOMINATOR;
6408        let body_chars = total_chars.saturating_sub(
6409            defaults
6410                .signature_chars
6411                .saturating_add(MAX_EMBED_TEXT_HEADER_CHARS),
6412        );
6413        Self {
6414            signature_chars: defaults.signature_chars,
6415            body_lines: usize::MAX,
6416            body_chars,
6417            total_chars,
6418        }
6419    }
6420}
6421
6422impl Default for EmbedTextCaps {
6423    fn default() -> Self {
6424        Self {
6425            signature_chars: DEFAULT_SIGNATURE_CHARS,
6426            body_lines: DEFAULT_BODY_LINES,
6427            body_chars: DEFAULT_BODY_CHARS,
6428            total_chars: MAX_EMBED_TEXT_CHARS,
6429        }
6430    }
6431}
6432
6433fn truncate_chars(value: &str, max_chars: usize) -> String {
6434    value.chars().take(max_chars).collect()
6435}
6436
6437fn first_leading_doc_comment(line_cache: &SourceLineCache<'_>) -> String {
6438    let Some((start, first)) = line_cache
6439        .lines
6440        .iter()
6441        .enumerate()
6442        .find(|(_, line)| !line.trim().is_empty())
6443    else {
6444        return String::new();
6445    };
6446
6447    let trimmed = first.trim_start();
6448    if trimmed.starts_with("/**") {
6449        let mut comment = Vec::new();
6450        for line in line_cache.lines.iter().skip(start) {
6451            comment.push(*line);
6452            if line.contains("*/") {
6453                break;
6454            }
6455        }
6456        return truncate_chars(&comment.join("\n"), 200);
6457    }
6458
6459    if trimmed.starts_with("///") || trimmed.starts_with("//!") {
6460        let comment = line_cache
6461            .lines
6462            .iter()
6463            .skip(start)
6464            .take_while(|line| {
6465                let trimmed = line.trim_start();
6466                trimmed.starts_with("///") || trimmed.starts_with("//!")
6467            })
6468            .copied()
6469            .collect::<Vec<_>>()
6470            .join("\n");
6471        return truncate_chars(&comment, 200);
6472    }
6473
6474    String::new()
6475}
6476
6477pub fn build_file_summary_chunk(
6478    file: &Path,
6479    project_root: &Path,
6480    source: &str,
6481    top_exports: &[&str],
6482    top_export_signatures: &[Option<&str>],
6483) -> SemanticChunk {
6484    let line_cache = SourceLineCache::new(source);
6485    build_file_summary_chunk_with_lines(
6486        file,
6487        project_root,
6488        &line_cache,
6489        top_exports,
6490        top_export_signatures,
6491    )
6492}
6493
6494fn build_file_summary_chunk_with_lines(
6495    file: &Path,
6496    project_root: &Path,
6497    line_cache: &SourceLineCache<'_>,
6498    top_exports: &[&str],
6499    top_export_signatures: &[Option<&str>],
6500) -> SemanticChunk {
6501    build_file_summary_chunk_with_lines_and_caps(
6502        file,
6503        project_root,
6504        line_cache,
6505        top_exports,
6506        top_export_signatures,
6507        EmbedTextCaps::default(),
6508    )
6509}
6510
6511fn build_file_summary_chunk_with_lines_and_caps(
6512    file: &Path,
6513    project_root: &Path,
6514    line_cache: &SourceLineCache<'_>,
6515    top_exports: &[&str],
6516    top_export_signatures: &[Option<&str>],
6517    caps: EmbedTextCaps,
6518) -> SemanticChunk {
6519    let relative = file.strip_prefix(project_root).unwrap_or(file);
6520    let rel_path = relative.to_string_lossy();
6521    let parent_dir = relative
6522        .parent()
6523        .map(|parent| parent.to_string_lossy().to_string())
6524        .unwrap_or_default();
6525    let name = file
6526        .file_stem()
6527        .map(|stem| stem.to_string_lossy().to_string())
6528        .unwrap_or_default();
6529    let doc = first_leading_doc_comment(line_cache);
6530    let exports = top_exports
6531        .iter()
6532        .take(5)
6533        .copied()
6534        .collect::<Vec<_>>()
6535        .join(",");
6536    let snippet = if doc.is_empty() {
6537        top_export_signatures
6538            .first()
6539            .and_then(|signature| signature.as_deref())
6540            .map(|signature| truncate_chars(signature, 200))
6541            .unwrap_or_default()
6542    } else {
6543        doc.clone()
6544    };
6545
6546    SemanticChunk {
6547        file: file.to_path_buf(),
6548        name,
6549        qualified_name: None,
6550        kind: SymbolKind::FileSummary,
6551        start_line: 0,
6552        end_line: 0,
6553        exported: false,
6554        embed_text: truncate_chars(
6555            &format!(
6556                "file:{rel_path} kind:file-summary name:{} parent:{parent_dir} doc:{doc} exports:{exports}",
6557                file.file_stem()
6558                    .map(|stem| stem.to_string_lossy().to_string())
6559                    .unwrap_or_default()
6560            ),
6561            caps.total_chars,
6562        ),
6563        snippet,
6564    }
6565}
6566
6567pub fn is_semantic_indexed_extension(path: &Path) -> bool {
6568    if path.file_name().and_then(|name| name.to_str()) == Some("Jenkinsfile") {
6569        return true;
6570    }
6571
6572    matches!(
6573        path.extension().and_then(|extension| extension.to_str()),
6574        Some(
6575            "ts" | "tsx"
6576                | "js"
6577                | "jsx"
6578                | "py"
6579                | "rs"
6580                | "go"
6581                | "c"
6582                | "h"
6583                | "cc"
6584                | "cpp"
6585                | "cxx"
6586                | "hpp"
6587                | "hh"
6588                | "cu"
6589                | "cuh"
6590                | "metal"
6591                | "zig"
6592                | "cs"
6593                | "sh"
6594                | "bash"
6595                | "zsh"
6596                | "inc"
6597                | "php"
6598                | "sol"
6599                | "scss"
6600                | "vue"
6601                | "yaml"
6602                | "yml"
6603                | "pas"
6604                | "pp"
6605                | "dpr"
6606                | "dpk"
6607                | "lpr"
6608                | "java"
6609                | "kt"
6610                | "kts"
6611                | "rb"
6612                | "swift"
6613                | "scala"
6614                | "sc"
6615                | "lua"
6616                | "pl"
6617                | "pm"
6618                | "t"
6619                | "r"
6620                | "R"
6621                | "groovy"
6622                | "gvy"
6623                | "gy"
6624                | "gsh"
6625                | "gradle"
6626                | "m"
6627                | "mm"
6628                | "toml",
6629        )
6630    )
6631}
6632
6633fn canonicalize_existing_or_deleted_path(path: &Path) -> PathBuf {
6634    if let Ok(canonical) = fs::canonicalize(path) {
6635        return canonical;
6636    }
6637
6638    let Some(parent) = path.parent() else {
6639        return path.to_path_buf();
6640    };
6641    let Some(file_name) = path.file_name() else {
6642        return path.to_path_buf();
6643    };
6644
6645    fs::canonicalize(parent)
6646        .map(|canonical_parent| canonical_parent.join(file_name))
6647        .unwrap_or_else(|_| path.to_path_buf())
6648}
6649
6650/// Files larger than this are skipped for semantic chunking. The read +
6651/// tree-sitter parse is transiently O(file size) (tree-sitter can use several×
6652/// the source bytes), and `par_iter` collection parses many files at once, so an
6653/// unbounded read here is an OOM vector on a repo with a few multi-MB generated/
6654/// vendored/minified files. A file this large yields almost no useful embedding
6655/// anyway (each chunk's embed_text is bounded by its resolved backend caps), so we
6656/// track it (0 chunks) instead of reading it — freshness then skips it on later
6657/// refreshes. 4 MiB keeps essentially all hand-written source while capping the
6658/// pathological tail.
6659const MAX_SEMANTIC_FILE_BYTES: u64 = 4 * 1024 * 1024;
6660
6661fn collect_semantic_file(
6662    project_root: &Path,
6663    file: &Path,
6664    embed_text_caps: EmbedTextCaps,
6665    phases: &mut SemanticCollectPhaseTimings,
6666) -> Result<(IndexedFileMetadata, Vec<SemanticChunk>), String> {
6667    let read_hash_started = Instant::now();
6668    let read_result = (|| {
6669        let metadata = fs::metadata(file).map_err(|error| error.to_string())?;
6670        if !metadata.is_file() {
6671            return Err("not a regular file".to_string());
6672        }
6673        let mtime = metadata.modified().map_err(|error| error.to_string())?;
6674        let size = metadata.len();
6675
6676        if !is_semantic_indexed_extension(file) {
6677            return Err("unsupported file extension".to_string());
6678        }
6679        let lang = detect_language(file).ok_or_else(|| "unsupported file extension".to_string())?;
6680
6681        let mut indexed_metadata = IndexedFileMetadata {
6682            mtime,
6683            size,
6684            content_hash: cache_freshness::zero_hash(),
6685        };
6686
6687        // OOM backstop: skip oversized files before the read + parse (tracked with
6688        // zero chunks by the caller, so freshness won't re-read them every refresh).
6689        if size > MAX_SEMANTIC_FILE_BYTES {
6690            return Ok((indexed_metadata, lang, None));
6691        }
6692
6693        let source = fs::read_to_string(file).map_err(|error| error.to_string())?;
6694        indexed_metadata.content_hash = if size <= cache_freshness::CONTENT_HASH_SIZE_CAP {
6695            cache_freshness::hash_bytes(source.as_bytes())
6696        } else {
6697            cache_freshness::zero_hash()
6698        };
6699        Ok((indexed_metadata, lang, Some(source)))
6700    })();
6701    phases.read_hash += read_hash_started.elapsed();
6702    let (indexed_metadata, lang, source) = read_result?;
6703    let Some(source) = source else {
6704        return Ok((indexed_metadata, Vec::new()));
6705    };
6706
6707    let chunks = collect_file_chunks_from_source_timed(
6708        project_root,
6709        file,
6710        lang,
6711        &source,
6712        embed_text_caps,
6713        phases,
6714    )?;
6715    Ok((indexed_metadata, chunks))
6716}
6717
6718#[cfg(feature = "semantic-chunk-census")]
6719#[doc(hidden)]
6720pub fn collect_file_chunks_for_census(
6721    project_root: &Path,
6722    file: &Path,
6723    census_caps: EmbedTextCaps,
6724) -> Result<(Vec<SemanticChunk>, Vec<SemanticChunk>), String> {
6725    if !is_semantic_indexed_extension(file) {
6726        return Err("unsupported file extension".to_string());
6727    }
6728    let lang = detect_language(file).ok_or_else(|| "unsupported file extension".to_string())?;
6729    if fs::metadata(file).is_ok_and(|metadata| metadata.len() > MAX_SEMANTIC_FILE_BYTES) {
6730        return Ok((Vec::new(), Vec::new()));
6731    }
6732
6733    let source = fs::read_to_string(file).map_err(|error| error.to_string())?;
6734    let tree =
6735        parse_source_with_cached_parser(file, &source, lang).map_err(|error| error.to_string())?;
6736    let symbols =
6737        extract_symbols_from_tree(&source, &tree, lang).map_err(|error| error.to_string())?;
6738    let today = symbols_to_chunks(file, &symbols, &source, project_root);
6739    let census = symbols_to_chunks_with_caps(file, &symbols, &source, project_root, census_caps);
6740    Ok((today, census))
6741}
6742
6743#[cfg(test)]
6744fn collect_file_chunks(project_root: &Path, file: &Path) -> Result<Vec<SemanticChunk>, String> {
6745    if !is_semantic_indexed_extension(file) {
6746        return Err("unsupported file extension".to_string());
6747    }
6748    let lang = detect_language(file).ok_or_else(|| "unsupported file extension".to_string())?;
6749    // OOM backstop: skip oversized files before the read + parse (tracked with
6750    // zero chunks by the caller, so freshness won't re-read them every refresh).
6751    if fs::metadata(file).is_ok_and(|m| m.len() > MAX_SEMANTIC_FILE_BYTES) {
6752        return Ok(Vec::new());
6753    }
6754    let source = fs::read_to_string(file).map_err(|error| error.to_string())?;
6755    collect_file_chunks_from_source(project_root, file, lang, &source)
6756}
6757
6758#[cfg(test)]
6759fn collect_file_chunks_from_source(
6760    project_root: &Path,
6761    file: &Path,
6762    lang: crate::parser::LangId,
6763    source: &str,
6764) -> Result<Vec<SemanticChunk>, String> {
6765    collect_file_chunks_from_source_timed(
6766        project_root,
6767        file,
6768        lang,
6769        source,
6770        EmbedTextCaps::default(),
6771        &mut SemanticCollectPhaseTimings::default(),
6772    )
6773}
6774
6775fn collect_file_chunks_from_source_timed(
6776    project_root: &Path,
6777    file: &Path,
6778    lang: crate::parser::LangId,
6779    source: &str,
6780    embed_text_caps: EmbedTextCaps,
6781    phases: &mut SemanticCollectPhaseTimings,
6782) -> Result<Vec<SemanticChunk>, String> {
6783    let parse_started = Instant::now();
6784    let tree_result =
6785        parse_source_with_cached_parser(file, source, lang).map_err(|error| error.to_string());
6786    phases.parse += parse_started.elapsed();
6787    let tree = tree_result?;
6788
6789    let extract_started = Instant::now();
6790    let symbols_result =
6791        extract_symbols_from_tree(source, &tree, lang).map_err(|error| error.to_string());
6792    phases.extract += extract_started.elapsed();
6793    let symbols = symbols_result?;
6794
6795    let build_started = Instant::now();
6796    let chunks = symbols_to_chunks_with_caps(file, &symbols, source, project_root, embed_text_caps);
6797    phases.build += build_started.elapsed();
6798    Ok(chunks)
6799}
6800
6801/// Build a display snippet from a symbol's source
6802fn build_snippet_with_lines(symbol: &Symbol, line_cache: &SourceLineCache<'_>) -> String {
6803    let start = (symbol.range.start_line as usize).min(line_cache.len());
6804    // range.end_line is inclusive 0-based; +1 makes it an exclusive slice bound.
6805    let end = (symbol.range.end_line as usize + 1).min(line_cache.len());
6806    if start < end {
6807        let snippet_lines: Vec<&str> = line_cache.lines[start..end]
6808            .iter()
6809            .take(5)
6810            .copied()
6811            .collect();
6812        let mut snippet = snippet_lines.join("\n");
6813        if end - start > 5 {
6814            snippet.push_str("\n  ...");
6815        }
6816        if snippet.len() > 300 {
6817            snippet = format!("{}...", &snippet[..snippet.floor_char_boundary(300)]);
6818        }
6819        snippet
6820    } else {
6821        String::new()
6822    }
6823}
6824
6825#[cfg(test)]
6826fn build_snippet(symbol: &Symbol, source: &str) -> String {
6827    let line_cache = SourceLineCache::new(source);
6828    build_snippet_with_lines(symbol, &line_cache)
6829}
6830
6831fn qualified_name_for_symbol(symbol: &Symbol) -> Option<String> {
6832    let mut parts = symbol
6833        .scope_chain
6834        .iter()
6835        .filter(|part| !part.is_empty())
6836        .cloned()
6837        .collect::<Vec<_>>();
6838    if !symbol.name.is_empty() {
6839        parts.push(symbol.name.clone());
6840    }
6841    (!parts.is_empty()).then(|| parts.join("."))
6842}
6843
6844/// Convert symbols to semantic chunks with enriched context
6845#[cfg(any(test, feature = "semantic-chunk-census"))]
6846fn symbols_to_chunks(
6847    file: &Path,
6848    symbols: &[Symbol],
6849    source: &str,
6850    project_root: &Path,
6851) -> Vec<SemanticChunk> {
6852    symbols_to_chunks_with_caps(
6853        file,
6854        symbols,
6855        source,
6856        project_root,
6857        EmbedTextCaps::default(),
6858    )
6859}
6860
6861fn symbols_to_chunks_with_caps(
6862    file: &Path,
6863    symbols: &[Symbol],
6864    source: &str,
6865    project_root: &Path,
6866    caps: EmbedTextCaps,
6867) -> Vec<SemanticChunk> {
6868    let line_cache = SourceLineCache::new(source);
6869    let mut chunks = Vec::new();
6870    let top_exports_with_signatures = symbols
6871        .iter()
6872        .filter(|symbol| {
6873            symbol.exported
6874                && symbol.parent.is_none()
6875                && !matches!(symbol.kind, SymbolKind::Heading)
6876        })
6877        .map(|symbol| (symbol.name.as_str(), symbol.signature.as_deref()))
6878        .collect::<Vec<_>>();
6879
6880    let has_only_headings = !symbols.is_empty()
6881        && symbols
6882            .iter()
6883            .all(|symbol| matches!(symbol.kind, SymbolKind::Heading));
6884    if top_exports_with_signatures.len() <= 2 && !has_only_headings {
6885        let top_exports = top_exports_with_signatures
6886            .iter()
6887            .map(|(name, _)| *name)
6888            .collect::<Vec<_>>();
6889        let top_export_signatures = top_exports_with_signatures
6890            .iter()
6891            .map(|(_, signature)| *signature)
6892            .collect::<Vec<_>>();
6893        chunks.push(build_file_summary_chunk_with_lines(
6894            file,
6895            project_root,
6896            &line_cache,
6897            &top_exports,
6898            &top_export_signatures,
6899        ));
6900    }
6901
6902    for symbol in symbols {
6903        // Skip Markdown / HTML heading chunks: empirically they dominate result
6904        // lists even for code-shaped queries because heading prose embeds well.
6905        // Agents querying for code lose the actual matches under doc noise.
6906        // README/docs queries are still served by grep on the same files.
6907        if matches!(symbol.kind, SymbolKind::Heading) {
6908            continue;
6909        }
6910
6911        // Skip very small symbols (single-line variables, etc.)
6912        let line_count = symbol
6913            .range
6914            .end_line
6915            .saturating_sub(symbol.range.start_line)
6916            + 1;
6917        if line_count < 2 && !matches!(symbol.kind, SymbolKind::Variable) {
6918            continue;
6919        }
6920
6921        let embed_text =
6922            build_embed_text_with_lines_and_caps(symbol, &line_cache, file, project_root, caps);
6923        let snippet = build_snippet_with_lines(symbol, &line_cache);
6924
6925        chunks.push(SemanticChunk {
6926            file: file.to_path_buf(),
6927            name: symbol.name.clone(),
6928            qualified_name: qualified_name_for_symbol(symbol),
6929            kind: symbol.kind.clone(),
6930            start_line: symbol.range.start_line,
6931            end_line: symbol.range.end_line,
6932            exported: symbol.exported,
6933            embed_text,
6934            snippet,
6935        });
6936
6937        // Note: Nested symbols are handled separately by the outline system
6938        // Each symbol is indexed individually
6939    }
6940
6941    chunks
6942}
6943
6944fn semantic_score_order(a: &(f32, usize), b: &(f32, usize)) -> std::cmp::Ordering {
6945    b.0.partial_cmp(&a.0)
6946        .unwrap_or(std::cmp::Ordering::Equal)
6947        .then_with(|| a.1.cmp(&b.1))
6948}
6949
6950/// Compute an embedding's L2 norm for its in-memory search cache.
6951fn vector_norm(vector: &[f32]) -> f32 {
6952    vector.iter().map(|value| value * value).sum::<f32>().sqrt()
6953}
6954
6955fn dot_product(a: &[f32], b: &[f32]) -> f32 {
6956    a.iter().zip(b).map(|(a, b)| a * b).sum::<f32>()
6957}
6958
6959/// Cosine similarity reference retained for focused unit tests.
6960#[cfg(test)]
6961fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
6962    if a.len() != b.len() {
6963        return 0.0;
6964    }
6965
6966    let mut dot = 0.0f32;
6967    let mut norm_a = 0.0f32;
6968    let mut norm_b = 0.0f32;
6969
6970    for i in 0..a.len() {
6971        dot += a[i] * b[i];
6972        norm_a += a[i] * a[i];
6973        norm_b += b[i] * b[i];
6974    }
6975
6976    let denom = norm_a.sqrt() * norm_b.sqrt();
6977    if denom == 0.0 {
6978        0.0
6979    } else {
6980        dot / denom
6981    }
6982}
6983
6984// Serialization helpers
6985fn symbol_kind_to_u8(kind: &SymbolKind) -> u8 {
6986    match kind {
6987        SymbolKind::Function => 0,
6988        SymbolKind::Class => 1,
6989        SymbolKind::Method => 2,
6990        SymbolKind::Struct => 3,
6991        SymbolKind::Interface => 4,
6992        SymbolKind::Enum => 5,
6993        SymbolKind::TypeAlias => 6,
6994        SymbolKind::Variable => 7,
6995        SymbolKind::Heading => 8,
6996        SymbolKind::FileSummary => 9,
6997        SymbolKind::Kernel => 10,
6998    }
6999}
7000
7001fn u8_to_symbol_kind(v: u8) -> SymbolKind {
7002    match v {
7003        0 => SymbolKind::Function,
7004        1 => SymbolKind::Class,
7005        2 => SymbolKind::Method,
7006        3 => SymbolKind::Struct,
7007        4 => SymbolKind::Interface,
7008        5 => SymbolKind::Enum,
7009        6 => SymbolKind::TypeAlias,
7010        7 => SymbolKind::Variable,
7011        8 => SymbolKind::Heading,
7012        9 => SymbolKind::FileSummary,
7013        10 => SymbolKind::Kernel,
7014        _ => SymbolKind::Heading,
7015    }
7016}
7017
7018#[cfg(test)]
7019mod tests {
7020    use super::*;
7021    use crate::config::{SemanticBackend, SemanticBackendConfig};
7022    use crate::parser::FileParser;
7023    use std::io::{Read, Write};
7024    use std::net::{TcpListener, TcpStream};
7025    use std::process::Command;
7026    use std::sync::atomic::{AtomicBool, AtomicU64};
7027    use std::thread;
7028    use tempfile::NamedTempFile;
7029
7030    // Only the unix-gated baseline test consumes these (see its comment for
7031    // why Windows cannot reproduce the hash); keep Windows -D warnings clean.
7032    #[cfg(unix)]
7033    const RUST_QUERY_BASELINE_OUTPUT_HASH: &str =
7034        "36315439db74ed8e186076f79ed261079b2b13a4443ed4272861a2518c78d98b";
7035
7036    #[cfg(unix)]
7037    fn rust_fixture_semantic_output_fingerprint(project_root: &Path) -> (usize, usize, String) {
7038        let fixture_root = project_root.join("tests/fixtures");
7039        // Re-materialize the fixtures with LF bytes before collecting: Windows
7040        // checkouts (core.autocrlf) hand collect_chunks CRLF sources, and the
7041        // extra byte per line shifts snippet/embed-text cap boundaries — so
7042        // post-hoc \r stripping cannot reproduce the LF-computed baseline.
7043        let lf_root = tempfile::tempdir().expect("lf fixture root");
7044        let fixture_files = [
7045            "imports_rs.rs",
7046            "member_rs.rs",
7047            "sample.rs",
7048            "structure_rs.rs",
7049        ]
7050        .map(|name| {
7051            let source = std::fs::read_to_string(fixture_root.join(name))
7052                .expect("read fixture")
7053                .replace("\r\n", "\n");
7054            // Preserve the tests/fixtures/<name> layout: chunk identity fields
7055            // (relative path, qualified name, embed-text header) derive from the
7056            // path relative to the project root, so a flat layout re-keys them.
7057            let path = lf_root.path().join("tests/fixtures").join(name);
7058            std::fs::create_dir_all(path.parent().unwrap()).expect("fixture dirs");
7059            std::fs::write(&path, source).expect("write LF fixture");
7060            path
7061        });
7062        let project_root = lf_root.path();
7063        let (chunks, _) =
7064            SemanticIndex::collect_chunks(project_root, &fixture_files, EmbedTextCaps::default());
7065        let normalized = chunks
7066            .iter()
7067            .map(|chunk| {
7068                (
7069                    chunk
7070                        .file
7071                        .strip_prefix(project_root)
7072                        .unwrap()
7073                        .to_string_lossy()
7074                        .replace('\\', "/"),
7075                    &chunk.name,
7076                    &chunk.qualified_name,
7077                    &chunk.kind,
7078                    chunk.start_line,
7079                    chunk.end_line,
7080                    chunk.exported,
7081                    &chunk.embed_text,
7082                    &chunk.snippet,
7083                )
7084            })
7085            .collect::<Vec<_>>();
7086        let output = format!("{normalized:#?}");
7087        (
7088            chunks.len(),
7089            output.len(),
7090            blake3::hash(output.as_bytes()).to_hex().to_string(),
7091        )
7092    }
7093
7094    // Unix-only: chunk embed text bakes the OS-native relative path into its
7095    // header (file-summary chunks), so a Windows run hashes "tests\fixtures\…"
7096    // and can never reproduce the unix-captured baseline even with LF-forced
7097    // sources. The property under test — the query-free Rust walk reproduces
7098    // the old RS_QUERY output byte-for-byte — is platform-independent and is
7099    // pinned where the baseline was captured.
7100    #[cfg(unix)]
7101    #[test]
7102    fn rust_semantic_fixture_output_matches_query_baseline() {
7103        let project_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
7104        let (_, _, output_hash) = rust_fixture_semantic_output_fingerprint(&project_root);
7105        assert_eq!(output_hash, RUST_QUERY_BASELINE_OUTPUT_HASH);
7106    }
7107
7108    #[test]
7109    #[ignore = "manual single-file semantic collect phase benchmark"]
7110    fn profile_rust_single_file_semantic_collect() {
7111        let crate_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
7112        let workspace_root = crate_root
7113            .parent()
7114            .and_then(Path::parent)
7115            .expect("workspace root");
7116        let files = [
7117            workspace_root.join("crates/aft/src/bash_background/registry.rs"),
7118            workspace_root.join("crates/aft-tokenizer/src/claude_data.rs"),
7119        ];
7120
7121        for file in files {
7122            let source = fs::read_to_string(&file).expect("read benchmark source");
7123            for run in 1..=5 {
7124                let mut phases = SemanticCollectPhaseTimings::default();
7125                let started = Instant::now();
7126                let chunks = collect_file_chunks_from_source_timed(
7127                    workspace_root,
7128                    &file,
7129                    crate::parser::LangId::Rust,
7130                    &source,
7131                    EmbedTextCaps::default(),
7132                    &mut phases,
7133                )
7134                .unwrap();
7135                eprintln!(
7136                    "semantic single-file file={} bytes={} run={run}: total={:?} parse={:?} extract={:?} build={:?} chunks={}",
7137                    file.strip_prefix(workspace_root).unwrap().display(),
7138                    source.len(),
7139                    started.elapsed(),
7140                    phases.parse,
7141                    phases.extract,
7142                    phases.build,
7143                    chunks.len()
7144                );
7145            }
7146        }
7147    }
7148
7149    #[test]
7150    fn semantic_index_includes_php_inc_and_scss_extensions() {
7151        for file in ["partial.inc", "index.php", "styles.scss"] {
7152            assert!(
7153                is_semantic_indexed_extension(Path::new(file)),
7154                "{file} should be semantic-index eligible"
7155            );
7156        }
7157    }
7158
7159    #[test]
7160    fn semantic_index_includes_groovy_extensions_and_jenkinsfile() {
7161        for file in [
7162            "script.groovy",
7163            "script.gvy",
7164            "script.gy",
7165            "shell.gsh",
7166            "build.gradle",
7167            "Jenkinsfile",
7168        ] {
7169            assert!(
7170                is_semantic_indexed_extension(Path::new(file)),
7171                "{file} should be semantic-index eligible"
7172            );
7173        }
7174        assert!(is_semantic_indexed_extension(Path::new("build.gradle.kts")));
7175    }
7176
7177    #[test]
7178    fn transient_marker_round_trips_and_classifies() {
7179        // A marked transient error is recognized and the marker is stripped for
7180        // display, leaving a clean message.
7181        let marked = format!("{TRANSIENT_EMBEDDING_MARKER}openai compatible request failed: error sending request for url (http://localhost:1234/v1/embeddings)");
7182        assert!(embedding_failure_is_transient(&marked));
7183        let clean = strip_transient_embedding_marker(&marked);
7184        assert!(!clean.contains(TRANSIENT_EMBEDDING_MARKER));
7185        assert!(clean.starts_with("openai compatible request failed:"));
7186
7187        // Permanent errors (HTTP 4xx, dimension mismatch) carry no marker and
7188        // are not classified transient — they must fail fast.
7189        for permanent in [
7190            "openai compatible request failed (HTTP 401): Unauthorized",
7191            "embedding dimension mismatch: index has 384, model returned 768",
7192            "too many files (>20000) for semantic indexing (max 20000)",
7193        ] {
7194            assert!(
7195                !embedding_failure_is_transient(permanent),
7196                "{permanent:?} must not be transient"
7197            );
7198            // Stripping a marker-free string is a no-op.
7199            assert_eq!(strip_transient_embedding_marker(permanent), permanent);
7200        }
7201    }
7202
7203    #[test]
7204    fn send_error_transience_separates_connect_timeout_from_4xx() {
7205        // 5xx / 429 are transient; other client errors are not.
7206        assert!(is_retryable_embedding_status(
7207            reqwest::StatusCode::INTERNAL_SERVER_ERROR
7208        ));
7209        assert!(is_retryable_embedding_status(
7210            reqwest::StatusCode::TOO_MANY_REQUESTS
7211        ));
7212        assert!(!is_retryable_embedding_status(
7213            reqwest::StatusCode::UNAUTHORIZED
7214        ));
7215        assert!(!is_retryable_embedding_status(
7216            reqwest::StatusCode::BAD_REQUEST
7217        ));
7218    }
7219
7220    #[test]
7221    fn query_timeout_marker_round_trips_and_classifies() {
7222        // A query-timeout error carries the budget that fired; the budget is
7223        // recoverable and the marker strips cleanly for display.
7224        let marked = format!(
7225            "{}openai compatible request failed: operation timed out",
7226            query_embedding_timeout_marker(3_000)
7227        );
7228        assert_eq!(query_embedding_timeout_budget(&marked), Some(3_000));
7229        let clean = strip_query_embedding_timeout_marker(&marked);
7230        assert!(!clean.contains(QUERY_EMBEDDING_TIMEOUT_MARKER_PREFIX));
7231        assert!(clean.starts_with("openai compatible request failed:"));
7232
7233        // Non-timeout errors carry no marker and no budget — they must not be
7234        // misclassified as timeouts.
7235        for permanent in [
7236            "openai compatible request failed (HTTP 401): Unauthorized",
7237            "failed to embed query: embedding model was not initialized",
7238            "openai compatible request failed: connection refused",
7239        ] {
7240            assert_eq!(
7241                query_embedding_timeout_budget(permanent),
7242                None,
7243                "{permanent:?} must not classify as a query timeout"
7244            );
7245            assert_eq!(
7246                strip_query_embedding_timeout_marker(permanent),
7247                permanent,
7248                "stripping a marker-free string is a no-op"
7249            );
7250        }
7251    }
7252
7253    fn install_test_crypto_provider() {
7254        // Reqwest and the direct test-server dependency enable different rustls
7255        // providers, so select one explicitly before either side builds TLS.
7256        let _ = rustls::crypto::ring::default_provider().install_default();
7257    }
7258
7259    fn start_platform_verifier_tls_server() -> (String, NamedTempFile, thread::JoinHandle<()>) {
7260        install_test_crypto_provider();
7261        let ca_key = rcgen::KeyPair::generate().expect("generate test CA key");
7262        let mut ca_params = rcgen::CertificateParams::default();
7263        ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
7264        ca_params.key_usages = vec![
7265            rcgen::KeyUsagePurpose::KeyCertSign,
7266            rcgen::KeyUsagePurpose::DigitalSignature,
7267        ];
7268        let ca_cert = ca_params
7269            .self_signed(&ca_key)
7270            .expect("generate test CA certificate");
7271
7272        let leaf_key = rcgen::KeyPair::generate().expect("generate test leaf key");
7273        let mut leaf_params = rcgen::CertificateParams::new(vec!["localhost".to_string()])
7274            .expect("generate leaf parameters");
7275        leaf_params.key_usages = vec![rcgen::KeyUsagePurpose::DigitalSignature];
7276        leaf_params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ServerAuth];
7277        let leaf_cert = leaf_params
7278            .signed_by(&leaf_key, &ca_cert, &ca_key)
7279            .expect("sign test leaf certificate");
7280
7281        let mut ca_file = NamedTempFile::new().expect("create test CA file");
7282        ca_file
7283            .write_all(ca_cert.pem().as_bytes())
7284            .expect("write test CA certificate");
7285
7286        let server_config = Arc::new(
7287            rustls::ServerConfig::builder()
7288                .with_no_client_auth()
7289                .with_single_cert(
7290                    vec![rustls::pki_types::CertificateDer::from(
7291                        leaf_cert.der().to_vec(),
7292                    )],
7293                    rustls::pki_types::PrivateKeyDer::Pkcs8(
7294                        rustls::pki_types::PrivatePkcs8KeyDer::from(leaf_key.serialize_der()),
7295                    ),
7296                )
7297                .expect("build test TLS server configuration"),
7298        );
7299        let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind test TLS server");
7300        let address = listener.local_addr().expect("read test TLS server address");
7301        let url = format!("https://localhost:{}/v1/embeddings", address.port());
7302        let handle = thread::spawn(move || {
7303            // Linux exercises both trust paths: the first handshake fails with
7304            // UnknownIssuer, and the second succeeds after SSL_CERT_FILE supplies
7305            // the throwaway CA. Other platforms only exercise the failure path;
7306            // their platform verifiers do not consult SSL_CERT_FILE.
7307            let expected_connections = if cfg!(target_os = "linux") { 2 } else { 1 };
7308            for _ in 0..expected_connections {
7309                let (stream, _) = listener.accept().expect("accept test TLS connection");
7310                stream
7311                    .set_read_timeout(Some(Duration::from_secs(10)))
7312                    .expect("set test TLS read timeout");
7313                let connection = rustls::ServerConnection::new(server_config.clone())
7314                    .expect("create test TLS server connection");
7315                let mut tls_stream = rustls::StreamOwned::new(connection, stream);
7316                let mut request = [0_u8; 4096];
7317                if tls_stream.read(&mut request).is_ok() {
7318                    let body = r#"{"data":[],"model":"test","object":"list"}"#;
7319                    let response = format!(
7320                        "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
7321                        body.len(), body
7322                    );
7323                    let _ = tls_stream.write_all(response.as_bytes());
7324                    tls_stream.conn.send_close_notify();
7325                    let _ = tls_stream.flush();
7326                }
7327            }
7328        });
7329
7330        (url, ca_file, handle)
7331    }
7332
7333    fn run_platform_verifier_tls_child() {
7334        install_test_crypto_provider();
7335        let url = env::var("AFT_PLATFORM_VERIFIER_TLS_URL").expect("test TLS URL");
7336        let tls_config = crate::platform_tls::client_config().expect("build platform TLS config");
7337        // This test asserts the ERROR CLASS (certificate trust failure), not
7338        // latency, so the budget must be unreachable by keychain slowness: on
7339        // macOS the first evaluation of an untrusted chain walks user trust
7340        // settings (trustd), which a pathological keychain entry plus machine
7341        // load has stretched past 120s — at which point the request surfaces a
7342        // transient "operation timed out" BEFORE the certificate verdict
7343        // exists and the assertion fails on the wrong error class. 120s was
7344        // tried twice and breached twice (485s observed once under load ~100).
7345        // Clean keychains answer in milliseconds; this budget only ever costs
7346        // time on machines with hostile trust settings, where a slow correct
7347        // verdict beats a fast wrong one.
7348        let client = Client::builder()
7349            .timeout(Duration::from_secs(600))
7350            .use_preconfigured_tls(tls_config)
7351            .build()
7352            .expect("build test embedding client");
7353        let result = send_embedding_request(
7354            || client.post(&url).body("{}"),
7355            "openai compatible",
7356            EmbeddingRequestPolicy::Query(QueryBudget {
7357                timeout_ms: 600_000,
7358            }),
7359        );
7360
7361        #[cfg(target_os = "linux")]
7362        if env::var_os("SSL_CERT_FILE").is_some() {
7363            let body = result.expect("SSL_CERT_FILE should make the private CA trusted");
7364            assert!(
7365                body.contains("\"data\""),
7366                "unexpected embedding response: {body}"
7367            );
7368            return;
7369        }
7370
7371        let error = result.expect_err("the private CA must not be trusted on this path");
7372        let lower = error.to_ascii_lowercase();
7373        assert!(
7374            ["certificate", "unknownissuer", "unknown issuer", "trust"]
7375                .iter()
7376                .any(|marker| lower.contains(marker)),
7377            "the rendered source chain must include a certificate trust failure: {error}"
7378        );
7379        assert!(
7380            !embedding_failure_is_transient(&error),
7381            "certificate trust failures must not be retried: {error}"
7382        );
7383    }
7384
7385    #[test]
7386    fn platform_verifier_tls_client_subprocess() {
7387        if env::var_os("AFT_PLATFORM_VERIFIER_TLS_CHILD").is_some() {
7388            run_platform_verifier_tls_child();
7389            return;
7390        }
7391
7392        // Run each trust configuration in a fresh process because the
7393        // TLS/platform-verifier configuration caches CA settings; SSL_CERT_FILE
7394        // must be set before that configuration is initialized for Linux CA
7395        // discovery to use it. The process-env lock prevents this test from
7396        // racing other tests that modify environment variables. macOS and Windows
7397        // exercise only the untrusted path because their platform verifiers do
7398        // not consult SSL_CERT_FILE.
7399        let _env_lock = crate::test_env::process_env_lock();
7400        let (url, _ca_file, server_handle) = start_platform_verifier_tls_server();
7401        let test_name = "semantic_index::tests::platform_verifier_tls_client_subprocess";
7402        #[cfg(target_os = "linux")]
7403        let ca_paths: &[Option<&Path>] = &[None, Some(_ca_file.path())];
7404        #[cfg(not(target_os = "linux"))]
7405        let ca_paths: &[Option<&Path>] = &[None];
7406
7407        for ca_path in ca_paths {
7408            let mut command = Command::new(env::current_exe().expect("test executable"));
7409            command
7410                .args(["--exact", test_name, "--nocapture"])
7411                .env("AFT_PLATFORM_VERIFIER_TLS_CHILD", "1")
7412                .env("AFT_PLATFORM_VERIFIER_TLS_URL", &url)
7413                .env_remove("SSL_CERT_FILE")
7414                .env_remove("SSL_CERT_DIR");
7415            if let Some(ca_path) = ca_path {
7416                command.env("SSL_CERT_FILE", ca_path);
7417            }
7418            let output = command.output().expect("run TLS child test");
7419            // Name the exit status and any terminating signal in the failure:
7420            // under heavy machine load this child has died with EMPTY output,
7421            // and a blind "child failed" leaves nothing to diagnose with.
7422            #[cfg(unix)]
7423            let signal = std::os::unix::process::ExitStatusExt::signal(&output.status);
7424            #[cfg(not(unix))]
7425            let signal: Option<i32> = None;
7426            assert!(
7427                output.status.success(),
7428                "TLS child failed: status={:?} code={:?} signal={:?}\nstdout:\n{}\nstderr:\n{}",
7429                output.status,
7430                output.status.code(),
7431                signal,
7432                String::from_utf8_lossy(&output.stdout),
7433                String::from_utf8_lossy(&output.stderr)
7434            );
7435        }
7436
7437        server_handle.join().expect("join test TLS server");
7438    }
7439
7440    #[test]
7441    fn local_backend_model_loading_body_is_transient() {
7442        // LM Studio / Ollama return a 4xx with a loading/unloaded message while
7443        // the model swaps; these must classify transient so the build self-heals.
7444        for body in [
7445            r#"{"error":"Model was unloaded while the request was still in queue.."}"#,
7446            r#"{"error":"model is loading, please wait"}"#,
7447            r#"{"error":"Model not loaded"}"#,
7448            "Loading model into memory",
7449        ] {
7450            assert!(
7451                embedding_response_body_is_transient(reqwest::StatusCode::BAD_REQUEST, body),
7452                "{body:?} should be body-transient"
7453            );
7454        }
7455
7456        // A genuine 4xx misconfiguration body must NOT be treated as transient,
7457        // even when it happens to contain generic words from the old broad
7458        // substring matcher.
7459        for body in [
7460            r#"{"error":"invalid api key"}"#,
7461            r#"{"error":"model 'foo' not found"}"#,
7462            "Bad Request: unknown field",
7463            "Bad Request: invalid loading model option",
7464            r#"{"error":"unauthorized while model is being loaded by another account"}"#,
7465        ] {
7466            assert!(
7467                !embedding_response_body_is_transient(reqwest::StatusCode::BAD_REQUEST, body),
7468                "{body:?} must not be body-transient"
7469            );
7470        }
7471
7472        assert!(
7473            !embedding_response_body_is_transient(
7474                reqwest::StatusCode::UNAUTHORIZED,
7475                r#"{"error":"model is loading, please wait"}"#
7476            ),
7477            "permanent auth failures must not become transient because of body text"
7478        );
7479    }
7480
7481    fn start_slow_embedding_server(
7482        expected_requests: usize,
7483        response_delay: Duration,
7484    ) -> (String, Arc<AtomicUsize>, thread::JoinHandle<()>) {
7485        let listener = TcpListener::bind("127.0.0.1:0").expect("bind slow embedding server");
7486        listener
7487            .set_nonblocking(true)
7488            .expect("set slow server nonblocking");
7489        let addr = listener.local_addr().expect("slow embedding server addr");
7490        let requests = Arc::new(AtomicUsize::new(0));
7491        let requests_for_thread = Arc::clone(&requests);
7492        let handle = thread::spawn(move || {
7493            let deadline = Instant::now() + Duration::from_secs(10);
7494            let mut handlers = Vec::new();
7495            while requests_for_thread.load(Ordering::SeqCst) < expected_requests
7496                && Instant::now() < deadline
7497            {
7498                match listener.accept() {
7499                    Ok((mut stream, _)) => {
7500                        requests_for_thread.fetch_add(1, Ordering::SeqCst);
7501                        handlers.push(thread::spawn(move || {
7502                            let mut request = [0u8; 4096];
7503                            let _ = stream.read(&mut request);
7504                            thread::sleep(response_delay);
7505                            let body =
7506                                r#"{"data":[{"embedding":[0.1,0.2,0.3],"index":0}]}"#;
7507                            let response = format!(
7508                                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
7509                                body.len(),
7510                                body
7511                            );
7512                            let _ = stream.write_all(response.as_bytes());
7513                        }));
7514                    }
7515                    Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
7516                        thread::sleep(Duration::from_millis(5));
7517                    }
7518                    Err(error) => panic!("accept slow embedding request: {error}"),
7519                }
7520            }
7521            for handler in handlers {
7522                handler.join().expect("slow embedding handler");
7523            }
7524        });
7525
7526        (format!("http://{addr}"), requests, handle)
7527    }
7528
7529    struct ProgrammableEmbeddingServer {
7530        base_url: String,
7531        per_item_delay_ms: Arc<AtomicU64>,
7532        never_answer: Arc<AtomicBool>,
7533        requests: Arc<Mutex<Vec<usize>>>,
7534        completed: Arc<Mutex<Vec<usize>>>,
7535        shutdown: Arc<AtomicBool>,
7536        handle: Option<thread::JoinHandle<()>>,
7537    }
7538
7539    impl ProgrammableEmbeddingServer {
7540        fn start(per_item_delay: Duration) -> Self {
7541            let listener = TcpListener::bind("127.0.0.1:0").expect("bind programmable server");
7542            listener
7543                .set_nonblocking(true)
7544                .expect("set programmable server nonblocking");
7545            let addr = listener.local_addr().expect("programmable server addr");
7546            let per_item_delay_ms = Arc::new(AtomicU64::new(
7547                per_item_delay.as_millis().min(u128::from(u64::MAX)) as u64,
7548            ));
7549            let never_answer = Arc::new(AtomicBool::new(false));
7550            let requests = Arc::new(Mutex::new(Vec::new()));
7551            let completed = Arc::new(Mutex::new(Vec::new()));
7552            let shutdown = Arc::new(AtomicBool::new(false));
7553            let thread_delay = Arc::clone(&per_item_delay_ms);
7554            let thread_never = Arc::clone(&never_answer);
7555            let thread_requests = Arc::clone(&requests);
7556            let thread_completed = Arc::clone(&completed);
7557            let thread_shutdown = Arc::clone(&shutdown);
7558            let handle = thread::spawn(move || {
7559                let mut handlers = Vec::new();
7560                while !thread_shutdown.load(Ordering::SeqCst) {
7561                    match listener.accept() {
7562                        Ok((stream, _)) => {
7563                            let delay = Arc::clone(&thread_delay);
7564                            let never = Arc::clone(&thread_never);
7565                            let requests = Arc::clone(&thread_requests);
7566                            let completed = Arc::clone(&thread_completed);
7567                            let shutdown = Arc::clone(&thread_shutdown);
7568                            handlers.push(thread::spawn(move || {
7569                                handle_programmable_embedding_request(
7570                                    stream, delay, never, requests, completed, shutdown,
7571                                );
7572                            }));
7573                        }
7574                        Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
7575                            thread::sleep(Duration::from_millis(2));
7576                        }
7577                        Err(error) => panic!("accept programmable embedding request: {error}"),
7578                    }
7579                }
7580                for handler in handlers {
7581                    handler.join().expect("programmable embedding handler");
7582                }
7583            });
7584
7585            Self {
7586                base_url: format!("http://{addr}"),
7587                per_item_delay_ms,
7588                never_answer,
7589                requests,
7590                completed,
7591                shutdown,
7592                handle: Some(handle),
7593            }
7594        }
7595
7596        fn set_per_item_delay(&self, delay: Duration) {
7597            self.per_item_delay_ms.store(
7598                delay.as_millis().min(u128::from(u64::MAX)) as u64,
7599                Ordering::SeqCst,
7600            );
7601        }
7602
7603        fn set_never_answer(&self, never_answer: bool) {
7604            self.never_answer.store(never_answer, Ordering::SeqCst);
7605        }
7606
7607        fn request_sizes(&self) -> Vec<usize> {
7608            self.requests.lock().unwrap().clone()
7609        }
7610
7611        fn completed_sizes(&self) -> Vec<usize> {
7612            self.completed.lock().unwrap().clone()
7613        }
7614    }
7615
7616    impl Drop for ProgrammableEmbeddingServer {
7617        fn drop(&mut self) {
7618            self.shutdown.store(true, Ordering::SeqCst);
7619            if let Some(handle) = self.handle.take() {
7620                handle.join().expect("programmable embedding server");
7621            }
7622        }
7623    }
7624
7625    fn handle_programmable_embedding_request(
7626        mut stream: TcpStream,
7627        per_item_delay_ms: Arc<AtomicU64>,
7628        never_answer: Arc<AtomicBool>,
7629        requests: Arc<Mutex<Vec<usize>>>,
7630        completed: Arc<Mutex<Vec<usize>>>,
7631        shutdown: Arc<AtomicBool>,
7632    ) {
7633        let mut buf = Vec::new();
7634        let mut chunk = [0u8; 4096];
7635        let mut header_end = None;
7636        let mut content_length = 0usize;
7637        loop {
7638            let count = match stream.read(&mut chunk) {
7639                Ok(count) => count,
7640                Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
7641                    thread::sleep(Duration::from_millis(1));
7642                    continue;
7643                }
7644                Err(error) => panic!("read programmable request: {error}"),
7645            };
7646            if count == 0 {
7647                return;
7648            }
7649            buf.extend_from_slice(&chunk[..count]);
7650            if header_end.is_none() {
7651                if let Some(position) = buf.windows(4).position(|window| window == b"\r\n\r\n") {
7652                    header_end = Some(position + 4);
7653                    for line in String::from_utf8_lossy(&buf[..position + 4]).lines() {
7654                        if line.to_ascii_lowercase().starts_with("content-length:") {
7655                            content_length = line
7656                                .split_once(':')
7657                                .and_then(|(_, value)| value.trim().parse().ok())
7658                                .unwrap_or(0);
7659                        }
7660                    }
7661                }
7662            }
7663            if header_end.is_some_and(|end| buf.len() >= end + content_length) {
7664                break;
7665            }
7666        }
7667        let body_start = header_end.expect("programmable request headers");
7668        let body: serde_json::Value =
7669            serde_json::from_slice(&buf[body_start..body_start + content_length])
7670                .expect("programmable request JSON");
7671        let input_count = body["input"]
7672            .as_array()
7673            .expect("embedding input array")
7674            .len();
7675        requests.lock().unwrap().push(input_count);
7676
7677        if never_answer.load(Ordering::SeqCst) {
7678            while !shutdown.load(Ordering::SeqCst) {
7679                thread::sleep(Duration::from_millis(2));
7680            }
7681            return;
7682        }
7683
7684        thread::sleep(Duration::from_millis(
7685            per_item_delay_ms
7686                .load(Ordering::SeqCst)
7687                .saturating_mul(input_count as u64),
7688        ));
7689        let data = (0..input_count)
7690            .map(|index| serde_json::json!({"embedding": [0.1, 0.2, 0.3], "index": index}))
7691            .collect::<Vec<_>>();
7692        let response_body = serde_json::json!({"data": data}).to_string();
7693        let response = format!(
7694            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
7695            response_body.len(),
7696            response_body,
7697        );
7698        if stream.write_all(response.as_bytes()).is_ok() {
7699            completed.lock().unwrap().push(input_count);
7700        }
7701    }
7702
7703    fn start_recording_embedding_server(
7704        expected_requests: usize,
7705    ) -> (String, Arc<Mutex<Vec<String>>>, thread::JoinHandle<()>) {
7706        let listener = TcpListener::bind("127.0.0.1:0").expect("bind recording server");
7707        let addr = listener.local_addr().expect("recording server addr");
7708        let inputs = Arc::new(Mutex::new(Vec::new()));
7709        let inputs_for_thread = Arc::clone(&inputs);
7710        let handle = thread::spawn(move || {
7711            for _ in 0..expected_requests {
7712                let (mut stream, _) = listener.accept().expect("accept recording request");
7713                let mut buf = Vec::new();
7714                let mut chunk = [0u8; 4096];
7715                let mut header_end = None;
7716                let mut content_length = 0usize;
7717                loop {
7718                    let count = stream.read(&mut chunk).expect("read recording request");
7719                    if count == 0 {
7720                        break;
7721                    }
7722                    buf.extend_from_slice(&chunk[..count]);
7723                    if header_end.is_none() {
7724                        if let Some(position) =
7725                            buf.windows(4).position(|window| window == b"\r\n\r\n")
7726                        {
7727                            header_end = Some(position + 4);
7728                            for line in String::from_utf8_lossy(&buf[..position + 4]).lines() {
7729                                if line.to_ascii_lowercase().starts_with("content-length:") {
7730                                    content_length = line
7731                                        .split_once(':')
7732                                        .map(|(_, value)| value.trim().parse().unwrap_or(0))
7733                                        .unwrap_or(0);
7734                                }
7735                            }
7736                        }
7737                    }
7738                    if header_end.is_some_and(|end| buf.len() >= end + content_length) {
7739                        break;
7740                    }
7741                }
7742                let body_start = header_end.expect("recording request headers");
7743                let body: serde_json::Value =
7744                    serde_json::from_slice(&buf[body_start..body_start + content_length])
7745                        .expect("recording request JSON");
7746                let input = body["input"][0]
7747                    .as_str()
7748                    .expect("single string embedding input")
7749                    .to_string();
7750                inputs_for_thread.lock().unwrap().push(input);
7751                let response_body = r#"{"data":[{"embedding":[0.1,0.2,0.3],"index":0}]}"#;
7752                let response = format!(
7753                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
7754                    response_body.len(),
7755                    response_body
7756                );
7757                stream
7758                    .write_all(response.as_bytes())
7759                    .expect("write recording response");
7760            }
7761        });
7762        (format!("http://{addr}"), inputs, handle)
7763    }
7764
7765    fn start_mock_http_server<F>(handler: F) -> (String, thread::JoinHandle<()>)
7766    where
7767        F: Fn(String, String, String) -> String + Send + 'static,
7768    {
7769        let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
7770        let addr = listener.local_addr().expect("local addr");
7771        let handle = thread::spawn(move || {
7772            let (mut stream, _) = listener.accept().expect("accept request");
7773            let mut buf = Vec::new();
7774            let mut chunk = [0u8; 4096];
7775            let mut header_end = None;
7776            let mut content_length = 0usize;
7777            loop {
7778                let n = stream.read(&mut chunk).expect("read request");
7779                if n == 0 {
7780                    break;
7781                }
7782                buf.extend_from_slice(&chunk[..n]);
7783                if header_end.is_none() {
7784                    if let Some(pos) = buf.windows(4).position(|window| window == b"\r\n\r\n") {
7785                        header_end = Some(pos + 4);
7786                        let headers = String::from_utf8_lossy(&buf[..pos + 4]);
7787                        for line in headers.lines() {
7788                            if let Some(value) = line.strip_prefix("Content-Length:") {
7789                                content_length = value.trim().parse::<usize>().unwrap_or(0);
7790                            }
7791                        }
7792                    }
7793                }
7794                if let Some(end) = header_end {
7795                    if buf.len() >= end + content_length {
7796                        break;
7797                    }
7798                }
7799            }
7800
7801            let end = header_end.expect("header terminator");
7802            let request = String::from_utf8_lossy(&buf[..end]).to_string();
7803            let body = String::from_utf8_lossy(&buf[end..end + content_length]).to_string();
7804            let mut lines = request.lines();
7805            let request_line = lines.next().expect("request line").to_string();
7806            let path = request_line
7807                .split_whitespace()
7808                .nth(1)
7809                .expect("request path")
7810                .to_string();
7811            let response_body = handler(request_line, path, body);
7812            let response = format!(
7813                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
7814                response_body.len(),
7815                response_body
7816            );
7817            stream
7818                .write_all(response.as_bytes())
7819                .expect("write response");
7820        });
7821
7822        (format!("http://{}", addr), handle)
7823    }
7824
7825    fn start_truncated_body_server(attempts: usize) -> (String, thread::JoinHandle<()>) {
7826        let listener = TcpListener::bind("127.0.0.1:0").expect("bind truncated test server");
7827        listener
7828            .set_nonblocking(true)
7829            .expect("nonblocking listener");
7830        let addr = listener.local_addr().expect("local addr");
7831        let handle = thread::spawn(move || {
7832            // The deadline is only a hang-backstop for the case where the client
7833            // makes FEWER than `attempts` connections. It MUST comfortably exceed
7834            // the client's full retry budget (3 attempts: 3x250ms read-timeouts +
7835            // 500ms + 1000ms backoffs ~= 2.25s) so the last connect is always
7836            // accepted — otherwise the 3rd connect lands after a too-short
7837            // deadline, the server thread is already gone, and the client gets a
7838            // connect error ("request failed") instead of the body-read error the
7839            // test asserts. Under loaded CI (esp. Windows) thread scheduling
7840            // drifts the connects later, so this needs generous headroom.
7841            let deadline = std::time::Instant::now() + Duration::from_secs(30);
7842            let mut accepted = 0usize;
7843            while accepted < attempts && std::time::Instant::now() < deadline {
7844                match listener.accept() {
7845                    Ok((mut stream, _)) => {
7846                        accepted += 1;
7847                        let mut buf = [0u8; 4096];
7848                        // The client (under test) uses a 250ms timeout and drops
7849                        // the connection when the truncated body never completes.
7850                        // On Windows that disconnect surfaces as a hard socket
7851                        // error (WSAECONNRESET) on these read/write calls, where
7852                        // Unix returns a clean EOF. Tolerate both: the mock does
7853                        // not need the request bytes, and a write to an
7854                        // already-hung-up client is expected.
7855                        let _ = stream.read(&mut buf);
7856                        let response = "HTTP/1.1 200 OK
7857Content-Type: application/json
7858Content-Length: 128
7859Connection: close
7860
7861{";
7862                        let _ = stream.write_all(response.as_bytes());
7863                    }
7864                    Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
7865                        thread::sleep(Duration::from_millis(10));
7866                    }
7867                    Err(error) => panic!("accept request: {error}"),
7868                }
7869            }
7870        });
7871
7872        (format!("http://{}", addr), handle)
7873    }
7874
7875    #[test]
7876    fn response_body_read_failures_are_marked_transient() {
7877        let (url, handle) = start_truncated_body_server(EMBEDDING_REQUEST_MAX_ATTEMPTS);
7878        // Generous client timeout: this test classifies BODY-TRUNCATION errors,
7879        // and a tight budget flips the failure into a connect/send timeout on a
7880        // loaded machine, changing which error string the assertions see.
7881        let client = Client::builder()
7882            .timeout(Duration::from_secs(5))
7883            .build()
7884            .expect("client");
7885
7886        let error = send_embedding_request(
7887            || client.post(&url).body("{}"),
7888            "test backend",
7889            EmbeddingRequestPolicy::Build(BuildRequestBudget {
7890                batch_size: 1,
7891                deadline_ms: 250,
7892            }),
7893        )
7894        .expect_err("truncated body should fail");
7895
7896        handle.join().unwrap();
7897        assert!(
7898            embedding_failure_is_transient(&error),
7899            "body read failures should be transient-marked: {error}"
7900        );
7901        // The mock closes the socket after writing a truncated body. Whether
7902        // the client observes that as a body-read EOF, as a send-stage
7903        // connection reset, or as hyper's UnexpectedMessage (the partial reply
7904        // arrived while the request was still being written) is an OS-level
7905        // race (Windows sends RST when the socket closes with unread request
7906        // bytes, and under load the mock's single read can return early). All
7907        // shapes are the backend dying mid-exchange and all must carry the
7908        // transient marker; the message prefix differs by stage.
7909        assert!(
7910            error.contains("response read failed") || error.contains("request failed"),
7911            "unexpected error shape: {error}"
7912        );
7913    }
7914
7915    fn test_vector_for_texts(texts: Vec<String>) -> Result<Vec<Vec<f32>>, String> {
7916        Ok(texts.iter().map(|_| vec![1.0, 0.0, 0.0]).collect())
7917    }
7918
7919    fn write_rust_file(path: &Path, function_name: &str) {
7920        fs::write(
7921            path,
7922            format!("pub fn {function_name}() -> bool {{\n    true\n}}\n"),
7923        )
7924        .unwrap();
7925    }
7926
7927    fn build_test_index(project_root: &Path, files: &[PathBuf]) -> SemanticIndex {
7928        let mut embed = test_vector_for_texts;
7929        SemanticIndex::build(project_root, files, &mut embed, 8).unwrap()
7930    }
7931
7932    fn test_project_root() -> PathBuf {
7933        std::env::current_dir().unwrap()
7934    }
7935
7936    #[test]
7937    fn empty_snapshot_replaces_nonempty_and_loads_as_valid_tombstone() {
7938        let project = tempfile::tempdir().expect("create project");
7939        let storage = tempfile::tempdir().expect("create storage");
7940        let source = project.path().join("lib.rs");
7941        write_rust_file(&source, "persisted_symbol");
7942        let populated = build_test_index(project.path(), std::slice::from_ref(&source));
7943        assert!(populated.write_to_disk(storage.path(), "project"));
7944
7945        let data_path = storage.path().join("semantic/project/semantic.bin");
7946        let populated_bytes = fs::read(&data_path).expect("read populated snapshot");
7947        let empty = SemanticIndex::new(project.path().to_path_buf(), populated.dimension());
7948        assert!(empty.write_to_disk(storage.path(), "project"));
7949        let empty_bytes = fs::read(&data_path).expect("read explicit empty snapshot");
7950        assert_ne!(empty_bytes, populated_bytes);
7951        let decoded = SemanticIndex::from_bytes(&empty_bytes, project.path())
7952            .expect("decode explicit empty snapshot");
7953        assert_eq!(decoded.entry_count(), 0);
7954        for _ in 0..2 {
7955            let loaded = SemanticIndex::read_from_disk(
7956                storage.path(),
7957                "project",
7958                project.path(),
7959                false,
7960                None,
7961            )
7962            .expect("explicit empty snapshot remains loadable");
7963            assert_eq!(loaded.entry_count(), 0);
7964        }
7965    }
7966
7967    #[test]
7968    fn persistence_failure_is_reported_to_caller() {
7969        let project = tempfile::tempdir().expect("create project");
7970        let storage_parent = tempfile::tempdir().expect("create storage parent");
7971        let storage_file = storage_parent.path().join("not-a-directory");
7972        fs::write(&storage_file, b"occupied").expect("create blocking file");
7973        let empty = SemanticIndex::new(project.path().to_path_buf(), 3);
7974
7975        assert!(!empty.write_to_disk(&storage_file, "project"));
7976    }
7977
7978    #[test]
7979    fn semantic_memory_estimate_is_zero_when_empty_and_scales_with_entries() {
7980        let root = test_project_root();
7981        let mut index = SemanticIndex::new(root.clone(), 3);
7982        assert_eq!(index.estimated_memory().estimated_bytes, Some(0));
7983
7984        let entry = |name: &str| EmbeddingEntry {
7985            chunk: SemanticChunk {
7986                file: root.join(format!("{name}.rs")),
7987                name: name.to_string(),
7988                qualified_name: Some(format!("module::{name}")),
7989                kind: SymbolKind::Function,
7990                start_line: 0,
7991                end_line: 1,
7992                exported: true,
7993                embed_text: format!("function {name} body"),
7994                snippet: format!("fn {name}() {{}}"),
7995            },
7996            norm: vector_norm(&[1.0, 2.0, 3.0]),
7997            vector: vec![1.0, 2.0, 3.0],
7998        };
7999        index.entries.push(entry("one"));
8000        let one_entry = index.estimated_memory().estimated_bytes.unwrap();
8001        assert!(one_entry > 0);
8002        index.entries.push(entry("two"));
8003        let two_entries = index.estimated_memory().estimated_bytes.unwrap();
8004        assert!(two_entries > one_entry);
8005    }
8006
8007    fn set_file_metadata(index: &mut SemanticIndex, file: &Path, mtime: SystemTime, size: u64) {
8008        index.file_mtimes.insert(file.to_path_buf(), mtime);
8009        index.file_sizes.insert(file.to_path_buf(), size);
8010        index
8011            .file_hashes
8012            .insert(file.to_path_buf(), cache_freshness::zero_hash());
8013    }
8014
8015    fn legacy_semantic_index_bytes(index: &SemanticIndex) -> Vec<u8> {
8016        let mut buf = Vec::new();
8017        let fingerprint_bytes = index.fingerprint.as_ref().and_then(|fingerprint| {
8018            let encoded = fingerprint.as_string();
8019            if encoded.is_empty() {
8020                None
8021            } else {
8022                Some(encoded.into_bytes())
8023            }
8024        });
8025        let file_mtimes: Vec<_> = index
8026            .file_mtimes
8027            .iter()
8028            .filter_map(|(path, mtime)| {
8029                cache_relative_path(&index.project_root, path)
8030                    .map(|relative| (relative, path, mtime))
8031            })
8032            .collect();
8033        let entries: Vec<_> = index
8034            .entries
8035            .iter()
8036            .filter_map(|entry| {
8037                cache_relative_path(&index.project_root, &entry.chunk.file)
8038                    .map(|relative| (relative, entry))
8039            })
8040            .collect();
8041
8042        buf.push(SEMANTIC_INDEX_VERSION_V6);
8043        buf.extend_from_slice(&(index.dimension as u32).to_le_bytes());
8044        buf.extend_from_slice(&(entries.len() as u32).to_le_bytes());
8045        let fp_bytes_ref: &[u8] = fingerprint_bytes.as_deref().unwrap_or(&[]);
8046        buf.extend_from_slice(&(fp_bytes_ref.len() as u32).to_le_bytes());
8047        buf.extend_from_slice(fp_bytes_ref);
8048
8049        buf.extend_from_slice(&(file_mtimes.len() as u32).to_le_bytes());
8050        for (relative, path, mtime) in &file_mtimes {
8051            let path_bytes = relative.to_string_lossy().as_bytes().to_vec();
8052            buf.extend_from_slice(&(path_bytes.len() as u32).to_le_bytes());
8053            buf.extend_from_slice(&path_bytes);
8054            let duration = mtime
8055                .duration_since(SystemTime::UNIX_EPOCH)
8056                .unwrap_or_default();
8057            buf.extend_from_slice(&duration.as_secs().to_le_bytes());
8058            buf.extend_from_slice(&duration.subsec_nanos().to_le_bytes());
8059            let size = index.file_sizes.get(*path).copied().unwrap_or_default();
8060            buf.extend_from_slice(&size.to_le_bytes());
8061            let hash = index
8062                .file_hashes
8063                .get(*path)
8064                .copied()
8065                .unwrap_or_else(cache_freshness::zero_hash);
8066            buf.extend_from_slice(hash.as_bytes());
8067        }
8068
8069        for (relative, entry) in &entries {
8070            let c = &entry.chunk;
8071            let file_bytes = relative.to_string_lossy().as_bytes().to_vec();
8072            buf.extend_from_slice(&(file_bytes.len() as u32).to_le_bytes());
8073            buf.extend_from_slice(&file_bytes);
8074
8075            let name_bytes = c.name.as_bytes();
8076            buf.extend_from_slice(&(name_bytes.len() as u32).to_le_bytes());
8077            buf.extend_from_slice(name_bytes);
8078
8079            buf.push(symbol_kind_to_u8(&c.kind));
8080            buf.extend_from_slice(&(c.start_line as u32).to_le_bytes());
8081            buf.extend_from_slice(&(c.end_line as u32).to_le_bytes());
8082            buf.push(c.exported as u8);
8083
8084            let snippet_bytes = c.snippet.as_bytes();
8085            buf.extend_from_slice(&(snippet_bytes.len() as u32).to_le_bytes());
8086            buf.extend_from_slice(snippet_bytes);
8087
8088            let embed_bytes = c.embed_text.as_bytes();
8089            buf.extend_from_slice(&(embed_bytes.len() as u32).to_le_bytes());
8090            buf.extend_from_slice(embed_bytes);
8091
8092            for &val in &entry.vector {
8093                buf.extend_from_slice(&val.to_le_bytes());
8094            }
8095        }
8096
8097        buf
8098    }
8099
8100    #[derive(Default)]
8101    struct RecordingEmbedder {
8102        calls: Vec<Vec<String>>,
8103    }
8104
8105    impl RecordingEmbedder {
8106        fn embed(&mut self, texts: Vec<String>) -> Result<Vec<Vec<f32>>, String> {
8107            let vectors = texts
8108                .iter()
8109                .map(|text| deterministic_test_vector(text))
8110                .collect();
8111            self.calls.push(texts);
8112            Ok(vectors)
8113        }
8114
8115        fn total_embedded_texts(&self) -> usize {
8116            self.calls.iter().map(Vec::len).sum()
8117        }
8118
8119        fn embedded_texts(&self) -> Vec<&str> {
8120            self.calls
8121                .iter()
8122                .flat_map(|batch| batch.iter().map(String::as_str))
8123                .collect()
8124        }
8125    }
8126
8127    fn deterministic_test_vector(text: &str) -> Vec<f32> {
8128        let hash = blake3::hash(text.as_bytes());
8129        let bytes = hash.as_bytes();
8130        vec![
8131            1.0,
8132            bytes[0] as f32 / 255.0,
8133            bytes[1] as f32 / 255.0,
8134            bytes[2] as f32 / 255.0,
8135        ]
8136    }
8137
8138    fn build_recorded_test_index(project_root: &Path, files: &[PathBuf]) -> SemanticIndex {
8139        let mut embedder = RecordingEmbedder::default();
8140        let mut embed = |texts: Vec<String>| embedder.embed(texts);
8141        SemanticIndex::build(project_root, files, &mut embed, 16).unwrap()
8142    }
8143
8144    fn force_stale(index: &mut SemanticIndex, file: &Path) {
8145        set_file_metadata(index, file, SystemTime::UNIX_EPOCH, 0);
8146    }
8147
8148    fn write_source(path: &Path, source: &str) {
8149        if let Some(parent) = path.parent() {
8150            fs::create_dir_all(parent).unwrap();
8151        }
8152        fs::write(path, source).unwrap();
8153    }
8154
8155    fn entries_for_file<'a>(index: &'a SemanticIndex, file: &Path) -> Vec<&'a EmbeddingEntry> {
8156        index
8157            .entries
8158            .iter()
8159            .filter(|entry| entry.chunk.file == file)
8160            .collect()
8161    }
8162
8163    fn entry_by_name<'a>(index: &'a SemanticIndex, file: &Path, name: &str) -> &'a EmbeddingEntry {
8164        index
8165            .entries
8166            .iter()
8167            .find(|entry| entry.chunk.file == file && entry.chunk.name == name)
8168            .unwrap_or_else(|| panic!("missing semantic entry {name} in {}", file.display()))
8169    }
8170
8171    fn file_summary_entry<'a>(index: &'a SemanticIndex, file: &Path) -> &'a EmbeddingEntry {
8172        index
8173            .entries
8174            .iter()
8175            .find(|entry| entry.chunk.file == file && entry.chunk.kind == SymbolKind::FileSummary)
8176            .unwrap_or_else(|| panic!("missing file-summary entry in {}", file.display()))
8177    }
8178
8179    #[test]
8180    fn borrowed_snapshots_deserialize_once_share_memory_and_drop_with_last_holder() {
8181        let owner = tempfile::tempdir().unwrap();
8182        let storage = tempfile::tempdir().unwrap();
8183        let borrower_a = tempfile::tempdir().unwrap();
8184        let borrower_b = tempfile::tempdir().unwrap();
8185        let relative = Path::new("src/lib.rs");
8186        for root in [owner.path(), borrower_a.path(), borrower_b.path()] {
8187            let file = root.join(relative);
8188            fs::create_dir_all(file.parent().unwrap()).unwrap();
8189            fs::write(&file, "pub fn shared_symbol() -> bool { true }\n").unwrap();
8190        }
8191        let owner_file = owner.path().join(relative);
8192        let metadata = fs::metadata(&owner_file).unwrap();
8193        let mut index = SemanticIndex::new(owner.path().to_path_buf(), 3);
8194        index.entries.push(EmbeddingEntry {
8195            chunk: SemanticChunk {
8196                file: owner_file.clone(),
8197                name: "shared_symbol".to_string(),
8198                qualified_name: None,
8199                kind: SymbolKind::Function,
8200                start_line: 0,
8201                end_line: 0,
8202                exported: true,
8203                embed_text: "shared symbol".to_string(),
8204                snippet: "pub fn shared_symbol() -> bool { true }".to_string(),
8205            },
8206            norm: vector_norm(&[1.0, 0.0, 0.0]),
8207            vector: vec![1.0, 0.0, 0.0],
8208        });
8209        index.file_mtimes.insert(
8210            owner_file.clone(),
8211            metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
8212        );
8213        index.file_sizes.insert(owner_file.clone(), metadata.len());
8214        index.file_hashes.insert(
8215            owner_file,
8216            blake3::hash(b"pub fn shared_symbol() -> bool { true }\n"),
8217        );
8218        index.set_fingerprint(SemanticIndexFingerprint {
8219            backend: "test".to_string(),
8220            model: "shared-base".to_string(),
8221            base_url: FALLBACK_BACKEND.to_string(),
8222            dimension: 3,
8223            chunking_version: default_chunking_version(),
8224            ..Default::default()
8225        });
8226        assert!(index.shared_base.is_none(), "owner indexes stay private");
8227
8228        let project_key = format!(
8229            "shared-base-{}",
8230            blake3::hash(owner.path().as_os_str().as_encoded_bytes()).to_hex()
8231        );
8232        let dir = storage.path().join("semantic").join(&project_key);
8233        fs::create_dir_all(&dir).unwrap();
8234        fs::write(dir.join("semantic.bin"), index.to_bytes()).unwrap();
8235        let loads_before = SHARED_SEMANTIC_BASE_LOADS.load(Ordering::Relaxed);
8236        let hits_before = SHARED_SEMANTIC_BASE_HITS.load(Ordering::Relaxed);
8237        let a = SemanticIndex::read_from_disk_borrow_tolerant(
8238            storage.path(),
8239            &project_key,
8240            borrower_a.path(),
8241        )
8242        .unwrap();
8243        let b = SemanticIndex::read_from_disk_borrow_tolerant(
8244            storage.path(),
8245            &project_key,
8246            borrower_b.path(),
8247        )
8248        .unwrap();
8249        let a_base = a.shared_base.as_ref().unwrap();
8250        let b_base = b.shared_base.as_ref().unwrap();
8251        assert!(Arc::ptr_eq(a_base, b_base));
8252        assert!(SHARED_SEMANTIC_BASE_LOADS.load(Ordering::Relaxed) > loads_before);
8253        assert!(SHARED_SEMANTIC_BASE_HITS.load(Ordering::Relaxed) > hits_before);
8254        assert_eq!(
8255            a.search(&[1.0, 0.0, 0.0], 1)[0].file,
8256            borrower_a.path().join(relative)
8257        );
8258        assert_eq!(
8259            b.search(&[1.0, 0.0, 0.0], 1)[0].file,
8260            borrower_b.path().join(relative)
8261        );
8262        assert_eq!(a.estimated_memory().estimated_bytes, Some(0));
8263        assert!(shared_semantic_bases_memory().estimated_bytes.unwrap_or(0) > 0);
8264
8265        let weak = Arc::downgrade(a_base);
8266        let ctx = crate::context::AppContext::new(
8267            Box::new(crate::parser::TreeSitterProvider::new()),
8268            crate::config::Config {
8269                project_root: Some(borrower_a.path().to_path_buf()),
8270                ..crate::config::Config::default()
8271            },
8272        );
8273        *ctx.semantic_index()
8274            .write()
8275            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(a);
8276        assert!(ctx.evict_idle_artifacts());
8277        assert!(
8278            weak.upgrade().is_some(),
8279            "the second borrower keeps the base live"
8280        );
8281        drop(b);
8282        assert!(
8283            weak.upgrade().is_none(),
8284            "the last borrower releases the base"
8285        );
8286    }
8287
8288    #[test]
8289    fn borrowed_snapshot_hash_change_falls_back_to_private_copy() {
8290        let owner = tempfile::tempdir().unwrap();
8291        let storage = tempfile::tempdir().unwrap();
8292        let borrower_a = tempfile::tempdir().unwrap();
8293        let borrower_b = tempfile::tempdir().unwrap();
8294        let relative = Path::new("src/lib.rs");
8295        for root in [owner.path(), borrower_a.path(), borrower_b.path()] {
8296            let file = root.join(relative);
8297            fs::create_dir_all(file.parent().unwrap()).unwrap();
8298            fs::write(&file, "pub fn hash_guard() {}\n").unwrap();
8299        }
8300        let owner_file = owner.path().join(relative);
8301        let metadata = fs::metadata(&owner_file).unwrap();
8302        let mut index = SemanticIndex::new(owner.path().to_path_buf(), 2);
8303        index.entries.push(EmbeddingEntry {
8304            chunk: SemanticChunk {
8305                file: owner_file.clone(),
8306                name: "hash_guard".to_string(),
8307                qualified_name: None,
8308                kind: SymbolKind::Function,
8309                start_line: 0,
8310                end_line: 0,
8311                exported: true,
8312                embed_text: "hash guard".to_string(),
8313                snippet: "pub fn hash_guard() {}".to_string(),
8314            },
8315            norm: vector_norm(&[1.0, 0.0]),
8316            vector: vec![1.0, 0.0],
8317        });
8318        index.file_mtimes.insert(
8319            owner_file.clone(),
8320            metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
8321        );
8322        index.file_sizes.insert(owner_file.clone(), metadata.len());
8323        index
8324            .file_hashes
8325            .insert(owner_file, blake3::hash(b"pub fn hash_guard() {}\n"));
8326        index.set_fingerprint(SemanticIndexFingerprint {
8327            backend: "test".to_string(),
8328            model: "hash-guard".to_string(),
8329            base_url: FALLBACK_BACKEND.to_string(),
8330            dimension: 2,
8331            chunking_version: default_chunking_version(),
8332            ..Default::default()
8333        });
8334        let project_key = format!(
8335            "hash-fallback-{}",
8336            blake3::hash(owner.path().as_os_str().as_encoded_bytes()).to_hex()
8337        );
8338        let dir = storage.path().join("semantic").join(&project_key);
8339        fs::create_dir_all(&dir).unwrap();
8340        fs::write(dir.join("semantic.bin"), index.to_bytes()).unwrap();
8341        let shared = SemanticIndex::read_from_disk_borrow_tolerant(
8342            storage.path(),
8343            &project_key,
8344            borrower_a.path(),
8345        )
8346        .unwrap();
8347        assert!(shared.shared_base.is_some());
8348
8349        let changed_vector = vec![0.0, 1.0];
8350        index.entries[0].norm = vector_norm(&changed_vector);
8351        index.entries[0].vector = changed_vector;
8352        fs::write(dir.join("semantic.bin"), index.to_bytes()).unwrap();
8353        let fallback = SemanticIndex::read_from_disk_borrow_tolerant(
8354            storage.path(),
8355            &project_key,
8356            borrower_b.path(),
8357        )
8358        .unwrap();
8359        assert!(
8360            fallback.shared_base.is_none(),
8361            "a different byte identity must not join the live shared generation"
8362        );
8363        drop(shared);
8364    }
8365
8366    #[test]
8367    fn borrow_only_root_skips_semantic_lock_and_persist() {
8368        let project = tempfile::tempdir().expect("project");
8369        let source = project.path().join("lib.rs");
8370        write_rust_file(&source, "borrow_only_symbol");
8371        let project_key = "shared-artifact-key".to_string();
8372        let storage = tempfile::tempdir().expect("storage");
8373        crate::root_cache::configure_artifact_access(project.path(), &project_key, true);
8374
8375        let _lock = SemanticIndexLock::acquire(storage.path(), &project_key, project.path())
8376            .expect("borrow-only lock downgrade");
8377        let cache_dir = storage.path().join("semantic").join(&project_key);
8378        assert!(!cache_dir.join("cache.lock").exists());
8379
8380        let index = build_test_index(project.path(), &[source]);
8381        index.write_to_disk(storage.path(), &project_key);
8382
8383        assert!(!cache_dir.join("semantic.bin").exists());
8384        assert!(!cache_dir.exists());
8385    }
8386
8387    #[test]
8388    fn corpus_refresh_failure_reports_exact_file_set_for_recovery() {
8389        let temp = tempfile::tempdir().unwrap();
8390        let root = std::fs::canonicalize(temp.path()).unwrap();
8391        let changed = root.join("changed.rs");
8392        let deleted = root.join("deleted.rs");
8393        let unchanged = root.join("unchanged.rs");
8394        write_rust_file(&changed, "changed_before");
8395        write_rust_file(&deleted, "deleted");
8396        write_rust_file(&unchanged, "unchanged");
8397        let mut index = build_test_index(
8398            &root,
8399            &[changed.clone(), deleted.clone(), unchanged.clone()],
8400        );
8401
8402        write_rust_file(&changed, "changed_after_with_a_longer_name");
8403        force_stale(&mut index, &changed);
8404        std::fs::remove_file(&deleted).unwrap();
8405        let added = root.join("added.rs");
8406        write_rust_file(&added, "added");
8407        let current_files = vec![changed.clone(), unchanged, added.clone()];
8408        let mut recovery_paths = Vec::new();
8409        let mut embed = |_texts: Vec<String>| -> Result<Vec<Vec<f32>>, String> {
8410            Err(format!("{TRANSIENT_EMBEDDING_MARKER}backend timeout"))
8411        };
8412        let mut progress = |_done: usize, _total: usize| {};
8413
8414        let result = index.refresh_stale_files_with_strategy_and_blob_reuse(
8415            &root,
8416            &current_files,
8417            &mut embed,
8418            64,
8419            &mut progress,
8420            cache_freshness::VerifyStrategy::Strict,
8421            &mut |_| None,
8422            Some(&mut recovery_paths),
8423        );
8424
8425        assert!(result.is_err());
8426        let mut expected = vec![added, changed, deleted];
8427        expected.sort();
8428        assert_eq!(recovery_paths, expected);
8429    }
8430
8431    #[test]
8432    fn refresh_stale_line_shift_reuses_all_chunks_and_retains_entries() {
8433        let temp = tempfile::tempdir().unwrap();
8434        let project_root = temp.path();
8435        let file = project_root.join("src/lib.rs");
8436        let original = "pub fn alpha() -> i32 {\n    1\n}\n\npub fn beta() -> i32 {\n    2\n}\n";
8437        write_source(&file, original);
8438
8439        let mut index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
8440        let original_entry_count = index.entries.len();
8441        let original_alpha_vector = entry_by_name(&index, &file, "alpha").vector.clone();
8442
8443        write_source(&file, &format!("\n{original}"));
8444        force_stale(&mut index, &file);
8445
8446        let mut embedder = RecordingEmbedder::default();
8447        let mut embed = |texts: Vec<String>| embedder.embed(texts);
8448        let mut progress = |_done: usize, _total: usize| {};
8449        let summary = index
8450            .refresh_stale_files(
8451                project_root,
8452                std::slice::from_ref(&file),
8453                &mut embed,
8454                16,
8455                &mut progress,
8456            )
8457            .unwrap();
8458
8459        assert_eq!(summary.changed, 1);
8460        assert_eq!(embedder.total_embedded_texts(), 0);
8461        assert_eq!(index.entries.len(), original_entry_count);
8462        let shifted_alpha = entry_by_name(&index, &file, "alpha");
8463        assert_eq!(shifted_alpha.chunk.start_line, 1);
8464        assert_eq!(shifted_alpha.vector, original_alpha_vector);
8465    }
8466
8467    #[test]
8468    fn refresh_invalidated_line_shift_emits_full_replacement_delta_for_apply() {
8469        let temp = tempfile::tempdir().unwrap();
8470        let project_root = temp.path();
8471        let file = project_root.join("src/lib.rs");
8472        let original = "pub fn alpha() -> i32 {\n    1\n}\n\npub fn beta() -> i32 {\n    2\n}\n";
8473        write_source(&file, original);
8474
8475        let mut worker_index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
8476        let mut serving_index = worker_index.clone();
8477        let original_entry_count = worker_index.entries.len();
8478
8479        write_source(&file, &format!("\n{original}"));
8480
8481        let mut embedder = RecordingEmbedder::default();
8482        let mut embed = |texts: Vec<String>| embedder.embed(texts);
8483        let mut progress = |_done: usize, _total: usize| {};
8484        let update = worker_index
8485            .refresh_invalidated_files(
8486                project_root,
8487                std::slice::from_ref(&file),
8488                &mut embed,
8489                16,
8490                100,
8491                &mut progress,
8492            )
8493            .unwrap();
8494
8495        assert_eq!(embedder.total_embedded_texts(), 0);
8496        assert_eq!(update.added_entries.len(), original_entry_count);
8497        assert_eq!(worker_index.entries.len(), original_entry_count);
8498
8499        serving_index.apply_refresh_update(
8500            update.added_entries,
8501            update.updated_metadata,
8502            &update.completed_paths,
8503        );
8504
8505        assert_eq!(serving_index.entries.len(), original_entry_count);
8506        assert_eq!(
8507            entries_for_file(&serving_index, &file).len(),
8508            original_entry_count
8509        );
8510        assert_eq!(
8511            entry_by_name(&serving_index, &file, "alpha")
8512                .chunk
8513                .start_line,
8514            1
8515        );
8516    }
8517
8518    #[test]
8519    fn refresh_invalidated_one_symbol_edit_embeds_only_changed_symbol() {
8520        let temp = tempfile::tempdir().unwrap();
8521        let project_root = temp.path();
8522        let file = project_root.join("src/lib.rs");
8523        write_source(
8524            &file,
8525            "pub fn alpha() -> i32 {\n    1\n}\n\npub fn beta() -> i32 {\n    2\n}\n",
8526        );
8527
8528        let mut index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
8529        let original_entry_count = index.entries.len();
8530        let beta_vector = entry_by_name(&index, &file, "beta").vector.clone();
8531
8532        write_source(
8533            &file,
8534            "pub fn alpha() -> i32 {\n    10\n}\n\npub fn beta() -> i32 {\n    2\n}\n",
8535        );
8536
8537        let mut embedder = RecordingEmbedder::default();
8538        let mut embed = |texts: Vec<String>| embedder.embed(texts);
8539        let mut progress = |_done: usize, _total: usize| {};
8540        let update = index
8541            .refresh_invalidated_files(
8542                project_root,
8543                std::slice::from_ref(&file),
8544                &mut embed,
8545                16,
8546                100,
8547                &mut progress,
8548            )
8549            .unwrap();
8550
8551        assert_eq!(embedder.total_embedded_texts(), 1);
8552        assert!(embedder.embedded_texts()[0].contains("name:alpha"));
8553        assert_eq!(update.added_entries.len(), original_entry_count);
8554        assert_eq!(entry_by_name(&index, &file, "beta").vector, beta_vector);
8555    }
8556
8557    #[test]
8558    fn refresh_reuses_one_old_vector_for_two_byte_identical_symbols() {
8559        let temp = tempfile::tempdir().unwrap();
8560        let project_root = temp.path();
8561        let file = project_root.join("src/dupe.js");
8562        let one_duplicate = "function duplicate() {\n  return 1;\n}\n";
8563        write_source(&file, one_duplicate);
8564
8565        let mut index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
8566        let original_vector = entry_by_name(&index, &file, "duplicate").vector.clone();
8567
8568        write_source(&file, &format!("{one_duplicate}\n{one_duplicate}"));
8569
8570        let mut embedder = RecordingEmbedder::default();
8571        let mut embed = |texts: Vec<String>| embedder.embed(texts);
8572        let mut progress = |_done: usize, _total: usize| {};
8573        index
8574            .refresh_invalidated_files(
8575                project_root,
8576                std::slice::from_ref(&file),
8577                &mut embed,
8578                16,
8579                100,
8580                &mut progress,
8581            )
8582            .unwrap();
8583
8584        let duplicate_entries = index
8585            .entries
8586            .iter()
8587            .filter(|entry| entry.chunk.file == file && entry.chunk.name == "duplicate")
8588            .collect::<Vec<_>>();
8589        assert_eq!(duplicate_entries.len(), 2);
8590        assert_eq!(embedder.total_embedded_texts(), 0);
8591        assert_eq!(duplicate_entries[0].vector, original_vector);
8592        assert_eq!(duplicate_entries[1].vector, original_vector);
8593    }
8594
8595    #[test]
8596    fn file_summary_reuses_on_body_edit_and_misses_on_leading_doc_edit() {
8597        let temp = tempfile::tempdir().unwrap();
8598        let project_root = temp.path();
8599        let file = project_root.join("src/lib.rs");
8600        write_source(
8601            &file,
8602            "//! module docs v1\n\npub fn alpha() -> i32 {\n    1\n}\n",
8603        );
8604
8605        let mut index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
8606        let summary_before = file_summary_entry(&index, &file).vector.clone();
8607
8608        write_source(
8609            &file,
8610            "//! module docs v1\n\npub fn alpha() -> i32 {\n    2\n}\n",
8611        );
8612        let mut body_embedder = RecordingEmbedder::default();
8613        let mut body_embed = |texts: Vec<String>| body_embedder.embed(texts);
8614        let mut progress = |_done: usize, _total: usize| {};
8615        index
8616            .refresh_invalidated_files(
8617                project_root,
8618                std::slice::from_ref(&file),
8619                &mut body_embed,
8620                16,
8621                100,
8622                &mut progress,
8623            )
8624            .unwrap();
8625        assert_eq!(body_embedder.total_embedded_texts(), 1);
8626        assert!(body_embedder.embedded_texts()[0].contains("name:alpha"));
8627        assert_eq!(file_summary_entry(&index, &file).vector, summary_before);
8628
8629        write_source(
8630            &file,
8631            "//! module docs v2\n\npub fn alpha() -> i32 {\n    2\n}\n",
8632        );
8633        let mut doc_embedder = RecordingEmbedder::default();
8634        let mut doc_embed = |texts: Vec<String>| doc_embedder.embed(texts);
8635        index
8636            .refresh_invalidated_files(
8637                project_root,
8638                std::slice::from_ref(&file),
8639                &mut doc_embed,
8640                16,
8641                100,
8642                &mut progress,
8643            )
8644            .unwrap();
8645
8646        assert_eq!(doc_embedder.total_embedded_texts(), 1);
8647        assert!(doc_embedder.embedded_texts()[0].contains("kind:file-summary"));
8648        assert_ne!(file_summary_entry(&index, &file).vector, summary_before);
8649    }
8650
8651    #[test]
8652    fn refresh_invalidated_deleted_file_drops_entries_without_embedding() {
8653        let temp = tempfile::tempdir().unwrap();
8654        let project_root = temp.path();
8655        let file = project_root.join("src/lib.rs");
8656        write_source(&file, "pub fn alpha() -> i32 {\n    1\n}\n");
8657
8658        let mut worker_index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
8659        let mut serving_index = worker_index.clone();
8660        fs::remove_file(&file).unwrap();
8661
8662        let mut embedder = RecordingEmbedder::default();
8663        let mut embed = |texts: Vec<String>| embedder.embed(texts);
8664        let mut progress = |_done: usize, _total: usize| {};
8665        let update = worker_index
8666            .refresh_invalidated_files(
8667                project_root,
8668                std::slice::from_ref(&file),
8669                &mut embed,
8670                16,
8671                100,
8672                &mut progress,
8673            )
8674            .unwrap();
8675
8676        assert_eq!(update.summary.deleted, 1);
8677        assert_eq!(embedder.total_embedded_texts(), 0);
8678        assert!(worker_index.entries.is_empty());
8679
8680        serving_index.apply_refresh_update(
8681            update.added_entries,
8682            update.updated_metadata,
8683            &update.completed_paths,
8684        );
8685        assert!(serving_index.entries.is_empty());
8686    }
8687
8688    #[test]
8689    fn watcher_collect_failure_does_not_resurrect_stale_entries() {
8690        let temp = tempfile::tempdir().unwrap();
8691        let project_root = temp.path();
8692        let file = project_root.join("src/lib.rs");
8693        write_source(&file, "pub fn alpha() -> i32 {\n    1\n}\n");
8694
8695        let mut worker_index = build_recorded_test_index(project_root, std::slice::from_ref(&file));
8696        let mut serving_index = worker_index.clone();
8697        fs::write(&file, [0xff, 0xfe, 0xfd]).unwrap();
8698
8699        let mut embedder = RecordingEmbedder::default();
8700        let mut embed = |texts: Vec<String>| embedder.embed(texts);
8701        let mut progress = |_done: usize, _total: usize| {};
8702        let update = worker_index
8703            .refresh_invalidated_files(
8704                project_root,
8705                std::slice::from_ref(&file),
8706                &mut embed,
8707                16,
8708                100,
8709                &mut progress,
8710            )
8711            .unwrap();
8712
8713        assert_eq!(embedder.total_embedded_texts(), 0);
8714        assert!(update.added_entries.is_empty());
8715        assert!(worker_index.entries.is_empty());
8716        assert!(!worker_index.file_mtimes.contains_key(&file));
8717
8718        serving_index.apply_refresh_update(
8719            update.added_entries,
8720            update.updated_metadata,
8721            &update.completed_paths,
8722        );
8723        assert!(serving_index.entries.is_empty());
8724        assert!(!serving_index.file_mtimes.contains_key(&file));
8725    }
8726
8727    #[test]
8728    fn refresh_invalidated_cap_deferral_remains_file_count_based() {
8729        let temp = tempfile::tempdir().unwrap();
8730        let project_root = temp.path();
8731        let indexed = project_root.join("src/a.rs");
8732        let deferred = project_root.join("src/b.rs");
8733        write_source(&indexed, "pub fn alpha() -> i32 {\n    1\n}\n");
8734        write_source(&deferred, "pub fn beta() -> i32 {\n    2\n}\n");
8735
8736        let mut index = build_recorded_test_index(project_root, std::slice::from_ref(&indexed));
8737        let mut embedder = RecordingEmbedder::default();
8738        let mut embed = |texts: Vec<String>| embedder.embed(texts);
8739        let mut progress = |_done: usize, _total: usize| {};
8740        let update = index
8741            .refresh_invalidated_files(
8742                project_root,
8743                std::slice::from_ref(&deferred),
8744                &mut embed,
8745                16,
8746                1,
8747                &mut progress,
8748            )
8749            .unwrap();
8750
8751        assert_eq!(update.summary.total_processed, 1);
8752        assert_eq!(update.summary.added, 0);
8753        assert_eq!(embedder.total_embedded_texts(), 0);
8754        assert_eq!(index.indexed_file_count(), 1);
8755        assert!(index.deferred_files.contains(&deferred));
8756        assert!(entries_for_file(&index, &deferred).is_empty());
8757    }
8758
8759    #[test]
8760    fn semantic_cache_serialization_skips_paths_outside_project_root() {
8761        let dir = tempfile::tempdir().expect("create temp dir");
8762        let project = fs::canonicalize(dir.path()).expect("canonical project");
8763        let outside = project.join("..").join("outside.rs");
8764        let mut index = SemanticIndex::new(project.clone(), 3);
8765        index
8766            .file_mtimes
8767            .insert(outside.clone(), SystemTime::UNIX_EPOCH);
8768        index.file_sizes.insert(outside.clone(), 1);
8769        index
8770            .file_hashes
8771            .insert(outside.clone(), cache_freshness::zero_hash());
8772        index.entries.push(EmbeddingEntry {
8773            chunk: SemanticChunk {
8774                file: outside,
8775                name: "outside".to_string(),
8776                qualified_name: None,
8777                kind: SymbolKind::Function,
8778                start_line: 0,
8779                end_line: 0,
8780                exported: false,
8781                embed_text: "outside".to_string(),
8782                snippet: "outside".to_string(),
8783            },
8784            norm: vector_norm(&[1.0, 0.0, 0.0]),
8785            vector: vec![1.0, 0.0, 0.0],
8786        });
8787
8788        let bytes = index.to_bytes();
8789        let loaded = SemanticIndex::from_bytes(&bytes, &project).expect("load serialized index");
8790        assert_eq!(loaded.entries.len(), 0);
8791        assert!(loaded.file_mtimes.is_empty());
8792    }
8793
8794    #[test]
8795    fn semantic_search_bounded_top_k_matches_reference_full_sort() {
8796        let project_root = test_project_root();
8797        let file = project_root.join("src/lib.rs");
8798        let mut index = SemanticIndex::new(project_root, 2);
8799        let entries = [
8800            ("alpha", vec![2.0, 0.0], false),
8801            ("beta", vec![0.0, 3.0], false),
8802            ("gamma", vec![4.0, 0.0], false),
8803            ("delta", vec![1.0, 1.0], true),
8804            ("epsilon", vec![-5.0, 0.0], false),
8805        ];
8806        for (line, (name, vector, exported)) in entries.into_iter().enumerate() {
8807            index.entries.push(EmbeddingEntry {
8808                chunk: SemanticChunk {
8809                    file: file.clone(),
8810                    name: name.to_string(),
8811                    qualified_name: None,
8812                    kind: SymbolKind::Function,
8813                    start_line: line as u32 + 1,
8814                    end_line: line as u32 + 1,
8815                    exported,
8816                    embed_text: name.to_string(),
8817                    snippet: format!("fn {name}() {{}}"),
8818                },
8819                norm: vector_norm(&vector),
8820                vector,
8821            });
8822        }
8823
8824        let query = vec![2.0, 0.0];
8825        let top_k = 4;
8826        let mut reference: Vec<(f32, usize)> = index
8827            .entries
8828            .iter()
8829            .enumerate()
8830            .map(|(idx, entry)| {
8831                // Recompute both norms for every entry as the reference
8832                // implementation, so cached norms cannot change ranking or scores.
8833                let mut dot = 0.0f32;
8834                let mut query_squared_norm = 0.0f32;
8835                let mut entry_squared_norm = 0.0f32;
8836                for i in 0..query.len() {
8837                    dot += query[i] * entry.vector[i];
8838                    query_squared_norm += query[i] * query[i];
8839                    entry_squared_norm += entry.vector[i] * entry.vector[i];
8840                }
8841                let denom = query_squared_norm.sqrt() * entry_squared_norm.sqrt();
8842                let mut score = if denom == 0.0 { 0.0 } else { dot / denom };
8843                if entry.chunk.exported {
8844                    score *= 1.1;
8845                }
8846                (score, idx)
8847            })
8848            .collect();
8849        reference.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
8850        let expected: Vec<(String, f32)> = reference
8851            .into_iter()
8852            .take(top_k)
8853            .map(|(score, idx)| (index.entries[idx].chunk.name.clone(), score))
8854            .collect();
8855
8856        let actual: Vec<(String, f32)> = index
8857            .search(&query, top_k)
8858            .into_iter()
8859            .map(|result| (result.name, result.score))
8860            .collect();
8861
8862        assert_eq!(
8863            actual.iter().map(|(name, _)| name).collect::<Vec<_>>(),
8864            expected.iter().map(|(name, _)| name).collect::<Vec<_>>()
8865        );
8866        for ((_, actual_score), (_, expected_score)) in actual.iter().zip(expected.iter()) {
8867            assert!((actual_score - expected_score).abs() < 1e-6);
8868        }
8869        assert_eq!(actual[0].0, "alpha");
8870        assert_eq!(actual[1].0, "gamma", "equal scores keep insertion order");
8871        assert!(index.search(&query, 0).is_empty());
8872    }
8873
8874    #[test]
8875    fn test_cosine_similarity_identical() {
8876        let a = vec![1.0, 0.0, 0.0];
8877        let b = vec![1.0, 0.0, 0.0];
8878        assert!((cosine_similarity(&a, &b) - 1.0).abs() < 0.001);
8879    }
8880
8881    #[test]
8882    fn test_cosine_similarity_orthogonal() {
8883        let a = vec![1.0, 0.0, 0.0];
8884        let b = vec![0.0, 1.0, 0.0];
8885        assert!(cosine_similarity(&a, &b).abs() < 0.001);
8886    }
8887
8888    #[test]
8889    fn test_cosine_similarity_opposite() {
8890        let a = vec![1.0, 0.0, 0.0];
8891        let b = vec![-1.0, 0.0, 0.0];
8892        assert!((cosine_similarity(&a, &b) + 1.0).abs() < 0.001);
8893    }
8894
8895    #[test]
8896    fn test_serialization_roundtrip() {
8897        let project_root = test_project_root();
8898        let file = project_root.join("src/main.rs");
8899        let mut index = SemanticIndex::new(project_root.clone(), DEFAULT_DIMENSION);
8900        index.entries.push(EmbeddingEntry {
8901            chunk: SemanticChunk {
8902                file: file.clone(),
8903                name: "handle_request".to_string(),
8904                qualified_name: None,
8905                kind: SymbolKind::Function,
8906                start_line: 10,
8907                end_line: 25,
8908                exported: true,
8909                embed_text: "file:src/main.rs kind:function name:handle_request".to_string(),
8910                snippet: "fn handle_request() {\n  // ...\n}".to_string(),
8911            },
8912            norm: vector_norm(&[0.1, 0.2, 0.3, 0.4]),
8913            vector: vec![0.1, 0.2, 0.3, 0.4],
8914        });
8915        index.dimension = 4;
8916        index
8917            .file_mtimes
8918            .insert(file.clone(), SystemTime::UNIX_EPOCH);
8919        index.file_sizes.insert(file, 0);
8920        index.set_fingerprint(SemanticIndexFingerprint {
8921            backend: "fastembed".to_string(),
8922            model: "all-MiniLM-L6-v2".to_string(),
8923            base_url: FALLBACK_BACKEND.to_string(),
8924            dimension: 4,
8925            chunking_version: default_chunking_version(),
8926            ..Default::default()
8927        });
8928
8929        let bytes = index.to_bytes();
8930        let restored = SemanticIndex::from_bytes(&bytes, &project_root).unwrap();
8931
8932        assert_eq!(restored.entries.len(), 1);
8933        assert_eq!(restored.entries[0].chunk.name, "handle_request");
8934        assert_eq!(restored.entries[0].vector, vec![0.1, 0.2, 0.3, 0.4]);
8935        assert_eq!(
8936            restored.entries[0].norm,
8937            vector_norm(&restored.entries[0].vector)
8938        );
8939        assert_eq!(restored.dimension, 4);
8940        assert_eq!(restored.backend_label(), Some("fastembed"));
8941        assert_eq!(restored.model_label(), Some("all-MiniLM-L6-v2"));
8942    }
8943
8944    #[test]
8945    fn semantic_cache_v6_loads_and_v7_round_trips_qualified_names() {
8946        let storage = tempfile::tempdir().expect("create storage dir");
8947        let project = storage.path().join("project");
8948        fs::create_dir_all(project.join("src")).expect("create project src");
8949        let file = project.join("src/lib.rs");
8950        fs::write(&file, "pub fn alpha() {}\npub fn beta() {}\n").expect("write source");
8951        let project_root = fs::canonicalize(&project).expect("canonical project");
8952        let file = fs::canonicalize(&file).expect("canonical file");
8953
8954        let mut index = SemanticIndex::new(project_root.clone(), 3);
8955        let mtime = SystemTime::UNIX_EPOCH + Duration::new(123, 456);
8956        index.file_mtimes.insert(file.clone(), mtime);
8957        index.file_sizes.insert(file.clone(), 42);
8958        index
8959            .file_hashes
8960            .insert(file.clone(), cache_freshness::zero_hash());
8961        index.entries.push(EmbeddingEntry {
8962            chunk: SemanticChunk {
8963                file: file.clone(),
8964                name: "alpha".to_string(),
8965                qualified_name: Some("Service.alpha".to_string()),
8966                kind: SymbolKind::Function,
8967                start_line: 0,
8968                end_line: 0,
8969                exported: true,
8970                embed_text: "file:src/lib.rs kind:function name:alpha".to_string(),
8971                snippet: "pub fn alpha() {}".to_string(),
8972            },
8973            norm: vector_norm(&[0.1, 0.2, 0.3]),
8974            vector: vec![0.1, 0.2, 0.3],
8975        });
8976        index.entries.push(EmbeddingEntry {
8977            chunk: SemanticChunk {
8978                file: file.clone(),
8979                name: "beta".to_string(),
8980                qualified_name: Some("Service.beta".to_string()),
8981                kind: SymbolKind::Function,
8982                start_line: 1,
8983                end_line: 1,
8984                exported: true,
8985                embed_text: "file:src/lib.rs kind:function name:beta".to_string(),
8986                snippet: "pub fn beta() {}".to_string(),
8987            },
8988            norm: vector_norm(&[0.4, 0.5, 0.6]),
8989            vector: vec![0.4, 0.5, 0.6],
8990        });
8991        let fingerprint = SemanticIndexFingerprint {
8992            backend: "fastembed".to_string(),
8993            model: "all-MiniLM-L6-v2".to_string(),
8994            base_url: FALLBACK_BACKEND.to_string(),
8995            dimension: 3,
8996            chunking_version: default_chunking_version(),
8997            ..Default::default()
8998        };
8999        let fingerprint_before = fingerprint.as_string();
9000        index.set_fingerprint(fingerprint.clone());
9001
9002        let legacy_bytes = legacy_semantic_index_bytes(&index);
9003        assert_eq!(legacy_bytes[0], SEMANTIC_INDEX_VERSION_V6);
9004        let legacy_dir = storage.path().join("semantic/legacy-proj");
9005        fs::create_dir_all(&legacy_dir).expect("create legacy semantic dir");
9006        let legacy_path = legacy_dir.join("semantic.bin");
9007        fs::write(&legacy_path, &legacy_bytes).expect("write legacy semantic.bin");
9008        let legacy_loaded = SemanticIndex::read_from_disk(
9009            storage.path(),
9010            "legacy-proj",
9011            &project_root,
9012            false,
9013            Some(&fingerprint_before),
9014        )
9015        .expect("load v6 semantic index");
9016        assert!(
9017            legacy_path.exists(),
9018            "compatible V6 cache must not be deleted"
9019        );
9020        assert!(legacy_loaded
9021            .entries
9022            .iter()
9023            .all(|entry| entry.chunk.qualified_name.is_none()));
9024        assert_eq!(
9025            legacy_loaded.fingerprint().unwrap().as_string(),
9026            fingerprint_before
9027        );
9028
9029        let v7_bytes = index.to_bytes();
9030        assert_eq!(v7_bytes[0], SEMANTIC_INDEX_VERSION_V7);
9031        assert_ne!(v7_bytes, legacy_bytes);
9032        let restored = SemanticIndex::from_bytes(&v7_bytes, &project_root).unwrap();
9033        assert_eq!(
9034            restored.entries[0].chunk.qualified_name.as_deref(),
9035            Some("Service.alpha")
9036        );
9037        assert_eq!(
9038            restored.entries[1].chunk.qualified_name.as_deref(),
9039            Some("Service.beta")
9040        );
9041        assert_eq!(
9042            restored.fingerprint().unwrap().as_string(),
9043            fingerprint_before
9044        );
9045
9046        index.write_to_disk(storage.path(), "proj");
9047        let data_path = storage.path().join("semantic/proj/semantic.bin");
9048        let persisted = fs::read(&data_path).expect("read semantic.bin");
9049        assert_eq!(persisted[0], SEMANTIC_INDEX_VERSION_V7);
9050
9051        let loaded = SemanticIndex::read_from_disk(
9052            storage.path(),
9053            "proj",
9054            &project_root,
9055            false,
9056            Some(&fingerprint_before),
9057        )
9058        .expect("load semantic index");
9059        assert_eq!(loaded.entries.len(), index.entries.len());
9060        assert_eq!(loaded.dimension, index.dimension);
9061        assert_eq!(
9062            loaded.fingerprint().unwrap().as_string(),
9063            fingerprint_before
9064        );
9065        assert_eq!(loaded.file_mtimes.get(&file), Some(&mtime));
9066        assert_eq!(loaded.file_sizes.get(&file), Some(&42));
9067        assert_eq!(
9068            loaded.file_hashes.get(&file),
9069            Some(&cache_freshness::zero_hash())
9070        );
9071        for (actual, expected) in loaded.entries.iter().zip(index.entries.iter()) {
9072            assert_eq!(actual.chunk.file, expected.chunk.file);
9073            assert_eq!(actual.chunk.name, expected.chunk.name);
9074            assert_eq!(actual.chunk.qualified_name, expected.chunk.qualified_name);
9075            assert_eq!(actual.chunk.kind, expected.chunk.kind);
9076            assert_eq!(actual.chunk.start_line, expected.chunk.start_line);
9077            assert_eq!(actual.chunk.end_line, expected.chunk.end_line);
9078            assert_eq!(actual.chunk.exported, expected.chunk.exported);
9079            assert_eq!(actual.chunk.embed_text, expected.chunk.embed_text);
9080            assert_eq!(actual.chunk.snippet, expected.chunk.snippet);
9081            assert_eq!(actual.vector, expected.vector);
9082        }
9083        assert_eq!(loaded.to_bytes(), persisted);
9084        assert_eq!(fingerprint.as_string(), fingerprint_before);
9085    }
9086
9087    #[test]
9088    fn symbol_kind_serialization_roundtrip_includes_file_summary_variant() {
9089        let cases = [
9090            (SymbolKind::Function, 0),
9091            (SymbolKind::Class, 1),
9092            (SymbolKind::Method, 2),
9093            (SymbolKind::Struct, 3),
9094            (SymbolKind::Interface, 4),
9095            (SymbolKind::Enum, 5),
9096            (SymbolKind::TypeAlias, 6),
9097            (SymbolKind::Variable, 7),
9098            (SymbolKind::Heading, 8),
9099            (SymbolKind::FileSummary, 9),
9100        ];
9101
9102        for (kind, encoded) in cases {
9103            assert_eq!(symbol_kind_to_u8(&kind), encoded);
9104            assert_eq!(u8_to_symbol_kind(encoded), kind);
9105        }
9106    }
9107
9108    #[test]
9109    fn test_search_top_k() {
9110        let mut index = SemanticIndex::new(test_project_root(), DEFAULT_DIMENSION);
9111        index.dimension = 3;
9112
9113        // Add entries with known vectors
9114        for (i, name) in ["auth", "database", "handler"].iter().enumerate() {
9115            let mut vec = vec![0.0f32; 3];
9116            vec[i] = 1.0; // orthogonal vectors
9117            index.entries.push(EmbeddingEntry {
9118                chunk: SemanticChunk {
9119                    file: PathBuf::from("/src/lib.rs"),
9120                    name: name.to_string(),
9121                    qualified_name: None,
9122                    kind: SymbolKind::Function,
9123                    start_line: (i * 10 + 1) as u32,
9124                    end_line: (i * 10 + 5) as u32,
9125                    exported: true,
9126                    embed_text: format!("kind:function name:{}", name),
9127                    snippet: format!("fn {}() {{}}", name),
9128                },
9129                norm: vector_norm(&vec),
9130                vector: vec,
9131            });
9132        }
9133
9134        // Query aligned with "auth" (index 0)
9135        let query = vec![0.9, 0.1, 0.0];
9136        let results = index.search(&query, 2);
9137
9138        assert_eq!(results.len(), 2);
9139        assert_eq!(results[0].name, "auth"); // highest score
9140        assert!(results[0].score > results[1].score);
9141    }
9142
9143    #[test]
9144    fn test_empty_index_search() {
9145        let index = SemanticIndex::new(test_project_root(), DEFAULT_DIMENSION);
9146        let results = index.search(&[0.1, 0.2, 0.3], 10);
9147        assert!(results.is_empty());
9148    }
9149
9150    #[test]
9151    fn single_line_symbol_builds_non_empty_snippet() {
9152        let symbol = Symbol {
9153            name: "answer".to_string(),
9154            kind: SymbolKind::Variable,
9155            range: crate::symbols::Range {
9156                start_line: 0,
9157                start_col: 0,
9158                end_line: 0,
9159                end_col: 24,
9160            },
9161            signature: Some("const answer = 42".to_string()),
9162            scope_chain: Vec::new(),
9163            exported: true,
9164            parent: None,
9165        };
9166        let source = "export const answer = 42;\n";
9167
9168        let snippet = build_snippet(&symbol, source);
9169
9170        assert_eq!(snippet, "export const answer = 42;");
9171    }
9172
9173    #[test]
9174    fn metal_chunk_collection_uses_shader_function_boundaries() {
9175        let project_root = Path::new("/project");
9176        let file = project_root.join("sample.metal");
9177        let source = include_str!("../tests/fixtures/sample.metal");
9178        let chunks = collect_file_chunks_from_source(
9179            project_root,
9180            &file,
9181            crate::parser::LangId::Metal,
9182            source,
9183        )
9184        .expect("collect Metal chunks");
9185
9186        let helper = chunks
9187            .iter()
9188            .find(|chunk| chunk.name == "brighten")
9189            .expect("helper chunk");
9190        assert_eq!((helper.start_line, helper.end_line), (3, 5));
9191        assert!(!helper.snippet.contains("brighten_buffer"));
9192
9193        let shader = chunks
9194            .iter()
9195            .find(|chunk| chunk.name == "brighten_buffer")
9196            .expect("shader chunk");
9197        assert_eq!(shader.kind, SymbolKind::Function);
9198        assert_eq!((shader.start_line, shader.end_line), (7, 9));
9199        assert!(shader.snippet.starts_with("kernel void brighten_buffer"));
9200        assert!(shader.snippet.contains("brighten(values[id])"));
9201    }
9202
9203    #[test]
9204    fn cuda_chunk_collection_uses_function_boundaries() {
9205        let project_root = Path::new("/project");
9206        let file = project_root.join("sample.cu");
9207        let source = include_str!("../tests/fixtures/sample.cu");
9208        let chunks = collect_file_chunks_from_source(
9209            project_root,
9210            &file,
9211            crate::parser::LangId::Cuda,
9212            source,
9213        )
9214        .expect("collect CUDA chunks");
9215
9216        let kernel = chunks
9217            .iter()
9218            .find(|chunk| chunk.name == "transform")
9219            .expect("kernel chunk");
9220        assert_eq!(kernel.kind, SymbolKind::Kernel);
9221        assert_eq!((kernel.start_line, kernel.end_line), (4, 7));
9222        assert!(kernel.snippet.contains("scale(data[index])"));
9223        assert!(!kernel.snippet.contains("launch_transform"));
9224
9225        let host = chunks
9226            .iter()
9227            .find(|chunk| chunk.name == "launch_transform")
9228            .expect("host function chunk");
9229        assert_eq!((host.start_line, host.end_line), (9, 11));
9230        assert!(host.snippet.contains("transform<<<grid, block>>>(data)"));
9231    }
9232
9233    #[test]
9234    fn toml_chunk_collection_uses_table_and_key_boundaries() {
9235        let project_root = Path::new("/project");
9236        let file = project_root.join("Cargo.toml");
9237        let source = "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n\n[dependencies.foo]\nversion = \"1\"\n";
9238        let chunks = collect_file_chunks_from_source(
9239            project_root,
9240            &file,
9241            crate::parser::LangId::Toml,
9242            source,
9243        )
9244        .expect("collect TOML chunks");
9245
9246        let package = chunks
9247            .iter()
9248            .find(|chunk| chunk.name == "package")
9249            .expect("package table chunk");
9250        assert_eq!((package.start_line, package.end_line), (0, 2));
9251        assert!(package.snippet.contains("name = \"demo\""));
9252        assert!(!package.snippet.contains("dependencies.foo"));
9253
9254        let name = chunks
9255            .iter()
9256            .find(|chunk| chunk.qualified_name.as_deref() == Some("package.name"))
9257            .expect("nested package.name key chunk");
9258        assert_eq!((name.start_line, name.end_line), (1, 1));
9259        assert_eq!(name.snippet, "name = \"demo\"");
9260
9261        let dependency = chunks
9262            .iter()
9263            .find(|chunk| chunk.name == "dependencies.foo")
9264            .expect("dependency table chunk");
9265        assert_eq!((dependency.start_line, dependency.end_line), (4, 5));
9266    }
9267
9268    #[test]
9269    fn optimized_file_chunk_collection_matches_file_parser_path() {
9270        let project_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
9271        let file = project_root.join("src/semantic_index.rs");
9272        let source = std::fs::read_to_string(&file).unwrap();
9273
9274        let mut legacy_parser = FileParser::new();
9275        let legacy_symbols = legacy_parser.extract_symbols(&file).unwrap();
9276        let legacy_chunks = symbols_to_chunks(&file, &legacy_symbols, &source, &project_root);
9277
9278        let optimized_chunks = collect_file_chunks(&project_root, &file).unwrap();
9279
9280        assert_eq!(
9281            chunk_fingerprint(&optimized_chunks),
9282            chunk_fingerprint(&legacy_chunks)
9283        );
9284    }
9285
9286    #[test]
9287    fn collect_file_chunks_indexes_java_symbols() {
9288        let dir = tempfile::tempdir().unwrap();
9289        let file = dir.path().join("Greeter.java");
9290        std::fs::write(
9291            &file,
9292            r#"package example;
9293
9294public class Greeter {
9295    public String greet(String name) {
9296        return "Hello, " + name;
9297    }
9298}
9299"#,
9300        )
9301        .unwrap();
9302
9303        let chunks = collect_file_chunks(dir.path(), &file).unwrap();
9304
9305        assert!(
9306            !chunks.is_empty(),
9307            "Java file should produce semantic chunks"
9308        );
9309        assert!(
9310            chunks
9311                .iter()
9312                .any(|chunk| chunk.name == "Greeter" && chunk.kind == SymbolKind::Class),
9313            "Java class symbol should be chunked: {chunks:?}"
9314        );
9315        assert!(
9316            chunks
9317                .iter()
9318                .any(|chunk| chunk.name == "greet" && chunk.kind == SymbolKind::Method),
9319            "Java method symbol should be chunked: {chunks:?}"
9320        );
9321    }
9322
9323    fn chunk_fingerprint(
9324        chunks: &[SemanticChunk],
9325    ) -> Vec<(String, SymbolKind, u32, u32, bool, String, String)> {
9326        chunks
9327            .iter()
9328            .map(|chunk| {
9329                (
9330                    chunk.name.clone(),
9331                    chunk.kind.clone(),
9332                    chunk.start_line,
9333                    chunk.end_line,
9334                    chunk.exported,
9335                    chunk.embed_text.clone(),
9336                    chunk.snippet.clone(),
9337                )
9338            })
9339            .collect()
9340    }
9341
9342    #[test]
9343    fn collect_file_chunks_skips_oversized_file() {
9344        let dir = tempfile::tempdir().unwrap();
9345        let big = dir.path().join("huge.ts");
9346        // Just over the cap: a valid TS file that would otherwise yield chunks.
9347        let filler = "export const x = 1;\n"
9348            .repeat(((MAX_SEMANTIC_FILE_BYTES as usize) / "export const x = 1;\n".len()) + 16);
9349        std::fs::write(&big, &filler).unwrap();
9350        assert!(big.metadata().unwrap().len() > MAX_SEMANTIC_FILE_BYTES);
9351
9352        // Oversized → tracked with zero chunks, NOT an error (so the caller keeps
9353        // the file in metadata and freshness skips re-reading it).
9354        let chunks = collect_file_chunks(dir.path(), &big).unwrap();
9355        assert!(chunks.is_empty(), "oversized file must yield no chunks");
9356
9357        // A small file of the same language still produces chunks.
9358        let small = dir.path().join("small.ts");
9359        std::fs::write(&small, "export function foo() { return 1; }\n").unwrap();
9360        let small_chunks = collect_file_chunks(dir.path(), &small).unwrap();
9361        assert!(!small_chunks.is_empty(), "small file should still chunk");
9362    }
9363
9364    #[test]
9365    fn rejects_oversized_dimension_during_deserialization() {
9366        let mut bytes = Vec::new();
9367        bytes.push(1u8);
9368        bytes.extend_from_slice(&((MAX_DIMENSION as u32) + 1).to_le_bytes());
9369        bytes.extend_from_slice(&0u32.to_le_bytes());
9370        bytes.extend_from_slice(&0u32.to_le_bytes());
9371
9372        assert!(SemanticIndex::from_bytes(&bytes, &test_project_root()).is_err());
9373    }
9374
9375    #[test]
9376    fn rejects_oversized_entry_count_during_deserialization() {
9377        let mut bytes = Vec::new();
9378        bytes.push(1u8);
9379        bytes.extend_from_slice(&(DEFAULT_DIMENSION as u32).to_le_bytes());
9380        bytes.extend_from_slice(&((MAX_ENTRIES as u32) + 1).to_le_bytes());
9381        bytes.extend_from_slice(&0u32.to_le_bytes());
9382
9383        assert!(SemanticIndex::from_bytes(&bytes, &test_project_root()).is_err());
9384    }
9385
9386    fn add_invalidation_fixture_entry(index: &mut SemanticIndex, file: PathBuf, ordinal: u64) {
9387        index.entries.push(EmbeddingEntry::new(
9388            SemanticChunk {
9389                file: file.clone(),
9390                name: format!("symbol_{ordinal}"),
9391                qualified_name: None,
9392                kind: SymbolKind::Function,
9393                start_line: ordinal as u32,
9394                end_line: ordinal as u32 + 1,
9395                exported: false,
9396                embed_text: format!("symbol {ordinal}"),
9397                snippet: format!("fn symbol_{ordinal}() {{}}"),
9398            },
9399            vec![ordinal as f32 + 1.0, 1.0],
9400        ));
9401        let mtime = SystemTime::UNIX_EPOCH + Duration::from_secs(ordinal + 1);
9402        index.file_mtimes.insert(file.clone(), mtime);
9403        index.file_sizes.insert(file.clone(), ordinal + 10);
9404        index
9405            .file_hashes
9406            .insert(file, blake3::hash(&ordinal.to_le_bytes()));
9407    }
9408
9409    #[test]
9410    fn freezing_declines_instead_of_panicking_when_a_dirty_path_is_outside_the_root() {
9411        // A delta path outside the root once turned the freeze into a panic:
9412        // the shareability check covered entries, metadata maps and deferred
9413        // files but not the dirty-path set, and the move then hit an expect.
9414        // Under the daemon that panic is a fatal actor exit (exit 4 three
9415        // times on 2026-09-14).
9416        let temp = tempfile::tempdir().unwrap();
9417        let project_root = temp
9418            .path()
9419            .join("owner")
9420            .canonicalize()
9421            .unwrap_or_else(|_| {
9422                fs::create_dir_all(temp.path().join("owner")).unwrap();
9423                temp.path().join("owner").canonicalize().unwrap()
9424            });
9425        let borrower = temp.path().join("borrower");
9426        fs::create_dir_all(&borrower).unwrap();
9427        let mut index = SemanticIndex::new(project_root.clone(), 2);
9428        let file = project_root.join("file_0.rs");
9429        fs::write(&file, "fn symbol_0() {}\n").unwrap();
9430        add_invalidation_fixture_entry(&mut index, file, 0);
9431        let config = SemanticBackendConfig::default();
9432        index.set_fingerprint(SemanticIndexFingerprint::for_config_dimension(&config, 2));
9433        // A fresh index carries no delta set (None means "structural diff");
9434        // seed one the way a refresh does so the stray path is really carried.
9435        index.set_dirty_paths(Some(BTreeSet::from([temp
9436            .path()
9437            .join("elsewhere")
9438            .join("stray.rs")])));
9439        let entries_before = index.entries.len();
9440
9441        let adopted = index.adopt_frozen_base_for_root(&borrower, &config);
9442
9443        assert!(adopted.is_none(), "an unshareable index must stay private");
9444        assert!(index.shared_base.is_none());
9445        assert_eq!(
9446            index.entries.len(),
9447            entries_before,
9448            "the private index survives intact"
9449        );
9450
9451        // The same index with an in-root dirty path freezes normally.
9452        let mut shareable = SemanticIndex::new(project_root.clone(), 2);
9453        let file = project_root.join("file_1.rs");
9454        fs::write(&file, "fn symbol_1() {}\n").unwrap();
9455        add_invalidation_fixture_entry(&mut shareable, file.clone(), 1);
9456        shareable.set_fingerprint(SemanticIndexFingerprint::for_config_dimension(&config, 2));
9457        shareable.set_dirty_paths(Some(BTreeSet::from([file])));
9458        assert!(shareable
9459            .adopt_frozen_base_for_root(&borrower, &config)
9460            .is_some());
9461        assert!(shareable.shared_base.is_some());
9462    }
9463
9464    #[test]
9465    fn batch_invalidation_matches_sequential_calls_with_one_retain_pass() {
9466        let temp = tempfile::tempdir().unwrap();
9467        let project_root = temp.path().canonicalize().unwrap();
9468        let mut source = SemanticIndex::new(project_root.clone(), 2);
9469        let files = (0..8)
9470            .map(|ordinal| {
9471                let file = project_root.join(format!("file_{ordinal}.rs"));
9472                fs::write(&file, format!("fn symbol_{ordinal}() {{}}\n")).unwrap();
9473                add_invalidation_fixture_entry(&mut source, file.clone(), ordinal);
9474                file
9475            })
9476            .collect::<Vec<_>>();
9477        let invalidated = vec![files[1].clone(), files[3].clone(), files[6].clone()];
9478
9479        let shared = Arc::new(source.into_shared_base().ok().unwrap());
9480        let mut shared_batched =
9481            SemanticIndex::from_shared_base(project_root.clone(), Arc::clone(&shared));
9482        shared_batched.invalidate_files(&invalidated);
9483        let mut source = SemanticIndex::from_shared_base(project_root, shared);
9484        source.materialize_shared_base();
9485        let mut sequential = source.clone();
9486        let mut batched = source;
9487        for file in &invalidated {
9488            sequential.invalidate_file(file);
9489        }
9490        batched.invalidate_files(&invalidated);
9491
9492        assert!(sequential.shared_base.is_none());
9493        assert!(batched.shared_base.is_none());
9494        assert!(shared_batched.shared_base.is_none());
9495        assert_eq!(batched.to_bytes(), sequential.to_bytes());
9496        assert_eq!(shared_batched.file_mtimes, batched.file_mtimes);
9497        assert_eq!(shared_batched.file_sizes, batched.file_sizes);
9498        assert_eq!(shared_batched.file_hashes, batched.file_hashes);
9499        assert_eq!(
9500            format!("{:?}", shared_batched.entries),
9501            format!("{:?}", batched.entries)
9502        );
9503        assert_eq!(
9504            sequential.removal_retain_passes_for_test(),
9505            invalidated.len()
9506        );
9507        assert_eq!(batched.removal_retain_passes_for_test(), 1);
9508        assert_eq!(shared_batched.removal_retain_passes_for_test(), 1);
9509    }
9510
9511    #[cfg(unix)]
9512    #[test]
9513    fn batch_invalidation_removes_raw_and_canonical_alias_metadata() {
9514        use std::os::unix::fs::symlink;
9515
9516        let temp = tempfile::tempdir().unwrap();
9517        let project_root = temp.path().canonicalize().unwrap();
9518        let real_dir = project_root.join("real");
9519        let alias_dir = project_root.join("alias");
9520        fs::create_dir(&real_dir).unwrap();
9521        symlink(&real_dir, &alias_dir).unwrap();
9522        let real_file = real_dir.join("lib.rs");
9523        let alias_file = alias_dir.join("lib.rs");
9524        let untouched = project_root.join("untouched.rs");
9525        fs::write(&real_file, "fn aliased() {}\n").unwrap();
9526        fs::write(&untouched, "fn untouched() {}\n").unwrap();
9527        assert_eq!(fs::canonicalize(&alias_file).unwrap(), real_file);
9528
9529        let mut index = SemanticIndex::new(project_root, 2);
9530        add_invalidation_fixture_entry(&mut index, alias_file.clone(), 1);
9531        add_invalidation_fixture_entry(&mut index, real_file.clone(), 2);
9532        add_invalidation_fixture_entry(&mut index, untouched.clone(), 3);
9533        let mut sequential = index.clone();
9534        sequential.invalidate_file(&alias_file);
9535        index.invalidate_files(std::slice::from_ref(&alias_file));
9536
9537        assert_eq!(index.to_bytes(), sequential.to_bytes());
9538        assert!(index
9539            .entries
9540            .iter()
9541            .all(|entry| entry.chunk.file != alias_file && entry.chunk.file != real_file));
9542        assert!(!index.file_mtimes.contains_key(&alias_file));
9543        assert!(!index.file_mtimes.contains_key(&real_file));
9544        assert!(index.file_mtimes.contains_key(&untouched));
9545        assert!(!index.file_sizes.contains_key(&alias_file));
9546        assert!(!index.file_sizes.contains_key(&real_file));
9547        assert!(index.file_sizes.contains_key(&untouched));
9548        assert!(!index.file_hashes.contains_key(&alias_file));
9549        assert!(!index.file_hashes.contains_key(&real_file));
9550        assert!(index.file_hashes.contains_key(&untouched));
9551        assert_eq!(index.removal_retain_passes_for_test(), 1);
9552    }
9553
9554    #[test]
9555    fn invalidate_file_removes_entries_and_mtime() {
9556        let target = PathBuf::from("/src/main.rs");
9557        let mut index = SemanticIndex::new(test_project_root(), DEFAULT_DIMENSION);
9558        index.entries.push(EmbeddingEntry {
9559            chunk: SemanticChunk {
9560                file: target.clone(),
9561                name: "main".to_string(),
9562                qualified_name: None,
9563                kind: SymbolKind::Function,
9564                start_line: 0,
9565                end_line: 1,
9566                exported: false,
9567                embed_text: "main".to_string(),
9568                snippet: "fn main() {}".to_string(),
9569            },
9570            norm: vector_norm(&[1.0; DEFAULT_DIMENSION]),
9571            vector: vec![1.0; DEFAULT_DIMENSION],
9572        });
9573        index
9574            .file_mtimes
9575            .insert(target.clone(), SystemTime::UNIX_EPOCH);
9576        index.file_sizes.insert(target.clone(), 0);
9577
9578        index.invalidate_file(&target);
9579
9580        assert!(index.entries.is_empty());
9581        assert!(!index.file_mtimes.contains_key(&target));
9582        assert!(!index.file_sizes.contains_key(&target));
9583    }
9584
9585    #[test]
9586    fn refresh_missing_changed_file_is_purged_after_collect() {
9587        let temp = tempfile::tempdir().unwrap();
9588        let project_root = temp.path();
9589        let file = project_root.join("src/lib.rs");
9590        fs::create_dir_all(file.parent().unwrap()).unwrap();
9591        write_rust_file(&file, "vanished_symbol");
9592
9593        let mut index = build_test_index(project_root, std::slice::from_ref(&file));
9594        let original_size = *index.file_sizes.get(&file).unwrap();
9595        set_file_metadata(&mut index, &file, SystemTime::UNIX_EPOCH, original_size + 1);
9596        fs::remove_file(&file).unwrap();
9597
9598        let mut embed = test_vector_for_texts;
9599        let mut progress = |_done: usize, _total: usize| {};
9600        let summary = index
9601            .refresh_stale_files(
9602                project_root,
9603                std::slice::from_ref(&file),
9604                &mut embed,
9605                8,
9606                &mut progress,
9607            )
9608            .unwrap();
9609
9610        assert_eq!(summary.changed, 0);
9611        assert_eq!(summary.added, 0);
9612        assert_eq!(summary.deleted, 1);
9613        assert!(index.entries.is_empty());
9614        assert!(!index.file_mtimes.contains_key(&file));
9615        assert!(!index.file_sizes.contains_key(&file));
9616        assert!(!index.file_hashes.contains_key(&file));
9617    }
9618
9619    #[test]
9620    fn refresh_collect_error_for_existing_path_preserves_cached_entry() {
9621        let temp = tempfile::tempdir().unwrap();
9622        let project_root = temp.path();
9623        let file = project_root.join("src/lib.rs");
9624        fs::create_dir_all(file.parent().unwrap()).unwrap();
9625        write_rust_file(&file, "kept_symbol");
9626
9627        let mut index = build_test_index(project_root, std::slice::from_ref(&file));
9628        let original_entry_count = index.entries.len();
9629        let original_mtime = *index.file_mtimes.get(&file).unwrap();
9630        let original_size = *index.file_sizes.get(&file).unwrap();
9631
9632        let stale_mtime = SystemTime::UNIX_EPOCH;
9633        set_file_metadata(&mut index, &file, stale_mtime, original_size + 1);
9634        fs::remove_file(&file).unwrap();
9635        fs::create_dir(&file).unwrap();
9636
9637        let mut embed = test_vector_for_texts;
9638        let mut progress = |_done: usize, _total: usize| {};
9639        let summary = index
9640            .refresh_stale_files(
9641                project_root,
9642                std::slice::from_ref(&file),
9643                &mut embed,
9644                8,
9645                &mut progress,
9646            )
9647            .unwrap();
9648
9649        assert_eq!(summary.changed, 0);
9650        assert_eq!(summary.added, 0);
9651        assert_eq!(summary.deleted, 0);
9652        assert_eq!(index.entries.len(), original_entry_count);
9653        assert!(index
9654            .entries
9655            .iter()
9656            .any(|entry| entry.chunk.name == "kept_symbol"));
9657        assert_eq!(index.file_mtimes.get(&file), Some(&stale_mtime));
9658        assert_ne!(index.file_mtimes.get(&file), Some(&original_mtime));
9659        assert_eq!(index.file_sizes.get(&file), Some(&(original_size + 1)));
9660    }
9661
9662    #[test]
9663    fn refresh_never_indexed_file_error_does_not_record_mtime() {
9664        let temp = tempfile::tempdir().unwrap();
9665        let project_root = temp.path();
9666        let missing = project_root.join("src/missing.rs");
9667        fs::create_dir_all(missing.parent().unwrap()).unwrap();
9668
9669        let mut index = SemanticIndex::new(test_project_root(), DEFAULT_DIMENSION);
9670        let mut embed = test_vector_for_texts;
9671        let mut progress = |_done: usize, _total: usize| {};
9672        let summary = index
9673            .refresh_stale_files(
9674                project_root,
9675                std::slice::from_ref(&missing),
9676                &mut embed,
9677                8,
9678                &mut progress,
9679            )
9680            .unwrap();
9681
9682        assert_eq!(summary.added, 0);
9683        assert_eq!(summary.changed, 0);
9684        assert_eq!(summary.deleted, 0);
9685        assert!(!index.file_mtimes.contains_key(&missing));
9686        assert!(!index.file_sizes.contains_key(&missing));
9687        assert!(index.entries.is_empty());
9688    }
9689
9690    #[test]
9691    fn refresh_reports_added_for_new_files() {
9692        let temp = tempfile::tempdir().unwrap();
9693        let project_root = temp.path();
9694        let existing = project_root.join("src/lib.rs");
9695        let added = project_root.join("src/new.rs");
9696        fs::create_dir_all(existing.parent().unwrap()).unwrap();
9697        write_rust_file(&existing, "existing_symbol");
9698        write_rust_file(&added, "added_symbol");
9699
9700        let mut index = build_test_index(project_root, std::slice::from_ref(&existing));
9701        let mut embed = test_vector_for_texts;
9702        let mut progress = |_done: usize, _total: usize| {};
9703        let summary = index
9704            .refresh_stale_files(
9705                project_root,
9706                &[existing.clone(), added.clone()],
9707                &mut embed,
9708                8,
9709                &mut progress,
9710            )
9711            .unwrap();
9712
9713        assert_eq!(summary.added, 1);
9714        assert_eq!(summary.changed, 0);
9715        assert_eq!(summary.deleted, 0);
9716        assert_eq!(summary.total_processed, 2);
9717        assert!(index.file_mtimes.contains_key(&added));
9718        assert!(index.entries.iter().any(|entry| entry.chunk.file == added));
9719    }
9720
9721    #[test]
9722    fn refresh_reports_deleted_for_removed_files() {
9723        let temp = tempfile::tempdir().unwrap();
9724        let project_root = temp.path();
9725        let deleted = project_root.join("src/deleted.rs");
9726        fs::create_dir_all(deleted.parent().unwrap()).unwrap();
9727        write_rust_file(&deleted, "deleted_symbol");
9728
9729        let mut index = build_test_index(project_root, std::slice::from_ref(&deleted));
9730        fs::remove_file(&deleted).unwrap();
9731
9732        let mut embed = test_vector_for_texts;
9733        let mut progress = |_done: usize, _total: usize| {};
9734        let summary = index
9735            .refresh_stale_files(project_root, &[], &mut embed, 8, &mut progress)
9736            .unwrap();
9737
9738        assert_eq!(summary.deleted, 1);
9739        assert_eq!(summary.changed, 0);
9740        assert_eq!(summary.added, 0);
9741        assert_eq!(summary.total_processed, 1);
9742        assert!(!index.file_mtimes.contains_key(&deleted));
9743        assert!(index.entries.is_empty());
9744    }
9745
9746    #[test]
9747    fn refresh_reports_changed_for_modified_files() {
9748        let temp = tempfile::tempdir().unwrap();
9749        let project_root = temp.path();
9750        let file = project_root.join("src/lib.rs");
9751        fs::create_dir_all(file.parent().unwrap()).unwrap();
9752        write_rust_file(&file, "old_symbol");
9753
9754        let mut index = build_test_index(project_root, std::slice::from_ref(&file));
9755        set_file_metadata(&mut index, &file, SystemTime::UNIX_EPOCH, 0);
9756        write_rust_file(&file, "new_symbol");
9757
9758        let mut embed = test_vector_for_texts;
9759        let mut progress = |_done: usize, _total: usize| {};
9760        let summary = index
9761            .refresh_stale_files(
9762                project_root,
9763                std::slice::from_ref(&file),
9764                &mut embed,
9765                8,
9766                &mut progress,
9767            )
9768            .unwrap();
9769
9770        assert_eq!(summary.changed, 1);
9771        assert_eq!(summary.added, 0);
9772        assert_eq!(summary.deleted, 0);
9773        assert_eq!(summary.total_processed, 1);
9774        assert!(index
9775            .entries
9776            .iter()
9777            .any(|entry| entry.chunk.name == "new_symbol"));
9778        assert!(!index
9779            .entries
9780            .iter()
9781            .any(|entry| entry.chunk.name == "old_symbol"));
9782    }
9783
9784    #[test]
9785    fn refresh_all_clean_reports_zero_counts_and_no_embedding_work() {
9786        let temp = tempfile::tempdir().unwrap();
9787        let project_root = temp.path();
9788        let file = project_root.join("src/lib.rs");
9789        fs::create_dir_all(file.parent().unwrap()).unwrap();
9790        write_rust_file(&file, "clean_symbol");
9791
9792        let mut index = build_test_index(project_root, std::slice::from_ref(&file));
9793        let original_entries = index.entries.len();
9794        let mut embed_called = false;
9795        let mut embed = |texts: Vec<String>| {
9796            embed_called = true;
9797            test_vector_for_texts(texts)
9798        };
9799        let mut progress = |_done: usize, _total: usize| {};
9800        let summary = index
9801            .refresh_stale_files(
9802                project_root,
9803                std::slice::from_ref(&file),
9804                &mut embed,
9805                8,
9806                &mut progress,
9807            )
9808            .unwrap();
9809
9810        assert!(summary.is_noop());
9811        assert_eq!(summary.total_processed, 1);
9812        assert!(!embed_called);
9813        assert_eq!(index.entries.len(), original_entries);
9814    }
9815
9816    #[test]
9817    fn detects_missing_onnx_runtime_from_dynamic_load_error() {
9818        let message = "Failed to load ONNX Runtime shared library libonnxruntime.dylib via dlopen: no such file";
9819
9820        assert!(is_onnx_runtime_unavailable(message));
9821    }
9822
9823    #[test]
9824    fn formats_missing_onnx_runtime_with_install_hint() {
9825        let message = format_embedding_init_error(
9826            "Failed to load ONNX Runtime shared library libonnxruntime.so via dlopen: no such file",
9827        );
9828
9829        assert!(message.starts_with("ONNX Runtime not found. Install via:"));
9830        assert!(message.contains("Original error:"));
9831    }
9832
9833    #[test]
9834    fn qwen_query_request_uses_documented_instruction_shape() {
9835        assert_eq!(
9836            query_embedding_text(
9837                "where is authentication handled",
9838                Some(crate::config::QWEN3_EMBEDDING_MODEL_CARD_RETRIEVAL_TASK),
9839            ),
9840            "Instruct: Given a web search query, retrieve relevant passages that answer the query\nQuery: where is authentication handled"
9841        );
9842        assert_eq!(
9843            query_embedding_text("where is authentication handled", None),
9844            "where is authentication handled"
9845        );
9846    }
9847
9848    #[test]
9849    fn query_instruction_does_not_change_index_fingerprint() {
9850        let mut config = SemanticBackendConfig {
9851            backend: SemanticBackend::OpenAiCompatible,
9852            model: "text-embedding-qwen3-embedding-0.6b".to_string(),
9853            base_url: Some("http://127.0.0.1:1234/v1".to_string()),
9854            ..SemanticBackendConfig::default()
9855        };
9856        let automatic = SemanticIndexFingerprint::for_config_dimension(&config, 1024);
9857        config.query_instruction = "off".to_string();
9858        let off = SemanticIndexFingerprint::for_config_dimension(&config, 1024);
9859        config.query_instruction = crate::config::QWEN3_EMBEDDING_CODE_SEARCH_TASK.to_string();
9860        let literal = SemanticIndexFingerprint::for_config_dimension(&config, 1024);
9861
9862        assert_eq!(automatic.as_string(), off.as_string());
9863        assert_eq!(off.as_string(), literal.as_string());
9864        assert!(automatic.matches(&off));
9865        assert!(off.matches(&literal));
9866    }
9867
9868    #[test]
9869    fn query_embedding_cache_keys_the_text_sent_to_the_server() {
9870        let (base_url, inputs, handle) = start_recording_embedding_server(2);
9871        let config = SemanticBackendConfig {
9872            backend: SemanticBackend::OpenAiCompatible,
9873            model: "text-embedding-qwen3-embedding-0.6b".to_string(),
9874            base_url: Some(base_url),
9875            query_instruction: crate::config::QWEN3_EMBEDDING_CODE_SEARCH_TASK.to_string(),
9876            ..SemanticBackendConfig::default()
9877        };
9878        let budget = QueryBudget::from_config(&config);
9879        let mut model = SemanticEmbeddingModel::from_config(&config).unwrap();
9880
9881        model.embed_query_cached("cache probe", budget).unwrap();
9882        model.query_instruction = None;
9883        model.embed_query_cached("cache probe", budget).unwrap();
9884        model.embed_query_cached("cache probe", budget).unwrap();
9885        handle.join().unwrap();
9886
9887        assert_eq!(model.query_embedding_cache_stats(), (1, 2, 2));
9888        assert_eq!(
9889            *inputs.lock().unwrap(),
9890            vec![
9891                format!(
9892                    "Instruct: {}\nQuery: cache probe",
9893                    crate::config::QWEN3_EMBEDDING_CODE_SEARCH_TASK
9894                ),
9895                "cache probe".to_string(),
9896            ]
9897        );
9898    }
9899
9900    #[test]
9901    fn interactive_query_budget_is_independent_from_build_timeout() {
9902        let mut config = SemanticBackendConfig {
9903            backend: SemanticBackend::OpenAiCompatible,
9904            model: "test-embedding".to_string(),
9905            base_url: Some("http://127.0.0.1:9".to_string()),
9906            api_key_env: None,
9907            timeout_ms: 0,
9908            query_timeout_ms: 0,
9909            max_batch_size: 64,
9910            max_files: 20_000,
9911            ..Default::default()
9912        };
9913
9914        let build_model = SemanticEmbeddingModel::from_config(&config).unwrap();
9915        let query_model = SemanticEmbeddingModel::from_config_for_query(&config).unwrap();
9916        assert_eq!(
9917            build_model.timeout_ms(),
9918            DEFAULT_OPENAI_EMBEDDING_TIMEOUT_MS,
9919            "background build keeps the longer default embedding timeout"
9920        );
9921        assert_eq!(
9922            query_model.timeout_ms(),
9923            DEFAULT_OPENAI_EMBEDDING_TIMEOUT_MS,
9924            "a query-created model remains safe for later background build reuse"
9925        );
9926        assert_eq!(
9927            QueryBudget::from_config(&config).timeout_ms(),
9928            DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS
9929        );
9930
9931        config.timeout_ms = 60_000;
9932        assert_eq!(
9933            QueryBudget::from_config(&config).timeout_ms(),
9934            DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS,
9935            "the build timeout must not affect interactive requests"
9936        );
9937
9938        config.query_timeout_ms = 700;
9939        assert_eq!(QueryBudget::from_config(&config).timeout_ms(), 700);
9940    }
9941
9942    #[test]
9943    fn single_item_build_timeout_is_dead_evidence_without_same_batch_retry() {
9944        let (base_url, requests, handle) =
9945            start_slow_embedding_server(1, Duration::from_millis(300));
9946        let config = SemanticBackendConfig {
9947            backend: SemanticBackend::OpenAiCompatible,
9948            model: "test-embedding".to_string(),
9949            base_url: Some(base_url),
9950            api_key_env: None,
9951            timeout_ms: 100,
9952            query_timeout_ms: DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS,
9953            max_batch_size: 64,
9954            max_files: 20_000,
9955            ..Default::default()
9956        };
9957        let mut model = SemanticEmbeddingModel::from_config(&config).unwrap();
9958
9959        let error = model
9960            .embed(vec!["slow build batch".to_string()])
9961            .expect_err("a single item exceeding the base deadline is dead evidence");
9962        handle.join().expect("slow embedding server");
9963
9964        assert!(embedding_failure_is_transient(&error), "error: {error}");
9965        assert!(
9966            error.contains("single-item request timed out at 100 ms: treating as down"),
9967            "error: {error}"
9968        );
9969        assert_eq!(
9970            requests.load(Ordering::SeqCst),
9971            1,
9972            "a timeout must shrink or terminate rather than retrying the same batch"
9973        );
9974    }
9975
9976    fn programmable_http_config(server: &ProgrammableEmbeddingServer) -> SemanticBackendConfig {
9977        SemanticBackendConfig {
9978            backend: SemanticBackend::OpenAiCompatible,
9979            model: "test-embedding".to_string(),
9980            base_url: Some(server.base_url.clone()),
9981            api_key_env: None,
9982            // The floor leaves a single item on a loaded CI runner (HTTP setup
9983            // plus scheduling is tens of ms there) far below the base deadline:
9984            // a one-item timeout is the "down" verdict, and this suite must
9985            // reach it only from the never-answer arm, never from contention.
9986            timeout_ms: 300,
9987            query_timeout_ms: DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS,
9988            max_batch_size: 64,
9989            max_files: 20_000,
9990            ..Default::default()
9991        }
9992    }
9993
9994    fn embedding_inputs(count: usize) -> Vec<String> {
9995        (0..count).map(|index| format!("chunk {index}")).collect()
9996    }
9997
9998    #[test]
9999    fn slow_backend_converges_without_being_marked_down() {
10000        // The reporter's shape (2 s/item against a 25 s floor) scaled so the
10001        // test exercises real HTTP deadlines in seconds, not minutes: a 64-item
10002        // batch cannot fit the initial deadline, a 4-item batch can.
10003        let server = ProgrammableEmbeddingServer::start(Duration::from_millis(50));
10004        let config = programmable_http_config(&server);
10005        let mut model = SemanticEmbeddingModel::from_config(&config).unwrap();
10006
10007        for _ in 0..3 {
10008            assert_eq!(model.embed(embedding_inputs(64)).unwrap().len(), 64);
10009        }
10010
10011        assert_eq!(model.adaptive_build_batch_size, config.max_batch_size);
10012        assert!(
10013            server.completed_sizes().contains(&64),
10014            "EMA-scaled deadlines must eventually let a recovered 64-item batch finish; requests={:?}, completed={:?}",
10015            server.request_sizes(),
10016            server.completed_sizes(),
10017        );
10018    }
10019
10020    #[test]
10021    fn never_answering_backend_is_declared_down_within_eleven_base_deadlines() {
10022        let server = ProgrammableEmbeddingServer::start(Duration::ZERO);
10023        server.set_never_answer(true);
10024        let config = programmable_http_config(&server);
10025        let mut model = SemanticEmbeddingModel::from_config(&config).unwrap();
10026        let started = Instant::now();
10027
10028        let error = model
10029            .embed(embedding_inputs(64))
10030            .expect_err("a backend that never answers must reach the singleton dead check");
10031        let elapsed = started.elapsed();
10032
10033        assert!(
10034            error.contains("single-item request timed out at 300 ms: treating as down"),
10035            "error: {error}"
10036        );
10037        assert_eq!(server.request_sizes(), vec![64, 32, 16, 8, 4, 2, 1]);
10038        let protocol_bound = Duration::from_millis(config.timeout_ms * 11);
10039        assert!(
10040            elapsed <= protocol_bound + Duration::from_secs(1),
10041            "never-answer ladder exceeded 11 base deadlines plus scheduler allowance: elapsed={elapsed:?}, protocol_bound={protocol_bound:?}"
10042        );
10043    }
10044
10045    #[test]
10046    fn refused_connection_is_an_immediate_honest_transient_failure() {
10047        let listener = TcpListener::bind("127.0.0.1:0").expect("reserve refused port");
10048        let addr = listener.local_addr().expect("refused port address");
10049        drop(listener);
10050        let config = SemanticBackendConfig {
10051            backend: SemanticBackend::OpenAiCompatible,
10052            model: "test-embedding".to_string(),
10053            base_url: Some(format!("http://{addr}")),
10054            api_key_env: None,
10055            timeout_ms: 500,
10056            max_batch_size: 64,
10057            ..Default::default()
10058        };
10059        let mut model = SemanticEmbeddingModel::from_config(&config).unwrap();
10060        let started = Instant::now();
10061
10062        let error = model
10063            .embed(vec!["connection probe".to_string()])
10064            .expect_err("closed listener must refuse the request");
10065
10066        assert!(embedding_failure_is_transient(&error), "error: {error}");
10067        // Unix answers a closed loopback port with RST, so the request fails at
10068        // connect. Windows Filtering Platform stealth mode drops the SYN instead,
10069        // so the same probe is a connect timeout at the base floor - which the
10070        // one-item rule already reads as down. Either arm is one deadline at most
10071        // and never the same-batch retry ladder.
10072        if cfg!(windows) {
10073            assert!(
10074                error.contains(
10075                    "embedding backend unreachable (connection refused or connect failure)"
10076                ) || error.contains("single-item request timed out at 500 ms: treating as down"),
10077                "error: {error}"
10078            );
10079            assert!(
10080                started.elapsed() < Duration::from_millis(500 * 2),
10081                "a dropped SYN must be judged within one base deadline, not a ladder"
10082            );
10083        } else {
10084            assert!(
10085                error.contains(
10086                    "embedding backend unreachable (connection refused or connect failure)"
10087                ),
10088                "error: {error}"
10089            );
10090            assert!(
10091                started.elapsed() < Duration::from_millis(500),
10092                "connection refusal should not wait through a retry ladder"
10093            );
10094        }
10095    }
10096
10097    #[test]
10098    fn aimd_grows_back_to_configured_max_after_backend_speeds_up() {
10099        let server = ProgrammableEmbeddingServer::start(Duration::from_millis(60));
10100        let config = programmable_http_config(&server);
10101        let mut model = SemanticEmbeddingModel::from_config(&config).unwrap();
10102
10103        assert_eq!(model.embed(embedding_inputs(64)).unwrap().len(), 64);
10104        assert!(
10105            model.adaptive_build_batch_size < config.max_batch_size,
10106            "the initial slowdown should reduce the active batch size"
10107        );
10108
10109        server.set_per_item_delay(Duration::from_millis(2));
10110        for _ in 0..4 {
10111            assert_eq!(model.embed(embedding_inputs(64)).unwrap().len(), 64);
10112            if model.adaptive_build_batch_size == config.max_batch_size {
10113                break;
10114            }
10115        }
10116
10117        assert_eq!(model.adaptive_build_batch_size, config.max_batch_size);
10118    }
10119
10120    #[test]
10121    fn openai_compatible_backend_embeds_with_mock_server() {
10122        let (base_url, handle) = start_mock_http_server(|request_line, path, _body| {
10123            assert!(request_line.starts_with("POST "));
10124            assert_eq!(path, "/v1/embeddings");
10125            "{\"data\":[{\"embedding\":[0.1,0.2,0.3],\"index\":0},{\"embedding\":[0.4,0.5,0.6],\"index\":1}]}".to_string()
10126        });
10127
10128        let config = SemanticBackendConfig {
10129            backend: SemanticBackend::OpenAiCompatible,
10130            model: "test-embedding".to_string(),
10131            base_url: Some(base_url),
10132            api_key_env: None,
10133            timeout_ms: 5_000,
10134            query_timeout_ms: DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS,
10135            max_batch_size: 64,
10136            max_files: 20_000,
10137            ..Default::default()
10138        };
10139
10140        let mut model = SemanticEmbeddingModel::from_config(&config).unwrap();
10141        let vectors = model
10142            .embed(vec!["hello".to_string(), "world".to_string()])
10143            .unwrap();
10144
10145        assert_eq!(vectors, vec![vec![0.1, 0.2, 0.3], vec![0.4, 0.5, 0.6]]);
10146        handle.join().unwrap();
10147    }
10148
10149    /// Regression for issue #36: AFT was sending TWO Content-Type headers
10150    /// on the OpenAI embeddings request — once implicitly via `.json(&body)`
10151    /// and again explicitly via `.header("Content-Type", "application/json")`.
10152    /// reqwest's `.header()` calls `HeaderMap::append`, which produces two
10153    /// headers on the wire. OpenAI's /v1/embeddings endpoint rejects that
10154    /// with `HTTP 400 "you must provide a model parameter"` even though the
10155    /// body actually contains `model`. The fix is to drop the explicit
10156    /// `.header("Content-Type", ...)` call. This test pins that we send
10157    /// exactly one Content-Type header.
10158    #[test]
10159    fn openai_compatible_request_has_single_content_type_header() {
10160        use std::sync::{Arc, Mutex};
10161        let captured: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
10162        let captured_for_thread = Arc::clone(&captured);
10163
10164        let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
10165        let addr = listener.local_addr().expect("local addr");
10166        let handle = thread::spawn(move || {
10167            let (mut stream, _) = listener.accept().expect("accept");
10168            let mut buf = Vec::new();
10169            let mut chunk = [0u8; 4096];
10170            let mut header_end = None;
10171            let mut content_length = 0usize;
10172            loop {
10173                let n = stream.read(&mut chunk).expect("read");
10174                if n == 0 {
10175                    break;
10176                }
10177                buf.extend_from_slice(&chunk[..n]);
10178                if header_end.is_none() {
10179                    if let Some(pos) = buf.windows(4).position(|window| window == b"\r\n\r\n") {
10180                        header_end = Some(pos + 4);
10181                        for line in String::from_utf8_lossy(&buf[..pos + 4]).lines() {
10182                            if let Some(value) = line.strip_prefix("Content-Length:") {
10183                                content_length = value.trim().parse::<usize>().unwrap_or(0);
10184                            }
10185                        }
10186                    }
10187                }
10188                if let Some(end) = header_end {
10189                    if buf.len() >= end + content_length {
10190                        break;
10191                    }
10192                }
10193            }
10194            *captured_for_thread.lock().unwrap() = buf;
10195            let body = "{\"data\":[{\"embedding\":[0.1,0.2,0.3],\"index\":0}]}";
10196            let response = format!(
10197                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
10198                body.len(),
10199                body
10200            );
10201            let _ = stream.write_all(response.as_bytes());
10202        });
10203
10204        let config = SemanticBackendConfig {
10205            backend: SemanticBackend::OpenAiCompatible,
10206            model: "text-embedding-3-small".to_string(),
10207            base_url: Some(format!("http://{}", addr)),
10208            api_key_env: None,
10209            timeout_ms: 5_000,
10210            query_timeout_ms: DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS,
10211            max_batch_size: 64,
10212            max_files: 20_000,
10213            ..Default::default()
10214        };
10215        let mut model = SemanticEmbeddingModel::from_config(&config).unwrap();
10216        let _ = model.embed(vec!["probe".to_string()]).unwrap();
10217        handle.join().unwrap();
10218
10219        let bytes = captured.lock().unwrap().clone();
10220        let request = String::from_utf8_lossy(&bytes);
10221
10222        // Lowercase line counts because HTTP headers are case-insensitive
10223        // and reqwest may emit `content-type` in lowercase under HTTP/2.
10224        let content_type_lines = request
10225            .lines()
10226            .filter(|line| {
10227                let lower = line.to_ascii_lowercase();
10228                lower.starts_with("content-type:")
10229            })
10230            .count();
10231        assert_eq!(
10232            content_type_lines, 1,
10233            "expected exactly one Content-Type header but found {content_type_lines}; full request:\n{request}",
10234        );
10235
10236        // The body must still include the model field — pin this so a future
10237        // change can't accidentally drop `model` while fixing duplicate headers.
10238        assert!(
10239            request.contains(r#""model":"text-embedding-3-small""#),
10240            "request body should contain model field; full request:\n{request}",
10241        );
10242    }
10243
10244    #[test]
10245    fn ollama_backend_embeds_with_mock_server() {
10246        let (base_url, handle) = start_mock_http_server(|request_line, path, _body| {
10247            assert!(request_line.starts_with("POST "));
10248            assert_eq!(path, "/api/embed");
10249            "{\"embeddings\":[[0.7,0.8,0.9],[1.0,1.1,1.2]]}".to_string()
10250        });
10251
10252        let config = SemanticBackendConfig {
10253            backend: SemanticBackend::Ollama,
10254            model: "embeddinggemma".to_string(),
10255            base_url: Some(base_url),
10256            api_key_env: None,
10257            timeout_ms: 5_000,
10258            query_timeout_ms: DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS,
10259            max_batch_size: 64,
10260            max_files: 20_000,
10261            ..Default::default()
10262        };
10263
10264        let mut model = SemanticEmbeddingModel::from_config(&config).unwrap();
10265        let vectors = model
10266            .embed(vec!["hello".to_string(), "world".to_string()])
10267            .unwrap();
10268
10269        assert_eq!(vectors, vec![vec![0.7, 0.8, 0.9], vec![1.0, 1.1, 1.2]]);
10270        handle.join().unwrap();
10271    }
10272
10273    #[test]
10274    fn read_from_disk_rejects_fingerprint_mismatch() {
10275        let storage = tempfile::tempdir().unwrap();
10276        let project_key = "proj";
10277
10278        let project_root = test_project_root();
10279        let file = project_root.join("src/main.rs");
10280        let mut index = SemanticIndex::new(project_root.clone(), DEFAULT_DIMENSION);
10281        index.entries.push(EmbeddingEntry {
10282            chunk: SemanticChunk {
10283                file: file.clone(),
10284                name: "handle_request".to_string(),
10285                qualified_name: None,
10286                kind: SymbolKind::Function,
10287                start_line: 10,
10288                end_line: 25,
10289                exported: true,
10290                embed_text: "file:src/main.rs kind:function name:handle_request".to_string(),
10291                snippet: "fn handle_request() {}".to_string(),
10292            },
10293            norm: vector_norm(&[0.1, 0.2, 0.3]),
10294            vector: vec![0.1, 0.2, 0.3],
10295        });
10296        index.dimension = 3;
10297        index
10298            .file_mtimes
10299            .insert(file.clone(), SystemTime::UNIX_EPOCH);
10300        index.file_sizes.insert(file, 0);
10301        index.set_fingerprint(SemanticIndexFingerprint {
10302            backend: "openai_compatible".to_string(),
10303            model: "test-embedding".to_string(),
10304            base_url: "http://127.0.0.1:1234/v1".to_string(),
10305            dimension: 3,
10306            chunking_version: default_chunking_version(),
10307            ..Default::default()
10308        });
10309        index.write_to_disk(storage.path(), project_key);
10310
10311        let data_path = storage
10312            .path()
10313            .join("semantic")
10314            .join(project_key)
10315            .join("semantic.bin");
10316        let before = fs::read(&data_path).unwrap();
10317
10318        let matching = index.fingerprint().unwrap().as_string();
10319        assert!(SemanticIndex::read_from_disk(
10320            storage.path(),
10321            project_key,
10322            &project_root,
10323            false,
10324            Some(&matching),
10325        )
10326        .is_some());
10327
10328        let mismatched = SemanticIndexFingerprint {
10329            backend: "ollama".to_string(),
10330            model: "embeddinggemma".to_string(),
10331            base_url: "http://127.0.0.1:11434".to_string(),
10332            dimension: 3,
10333            chunking_version: default_chunking_version(),
10334            ..Default::default()
10335        }
10336        .as_string();
10337        assert!(SemanticIndex::read_from_disk(
10338            storage.path(),
10339            project_key,
10340            &project_root,
10341            false,
10342            Some(&mismatched),
10343        )
10344        .is_none());
10345        assert_eq!(fs::read(&data_path).unwrap(), before);
10346    }
10347
10348    #[test]
10349    fn synapse_fingerprint_pin_matches_only_equivalent_alias_at_same_epoch() {
10350        let cached = SemanticIndexFingerprint {
10351            backend: "synapse".to_string(),
10352            model: "configured-model".to_string(),
10353            dimension: 768,
10354            chunking_version: 2,
10355            synapse_fingerprint: Some("fp-old".to_string()),
10356            synapse_table_epoch: Some(9),
10357            ..Default::default()
10358        };
10359        let mut served = cached.clone();
10360        served.synapse_fingerprint = Some("fp-current".to_string());
10361        served.synapse_equivalent_to = vec!["fp-old".to_string()];
10362        assert!(cached.matches_expected(&served.as_string()));
10363
10364        served.synapse_table_epoch = Some(10);
10365        assert!(!cached.matches_expected(&served.as_string()));
10366    }
10367
10368    #[test]
10369    fn fingerprint_mismatch_details_redact_base_url_and_list_changed_fields() {
10370        let cached = SemanticIndexFingerprint {
10371            backend: "openai_compatible".to_string(),
10372            model: "cached-model".to_string(),
10373            base_url: "https://user:secret@example.com/v1/embeddings".to_string(),
10374            dimension: 3,
10375            chunking_version: 2,
10376            ..Default::default()
10377        };
10378        let current = SemanticIndexFingerprint {
10379            backend: "ollama".to_string(),
10380            model: "current-model".to_string(),
10381            base_url: "https://example.org/api/embed".to_string(),
10382            dimension: 4,
10383            chunking_version: 3,
10384            ..Default::default()
10385        };
10386
10387        let details = format_fingerprint_mismatch_details(Some(&cached), &current);
10388
10389        assert!(details.contains("backend kind cached=openai_compatible current=ollama"));
10390        assert!(details.contains("model cached=cached-model current=current-model"));
10391        assert!(details.contains("base_url host cached=example.com current=example.org"));
10392        assert!(details.contains("dimension cached=3 current=4"));
10393        assert!(details.contains("chunking version cached=2 current=3"));
10394        assert!(!details.contains("secret"));
10395        assert!(!details.contains("/v1/embeddings"));
10396        assert!(!details.contains("/api/embed"));
10397    }
10398
10399    #[test]
10400    fn read_from_disk_rejects_v3_cache_for_snippet_rebuild() {
10401        let storage = tempfile::tempdir().unwrap();
10402        let project_key = "proj-v3";
10403        let dir = storage.path().join("semantic").join(project_key);
10404        fs::create_dir_all(&dir).unwrap();
10405
10406        let mut index = SemanticIndex::new(test_project_root(), DEFAULT_DIMENSION);
10407        index.entries.push(EmbeddingEntry {
10408            chunk: SemanticChunk {
10409                file: PathBuf::from("/src/main.rs"),
10410                name: "handle_request".to_string(),
10411                qualified_name: None,
10412                kind: SymbolKind::Function,
10413                start_line: 0,
10414                end_line: 0,
10415                exported: true,
10416                embed_text: "file:src/main.rs kind:function name:handle_request".to_string(),
10417                snippet: "fn handle_request() {}".to_string(),
10418            },
10419            norm: vector_norm(&[0.1, 0.2, 0.3]),
10420            vector: vec![0.1, 0.2, 0.3],
10421        });
10422        index.dimension = 3;
10423        index
10424            .file_mtimes
10425            .insert(PathBuf::from("/src/main.rs"), SystemTime::UNIX_EPOCH);
10426        index.file_sizes.insert(PathBuf::from("/src/main.rs"), 0);
10427        let fingerprint = SemanticIndexFingerprint {
10428            backend: "fastembed".to_string(),
10429            model: "test".to_string(),
10430            base_url: FALLBACK_BACKEND.to_string(),
10431            dimension: 3,
10432            chunking_version: default_chunking_version(),
10433            ..Default::default()
10434        };
10435        index.set_fingerprint(fingerprint.clone());
10436
10437        let mut bytes = index.to_bytes();
10438        bytes[0] = SEMANTIC_INDEX_VERSION_V3;
10439        let data_path = dir.join("semantic.bin");
10440        fs::write(&data_path, &bytes).unwrap();
10441
10442        assert!(SemanticIndex::read_from_disk(
10443            storage.path(),
10444            project_key,
10445            &test_project_root(),
10446            false,
10447            Some(&fingerprint.as_string())
10448        )
10449        .is_none());
10450        assert_eq!(fs::read(&data_path).unwrap(), bytes);
10451    }
10452
10453    fn make_symbol(kind: SymbolKind, name: &str, start: u32, end: u32) -> crate::symbols::Symbol {
10454        crate::symbols::Symbol {
10455            name: name.to_string(),
10456            kind,
10457            range: crate::symbols::Range {
10458                start_line: start,
10459                start_col: 0,
10460                end_line: end,
10461                end_col: 0,
10462            },
10463            signature: None,
10464            scope_chain: Vec::new(),
10465            exported: false,
10466            parent: None,
10467        }
10468    }
10469
10470    #[test]
10471    fn symbols_to_chunks_sets_qualified_name_without_changing_embed_text() {
10472        let project_root = PathBuf::from("/proj");
10473        let file = project_root.join("src/engine.ts");
10474        let source = "class Index {\n}\n";
10475        let mut symbol = make_symbol(SymbolKind::Class, "Index", 0, 1);
10476        symbol.scope_chain = vec!["Engine".to_string()];
10477        symbol.signature = Some("class Index".to_string());
10478        let embed_text = build_embed_text(&symbol, source, &file, &project_root);
10479
10480        let chunks = symbols_to_chunks(&file, &[symbol], source, &project_root);
10481        let chunk = chunks
10482            .iter()
10483            .find(|chunk| chunk.name == "Index")
10484            .expect("class chunk");
10485
10486        assert_eq!(chunk.name, "Index");
10487        assert_eq!(chunk.qualified_name.as_deref(), Some("Engine.Index"));
10488        assert_eq!(chunk.embed_text, embed_text);
10489        assert!(!chunk.embed_text.contains("Engine.Index"));
10490    }
10491
10492    /// Heading symbols (Markdown / HTML headings) must NOT be indexed —
10493    /// they overwhelmingly dominated semantic results even on code-shaped
10494    /// queries because heading prose embeds far more strongly than code
10495    /// chunks. Skipping headings keeps aft_search a code-finder.
10496    #[test]
10497    fn symbols_to_chunks_skips_heading_symbols() {
10498        let project_root = PathBuf::from("/proj");
10499        let file = project_root.join("README.md");
10500        let source = "# Title\n\nbody text\n\n## Section\n\nmore text\n";
10501
10502        let symbols = vec![
10503            make_symbol(SymbolKind::Heading, "Title", 0, 2),
10504            make_symbol(SymbolKind::Heading, "Section", 4, 6),
10505        ];
10506
10507        let chunks = symbols_to_chunks(&file, &symbols, source, &project_root);
10508        assert!(
10509            chunks.is_empty(),
10510            "Heading symbols must be filtered out before embedding; got {} chunk(s)",
10511            chunks.len()
10512        );
10513    }
10514
10515    /// A symbol with an enormous signature (e.g. a YAML/Kubernetes CronJob
10516    /// whose inline `command:` script is parsed into the signature) must not
10517    /// produce an embed_text that overflows the embedding backend's physical
10518    /// batch. Before the clamp, the unbounded `signature:` append created a
10519    /// multi-KB input that aborted the whole index build and degraded every
10520    /// search to lexical-only.
10521    #[test]
10522    fn build_embed_text_clamps_oversized_signature() {
10523        let project_root = PathBuf::from("/proj");
10524        let file = project_root.join("cronjob.yaml");
10525        let huge_sig = "kubectl ".repeat(2000); // ~16 KB
10526        let source = "apiVersion: batch/v1\nkind: CronJob\n";
10527
10528        let mut symbol = make_symbol(SymbolKind::Class, "cluster-janitor", 0, 1);
10529        symbol.signature = Some(huge_sig);
10530
10531        let text = build_embed_text(&symbol, source, &file, &project_root);
10532        assert!(
10533            text.chars().count() <= MAX_EMBED_TEXT_CHARS,
10534            "embed_text must be clamped to {} chars, got {}",
10535            MAX_EMBED_TEXT_CHARS,
10536            text.chars().count()
10537        );
10538    }
10539
10540    #[test]
10541    fn embed_text_caps_resolve_per_backend_without_changing_defaults() {
10542        let defaults = EmbedTextCaps::default();
10543
10544        let mut local = SemanticBackendConfig {
10545            max_input_tokens: Some(960),
10546            ..SemanticBackendConfig::default()
10547        };
10548        assert_eq!(EmbedTextCaps::from_config(&local), defaults);
10549
10550        local.backend = SemanticBackend::OpenAiCompatible;
10551        local.base_url = Some("http://127.0.0.1:1234/v1".to_string());
10552        local.max_input_tokens = None;
10553        assert_eq!(EmbedTextCaps::from_config(&local), defaults);
10554
10555        local.max_input_tokens = Some(531);
10556        let expanded = EmbedTextCaps::from_config(&local);
10557        assert_eq!(expanded.signature_chars, 400);
10558        assert_eq!(expanded.body_lines, usize::MAX);
10559        assert_eq!(expanded.body_chars, 1001);
10560        assert_eq!(expanded.total_chars, 1858);
10561
10562        for backend in [SemanticBackend::Ollama, SemanticBackend::Synapse] {
10563            local.backend = backend;
10564            assert_eq!(EmbedTextCaps::from_config(&local), expanded);
10565        }
10566    }
10567
10568    #[test]
10569    fn semantic_fingerprint_changes_when_embed_text_caps_change() {
10570        let mut config = SemanticBackendConfig {
10571            backend: SemanticBackend::OpenAiCompatible,
10572            model: "test-embedding".to_string(),
10573            base_url: Some("http://127.0.0.1:1234/v1".to_string()),
10574            ..SemanticBackendConfig::default()
10575        };
10576        let legacy = SemanticIndexFingerprint::for_config_dimension(&config, 1024);
10577
10578        config.max_input_tokens = Some(531);
10579        let expanded = SemanticIndexFingerprint::for_config_dimension(&config, 1024);
10580
10581        assert_ne!(legacy.embed_text_caps, expanded.embed_text_caps);
10582        assert_ne!(legacy.as_string(), expanded.as_string());
10583        assert!(!legacy.matches(&expanded));
10584    }
10585
10586    #[test]
10587    fn file_summary_embed_text_is_independent_of_symbol_caps() {
10588        let project_root = PathBuf::from("/proj");
10589        let file = project_root.join("src/long.rs");
10590        let source = "//! module docs\npub fn exported() {}\n";
10591        let mut symbol = make_symbol(SymbolKind::Function, "exported", 1, 1);
10592        symbol.exported = true;
10593        symbol.signature = Some("pub fn exported()".to_string());
10594
10595        let legacy = symbols_to_chunks_with_caps(
10596            &file,
10597            std::slice::from_ref(&symbol),
10598            source,
10599            &project_root,
10600            EmbedTextCaps::default(),
10601        );
10602        let expanded = symbols_to_chunks_with_caps(
10603            &file,
10604            &[symbol],
10605            source,
10606            &project_root,
10607            EmbedTextCaps {
10608                signature_chars: 400,
10609                body_lines: usize::MAX,
10610                body_chars: 2500,
10611                total_chars: 3357,
10612            },
10613        );
10614
10615        let legacy_summary = legacy
10616            .iter()
10617            .find(|chunk| chunk.kind == SymbolKind::FileSummary)
10618            .expect("legacy file summary");
10619        let expanded_summary = expanded
10620            .iter()
10621            .find(|chunk| chunk.kind == SymbolKind::FileSummary)
10622            .expect("expanded file summary");
10623        assert_eq!(legacy_summary.embed_text, expanded_summary.embed_text);
10624    }
10625
10626    #[test]
10627    fn unbounded_chunk_caps_preserve_full_signature_and_body() {
10628        let project_root = PathBuf::from("/proj");
10629        let file = project_root.join("long.rs");
10630        let source = (0..20)
10631            .map(|line| format!("line_{line:02}_{}", "body".repeat(20)))
10632            .collect::<Vec<_>>()
10633            .join("\n");
10634        let mut symbol = make_symbol(SymbolKind::Function, "long_function", 0, 19);
10635        symbol.signature = Some(format!(
10636            "fn long_function({}) SIGNATURE_END",
10637            "x".repeat(500)
10638        ));
10639        let line_cache = SourceLineCache::new(&source);
10640
10641        let today = build_embed_text_with_lines_and_caps(
10642            &symbol,
10643            &line_cache,
10644            &file,
10645            &project_root,
10646            EmbedTextCaps::default(),
10647        );
10648        let full = build_embed_text_with_lines_and_caps(
10649            &symbol,
10650            &line_cache,
10651            &file,
10652            &project_root,
10653            EmbedTextCaps {
10654                signature_chars: usize::MAX,
10655                body_lines: usize::MAX,
10656                body_chars: usize::MAX,
10657                total_chars: usize::MAX,
10658            },
10659        );
10660
10661        assert!(!today.contains("SIGNATURE_END"));
10662        assert!(!today.contains("line_19"));
10663        assert!(full.contains("SIGNATURE_END"));
10664        assert!(full.contains("line_19"));
10665        assert!(full.len() > today.len());
10666    }
10667
10668    /// Code symbols (functions, classes, methods, structs, etc.) must still
10669    /// be indexed alongside the heading skip — otherwise we'd starve the
10670    /// index entirely.
10671    #[test]
10672    fn symbols_to_chunks_keeps_code_symbols_alongside_skipped_headings() {
10673        let project_root = PathBuf::from("/proj");
10674        let file = project_root.join("src/lib.rs");
10675        let source = "pub fn handle_request() -> bool {\n    true\n}\n";
10676
10677        let symbols = vec![
10678            // A heading mixed in (e.g. from a doc comment block elsewhere).
10679            make_symbol(SymbolKind::Heading, "doc heading", 0, 1),
10680            make_symbol(SymbolKind::Function, "handle_request", 0, 2),
10681            make_symbol(SymbolKind::Struct, "AuthService", 4, 6),
10682        ];
10683
10684        let chunks = symbols_to_chunks(&file, &symbols, source, &project_root);
10685        assert_eq!(
10686            chunks.len(),
10687            3,
10688            "Expected file-summary + 2 code chunks (Function + Struct), got {}",
10689            chunks.len()
10690        );
10691        let names: Vec<&str> = chunks.iter().map(|c| c.name.as_str()).collect();
10692        assert!(chunks
10693            .iter()
10694            .any(|chunk| matches!(chunk.kind, SymbolKind::FileSummary)));
10695        assert!(names.contains(&"handle_request"));
10696        assert!(names.contains(&"AuthService"));
10697        assert!(
10698            !names.contains(&"doc heading"),
10699            "Heading symbol leaked into chunks: {names:?}"
10700        );
10701    }
10702
10703    #[test]
10704    fn validate_ssrf_allows_loopback_hostnames() {
10705        // Loopback hostnames are explicitly allowed so self-hosted backends
10706        // (Ollama at http://localhost:11434) work at their default config.
10707        for host in &[
10708            "http://localhost",
10709            "http://localhost:8080",
10710            "http://localhost:11434", // Ollama default
10711            "http://localhost.localdomain",
10712            "http://foo.localhost",
10713        ] {
10714            assert!(
10715                validate_base_url_no_ssrf(host).is_ok(),
10716                "Expected {host} to be allowed (loopback), got: {:?}",
10717                validate_base_url_no_ssrf(host)
10718            );
10719        }
10720    }
10721
10722    #[test]
10723    fn validate_ssrf_allows_loopback_ips() {
10724        // 127.0.0.0/8 is loopback — by definition same-machine and not an
10725        // SSRF target. Allow it so Ollama at http://127.0.0.1:11434 works.
10726        for url in &[
10727            "http://127.0.0.1",
10728            "http://127.0.0.1:11434", // Ollama default
10729            "http://127.0.0.1:8080",
10730            "http://127.1.2.3",
10731        ] {
10732            let result = validate_base_url_no_ssrf(url);
10733            assert!(
10734                result.is_ok(),
10735                "Expected {url} to be allowed (loopback), got: {:?}",
10736                result
10737            );
10738        }
10739    }
10740
10741    #[test]
10742    fn validate_ssrf_rejects_private_non_loopback_ips() {
10743        // Non-loopback private/reserved IPs remain rejected — homelab/intranet
10744        // services on LAN IPs are real SSRF targets even though the user
10745        // configured them. Users who want this can opt in by binding the
10746        // service to a public-routable address.
10747        for url in &[
10748            "http://192.168.1.1",
10749            "http://10.0.0.1",
10750            "http://172.16.0.1",
10751            "http://169.254.169.254",
10752            "http://100.64.0.1",
10753        ] {
10754            let result = validate_base_url_no_ssrf(url);
10755            assert!(
10756                result.is_err(),
10757                "Expected {url} to be rejected (non-loopback private), got: {:?}",
10758                result
10759            );
10760        }
10761    }
10762
10763    #[test]
10764    fn validate_ssrf_rejects_mdns_local_hostnames() {
10765        // mDNS .local hostnames typically resolve to LAN devices, not
10766        // loopback. Rejecting them before DNS lookup gives a clearer error.
10767        for host in &[
10768            "http://printer.local",
10769            "http://nas.local:8080",
10770            "http://homelab.local",
10771        ] {
10772            let result = validate_base_url_no_ssrf(host);
10773            assert!(
10774                result.is_err(),
10775                "Expected {host} to be rejected (mDNS), got: {:?}",
10776                result
10777            );
10778        }
10779    }
10780
10781    #[test]
10782    fn normalize_base_url_allows_localhost_for_tests() {
10783        // normalize_base_url itself should NOT block localhost — only
10784        // validate_base_url_no_ssrf does. Tests construct backends directly.
10785        assert!(normalize_base_url("http://127.0.0.1:9999").is_ok());
10786        assert!(normalize_base_url("http://localhost:8080").is_ok());
10787    }
10788
10789    #[test]
10790    fn ssrf_guard_blocks_reserved_ranges_but_allows_loopback() {
10791        use std::net::IpAddr;
10792        let blocked = |s: &str| is_private_non_loopback_ip(&s.parse::<IpAddr>().unwrap());
10793
10794        // Private / link-local / CGNAT — blocked (unchanged behavior).
10795        assert!(blocked("10.0.0.1"));
10796        assert!(blocked("192.168.1.1"));
10797        assert!(blocked("169.254.0.1"));
10798        assert!(blocked("100.64.0.1"));
10799        // Newly covered by delegating to url_fetch's complete list:
10800        assert!(
10801            blocked("198.18.0.1"),
10802            "RFC2544 benchmark range must be blocked"
10803        );
10804        assert!(blocked("224.0.0.1"), "multicast must be blocked");
10805        assert!(blocked("fc00::1"), "IPv6 ULA must be blocked");
10806        assert!(blocked("fe80::1"), "IPv6 link-local must be blocked");
10807
10808        // Loopback — allowed (local Ollama endpoint), incl. IPv4-mapped form.
10809        assert!(!blocked("127.0.0.1"), "loopback must stay allowed");
10810        assert!(!blocked("::1"), "IPv6 loopback must stay allowed");
10811        assert!(
10812            !blocked("::ffff:127.0.0.1"),
10813            "IPv4-mapped loopback must stay allowed (matches prior carve-out)"
10814        );
10815
10816        // A public address must NOT be flagged.
10817        assert!(!blocked("8.8.8.8"));
10818    }
10819
10820    /// Pin the user-facing wording of the ONNX version-mismatch error.
10821    /// The auto-fix path MUST be listed first because it's the only safe
10822    /// option that doesn't require sudo or risk breaking other apps that
10823    /// link the system library. Regression of any of these strings would
10824    /// either mislead users (system rm before auto-fix) or break the
10825    /// `aft doctor --fix` discovery path.
10826    #[test]
10827    fn ort_mismatch_message_recommends_auto_fix_first() {
10828        let msg =
10829            format_ort_version_mismatch("1.9.0", "/usr/lib/x86_64-linux-gnu/libonnxruntime.so");
10830
10831        // The reported version and path must appear verbatim.
10832        assert!(
10833            msg.contains("v1.9.0"),
10834            "should report detected version: {msg}"
10835        );
10836        assert!(
10837            msg.contains("/usr/lib/x86_64-linux-gnu/libonnxruntime.so"),
10838            "should report system path: {msg}"
10839        );
10840        assert!(msg.contains("v1.20+"), "should state requirement: {msg}");
10841
10842        // Solution ordering: auto-fix is #1, system rm is #2, install is #3.
10843        let auto_fix_pos = msg
10844            .find("Auto-fix")
10845            .expect("Auto-fix solution missing — users won't discover --fix");
10846        let remove_pos = msg
10847            .find("Remove the old library")
10848            .expect("system-rm solution missing");
10849        assert!(
10850            auto_fix_pos < remove_pos,
10851            "Auto-fix must come before manual rm — see PR comment thread"
10852        );
10853
10854        // The auto-fix command must be runnable as-is on a fresh system.
10855        assert!(
10856            msg.contains("npx @cortexkit/aft doctor --fix"),
10857            "auto-fix command must be present and copy-pasteable: {msg}"
10858        );
10859    }
10860
10861    #[cfg(any(target_os = "linux", target_os = "macos"))]
10862    #[test]
10863    fn loaded_ort_version_detection_prefers_actual_loaded_library_path() {
10864        let requested = "libonnxruntime.so";
10865        let actual = "/usr/local/lib/libonnxruntime.so.1.19.0";
10866
10867        assert_eq!(detect_ort_version_from_path(requested), None);
10868        let (version, source) =
10869            detect_ort_version_from_resolved_or_requested(Some(actual.to_string()), requested);
10870
10871        assert_eq!(version, Some("1.19.0".to_string()));
10872        assert_eq!(source, actual);
10873
10874        let msg = format_ort_version_mismatch(&version.unwrap(), &source);
10875        assert!(msg.contains("v1.19.0"));
10876        assert!(msg.contains(actual));
10877    }
10878
10879    /// macOS dylib paths must not produce a malformed message when the
10880    /// system path lacks a trailing slash. This is a regression guard
10881    /// for the "{}\n{}" format string contract.
10882    #[test]
10883    fn ort_mismatch_message_handles_macos_dylib_path() {
10884        let msg = format_ort_version_mismatch("1.9.0", "/opt/homebrew/lib/libonnxruntime.dylib");
10885        assert!(msg.contains("v1.9.0"));
10886        assert!(msg.contains("/opt/homebrew/lib/libonnxruntime.dylib"));
10887        // The dylib path must appear in the auto-fix paragraph (single
10888        // quotes around it) AND in the manual-rm paragraph; verify
10889        // both placements survived the format string.
10890        assert!(
10891            msg.contains("'/opt/homebrew/lib/libonnxruntime.dylib'"),
10892            "system path should be quoted in the auto-fix sentence: {msg}"
10893        );
10894    }
10895
10896    // ── managed ONNX Runtime resolver tests ──────────────────────────────────
10897
10898    /// Build a fake `<storage>/onnxruntime/<version>/<libname>` tree. Returns
10899    /// the storage root. `lib_name` is the platform library filename the
10900    /// resolver looks for.
10901    fn fake_managed_ort_tree(storage: &std::path::Path, lib_name: &str, versions: &[(&str, bool)]) {
10902        for (version, has_lib) in versions {
10903            let dir = storage.join("onnxruntime").join(version);
10904            std::fs::create_dir_all(&dir).unwrap();
10905            if *has_lib {
10906                std::fs::write(dir.join(lib_name), b"fake-ort").unwrap();
10907            }
10908        }
10909    }
10910
10911    #[test]
10912    fn managed_ort_resolver_picks_highest_compatible_version() {
10913        let _env_lock = crate::test_env::process_env_lock();
10914        let storage = tempfile::tempdir().unwrap();
10915        fake_managed_ort_tree(
10916            storage.path(),
10917            MANAGED_ORT_LIB_NAME,
10918            &[
10919                ("1.19.0", true), // below the 1.20 floor — must be ignored
10920                ("1.20.1", true),
10921                ("1.24.4", true), // highest compatible — must win
10922                ("1.23.0", true),
10923            ],
10924        );
10925        let found = find_managed_onnx_runtime(storage.path()).expect("resolver finds a runtime");
10926        assert_eq!(
10927            found,
10928            storage
10929                .path()
10930                .join("onnxruntime")
10931                .join("1.24.4")
10932                .join(MANAGED_ORT_LIB_NAME)
10933        );
10934    }
10935
10936    #[test]
10937    fn managed_ort_resolver_ignores_non_version_and_pre_120_dirs() {
10938        let _env_lock = crate::test_env::process_env_lock();
10939        let storage = tempfile::tempdir().unwrap();
10940        fake_managed_ort_tree(
10941            storage.path(),
10942            MANAGED_ORT_LIB_NAME,
10943            &[
10944                ("1.19.0", true),     // pre-1.20 — ignored
10945                ("1.24.4.tmp", true), // not a parseable version — ignored
10946                ("latest", true),     // not a version — ignored
10947                ("1.24.4", false),    // compatible but no library file — ignored
10948            ],
10949        );
10950        assert_eq!(
10951            find_managed_onnx_runtime(storage.path()),
10952            None,
10953            "no compatible version with a library file should resolve"
10954        );
10955    }
10956
10957    #[test]
10958    fn empty_onnx_runtime_override_is_unset_with_an_injected_lookup() {
10959        assert!(!onnx_runtime_override_configured_with(|key| {
10960            assert_eq!(key, "ORT_DYLIB_PATH");
10961            Some(std::ffi::OsString::new())
10962        }));
10963        assert!(onnx_runtime_override_configured_with(|_| Some(
10964            std::ffi::OsString::from("/runtime/libonnxruntime.so")
10965        )));
10966    }
10967
10968    #[test]
10969    fn managed_ort_resolver_absent_tree_falls_through() {
10970        let _env_lock = crate::test_env::process_env_lock();
10971        let storage = tempfile::tempdir().unwrap();
10972        // No onnxruntime/ dir at all.
10973        assert_eq!(find_managed_onnx_runtime(storage.path()), None);
10974        // Empty onnxruntime/ dir.
10975        std::fs::create_dir_all(storage.path().join("onnxruntime")).unwrap();
10976        assert_eq!(find_managed_onnx_runtime(storage.path()), None);
10977    }
10978
10979    #[test]
10980    fn managed_ort_resolver_prefers_version_root_over_lib_subdir() {
10981        let _env_lock = crate::test_env::process_env_lock();
10982        let storage = tempfile::tempdir().unwrap();
10983        let version_dir = storage.path().join("onnxruntime").join("1.24.4");
10984        std::fs::create_dir_all(version_dir.join("lib")).unwrap();
10985        // Both the version root and the lib/ subdir hold the library; the root
10986        // must win (mirrors resolveCachedOnnxRuntimeDir).
10987        std::fs::write(version_dir.join(MANAGED_ORT_LIB_NAME), b"root").unwrap();
10988        std::fs::write(version_dir.join("lib").join(MANAGED_ORT_LIB_NAME), b"lib").unwrap();
10989        let found = find_managed_onnx_runtime(storage.path()).expect("resolver finds a runtime");
10990        assert_eq!(found, version_dir.join(MANAGED_ORT_LIB_NAME));
10991    }
10992
10993    #[test]
10994    fn managed_ort_resolver_accepts_lib_subdir_only() {
10995        let _env_lock = crate::test_env::process_env_lock();
10996        let storage = tempfile::tempdir().unwrap();
10997        let version_dir = storage.path().join("onnxruntime").join("1.24.4");
10998        std::fs::create_dir_all(version_dir.join("lib")).unwrap();
10999        // Library only under lib/ (manual Microsoft-archive install, #71).
11000        std::fs::write(version_dir.join("lib").join(MANAGED_ORT_LIB_NAME), b"lib").unwrap();
11001        let found = find_managed_onnx_runtime(storage.path()).expect("resolver finds a runtime");
11002        assert_eq!(found, version_dir.join("lib").join(MANAGED_ORT_LIB_NAME));
11003    }
11004
11005    #[test]
11006    fn managed_ort_resolver_pre_set_env_short_circuits_without_reading_tree() {
11007        let _env_lock = crate::test_env::process_env_lock();
11008        let storage = tempfile::tempdir().unwrap();
11009        // Plant a poison dir that would panic the resolver if it were read:
11010        // a version dir whose name is a valid version but whose library file is
11011        // a directory (so `is_file()` would be false) — harmless, but the point
11012        // is the resolver must never even look.
11013        let poison = storage.path().join("onnxruntime").join("1.24.4");
11014        std::fs::create_dir_all(poison.join(MANAGED_ORT_LIB_NAME)).unwrap();
11015
11016        let before = MANAGED_ORT_PROBE_READS.load(Ordering::Relaxed);
11017        // Pre-set ORT_DYLIB_PATH — the resolver must not run at all.
11018        std::env::set_var("ORT_DYLIB_PATH", "/explicit/override/libonnxruntime.so");
11019        resolve_managed_onnx_runtime(storage.path());
11020        std::env::remove_var("ORT_DYLIB_PATH");
11021        assert_eq!(
11022            MANAGED_ORT_PROBE_READS.load(Ordering::Relaxed),
11023            before,
11024            "resolver must not read the storage tree when ORT_DYLIB_PATH is pre-set"
11025        );
11026    }
11027
11028    #[test]
11029    fn cancelled_build_stops_before_the_next_embed_batch() {
11030        let project = tempfile::tempdir().expect("project directory");
11031        let files = (0..16)
11032            .map(|index| {
11033                let path = project.path().join(format!("batch_{index}.rs"));
11034                std::fs::write(&path, format!("pub fn batch_symbol_{index}() {{}}\n"))
11035                    .expect("write source");
11036                path
11037            })
11038            .collect::<Vec<_>>();
11039        let cancelled = std::sync::atomic::AtomicBool::new(false);
11040        let embed_calls = AtomicUsize::new(0);
11041        let total_chunks = AtomicUsize::new(0);
11042        let mut embed = |texts: Vec<String>| {
11043            let call = embed_calls.fetch_add(1, Ordering::SeqCst) + 1;
11044            assert_eq!(texts.len(), 1, "one chunk per mocked batch");
11045            if call == 1 {
11046                cancelled.store(true, Ordering::SeqCst);
11047            }
11048            Ok(vec![vec![1.0, 2.0, 3.0]])
11049        };
11050        let mut progress = |done: usize, total: usize| {
11051            assert!(done <= total);
11052            total_chunks.store(total, Ordering::SeqCst);
11053        };
11054        let mut should_continue = || !cancelled.load(Ordering::SeqCst);
11055
11056        let error = SemanticIndex::build_with_progress_and_cancellation(
11057            project.path(),
11058            &files,
11059            &mut embed,
11060            1,
11061            &mut progress,
11062            &mut should_continue,
11063        )
11064        .expect_err("the second batch boundary observes cancellation");
11065
11066        let total_chunks = total_chunks.load(Ordering::SeqCst);
11067        assert!(error.contains("semantic build superseded"));
11068        assert_eq!(embed_calls.load(Ordering::SeqCst), 1);
11069        assert!(
11070            total_chunks > 4,
11071            "fixture must contain enough chunks to demonstrate an early stop, got {total_chunks}"
11072        );
11073    }
11074
11075    #[test]
11076    fn managed_ort_resolver_sets_env_when_found() {
11077        let _env_lock = crate::test_env::process_env_lock();
11078        let storage = tempfile::tempdir().unwrap();
11079        fake_managed_ort_tree(storage.path(), MANAGED_ORT_LIB_NAME, &[("1.24.4", true)]);
11080        std::env::remove_var("ORT_DYLIB_PATH");
11081        resolve_managed_onnx_runtime(storage.path());
11082        let set = std::env::var_os("ORT_DYLIB_PATH").expect("resolver sets ORT_DYLIB_PATH");
11083        assert_eq!(
11084            PathBuf::from(set),
11085            storage
11086                .path()
11087                .join("onnxruntime")
11088                .join("1.24.4")
11089                .join(MANAGED_ORT_LIB_NAME)
11090        );
11091        std::env::remove_var("ORT_DYLIB_PATH");
11092    }
11093}