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