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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/commands/watch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
33 changes: 33 additions & 0 deletions src/config/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,8 @@ pub struct CoraFile {
pub profile: Option<crate::engine::profiles::ProfileRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub analysis: Option<AnalysisConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub brain: Option<BrainSection>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
Expand Down Expand Up @@ -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
Expand All @@ -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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub embedding: Option<BrainEmbeddingMode>,
}

/// Embedding backend mode for Brain Mode.
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
Expand Down Expand Up @@ -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(())
}
}
Expand Down
30 changes: 30 additions & 0 deletions src/index/brain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,22 @@ pub fn embed_project(conn: &Connection, project_id: i64) -> Result<usize> {
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(
Expand Down Expand Up @@ -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)> {
Expand Down
8 changes: 6 additions & 2 deletions src/index/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading