From 400910f1eaa6810e752b650a01b7f013af1bcc9c Mon Sep 17 00:00:00 2001 From: "Anaz S. Aji" Date: Sat, 29 Aug 2026 19:28:29 +0700 Subject: [PATCH 1/5] feat(brain): opt-in vecq vector store backend (#543) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(brain): opt-in vecq vector store backend Add `brain.vector_store: usearch|vecq` (default usearch). vecq-core 0.2.0 replaces the HNSW store with a 4-bit quantized brute-force scan: pure Rust (no C++ FFI), bit-identical deterministic results, ~6x smaller index, 14x faster builds — query latency stays sub-ms at cora's typical index sizes and the recall trade is absorbed by Brain's RRF fusion (#542). - CodeVectorIndex gains an enum backend (Usearch | Vecq); every backend owns its file extension (.usearch / .vecq) so switching stores never misreads the other's format - config applied at every Brain entry point (index, watch, brain cmd, MCP brain_search) so CLI and MCP cannot silently diverge - vecq insert/remove map symbol ids to native u64 keys (tombstones); search converts similarity to the cosine-distance contract Known limitation (documented + tested): vecq-core 0.2.0 from_bytes does not serialize the keyed map (vecq#32), so reload rebuilds fresh instead of serving a silently-empty index; `cora index` re-embeds. Persistence lands when vecq ships key serialization. Regression tests: backend roundtrip (insert/replace/remove/search + distance conversion), per-backend persistence extensions, lenient store-kind parsing. Signed-off-by: ajianaz * fix(brain): wire brain section config, self-heal vecq reload, dedupe config load Three fixes from PR #543 review: 1. Config wiring: .cora.yaml 'brain:' section was never parsed — CoraFile had no brain field, so vector_store: vecq silently fell back to usearch and embedding mode was ignored. Add BrainSection + field-wise merge_into (matches other sections). 2. Vecq reload self-heal: a reloaded vecq index is rebuilt empty (upstream vecq#32 lacks key serialization) and arrived with dirty=false, so a no-change 'cora index' run saved an empty index over the stored one — permanently losing vector signal. Now reload marks the index dirty, embed_project clears stale embed fingerprints when it sees dirty+empty, and cora index forces re-embed when the on-disk vecq file needs a rebuild. 3. Brain command: load_config was called twice; fold into one load shared by resolve_backend and apply_config_store. Verified: 937/937 tests pass; e2e with CODECORA_HOME sandbox — vector_store: vecq now creates cora_index.vecq config now works, and a no-change re-run logs 'cleared=2, re-embedding all symbols' instead of silently persisting an empty index. --------- Signed-off-by: ajianaz Co-authored-by: ajianaz --- CHANGELOG.md | 4 + Cargo.lock | 7 + Cargo.toml | 1 + src/commands/watch.rs | 1 + src/config/schema.rs | 33 ++++ src/index/brain.rs | 30 ++++ src/index/mod.rs | 8 +- src/index/vector.rs | 360 ++++++++++++++++++++++++++++++++++++------ src/main.rs | 13 +- src/mcp/tools.rs | 1 + 10 files changed, 405 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4679547..c3a3368 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Opt-in `vecq` vector store for Brain Mode.** Set `brain.vector_store: vecq` in `.cora.yaml` to replace the usearch HNSW index with vecq 4-bit quantized scan (pure Rust, deterministic, ~6x smaller). Opt-in only; keyed persistence awaits vecq#32, so indexes rebuild fresh on load for now (#542). + ## [0.14.0] - 2026-08-28 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index cf0a2d3..645686a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -350,6 +350,7 @@ dependencies = [ "tree-sitter-scala", "tree-sitter-typescript", "usearch", + "vecq-core", "which", ] @@ -2396,6 +2397,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "vecq-core" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5f5e3c486177edd6c21108dfe621a7e9c85730c0dbb0ce76673e6682a68e90b" + [[package]] name = "version_check" version = "0.9.5" diff --git a/Cargo.toml b/Cargo.toml index 4623ae6..f1084c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,6 +72,7 @@ rusqlite = { version = "0.31", features = ["bundled"] } # Vector search (Phase 3 — Brain Mode) usearch = "2" +vecq-core = "0.2.0" fs2 = "0.4" # Self-update (upgrade command) diff --git a/src/commands/watch.rs b/src/commands/watch.rs index 0d4c0da..c18920e 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -51,6 +51,7 @@ pub fn run_watch( .map(|c| c.brain.embedding.to_string()) .unwrap_or_else(|| "auto".to_string()); crate::embed::resolve_backend(&brain_mode); + crate::index::vector::apply_config_store(config.as_ref()); let skip_ref: Option<&[String]> = skip_patterns.as_deref(); diff --git a/src/config/schema.rs b/src/config/schema.rs index 56bdd67..9d60c79 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -328,6 +328,8 @@ pub struct CoraFile { pub profile: Option, #[serde(skip_serializing_if = "Option::is_none")] pub analysis: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub brain: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -445,8 +447,19 @@ pub struct AnalysisConfig { /// By default (`auto`), cora selects the best available backend at runtime: /// pretrained 768d (if compiled with `pretrained-embed` feature) → hashing 256d fallback. /// Users can force a specific backend via `.cora.yaml`. +fn default_vector_store() -> String { + "usearch".to_string() +} + #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] pub struct BrainConfig { + /// Vector store backend for Brain Mode search. + /// + /// - `"usearch"` (default) — HNSW graph, f32 + /// - `"vecq"` — 4-bit quantized brute-force scan (pure Rust, deterministic, + /// ~6x smaller index; recall trade absorbed by RRF fusion) + #[serde(default = "default_vector_store")] + pub vector_store: String, /// Embedding backend selection. /// /// - `"auto"` (default) — best available: pretrained → hashing @@ -458,6 +471,16 @@ pub struct BrainConfig { pub embedding: BrainEmbeddingMode, } +/// `.cora.yaml` `brain:` section — mirrors the subset of [`BrainConfig`] +/// that is meaningful in a config file. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct BrainSection { + #[serde(skip_serializing_if = "Option::is_none")] + pub vector_store: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub embedding: Option, +} + /// Embedding backend mode for Brain Mode. #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] #[serde(rename_all = "lowercase")] @@ -766,6 +789,16 @@ impl CoraFile { .clone_from(&analysis.entry_point_patterns); } } + // Merge brain config (Brain Mode vector store / embedding backend). + // Field-wise like the other sections: unset fields keep Config defaults. + if let Some(brain) = &self.brain { + if let Some(v) = &brain.vector_store { + config.brain.vector_store.clone_from(v); + } + if let Some(v) = &brain.embedding { + config.brain.embedding = v.clone(); + } + } Ok(()) } } diff --git a/src/index/brain.rs b/src/index/brain.rs index 49b892e..16b8c06 100644 --- a/src/index/brain.rs +++ b/src/index/brain.rs @@ -158,6 +158,22 @@ pub fn embed_project(conn: &Connection, project_id: i64) -> Result { cache.as_mut().unwrap() }; + // ── Self-heal after vecq reload-discard (#542, vecq#32) ────────── + // A vecq index reloaded from disk is rebuilt empty (upstream lacks key + // serialization) and arrives dirty. Stored fingerprints would skip all + // unchanged symbols, permanently excluding them from vector search. + // Clear fingerprints so this run re-embeds every symbol in the project. + if vi.is_dirty() && vi.is_empty() { + let cleared = conn.execute( + "UPDATE symbols SET embed_fingerprint = NULL WHERE project_id = ?1", + rusqlite::params![project_id], + )?; + tracing::info!( + cleared, + "vector index rebuilt from empty (vecq reload) — re-embedding all symbols" + ); + } + // ── Incremental: fetch stored fingerprints ────────────────────── // Only re-embed symbols whose name+signature has changed. let mut stmt = conn.prepare( @@ -355,6 +371,20 @@ fn fts5_search(conn: &Connection, project_id: i64, query: &str, limit: usize) -> } } +/// True when the on-disk vector index exists but will be discarded on reload — +/// i.e. vecq backend with a non-empty `.vecq` file that cannot restore its +/// key map (vecq#32). `cora index` uses this to force a re-embed even when +/// no files changed, so the vector signal heals instead of staying empty. +pub fn vector_index_needs_rebuild() -> bool { + if crate::index::vector::current_vector_store() != crate::index::vector::VectorStoreKind::Vecq { + return false; + } + let path = vector_index_path().with_extension("vecq"); + std::fs::metadata(&path) + .map(|m| m.len() > 24) + .unwrap_or(false) +} + /// 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)> { diff --git a/src/index/mod.rs b/src/index/mod.rs index 5ed9ae9..1ef8e99 100644 --- a/src/index/mod.rs +++ b/src/index/mod.rs @@ -531,8 +531,12 @@ fn index_project_with_id( stats.files_scanned, stats.files_indexed, stats.symbols_indexed, stats.errors ); - // Embed symbols into vector index for brain search — only when files changed - if stats.files_indexed > 0 { + // Embed symbols into vector index for brain search — when files changed, + // or when the stored index was discarded on reload (vecq #542: a fresh + // process rebuilds the vecq index empty; if all files are unchanged this + // would be the only chance to re-embed — without it the vector signal + // stays dead until a file actually changes). + if stats.files_indexed > 0 || brain::vector_index_needs_rebuild() { match brain::embed_project(conn, project_id) { Ok(n) => { stats.embedded_symbols = Some(n); diff --git a/src/index/vector.rs b/src/index/vector.rs index 24448cb..51ff239 100644 --- a/src/index/vector.rs +++ b/src/index/vector.rs @@ -12,9 +12,54 @@ use fs2::FileExt; use std::collections::HashMap; use std::fs::File; use std::path::{Path, PathBuf}; +use std::sync::LazyLock; use usearch::{Index, IndexOptions, MetricKind, ScalarKind}; const USEARCH_EXT: &str = "usearch"; +const VECQ_EXT: &str = "vecq"; +const VECQ_SEED: u64 = 42; + +/// Which physical vector store backs `CodeVectorIndex` (#542). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum VectorStoreKind { + /// HNSW graph over f32 vectors (the historical default). + #[default] + Usearch, + /// vecq 4-bit quantized scan — pure Rust, deterministic, ~6x smaller. + Vecq, +} + +impl VectorStoreKind { + pub fn parse(s: &str) -> Self { + match s.trim().to_lowercase().as_str() { + "vecq" => Self::Vecq, + _ => Self::Usearch, // "usearch", "" and anything unknown + } + } +} + +static VECTOR_STORE: LazyLock> = + LazyLock::new(|| std::sync::RwLock::new(VectorStoreKind::Usearch)); + +/// Select the physical vector store used by NEW indexes (process-wide). +/// Existing on-disk indexes keep their own format via file extension. +pub fn set_vector_store(kind: VectorStoreKind) { + *VECTOR_STORE.write().unwrap() = kind; +} + +pub fn current_vector_store() -> VectorStoreKind { + *VECTOR_STORE.read().unwrap() +} + +/// Apply `brain.vector_store` from a loaded config (any process entry that +/// touches Brain search/embedding must call this before opening the index, +/// or `.vecq`/`.usearch` files silently diverge between runs). +pub fn apply_config_store(config: Option<&crate::config::schema::Config>) { + let kind = config + .map(|c| VectorStoreKind::parse(&c.brain.vector_store)) + .unwrap_or_default(); + set_vector_store(kind); +} /// Hashing-trick embedding dimensions (zero-dependency fallback). pub const FALLBACK_DIMS: usize = 256; @@ -36,26 +81,42 @@ pub const DEFAULT_DIMS: usize = FALLBACK_DIMS; /// - Disk persistence via buffer-based serialization (same as uteke) /// - Cross-process safety via exclusive file lock pub struct CodeVectorIndex { - index: Index, - /// Maps usearch integer key → symbol database ID. - key_to_symbol: HashMap, - /// Maps symbol database ID → usearch integer key. - symbol_to_key: HashMap, - next_key: u64, + inner: Inner, path: Option, dirty: bool, _lock_file: Option, } +enum Inner { + Usearch { + index: Index, + /// Maps usearch integer key → symbol database ID. + key_to_symbol: HashMap, + /// Maps symbol database ID → usearch integer key. + symbol_to_key: HashMap, + next_key: u64, + }, + /// vecq keys are u64 natively (tombstone removal + compact), so symbol + /// IDs map directly — no sidecar needed. + Vecq(Box), +} + impl CodeVectorIndex { - /// Create a new empty in-memory index. + /// Create a new empty in-memory index (backend selected by + /// [`current_vector_store`]). pub fn new(dims: usize) -> Result { - let index = create_usearch_index(dims)?; Ok(Self { - index, - key_to_symbol: HashMap::new(), - symbol_to_key: HashMap::new(), - next_key: 0, + inner: match current_vector_store() { + VectorStoreKind::Usearch => Inner::Usearch { + index: create_usearch_index(dims)?, + key_to_symbol: HashMap::new(), + symbol_to_key: HashMap::new(), + next_key: 0, + }, + VectorStoreKind::Vecq => { + Inner::Vecq(Box::new(vecq_core::VecqIndex::new(dims, VECQ_SEED))) + } + }, path: None, dirty: false, _lock_file: None, @@ -64,7 +125,19 @@ impl CodeVectorIndex { /// Load from disk, or create empty if not exists. /// Acquires exclusive file lock for cross-process safety. + /// + /// Each backend owns its own file extension (`.usearch` vs `.vecq`), so + /// switching `brain.vector_store` never misreads the other's format. pub fn load_or_create(path: &Path, dims: usize) -> Result { + match current_vector_store() { + VectorStoreKind::Vecq => { + Self::load_or_create_vecq(&path.with_extension(VECQ_EXT), dims) + } + VectorStoreKind::Usearch => Self::load_or_create_usearch(path, dims), + } + } + + fn load_or_create_usearch(path: &Path, dims: usize) -> Result { if !path.exists() { std::fs::write(path, []).context("create usearch file")?; } @@ -81,6 +154,49 @@ impl CodeVectorIndex { Ok(idx) } + fn load_or_create_vecq(path: &Path, dims: usize) -> Result { + if !path.exists() { + std::fs::write(path, []).context("create vecq file")?; + } + let mut lock_file = acquire_file_lock(path)?; + + let mut buffer = Vec::new(); + let mut dirty = false; + if lock_file.metadata().context("read vecq metadata")?.len() > 0 { + use std::io::{Read, Seek, SeekFrom}; + lock_file + .seek(SeekFrom::Start(0)) + .context("seek vecq file")?; + lock_file + .read_to_end(&mut buffer) + .context("read vecq file")?; + } + + // KNOWN LIMITATION (#542): vecq-core 0.2.0 `from_bytes` restores codes + // and scales but NOT the keyed map (see codecoradev/vecq#32), so a + // reloaded index would silently search empty. Until key serialization + // ships upstream, reload always rebuilds fresh. To keep the next + // `cora index` honest, mark the index dirty here — `embed_project` + // will then clear embed fingerprints and re-embed everything instead + // of skipping unchanged symbols against an empty index. + if buffer.len() >= 24 { + tracing::warn!( + "vecq persistence lacks key serialization upstream (vecq#32) — \ + recreating index; `cora index` will re-embed all symbols" + ); + dirty = true; + } + let index = vecq_core::VecqIndex::new(dims, VECQ_SEED); + + Ok(Self { + inner: Inner::Vecq(Box::new(index)), + path: Some(path.to_path_buf()), + dirty, + _lock_file: Some(lock_file), + }) + } + + #[allow(clippy::too_many_lines)] fn load_from_file(file: &mut File, path: &Path) -> Result { use std::io::{Read, Seek, SeekFrom}; @@ -115,10 +231,12 @@ impl CodeVectorIndex { } Ok(Self { - index, - key_to_symbol, - symbol_to_key, - next_key, + inner: Inner::Usearch { + index, + key_to_symbol, + symbol_to_key, + next_key, + }, path: None, dirty: false, _lock_file: None, @@ -127,10 +245,31 @@ impl CodeVectorIndex { /// Save index and key mappings to disk. pub fn save(&mut self) -> Result<()> { + if let Inner::Vecq(index) = &mut self.inner { + let path = self + .path + .as_ref() + .context("vecq index has no path")? + .with_extension(VECQ_EXT); + let buffer = index.to_bytes(); + let tmp_path = path.with_extension(format!("{VECQ_EXT}.tmp")); + std::fs::write(&tmp_path, &buffer).context("write temp vecq index")?; + std::fs::rename(&tmp_path, path).context("rename temp vecq")?; + self.dirty = false; + return Ok(()); + } if let Some(ref path) = self.path { - let buf_len = self.index.serialized_length(); + let Inner::Usearch { + index, + key_to_symbol, + .. + } = &self.inner + else { + unreachable!("vecq path returned earlier"); + }; + let buf_len = index.serialized_length(); let mut buffer = vec![0u8; buf_len]; - self.index + index .save_to_buffer(&mut buffer) .context("save usearch to buffer")?; @@ -141,7 +280,7 @@ impl CodeVectorIndex { // Save key mapping sidecar let mapping_path = path.with_extension("keys"); let mut lines = Vec::new(); - for (&key, &sym_id) in &self.key_to_symbol { + for (&key, &sym_id) in key_to_symbol { lines.push(format!("{key}\t{sym_id}")); } atomic_write(&mapping_path, lines.join("\n").as_bytes())?; @@ -153,30 +292,43 @@ impl CodeVectorIndex { /// Insert a symbol embedding. If symbol ID exists, replaces it. pub fn insert(&mut self, symbol_id: i64, embedding: &[f32]) -> Result<()> { + if let Inner::Vecq(index) = &mut self.inner { + let key = symbol_id as u64; + if index.contains_key(key) { + index.remove_keyed(key); + } + index.add_keyed(key, embedding); + self.dirty = true; + return Ok(()); + } + let Inner::Usearch { + index, + key_to_symbol, + symbol_to_key, + next_key, + } = &mut self.inner + else { + unreachable!("usearch insert path guarded above"); + }; + // Remove old entry if exists - if let Some(&old_key) = self.symbol_to_key.get(&symbol_id) { - self.key_to_symbol.remove(&old_key); - self.index - .remove(old_key) - .context("remove old usearch entry")?; + if let Some(&old_key) = symbol_to_key.get(&symbol_id) { + key_to_symbol.remove(&old_key); + index.remove(old_key).context("remove old usearch entry")?; } - let key = self.next_key; - self.next_key += 1; - self.key_to_symbol.insert(key, symbol_id); - self.symbol_to_key.insert(symbol_id, key); + let key = *next_key; + *next_key += 1; + key_to_symbol.insert(key, symbol_id); + symbol_to_key.insert(symbol_id, key); // Auto-reserve if at capacity - if self.index.size() >= self.index.capacity() { - let new_cap = (self.index.capacity() + 1024).max(1024); - self.index - .reserve(new_cap) - .context("reserve usearch capacity")?; + if index.size() >= index.capacity() { + let new_cap = (index.capacity() + 1024).max(1024); + index.reserve(new_cap).context("reserve usearch capacity")?; } - self.index - .add(key, embedding) - .context("insert into usearch")?; + index.add(key, embedding).context("insert into usearch")?; self.dirty = true; Ok(()) @@ -184,10 +336,24 @@ impl CodeVectorIndex { /// Remove a symbol by database ID. Incremental, no rebuild. pub fn remove(&mut self, symbol_id: i64) -> bool { - if let Some(&key) = self.symbol_to_key.get(&symbol_id) { - self.key_to_symbol.remove(&key); - self.symbol_to_key.remove(&symbol_id); - if let Err(e) = self.index.remove(key) { + if let Inner::Vecq(index) = &mut self.inner { + let removed = index.remove_keyed(symbol_id as u64); + self.dirty |= removed; + return removed; + } + let Inner::Usearch { + index, + key_to_symbol, + symbol_to_key, + next_key: _, + } = &mut self.inner + else { + return false; + }; + if let Some(&key) = symbol_to_key.get(&symbol_id) { + key_to_symbol.remove(&key); + symbol_to_key.remove(&symbol_id); + if let Err(e) = index.remove(key) { tracing::error!("Failed to remove from usearch: {e}"); } self.dirty = true; @@ -199,11 +365,30 @@ impl CodeVectorIndex { /// Search for k nearest neighbors. Returns (symbol_id, cosine_distance) pairs. pub fn search(&self, query: &[f32], k: usize) -> Vec<(i64, f32)> { - if self.index.size() == 0 { + if let Inner::Vecq(index) = &self.inner { + if index.is_empty() { + return Vec::new(); + } + let count = k.max(1); + return index + .search_keyed(query, count) + .into_iter() + .map(|(key, sim)| (key as i64, 1.0 - sim)) + .collect(); + } + if self.index_size() == 0 { return Vec::new(); } let count = k.max(1); - let results = match self.index.search(query, count) { + let Inner::Usearch { + index, + key_to_symbol, + .. + } = &self.inner + else { + unreachable!("vecq path returned earlier"); + }; + let results = match index.search(query, count) { Ok(r) => r, Err(e) => { tracing::error!("usearch search failed: {e}"); @@ -215,18 +400,24 @@ impl CodeVectorIndex { .keys .iter() .zip(results.distances.iter()) - .filter_map(|(key, dist)| self.key_to_symbol.get(key).map(|&id| (id, *dist))) + .filter_map(|(key, dist)| key_to_symbol.get(key).map(|&id| (id, *dist))) .collect() } /// Number of vectors in the index. pub fn len(&self) -> usize { - self.index.size() + match &self.inner { + Inner::Usearch { index, .. } => index.size(), + Inner::Vecq(index) => index.len(), + } } /// Embedding dimensionality. pub fn dims(&self) -> usize { - self.index.dimensions() + match &self.inner { + Inner::Usearch { index, .. } => index.dimensions(), + Inner::Vecq(index) => index.dim(), + } } /// Whether the index has unsaved changes. @@ -236,7 +427,12 @@ impl CodeVectorIndex { /// Whether the index is empty. pub fn is_empty(&self) -> bool { - self.index.size() == 0 + self.len() == 0 + } + + #[allow(dead_code)] + fn index_size(&self) -> usize { + self.len() } } @@ -292,8 +488,17 @@ mod tests { v } + /// The global store selector is process-wide — serialize every test that + /// constructs an index so backend flips never race parallel tests. + static STORE_LOCK: LazyLock> = LazyLock::new(|| std::sync::Mutex::new(())); + + fn with_store_lock() -> std::sync::MutexGuard<'static, ()> { + STORE_LOCK.lock().unwrap_or_else(|e| e.into_inner()) + } + #[test] fn test_empty_search() { + let _g = with_store_lock(); let idx = CodeVectorIndex::new(768).unwrap(); assert!(idx.is_empty()); let results = idx.search(&[0.0f32; 768], 5); @@ -302,6 +507,7 @@ mod tests { #[test] fn test_insert_and_search() { + let _g = with_store_lock(); let mut idx = CodeVectorIndex::new(768).unwrap(); let v1 = make_unit_vec(768, 0); // unit vector along dim 0 @@ -330,6 +536,7 @@ mod tests { #[test] fn test_replace_on_duplicate_insert() { + let _g = with_store_lock(); let mut idx = CodeVectorIndex::new(64).unwrap(); let v1 = make_unit_vec(64, 0); @@ -348,6 +555,7 @@ mod tests { #[test] fn test_remove() { + let _g = with_store_lock(); let mut idx = CodeVectorIndex::new(64).unwrap(); idx.insert(1, &make_unit_vec(64, 0)).unwrap(); @@ -363,6 +571,7 @@ mod tests { #[test] fn test_save_and_load() { + let _g = with_store_lock(); let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("test.usearch"); @@ -381,6 +590,65 @@ mod tests { assert_eq!(results[0].0, 10); } + #[test] + fn test_vecq_backend_roundtrip() { + let _g = with_store_lock(); + set_vector_store(VectorStoreKind::Vecq); + let mut idx = CodeVectorIndex::new(64).unwrap(); + + idx.insert(1, &make_unit_vec(64, 0)).unwrap(); + idx.insert(2, &make_unit_vec(64, 1)).unwrap(); + assert_eq!(idx.len(), 2); + + // Replace semantics + idx.insert(1, &make_unit_vec(64, 2)).unwrap(); + assert_eq!(idx.len(), 2); + + // Removal + assert!(idx.remove(2)); + assert!(!idx.remove(2)); + assert_eq!(idx.len(), 1); + + let results = idx.search(&make_unit_vec(64, 2), 5); + assert_eq!(results.len(), 1); + assert_eq!(results[0].0, 1); + // distance conversion: similarity ~1.0 -> distance ~0.0 + assert!(results[0].1 < 0.05, "dist={:?}", results[0].1); + + set_vector_store(VectorStoreKind::Usearch); + } + + #[test] + fn test_vecq_save_and_load_uses_own_extension() { + let _g = with_store_lock(); + set_vector_store(VectorStoreKind::Vecq); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("test.usearch"); // caller names it; backend picks .vecq + + { + let mut idx = CodeVectorIndex::new(64).unwrap(); + idx.insert(10, &make_unit_vec(64, 0)).unwrap(); + idx.path = Some(path.clone()); + idx.save().unwrap(); + } + + assert!(dir.path().join("test.vecq").exists()); + // Upstream gap (vecq#32): keys are not serialized, so reload rebuilds + // fresh instead of serving a silently-empty index. + let idx2 = CodeVectorIndex::load_or_create(&path, 64).unwrap(); + assert!(idx2.is_empty(), "reload must not serve a keyless index"); + set_vector_store(VectorStoreKind::Usearch); + } + + #[test] + fn vector_store_parse_is_lenient() { + assert_eq!(VectorStoreKind::parse("vecq"), VectorStoreKind::Vecq); + assert_eq!(VectorStoreKind::parse("VECQ "), VectorStoreKind::Vecq); + assert_eq!(VectorStoreKind::parse("usearch"), VectorStoreKind::Usearch); + assert_eq!(VectorStoreKind::parse(""), VectorStoreKind::Usearch); + assert_eq!(VectorStoreKind::parse("bogus"), VectorStoreKind::Usearch); + } + #[test] fn test_cosine_distance_to_similarity() { assert!((cosine_distance_to_similarity(0.0) - 1.0).abs() < f32::EPSILON); diff --git a/src/main.rs b/src/main.rs index daf9329..1558085 100644 --- a/src/main.rs +++ b/src/main.rs @@ -789,6 +789,7 @@ async fn main() -> Result<()> { .map(|c| c.brain.embedding.to_string()) .unwrap_or_else(|| "auto".to_string()); crate::embed::resolve_backend(&brain_mode); + index::vector::apply_config_store(config.as_ref()); eprintln!("{}", "🔍 Indexing project...".cyan()); let stats = index::index_project_with_skip( @@ -1184,7 +1185,7 @@ async fn main() -> Result<()> { let project_id = index::ensure_project(&conn, &project_root)?; // Resolve embedding backend from config for query embedding - let brain_mode = crate::config::loader::load_config( + let brain_cfg = crate::config::loader::load_config( cli.global.config.as_deref(), None, None, @@ -1192,11 +1193,13 @@ async fn main() -> Result<()> { None, false, ) - .ok() - .map(|c| c.brain.embedding.to_string()) - .unwrap_or_else(|| "auto".to_string()); + .ok(); + let brain_mode = brain_cfg + .as_ref() + .map(|c| c.brain.embedding.to_string()) + .unwrap_or_else(|| "auto".to_string()); crate::embed::resolve_backend(&brain_mode); - + index::vector::apply_config_store(brain_cfg.as_ref()); let results = index::brain::brain_search(&conn, project_id, &query_str, limit)?; if json { diff --git a/src/mcp/tools.rs b/src/mcp/tools.rs index 6b27765..6a84c43 100644 --- a/src/mcp/tools.rs +++ b/src/mcp/tools.rs @@ -1001,6 +1001,7 @@ fn handle_brain_search(params: &serde_json::Value) -> ToolResult { Err(e) => return ToolResult::error(e.to_string()), }; + crate::index::vector::apply_config_store(load_project_config().ok().as_ref()); match crate::index::brain::brain_search(&conn, project_id, query, limit) { Ok(results) => { if results.is_empty() { From 01be21f9e43a3cda8cf36760f252d9cf88928881 Mon Sep 17 00:00:00 2001 From: "Anaz S. Aji" Date: Mon, 31 Aug 2026 11:01:20 +0700 Subject: [PATCH 2/5] fix(brain): load vector index from disk on the search path (#545) (#546) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VECTOR_CACHE was only populated by embed_project (i.e. 'cora index'), so a fresh search process — 'cora brain' CLI or MCP brain_search — always saw an empty cache and the vector signal never fired; results silently degraded to FTS + graph only. Verified on develop and on the vecq PR branch with the same sandboxed e2e run. vector_search now lazy-loads the on-disk index once per process via ensure_vector_cache (double-checked read/write locking; a concurrent double load is idempotent). Load failures degrade gracefully with a warn log. Also guard dimension mismatch (embedding provider switched since the index was built) — previously a panic inside the backend. Note: the vecq backend still degrades to FTS-only in fresh search processes — that is the documented upstream limitation (vecq#32, no key serialization), tracked separately. Regression tests (tests/brain_search_process.rs): fresh-process brain search must emit the vector signal (default usearch backend), and brain.vector_store: vecq must produce a .vecq file — both run with isolated CODECORA_HOME sandboxes. Co-authored-by: ajianaz --- src/index/brain.rs | 42 ++++++++++++++++- tests/brain_search_process.rs | 88 +++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 tests/brain_search_process.rs 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" + ); +} From d9f9bacea907ea9d3c98a8e389226058b5270ee8 Mon Sep 17 00:00:00 2001 From: "Anaz S. Aji" Date: Mon, 31 Aug 2026 11:33:44 +0700 Subject: [PATCH 3/5] =?UTF-8?q?feat(brain):=20vecq-core=200.3.0=20?= =?UTF-8?q?=E2=80=94=20keyed=20persistence,=20residual=20default,=20brain.?= =?UTF-8?q?vector=5Fbits=20knob=20(#548)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upgrade the opt-in vecq store to vecq-core 0.3.0 and close #547. - Keyed persistence (upstream vecq#32 closed, format v1.3+): symbol-id keys survive reload. load_or_create_vecq now deserializes for real and keeps a healthy keyed index; legacy keyless files, corrupt files, and dim mismatches rebuild dirty so the next re-embeds. A dim guard is mandatory: add_keyed panics on wrong-dim vectors. - brain.vector_bits config (residual|4|5|6, lenient, default residual): residual is best recall@10 at 4-bit scan speed in a recall study on cora's own embeddings (ahead of plain 5-bit at 1k/5k/13k scales). A width change rebuilds the index once so the config takes effect; vecq_file_needs_rebuild is width-aware. - BrainConfig gets a manual Default (derived Default produced empty strings, inconsistent with the serde defaults). - Cross-project heal: the vector index is one shared file; a rebuild (width/dims/legacy/corrupt) now clears embed fingerprints for ALL projects, not just the active one, and the usearch dims-mismatch deletion path joins the same heal (pre-existing fingerprint leak). Tests: keyed roundtrip, legacy keyless rebuild, corrupt rebuild, dim-mismatch rebuild, width-switch rebuild, roundtrip across all four widths, lenient parsing, config merge. 947 pass incl. the fresh-process integration tests from #546. Closes #547 Co-authored-by: ajianaz --- CHANGELOG.md | 12 +- Cargo.lock | 4 +- Cargo.toml | 2 +- src/config/schema.rs | 80 +++++++++- src/index/brain.rs | 59 +++++--- src/index/mod.rs | 4 +- src/index/vector.rs | 347 +++++++++++++++++++++++++++++++++++++------ 7 files changed, 427 insertions(+), 81 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3a3368..0211cc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **Opt-in `vecq` vector store for Brain Mode.** Set `brain.vector_store: vecq` in `.cora.yaml` to replace the usearch HNSW index with vecq 4-bit quantized scan (pure Rust, deterministic, ~6x smaller). Opt-in only; keyed persistence awaits vecq#32, so indexes rebuild fresh on load for now (#542). +- **Opt-in `vecq` vector store for Brain Mode.** Set `brain.vector_store: vecq` in `.cora.yaml` to replace the usearch HNSW index with a vecq quantized scan (pure Rust, deterministic, ~5x smaller). Keyed persistence included: symbol ids survive reload, so a fresh process serves the index as-is and `cora index` no longer re-embeds unchanged projects (#542, #547). +- **`brain.vector_bits` quantization-width knob.** `residual` (default) | `4` | `5` | `6` — 4-bit base codes with second-pass residual rescoring, or plain Lloyd-Max widths. The default is residual: best recall@10 at 4-bit scan speed in a recall study on cora's own embeddings, ahead of plain 5-bit at 1k/5k/13k-symbol scales. Changing the width rebuilds the index once on the next `cora index` instead of silently serving the old width; unknown values fall back to `residual`. + +### Fixed + +- **Vector signal never fired in a fresh process.** `cora brain` and MCP `brain_search` only saw the vector index if the same process had run the embed — otherwise results silently degraded to FTS-only. The search path now lazy-loads the on-disk index once per process, with a dimension guard against backend switches (#545). +- **Stale embed fingerprints after a global vector-index rebuild.** The vector index is a single file shared by all projects; rebuilding it (width/dims change, legacy file, corruption) wiped every project's vectors while their fingerprints still said "embedded" — the incremental path would skip those symbols forever. A rebuild now clears fingerprints for all projects, and the usearch dims-mismatch path (which deleted the index without clearing) joins the same heal. + +### Changed + +- **vecq-core dependency 0.2.0 → 0.3.0.** Picks up the 4-bit+residual mode, plain 5/6-bit widths, runtime-detected AVX2 scoring, and file formats v1.3–v1.5 with the keyed-slot table. Pre-0.3.0 `.vecq` files carry no key table and rebuild once with a warning, then upgrade to the keyed format. ## [0.14.0] - 2026-08-28 diff --git a/Cargo.lock b/Cargo.lock index 645686a..e9ee6a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2399,9 +2399,9 @@ checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] name = "vecq-core" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5f5e3c486177edd6c21108dfe621a7e9c85730c0dbb0ce76673e6682a68e90b" +checksum = "543ddd2e748c26c39bd8ca5ab9c43eb01ba0ca13750a1428b08e8c7dc0d9def7" [[package]] name = "version_check" diff --git a/Cargo.toml b/Cargo.toml index f1084c9..c77e731 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,7 +72,7 @@ rusqlite = { version = "0.31", features = ["bundled"] } # Vector search (Phase 3 — Brain Mode) usearch = "2" -vecq-core = "0.2.0" +vecq-core = "0.3.0" fs2 = "0.4" # Self-update (upgrade command) diff --git a/src/config/schema.rs b/src/config/schema.rs index 9d60c79..4a6e633 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -451,15 +451,29 @@ fn default_vector_store() -> String { "usearch".to_string() } -#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +fn default_vector_bits() -> String { + "residual".to_string() +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct BrainConfig { /// Vector store backend for Brain Mode search. /// /// - `"usearch"` (default) — HNSW graph, f32 - /// - `"vecq"` — 4-bit quantized brute-force scan (pure Rust, deterministic, - /// ~6x smaller index; recall trade absorbed by RRF fusion) + /// - `"vecq"` — quantized brute-force scan (pure Rust, deterministic, + /// ~5x smaller index; recall trade absorbed by RRF fusion) #[serde(default = "default_vector_store")] pub vector_store: String, + /// Quantization width for the `vecq` store (ignored by `usearch`). + /// + /// - `"residual"` (default) — 4-bit base + second-pass residual codes: + /// best recall at 4-bit scan speed on cora's default embeddings + /// - `"4"` / `"5"` / `"6"` — plain Lloyd-Max width + /// + /// Invalid values fall back to `"residual"`. Changing the width rebuilds + /// the index once on the next `cora index`. + #[serde(default = "default_vector_bits")] + pub vector_bits: String, /// Embedding backend selection. /// /// - `"auto"` (default) — best available: pretrained → hashing @@ -471,6 +485,19 @@ pub struct BrainConfig { pub embedding: BrainEmbeddingMode, } +// Manual Default so `Config::default()` carries the documented values +// ("usearch"/"residual") — the derived one would leave empty strings, and +// the store/width parsers only meet those values leniently. +impl Default for BrainConfig { + fn default() -> Self { + Self { + vector_store: default_vector_store(), + vector_bits: default_vector_bits(), + embedding: BrainEmbeddingMode::default(), + } + } +} + /// `.cora.yaml` `brain:` section — mirrors the subset of [`BrainConfig`] /// that is meaningful in a config file. #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -478,6 +505,8 @@ pub struct BrainSection { #[serde(skip_serializing_if = "Option::is_none")] pub vector_store: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub vector_bits: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub embedding: Option, } @@ -795,6 +824,9 @@ impl CoraFile { if let Some(v) = &brain.vector_store { config.brain.vector_store.clone_from(v); } + if let Some(v) = &brain.vector_bits { + config.brain.vector_bits.clone_from(v); + } if let Some(v) = &brain.embedding { config.brain.embedding = v.clone(); } @@ -870,6 +902,48 @@ mod tests { assert_eq!(cfg.output.format, "pretty"); } + #[test] + fn brain_vector_bits_defaults_to_residual() { + let cfg = Config::default(); + assert_eq!(cfg.brain.vector_bits, "residual"); + assert_eq!(cfg.brain.vector_store, "usearch"); + } + + #[test] + fn merge_brain_vector_store_and_bits() { + let mut cfg = Config::default(); + let cora = CoraFile::from_str( + r" +brain: + vector_store: vecq + vector_bits: '6' + embedding: hashing +", + ) + .unwrap(); + + cora.merge_into(&mut cfg).unwrap(); + + assert_eq!(cfg.brain.vector_store, "vecq"); + assert_eq!(cfg.brain.vector_bits, "6"); + } + + #[test] + fn merge_brain_vector_bits_absent_keeps_default() { + let mut cfg = Config::default(); + let cora = CoraFile::from_str( + r" +brain: + vector_store: vecq +", + ) + .unwrap(); + + cora.merge_into(&mut cfg).unwrap(); + + assert_eq!(cfg.brain.vector_bits, "residual"); + } + #[test] fn merge_provider_overrides() { let mut cfg = Config::default(); diff --git a/src/index/brain.rs b/src/index/brain.rs index b9746f8..de88f0f 100644 --- a/src/index/brain.rs +++ b/src/index/brain.rs @@ -62,17 +62,20 @@ fn vector_index_path() -> std::path::PathBuf { /// The index file contains usearch binary data — we can't easily read the /// dimensionality without loading it. Instead, we check the `embedding_dims` /// column in the projects table, which is written during embedding. -fn check_dimension_compat(vi_path: &std::path::Path, expected_dims: usize) { +fn check_dimension_compat(vi_path: &std::path::Path, expected_dims: usize) -> bool { // Attempt to load the index just to check its dimensions. // If it fails (empty, corrupt, etc.), we'll create fresh — no warning needed. + // Returns true when a stale index was removed: the vector index is a + // single file shared by every project, so its fingerprints are now all + // stale and callers must clear them. let Ok(file) = std::fs::File::open(vi_path) else { - return; + return false; }; let Ok(metadata) = file.metadata() else { - return; + return false; }; if metadata.len() == 0 { - return; // Empty file — will create fresh + return false; // Empty file — will create fresh } // Try to load and check dims @@ -102,18 +105,22 @@ fn check_dimension_compat(vi_path: &std::path::Path, expected_dims: usize) { // Delete the stale index so embed_project creates a fresh one if let Err(e) = std::fs::remove_file(vi_path) { tracing::warn!(" Failed to remove stale index: {e}"); + false } else { let keys_path = vi_path.with_extension("keys"); let _ = std::fs::remove_file(&keys_path); tracing::info!(" Removed stale vector index — will create fresh on next index"); + true } } Ok(Some(_)) => { // Dimensions match — all good + false } _ => { // Couldn't read dimensions (corrupt, unsupported, etc.) // embed_project will handle creation + false } } } @@ -140,9 +147,11 @@ pub fn embed_project(conn: &Connection, project_id: i64) -> Result { // Check for existing index with mismatched dimensions (e.g. user rebuilt // with/without pretrained-embed feature). Warn but continue — the index // will be corrupted for searches until re-indexed. - if vi_path.exists() { - check_dimension_compat(&vi_path, active); - } + let dims_stale = if vi_path.exists() { + check_dimension_compat(&vi_path, active) + } else { + false + }; // Acquire write lock — block searches while embedding. // This is fine because embedding only happens during `cora index`, @@ -158,19 +167,21 @@ pub fn embed_project(conn: &Connection, project_id: i64) -> Result { cache.as_mut().unwrap() }; - // ── Self-heal after vecq reload-discard (#542, vecq#32) ────────── - // A vecq index reloaded from disk is rebuilt empty (upstream lacks key - // serialization) and arrives dirty. Stored fingerprints would skip all - // unchanged symbols, permanently excluding them from vector search. - // Clear fingerprints so this run re-embeds every symbol in the project. - if vi.is_dirty() && vi.is_empty() { - let cleared = conn.execute( - "UPDATE symbols SET embed_fingerprint = NULL WHERE project_id = ?1", - rusqlite::params![project_id], - )?; + // ── Self-heal after a global index rebuild (#542, vecq#32) ─────── + // The vector index is one shared file across projects. A vecq reload + // that had to rebuild it (legacy pre-0.3.0 file, width change, dim + // change, corruption) or a stale usearch index removed for a dims + // mismatch leaves every project's vectors gone while their stored + // fingerprints still say "embedded" — the incremental path would skip + // those symbols forever. Clear fingerprints for ALL projects: the + // current project re-embeds in this run; every other project re-embeds + // on its next `cora index` run that reaches embed_project + // (cross-project scheduling: #545). + if dims_stale || (vi.is_dirty() && vi.is_empty()) { + let cleared = conn.execute("UPDATE symbols SET embed_fingerprint = NULL", [])?; tracing::info!( cleared, - "vector index rebuilt from empty (vecq reload) — re-embedding all symbols" + "vector index rebuilt from empty — all projects re-embed on their next index run" ); } @@ -372,17 +383,17 @@ fn fts5_search(conn: &Connection, project_id: i64, query: &str, limit: usize) -> } /// True when the on-disk vector index exists but will be discarded on reload — -/// i.e. vecq backend with a non-empty `.vecq` file that cannot restore its -/// key map (vecq#32). `cora index` uses this to force a re-embed even when -/// no files changed, so the vector signal heals instead of staying empty. +/// i.e. vecq backend whose `.vecq` file predates keyed persistence (vecq#32), +/// is unreadable, or was built with different dims. `cora index` uses this to +/// force a re-embed even when no files changed, so the vector signal heals +/// instead of staying empty. Healthy keyed files (vecq-core 0.3.0+) reload +/// as-is and return false. pub fn vector_index_needs_rebuild() -> bool { if crate::index::vector::current_vector_store() != crate::index::vector::VectorStoreKind::Vecq { return false; } let path = vector_index_path().with_extension("vecq"); - std::fs::metadata(&path) - .map(|m| m.len() > 24) - .unwrap_or(false) + path.exists() && crate::index::vector::vecq_file_needs_rebuild(&path, active_dims()) } /// Return a read guard over the vector index cache, loading the on-disk diff --git a/src/index/mod.rs b/src/index/mod.rs index 1ef8e99..32b520e 100644 --- a/src/index/mod.rs +++ b/src/index/mod.rs @@ -532,8 +532,8 @@ fn index_project_with_id( ); // Embed symbols into vector index for brain search — when files changed, - // or when the stored index was discarded on reload (vecq #542: a fresh - // process rebuilds the vecq index empty; if all files are unchanged this + // or when the stored index cannot serve keyed search after reload + // (legacy pre-0.3.0 vecq file, vecq #542: if all files are unchanged this // would be the only chance to re-embed — without it the vector signal // stays dead until a file actually changes). if stats.files_indexed > 0 || brain::vector_index_needs_rebuild() { diff --git a/src/index/vector.rs b/src/index/vector.rs index 51ff239..4d6e3df 100644 --- a/src/index/vector.rs +++ b/src/index/vector.rs @@ -25,7 +25,7 @@ pub enum VectorStoreKind { /// HNSW graph over f32 vectors (the historical default). #[default] Usearch, - /// vecq 4-bit quantized scan — pure Rust, deterministic, ~6x smaller. + /// vecq quantized scan — pure Rust, deterministic, ~5x smaller. Vecq, } @@ -38,9 +38,70 @@ impl VectorStoreKind { } } +/// vecq quantization width (`brain.vector_bits` in `.cora.yaml`). +/// +/// Default is 4-bit + residual rescore: on cora's default hashing-trick +/// embeddings it measured the best recall@10 at 4-bit scan speed across +/// 1k/5k/13k-symbol indexes (recall study, 2026-08) — plain 5-bit, the +/// vecq-core default, trailed by ~4-7 points at every scale. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum VectorBits { + /// 4-bit base codes + second-pass residual rescoring (format v1.4). + #[default] + Residual, + /// Plain 4-bit Lloyd-Max (max compression, lowest recall). + B4, + /// Plain 5-bit Lloyd-Max (vecq-core's out-of-the-box default). + B5, + /// Plain 6-bit Lloyd-Max (highest plain recall, slowest scan). + B6, +} + +impl VectorBits { + /// Lenient like [`VectorStoreKind::parse`]: empty/unknown → Residual. + pub fn parse(s: &str) -> Self { + match s.trim().to_lowercase().as_str() { + "4" => Self::B4, + "5" => Self::B5, + "6" => Self::B6, + _ => Self::Residual, // "residual", "" and anything unknown + } + } + + fn create(&self, dims: usize) -> vecq_core::VecqIndex { + match self { + Self::Residual => vecq_core::VecqIndex::with_residual(dims, VECQ_SEED), + Self::B4 => Self::create_plain(dims, 4), + Self::B5 => Self::create_plain(dims, 5), + Self::B6 => Self::create_plain(dims, 6), + } + } + + fn create_plain(dims: usize, bits: u8) -> vecq_core::VecqIndex { + let mut index = vecq_core::VecqIndex::new(dims, VECQ_SEED); + index.set_bits(bits); + index + } + + /// Whether an on-disk index was built at this width. A mismatch triggers + /// one rebuild so a config change actually takes effect instead of + /// silently serving the old width forever. + fn matches(&self, index: &vecq_core::VecqIndex) -> bool { + match self { + Self::Residual => index.is_residual(), + Self::B4 => !index.is_residual() && index.bits() == 4, + Self::B5 => !index.is_residual() && index.bits() == 5, + Self::B6 => !index.is_residual() && index.bits() == 6, + } + } +} + static VECTOR_STORE: LazyLock> = LazyLock::new(|| std::sync::RwLock::new(VectorStoreKind::Usearch)); +static VECTOR_BITS: LazyLock> = + LazyLock::new(|| std::sync::RwLock::new(VectorBits::default())); + /// Select the physical vector store used by NEW indexes (process-wide). /// Existing on-disk indexes keep their own format via file extension. pub fn set_vector_store(kind: VectorStoreKind) { @@ -51,14 +112,30 @@ pub fn current_vector_store() -> VectorStoreKind { *VECTOR_STORE.read().unwrap() } -/// Apply `brain.vector_store` from a loaded config (any process entry that -/// touches Brain search/embedding must call this before opening the index, -/// or `.vecq`/`.usearch` files silently diverge between runs). +/// Select the vecq quantization width used by NEW indexes (process-wide). +pub fn set_vector_bits(bits: VectorBits) { + *VECTOR_BITS.write().unwrap() = bits; +} + +pub fn current_vector_bits() -> VectorBits { + *VECTOR_BITS.read().unwrap() +} + +/// Apply `brain.vector_store` + `brain.vector_bits` from a loaded config (any +/// process entry that touches Brain search/embedding must call this before +/// opening the index, or `.vecq`/`.usearch` files silently diverge between +/// runs). pub fn apply_config_store(config: Option<&crate::config::schema::Config>) { - let kind = config - .map(|c| VectorStoreKind::parse(&c.brain.vector_store)) + let (kind, bits) = config + .map(|c| { + ( + VectorStoreKind::parse(&c.brain.vector_store), + VectorBits::parse(&c.brain.vector_bits), + ) + }) .unwrap_or_default(); set_vector_store(kind); + set_vector_bits(bits); } /// Hashing-trick embedding dimensions (zero-dependency fallback). @@ -113,9 +190,7 @@ impl CodeVectorIndex { symbol_to_key: HashMap::new(), next_key: 0, }, - VectorStoreKind::Vecq => { - Inner::Vecq(Box::new(vecq_core::VecqIndex::new(dims, VECQ_SEED))) - } + VectorStoreKind::Vecq => Inner::Vecq(Box::new(current_vector_bits().create(dims))), }, path: None, dirty: false, @@ -161,7 +236,6 @@ impl CodeVectorIndex { let mut lock_file = acquire_file_lock(path)?; let mut buffer = Vec::new(); - let mut dirty = false; if lock_file.metadata().context("read vecq metadata")?.len() > 0 { use std::io::{Read, Seek, SeekFrom}; lock_file @@ -172,21 +246,57 @@ impl CodeVectorIndex { .context("read vecq file")?; } - // KNOWN LIMITATION (#542): vecq-core 0.2.0 `from_bytes` restores codes - // and scales but NOT the keyed map (see codecoradev/vecq#32), so a - // reloaded index would silently search empty. Until key serialization - // ships upstream, reload always rebuilds fresh. To keep the next - // `cora index` honest, mark the index dirty here — `embed_project` - // will then clear embed fingerprints and re-embed everything instead - // of skipping unchanged symbols against an empty index. - if buffer.len() >= 24 { - tracing::warn!( - "vecq persistence lacks key serialization upstream (vecq#32) — \ - recreating index; `cora index` will re-embed all symbols" - ); - dirty = true; + let bits = current_vector_bits(); + let mut index = bits.create(dims); + let mut dirty = false; + if !buffer.is_empty() { + match vecq_core::VecqIndex::from_bytes(&buffer) { + Ok(loaded) + if loaded.dim() == dims && bits.matches(&loaded) && vecq_has_keys(&loaded) => + { + index = loaded; + } + // Dim mismatch (e.g. rebuilt with/without pretrained-embed): + // keeping the loaded dims would panic on the next insert. + Ok(loaded) if loaded.dim() != dims => { + tracing::warn!( + "vecq index dims {} != current {dims} — recreating; \ + `cora index` will re-embed all symbols", + loaded.dim() + ); + dirty = true; + } + Ok(loaded) if !bits.matches(&loaded) => { + tracing::warn!( + "vecq index width ({}) != configured brain.vector_bits ({bits:?}) — \ + recreating; `cora index` will re-embed all symbols", + if loaded.is_residual() { + "residual".to_string() + } else { + format!("{}-bit", loaded.bits()) + } + ); + dirty = true; + } + // Legacy vecq-core 0.2.x file (vecq#32): parses fine but + // carries no keyed-slot table, and cora's symbol ids live in + // the key map — searching it would silently return nothing. + Ok(_) => { + tracing::warn!( + "vecq file predates keyed persistence (vecq#32) — \ + recreating index; `cora index` will re-embed all symbols" + ); + dirty = true; + } + Err(e) => { + tracing::warn!( + "vecq index unreadable ({e}) — recreating; \ + `cora index` will re-embed all symbols" + ); + dirty = true; + } + } } - let index = vecq_core::VecqIndex::new(dims, VECQ_SEED); Ok(Self { inner: Inner::Vecq(Box::new(index)), @@ -446,6 +556,37 @@ fn create_usearch_index(dims: usize) -> Result { Index::new(&options).context("create usearch index") } +/// cora only inserts via `add_keyed`, so every live slot of a healthy index +/// carries a key. Files written by vecq-core 0.2.x (before format v1.3) +/// load with vectors but no key table — searching them would silently +/// return nothing (vecq#32). +fn vecq_has_keys(index: &vecq_core::VecqIndex) -> bool { + index.is_empty() || (0..index.slots()).any(|s| index.key_of(s).is_some()) +} + +/// True when the `.vecq` file exists but cannot serve keyed search after +/// reload — legacy vecq#32 file, unreadable, built with different dims, or +/// at a different width than the configured `brain.vector_bits`. Mirrors +/// exactly what [`CodeVectorIndex::load_or_create`] will decide, so +/// `cora index` can force a re-embed when the index is about to be rebuilt. +pub fn vecq_file_needs_rebuild(path: &Path, dims: usize) -> bool { + let bytes = match std::fs::read(path) { + Ok(b) => b, + Err(_) => return false, // no file / unreadable — nothing to rebuild + }; + if bytes.is_empty() { + return false; + } + match vecq_core::VecqIndex::from_bytes(&bytes) { + Ok(loaded) => { + loaded.dim() != dims + || !current_vector_bits().matches(&loaded) + || !vecq_has_keys(&loaded) + } + Err(_) => true, + } +} + /// Convert cosine distance (0..2) to cosine similarity (0..1). pub fn cosine_distance_to_similarity(distance: f32) -> f32 { (1.0 - distance).clamp(0.0, 1.0) @@ -591,52 +732,151 @@ mod tests { } #[test] - fn test_vecq_backend_roundtrip() { - let _g = with_store_lock(); - set_vector_store(VectorStoreKind::Vecq); - let mut idx = CodeVectorIndex::new(64).unwrap(); + fn test_vecq_backend_roundtrip_all_widths() { + for bits in [ + VectorBits::Residual, + VectorBits::B4, + VectorBits::B5, + VectorBits::B6, + ] { + let _g = with_store_lock(); + set_vector_store(VectorStoreKind::Vecq); + set_vector_bits(bits); + let mut idx = CodeVectorIndex::new(64).unwrap(); - idx.insert(1, &make_unit_vec(64, 0)).unwrap(); - idx.insert(2, &make_unit_vec(64, 1)).unwrap(); - assert_eq!(idx.len(), 2); + idx.insert(1, &make_unit_vec(64, 0)).unwrap(); + idx.insert(2, &make_unit_vec(64, 1)).unwrap(); + assert_eq!(idx.len(), 2); - // Replace semantics - idx.insert(1, &make_unit_vec(64, 2)).unwrap(); - assert_eq!(idx.len(), 2); + // Replace semantics + idx.insert(1, &make_unit_vec(64, 2)).unwrap(); + assert_eq!(idx.len(), 2); - // Removal - assert!(idx.remove(2)); - assert!(!idx.remove(2)); - assert_eq!(idx.len(), 1); + // Removal + assert!(idx.remove(2)); + assert!(!idx.remove(2)); + assert_eq!(idx.len(), 1); - let results = idx.search(&make_unit_vec(64, 2), 5); - assert_eq!(results.len(), 1); - assert_eq!(results[0].0, 1); - // distance conversion: similarity ~1.0 -> distance ~0.0 - assert!(results[0].1 < 0.05, "dist={:?}", results[0].1); + let results = idx.search(&make_unit_vec(64, 2), 5); + assert_eq!(results.len(), 1); + assert_eq!(results[0].0, 1); + // distance conversion: similarity ~1.0 -> distance ~0.0 + assert!(results[0].1 < 0.05, "dist={:?}", results[0].1); - set_vector_store(VectorStoreKind::Usearch); + set_vector_bits(VectorBits::default()); + set_vector_store(VectorStoreKind::Usearch); + } } #[test] - fn test_vecq_save_and_load_uses_own_extension() { + fn test_vecq_save_and_load_restores_keys() { let _g = with_store_lock(); set_vector_store(VectorStoreKind::Vecq); + set_vector_bits(VectorBits::default()); // residual let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("test.usearch"); // caller names it; backend picks .vecq { let mut idx = CodeVectorIndex::new(64).unwrap(); idx.insert(10, &make_unit_vec(64, 0)).unwrap(); + idx.insert(20, &make_unit_vec(64, 1)).unwrap(); idx.path = Some(path.clone()); idx.save().unwrap(); } assert!(dir.path().join("test.vecq").exists()); - // Upstream gap (vecq#32): keys are not serialized, so reload rebuilds - // fresh instead of serving a silently-empty index. + // vecq-core 0.3.0 keyed persistence (vecq#32 closed): symbol-id keys + // survive reload, so a fresh process serves the index as-is. let idx2 = CodeVectorIndex::load_or_create(&path, 64).unwrap(); - assert!(idx2.is_empty(), "reload must not serve a keyless index"); + assert_eq!(idx2.len(), 2, "keyed map must survive reload"); + assert!(!idx2.is_dirty(), "healthy keyed file loads clean"); + + let results = idx2.search(&make_unit_vec(64, 0), 1); + assert_eq!(results[0].0, 10); + set_vector_store(VectorStoreKind::Usearch); + } + + #[test] + fn test_vecq_legacy_keyless_file_rebuilds() { + let _g = with_store_lock(); + set_vector_store(VectorStoreKind::Vecq); + // The simulated legacy file is plain 5-bit; match that width so the + // load hits the keyless arm rather than the width-mismatch arm. + set_vector_bits(VectorBits::B5); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("test.usearch"); + let vecq_path = dir.path().join("test.vecq"); + + // Simulate a vecq-core 0.2.x file (vecq#32): vectors serialized + // without a keyed-slot table, which is what unkeyed `add()` produces. + let mut legacy = vecq_core::VecqIndex::new(64, VECQ_SEED); + legacy.add(&make_unit_vec(64, 0)); + std::fs::write(&vecq_path, legacy.to_bytes()).unwrap(); + + let idx = CodeVectorIndex::load_or_create(&path, 64).unwrap(); + assert!(idx.is_empty(), "keyless file must not be served"); + assert!(idx.is_dirty(), "keyless file must trigger a rebuild"); + set_vector_bits(VectorBits::default()); + set_vector_store(VectorStoreKind::Usearch); + } + + #[test] + fn test_vecq_width_switch_rebuilds() { + let _g = with_store_lock(); + set_vector_store(VectorStoreKind::Vecq); + set_vector_bits(VectorBits::default()); // residual + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("test.usearch"); + + { + let mut idx = CodeVectorIndex::new(64).unwrap(); + idx.insert(10, &make_unit_vec(64, 0)).unwrap(); + idx.path = Some(path.clone()); + idx.save().unwrap(); + } + + // A healthy keyed index must NOT be kept when the configured width + // changed — the rebuild makes brain.vector_bits actually take effect. + set_vector_bits(VectorBits::B5); + let idx = CodeVectorIndex::load_or_create(&path, 64).unwrap(); + assert!(idx.is_empty(), "width-mismatched file must not be served"); + assert!(idx.is_dirty(), "width change must trigger a rebuild"); + set_vector_bits(VectorBits::default()); + set_vector_store(VectorStoreKind::Usearch); + } + + #[test] + fn test_vecq_corrupt_file_rebuilds() { + let _g = with_store_lock(); + set_vector_store(VectorStoreKind::Vecq); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("test.usearch"); + std::fs::write(dir.path().join("test.vecq"), vec![0u8; 64]).unwrap(); + + let idx = CodeVectorIndex::load_or_create(&path, 64).unwrap(); + assert!(idx.is_empty()); + assert!(idx.is_dirty()); + set_vector_store(VectorStoreKind::Usearch); + } + + #[test] + fn test_vecq_dim_mismatch_rebuilds() { + let _g = with_store_lock(); + set_vector_store(VectorStoreKind::Vecq); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("test.usearch"); + + { + let mut idx = CodeVectorIndex::new(64).unwrap(); + idx.insert(10, &make_unit_vec(64, 0)).unwrap(); + idx.path = Some(path.clone()); + idx.save().unwrap(); + } + + let idx = CodeVectorIndex::load_or_create(&path, 128).unwrap(); + assert!(idx.is_empty()); + assert!(idx.is_dirty()); + assert_eq!(idx.dims(), 128); set_vector_store(VectorStoreKind::Usearch); } @@ -649,6 +889,17 @@ mod tests { assert_eq!(VectorStoreKind::parse("bogus"), VectorStoreKind::Usearch); } + #[test] + fn vector_bits_parse_is_lenient() { + assert_eq!(VectorBits::parse("residual"), VectorBits::Residual); + assert_eq!(VectorBits::parse(" RESIDUAL "), VectorBits::Residual); + assert_eq!(VectorBits::parse("4"), VectorBits::B4); + assert_eq!(VectorBits::parse("5"), VectorBits::B5); + assert_eq!(VectorBits::parse("6"), VectorBits::B6); + assert_eq!(VectorBits::parse(""), VectorBits::Residual); + assert_eq!(VectorBits::parse("bogus"), VectorBits::Residual); + } + #[test] fn test_cosine_distance_to_similarity() { assert!((cosine_distance_to_similarity(0.0) - 1.0).abs() < f32::EPSILON); From 11f898850f67da1160d906addecf42d276de4538 Mon Sep 17 00:00:00 2001 From: "Anaz S. Aji" Date: Mon, 31 Aug 2026 11:42:01 +0700 Subject: [PATCH 4/5] =?UTF-8?q?docs(cli):=20document=20cora=20upgrade=20?= =?UTF-8?q?=E2=80=94=20self-upgrade=20was=20undocumented=20(#549)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Self-Upgrade section to docs/cli-reference.md (--check, default flow with SHA-256 checksum verification, -y for CI), a README install/ upgrade note and a command-table row, per #541. Correction vs the issue body: the background update check is disabled via the CORA_NO_UPDATE_CHECK=1 environment variable — no --no-update-check flag exists. Closes #541 Co-authored-by: ajianaz --- README.md | 3 +++ docs/cli-reference.md | 13 +++++++++++++ 2 files changed, 16 insertions(+) diff --git a/README.md b/README.md index f0445a5..63b0d9e 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,8 @@ cargo install --git https://github.com/codecoradev/cora-code > Pin a version: `CORA_VERSION=v0.6.1 curl -fsSL ... | sh` +**Upgrading:** run `cora upgrade` (downloads the latest release, verifies its SHA-256 checksum, replaces the binary) or `cora upgrade --check` to just see if one is available. If you installed via `cargo install --path .`, re-run that instead. + **Verify which `cora` you're running** — `which -a cora` will reveal stale copies from other channels: ```bash @@ -213,6 +215,7 @@ Works on **all CI platforms** — [Gitea, GitLab, Bitbucket →](https://codecor | `cora serve` | Start MCP server + auto-reindex on startup | | `cora install` | Auto-detect and configure AI coding agents | | `cora hook install` | Install pre-commit hook | +| `cora upgrade` | Self-upgrade from GitHub Releases (checksum-verified) | See **[CLI Reference →](https://codecora.dev/cora/docs/cli-reference)** for all flags and examples. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index c23df1e..a731a2a 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -166,6 +166,19 @@ See [Code Intelligence](./code-intelligence) for detailed usage. | `cora mcp` | Start MCP server for AI coding agents (Claude Code, Cursor, Windsurf) | | `cora serve` | Start MCP server with auto-reindex on startup | +### Self-Upgrade + +| Command | Description | +|---------|-------------| +| `cora upgrade --check` | Check for a newer release; no download | +| `cora upgrade` | Detect OS/arch, download the latest GitHub release asset, verify its SHA-256 checksum, and replace the running binary | +| `cora upgrade -y` | Same, but skip the confirmation prompt (CI/automation) | + +Notes: + +- Background update notification: a non-blocking check runs on startup, cached for 24 h at `~/.codecora/cora-code/update-cache.json`. Notices go to stderr only. Disable with the `CORA_NO_UPDATE_CHECK=1` environment variable. +- If the running binary came from `cargo install --path .`, prefer re-running that instead of `cora upgrade` (the release asset would shadow your source build). + ## Quick Examples ```bash From 7e7efe1a0cf69dc7383a8a9a50e402681b3741a8 Mon Sep 17 00:00:00 2001 From: "Anaz S. Aji" Date: Mon, 31 Aug 2026 11:51:04 +0700 Subject: [PATCH 5/5] chore(release): v0.15.0 (#550) Co-authored-by: ajianaz --- CHANGELOG.md | 2 ++ Cargo.lock | 2 +- Cargo.toml | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0211cc6..5ebd72f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.15.0] - 2026-08-31 + ### Added - **Opt-in `vecq` vector store for Brain Mode.** Set `brain.vector_store: vecq` in `.cora.yaml` to replace the usearch HNSW index with a vecq quantized scan (pure Rust, deterministic, ~5x smaller). Keyed persistence included: symbol ids survive reload, so a fresh process serves the index as-is and `cora index` no longer re-embeds unchanged projects (#542, #547). diff --git a/Cargo.lock b/Cargo.lock index e9ee6a2..d521907 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -303,7 +303,7 @@ dependencies = [ [[package]] name = "cora-code" -version = "0.14.0" +version = "0.15.0" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index c77e731..d85a8fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cora-code" -version = "0.14.0" +version = "0.15.0" edition = "2024" description = "CLI-first AI code review — BYOK, diff/scan/branch, pre-commit hooks" license = "Apache-2.0"