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;
31const 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;
115const SEMANTIC_INDEX_VERSION_V3: u8 = 3;
120const SEMANTIC_INDEX_VERSION_V4: u8 = 4;
123const SEMANTIC_INDEX_VERSION_V5: u8 = 5;
126const SEMANTIC_INDEX_VERSION_V6: u8 = 6;
128const SEMANTIC_INDEX_VERSION_V7: u8 = 7;
130const 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";
141const 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#[cfg(test)]
161static MANAGED_ORT_PROBE_READS: AtomicUsize = AtomicUsize::new(0);
162
163#[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 #[serde(default)]
261 pub embed_text_caps: EmbedTextCaps,
262 #[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 #[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 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(¤t)
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, ¤t.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(¤t.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(¤t.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, ¤t)
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(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
551fn 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
566pub 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 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 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 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
624fn is_private_non_loopback_ip(ip: &std::net::IpAddr) -> bool {
635 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
674fn 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 embedding_send_error_is_transient(error)
717}
718
719fn embedding_send_error_is_transient(error: &reqwest::Error) -> bool {
724 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 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
801fn 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
819pub const TRANSIENT_EMBEDDING_MARKER: &str = "[transient] ";
826
827pub fn embedding_failure_is_transient(error: &str) -> bool {
830 error.contains(TRANSIENT_EMBEDDING_MARKER)
831}
832
833pub 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
865pub const QUERY_EMBEDDING_TIMEOUT_MARKER_PREFIX: &str = "[query-timeout:";
878pub const QUERY_EMBEDDING_TIMEOUT_MARKER_SUFFIX: &str = "]";
879
880pub(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
888pub 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
898pub 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 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 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 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 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 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 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 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 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 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#[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
1701const MANAGED_ORT_MIN_MINOR: u32 = 20;
1707
1708pub 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
1754fn 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 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
1796fn 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
1810fn 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 if parts.next().is_some() {
1820 return None;
1821 }
1822 Some((major, minor))
1823}
1824
1825pub 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 let (detected_version, version_source) =
1863 detect_ort_version_from_loaded_library(handle, lib_name);
1864
1865 libc::dlclose(handle);
1866
1867 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 let lib_name = dylib_path.as_deref().unwrap_or("onnxruntime.dll");
1889
1890 #[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, dw_file_version_ls: u32, 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 let mut detected_major: u32 = 0;
1962 let mut detected_minor: u32 = 0;
1963 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 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#[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 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 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#[cfg(any(target_os = "linux", target_os = "macos"))]
2108fn extract_version_from_filename(name: &str) -> Option<String> {
2109 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
2127pub(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#[derive(Debug, Clone)]
2189pub struct SemanticChunk {
2190 pub file: PathBuf,
2192 pub name: String,
2194 pub qualified_name: Option<String>,
2196 pub kind: SymbolKind,
2198 pub start_line: u32,
2200 pub end_line: u32,
2201 pub exported: bool,
2203 pub embed_text: String,
2205 pub snippet: String,
2207}
2208
2209#[derive(Debug, Clone)]
2211pub struct EmbeddingEntry {
2212 chunk: SemanticChunk,
2213 vector: Vec<f32>,
2214 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#[derive(Debug, Clone)]
2392pub struct SemanticIndex {
2393 entries: Vec<EmbeddingEntry>,
2394 file_mtimes: HashMap<PathBuf, SystemTime>,
2396 file_sizes: HashMap<PathBuf, u64>,
2398 any_missing_sizes: bool,
2400 file_hashes: HashMap<PathBuf, blake3::Hash>,
2401 dimension: usize,
2403 fingerprint: Option<SemanticIndexFingerprint>,
2404 project_root: PathBuf,
2405 deferred_files: HashSet<PathBuf>,
2406 shared_base: Option<Arc<SharedSemanticBase>>,
2407 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#[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 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 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#[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 if !deleted.is_empty() {
3861 self.remove_indexed_files(&deleted);
3862 }
3863
3864 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 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 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 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 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 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 pub fn search(&self, query_vector: &[f32], top_k: usize) -> Vec<SemanticResult> {
4345 self.search_filtered(query_vector, top_k, |_| true)
4346 }
4347
4348 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 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 .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 pub fn len(&self) -> usize {
4447 self.entry_count()
4448 }
4449
4450 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 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 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 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 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 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 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 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 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 for (relative, entry) in persisted_entries {
5812 let c = &entry.chunk;
5813
5814 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 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 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 write_counted(writer, &[symbol_kind_to_u8(&c.kind)], &mut bytes_written)?;
5844
5845 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 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 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 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 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 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 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 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 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 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 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 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
6292fn 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 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 text.push_str(&format!(
6335 " signature:{}",
6336 truncate_chars(sig, caps.signature_chars)
6337 ));
6338 }
6339
6340 let start = (symbol.range.start_line as usize).min(line_cache.len());
6342 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 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
6376const MAX_EMBED_TEXT_CHARS: usize = 1600;
6378const DEFAULT_SIGNATURE_CHARS: usize = 400;
6379const DEFAULT_BODY_LINES: usize = 15;
6380const DEFAULT_BODY_CHARS: usize = 300;
6381pub 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
6650const 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 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 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
6801fn build_snippet_with_lines(symbol: &Symbol, line_cache: &SourceLineCache<'_>) -> String {
6803 let start = (symbol.range.start_line as usize).min(line_cache.len());
6804 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#[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 if matches!(symbol.kind, SymbolKind::Heading) {
6908 continue;
6909 }
6910
6911 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 }
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
6950fn 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#[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
6984fn 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 #[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 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 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 #[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 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 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 assert_eq!(strip_transient_embedding_marker(permanent), permanent);
7200 }
7201 }
7202
7203 #[test]
7204 fn send_error_transience_separates_connect_timeout_from_4xx() {
7205 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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 ¤t_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 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 for (i, name) in ["auth", "database", "handler"].iter().enumerate() {
9115 let mut vec = vec![0.0f32; 3];
9116 vec[i] = 1.0; 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 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"); 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 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 let chunks = collect_file_chunks(dir.path(), &big).unwrap();
9355 assert!(chunks.is_empty(), "oversized file must yield no chunks");
9356
9357 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 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 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 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 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 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 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 #[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 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 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), ¤t);
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 #[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 #[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); 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 #[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 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 for host in &[
10708 "http://localhost",
10709 "http://localhost:8080",
10710 "http://localhost:11434", "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 for url in &[
10727 "http://127.0.0.1",
10728 "http://127.0.0.1:11434", "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 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 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 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 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 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 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 assert!(!blocked("8.8.8.8"));
10818 }
10819
10820 #[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 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 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 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 #[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 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 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), ("1.20.1", true),
10921 ("1.24.4", true), ("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), ("1.24.4.tmp", true), ("latest", true), ("1.24.4", false), ],
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 assert_eq!(find_managed_onnx_runtime(storage.path()), None);
10974 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 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 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 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 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}