diff --git a/src/index/brain.rs b/src/index/brain.rs index 16b8c06..b9746f8 100644 --- a/src/index/brain.rs +++ b/src/index/brain.rs @@ -385,11 +385,43 @@ pub fn vector_index_needs_rebuild() -> bool { .unwrap_or(false) } +/// Return a read guard over the vector index cache, loading the on-disk +/// index once per process if needed (#545). VECTOR_CACHE used to be +/// populated only by `embed_project` (i.e. `cora index`), so a fresh search +/// process (`cora brain`, MCP tools) always saw an empty cache and the +/// vector signal never fired. Load failures degrade gracefully: the cache +/// stays `None` and search falls back to FTS + graph signals. +/// +/// `RwLockWriteGuard::downgrade` is still unstable, so this drops the write +/// guard and re-acquires a read lock; a concurrent reader may load twice, +/// which is idempotent (same file, same result). +fn ensure_vector_cache() -> std::sync::RwLockReadGuard<'static, Option> { + { + let cache = VECTOR_CACHE.read().unwrap(); + if cache.is_some() { + return cache; + } + } + + let mut cache = VECTOR_CACHE.write().unwrap(); + if cache.is_none() { + let dims = active_dims(); + match CodeVectorIndex::load_or_create(&vector_index_path(), dims) { + Ok(vi) => *cache = Some(vi), + Err(e) => tracing::warn!("vector index load failed (search degrades to FTS): {e}"), + } + } + drop(cache); + + VECTOR_CACHE.read().unwrap() +} + /// usearch vector search → (symbol_id, cosine_similarity) pairs, filtered to project. /// Uses cached vector index and cached project ID set — no disk I/O per query. fn vector_search(conn: &Connection, project_id: i64, query: &str, limit: usize) -> Vec<(i64, f32)> { - // Read-lock the cached vector index — no disk load - let cache = VECTOR_CACHE.read().unwrap(); + // Read-lock the cached vector index — lazy-loads from disk on the first + // search in this process (#545). + let cache = ensure_vector_cache(); let vi = match cache.as_ref() { Some(v) if !v.is_empty() => v, _ => return Vec::new(), @@ -397,6 +429,12 @@ fn vector_search(conn: &Connection, project_id: i64, query: &str, limit: usize) let vec = embed_code_dispatch(query); + // Dimension mismatch (e.g. embedding provider switched since the index + // was built) would panic inside the backend — degrade to other signals. + if vi.dims() != vec.len() { + return Vec::new(); + } + // Over-fetch to compensate for post-filter by project_id. let over_fetch = (limit * 5).max(50); let raw = vi.search(&vec, over_fetch); diff --git a/tests/brain_search_process.rs b/tests/brain_search_process.rs new file mode 100644 index 0000000..20b853d --- /dev/null +++ b/tests/brain_search_process.rs @@ -0,0 +1,88 @@ +//! Regression tests for #545: `cora brain` in a fresh process must load +//! the on-disk vector index and emit the `vector` signal. Before the fix, +//! `VECTOR_CACHE` was only populated by `embed_project` (`cora index`), so +//! every search-only process silently degraded to FTS-only results. + +use assert_cmd::prelude::*; +use std::path::PathBuf; +use std::process::Command; + +fn cora_cmd() -> Command { + Command::cargo_bin("cora").unwrap() +} + +/// Isolated CODECORA_HOME + tiny project, so tests never touch real data. +fn sandbox(name: &str) -> (PathBuf, PathBuf) { + let root = std::env::temp_dir().join(format!("cora-545-{name}")); + let _ = std::fs::remove_dir_all(&root); + let proj = root.join("proj"); + std::fs::create_dir_all(&proj).unwrap(); + std::fs::write( + proj.join("Cargo.toml"), + "[package]\nname = \"p545\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", + ) + .unwrap(); + std::fs::write( + proj.join("lib.rs"), + "pub fn alpha_widget() {}\npub fn beta_gadget() {}\n", + ) + .unwrap(); + let home = root.join("home"); + std::fs::create_dir_all(&home).unwrap(); + (proj, home) +} + +fn run(cora: &mut Command) -> String { + let out = cora.assert().success().get_output().stdout.clone(); + String::from_utf8(out).unwrap() +} + +#[test] +fn brain_fresh_process_emits_vector_signal() { + let (proj, home) = sandbox("usearch"); + + // 1. Index in one process (default usearch backend). + run(cora_cmd() + .args(["index"]) + .current_dir(&proj) + .env("CODECORA_HOME", &home)); + + let db_dir = home.join("cora-code"); + assert!( + db_dir.join("cora_index.usearch").exists(), + "usearch index file should exist after `cora index`" + ); + + // 2. Search in a FRESH process — vector signal must fire. + let out = run(cora_cmd() + .args(["brain", "alpha"]) + .current_dir(&proj) + .env("CODECORA_HOME", &home)); + + assert!( + out.contains("vector"), + "fresh-process brain search must emit the vector signal, got:\n{out}" + ); +} + +#[test] +fn brain_vecq_backend_uses_own_extension() { + let (proj, home) = sandbox("vecq"); + + std::fs::write(proj.join(".cora.yaml"), "brain:\n vector_store: vecq\n").unwrap(); + + run(cora_cmd() + .args(["index"]) + .current_dir(&proj) + .env("CODECORA_HOME", &home)); + + let db_dir = home.join("cora-code"); + assert!( + db_dir.join("cora_index.vecq").exists(), + "vecq config must produce a .vecq index file (config wiring #543)" + ); + assert!( + !db_dir.join("cora_index.usearch").exists(), + "vecq config must not create a usearch file" + ); +}