Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions src/index/brain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -385,18 +385,56 @@ 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<CodeVectorIndex>> {
{
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(),
};

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);
Expand Down
88 changes: 88 additions & 0 deletions tests/brain_search_process.rs
Original file line number Diff line number Diff line change
@@ -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"
);
}
Loading