use crate::types::ScoredChunkId;
use anyhow::Result;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DenseBackendKind {
#[default]
CoderankHnsw,
ColbertPlaid,
}
impl DenseBackendKind {
pub fn name(self) -> &'static str {
match self {
DenseBackendKind::CoderankHnsw => "coderank-hnsw",
DenseBackendKind::ColbertPlaid => "colbert-plaid",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"coderank-hnsw" => Some(DenseBackendKind::CoderankHnsw),
"colbert-plaid" => Some(DenseBackendKind::ColbertPlaid),
_ => None,
}
}
}
pub fn dense_subdir(index_dir: &Path, backend: DenseBackendKind) -> PathBuf {
index_dir.join("dense").join(backend.name())
}
pub fn active_dense_dir(index_dir: &Path, backend: DenseBackendKind, fingerprint: &str) -> PathBuf {
dense_subdir(index_dir, backend).join(fingerprint)
}
pub fn dense_sentinel_file(backend: DenseBackendKind) -> &'static str {
match backend {
DenseBackendKind::CoderankHnsw => "index.bin",
DenseBackendKind::ColbertPlaid => "plaid_mapping.bin",
}
}
pub fn resolve_active_dense_dir(index_dir: &Path, backend: DenseBackendKind) -> PathBuf {
if let Some(fp) = read_active_pointer(index_dir, backend) {
let versioned = active_dense_dir(index_dir, backend, &fp);
if versioned.join(dense_sentinel_file(backend)).exists() {
return versioned;
}
}
dense_subdir(index_dir, backend)
}
fn active_pointer_path(index_dir: &Path, backend: DenseBackendKind) -> PathBuf {
dense_subdir(index_dir, backend).join("ACTIVE")
}
pub fn read_active_pointer(index_dir: &Path, backend: DenseBackendKind) -> Option<String> {
std::fs::read_to_string(active_pointer_path(index_dir, backend))
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
pub fn write_active_pointer(
index_dir: &Path,
backend: DenseBackendKind,
fingerprint: &str,
) -> Result<()> {
let dir = dense_subdir(index_dir, backend);
std::fs::create_dir_all(&dir)?;
let final_path = dir.join("ACTIVE");
let tmp_path = dir.join(format!(".ACTIVE.{}.tmp", std::process::id()));
std::fs::write(&tmp_path, fingerprint.as_bytes())?;
std::fs::rename(&tmp_path, &final_path)?;
Ok(())
}
pub fn verify_persisted_backend_matches(index_dir: &Path, expected: &str) -> Result<()> {
let meta_path = index_dir.join("meta.json");
let Ok(meta_str) = std::fs::read_to_string(&meta_path) else {
return Ok(());
};
let Ok(meta) = serde_json::from_str::<crate::types::IndexMeta>(&meta_str) else {
return Ok(());
};
if meta.dense_backend != expected {
anyhow::bail!(
"dense backend mismatch: index built with dense_backend={}, \
config says dense_backend={}. Run `semantex index --rebuild` \
to reconcile.",
meta.dense_backend,
expected,
);
}
Ok(())
}
pub fn verify_persisted_fingerprint_matches(index_dir: &Path, expected: &str) -> Result<()> {
let meta_path = index_dir.join("meta.json");
let Ok(meta_str) = std::fs::read_to_string(&meta_path) else {
return Ok(());
};
let Ok(meta) = serde_json::from_str::<crate::types::IndexMeta>(&meta_str) else {
return Ok(());
};
if meta.embedder_fingerprint != expected {
anyhow::bail!(
"embedder changed: index built with embedder_fingerprint={}, \
config's active embedder has fingerprint={}. Run \
`semantex index --rebuild` to re-embed under the new model.",
meta.embedder_fingerprint,
expected,
);
}
Ok(())
}
pub type DenseHit = ScoredChunkId;
pub trait DenseBackend: Send + Sync {
fn name(&self) -> &'static str;
fn search(&self, query: &str, k: usize) -> Result<Vec<DenseHit>>;
fn search_with_subset(&self, query: &str, k: usize, subset: &[u64]) -> Result<Vec<DenseHit>>;
fn positional_chunk_ids(&self) -> Option<&[u64]> {
None
}
fn embed_text_vector(&self, _query: &str) -> Option<Vec<f32>> {
None
}
fn embed_doc_vectors(&self, _chunk_ids: &[u64]) -> Option<Vec<(u64, Vec<f32>)>> {
None
}
}
pub trait DenseIndexBuilder: Send + Sync {
fn name(&self) -> &'static str;
fn build(&mut self, chunks: &[(u64, &str)]) -> Result<()>;
fn insert(&mut self, chunks: &[(u64, &str)]) -> Result<()>;
fn delete(&mut self, chunk_ids: &[u64]) -> Result<()>;
fn persist(&self, dir: &Path) -> Result<()>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn embed_doc_vectors_defaults_to_none() {
struct StubBackend;
impl DenseBackend for StubBackend {
fn name(&self) -> &'static str {
"stub"
}
fn search(&self, _q: &str, _k: usize) -> Result<Vec<DenseHit>> {
Ok(vec![])
}
fn search_with_subset(&self, _q: &str, _k: usize, _s: &[u64]) -> Result<Vec<DenseHit>> {
Ok(vec![])
}
}
let b = StubBackend;
assert!(b.embed_doc_vectors(&[1, 2, 3]).is_none());
assert!(b.embed_text_vector("anything").is_none());
}
#[test]
fn dense_backend_kind_default_is_coderank_hnsw() {
assert_eq!(DenseBackendKind::default(), DenseBackendKind::CoderankHnsw);
assert_eq!(DenseBackendKind::default().name(), "coderank-hnsw");
}
#[test]
fn parse_unknown_backend_is_none() {
assert_eq!(DenseBackendKind::parse("totally-made-up"), None);
assert_eq!(DenseBackendKind::parse(""), None);
}
#[test]
fn parse_coderank_hnsw_backend() {
assert_eq!(
DenseBackendKind::parse("coderank-hnsw"),
Some(DenseBackendKind::CoderankHnsw)
);
assert_eq!(
DenseBackendKind::parse(" Coderank-HNSW "),
Some(DenseBackendKind::CoderankHnsw)
);
assert_eq!(DenseBackendKind::CoderankHnsw.name(), "coderank-hnsw");
}
#[test]
fn parse_colbert_plaid_backend() {
assert_eq!(
DenseBackendKind::parse("colbert-plaid"),
Some(DenseBackendKind::ColbertPlaid)
);
assert_eq!(
DenseBackendKind::parse(" Colbert-PLAID "),
Some(DenseBackendKind::ColbertPlaid)
);
assert_eq!(DenseBackendKind::ColbertPlaid.name(), "colbert-plaid");
assert_eq!(
dense_sentinel_file(DenseBackendKind::ColbertPlaid),
"plaid_mapping.bin"
);
}
#[test]
fn coderank_dense_subdir() {
let p = dense_subdir(Path::new("/x/.semantex"), DenseBackendKind::CoderankHnsw);
assert_eq!(p, Path::new("/x/.semantex/dense/coderank-hnsw"));
}
#[test]
fn verify_backend_matches_on_agreement() {
let tmp = tempfile::TempDir::new().unwrap();
let index_dir = tmp.path();
write_meta_with_backend(index_dir, "coderank-hnsw");
verify_persisted_backend_matches(index_dir, "coderank-hnsw").unwrap();
}
#[test]
fn verify_backend_errors_on_mismatch() {
let tmp = tempfile::TempDir::new().unwrap();
let index_dir = tmp.path();
write_meta_with_backend(index_dir, "colbert-plaid");
let err = verify_persisted_backend_matches(index_dir, "coderank-hnsw")
.expect_err("mismatched backend must error");
let msg = err.to_string();
assert!(msg.contains("dense backend mismatch"), "got: {msg}");
assert!(
msg.contains("colbert-plaid") && msg.contains("coderank-hnsw"),
"got: {msg}"
);
assert!(msg.contains("semantex index --rebuild"), "got: {msg}");
}
#[test]
fn verify_backend_skips_when_meta_missing() {
let tmp = tempfile::TempDir::new().unwrap();
verify_persisted_backend_matches(tmp.path(), "coderank-hnsw").unwrap();
}
#[test]
fn verify_fingerprint_errors_on_mismatch() {
let tmp = tempfile::TempDir::new().unwrap();
let index_dir = tmp.path();
write_meta_with_fingerprint(index_dir, "coderank-hnsw", "OLDFP");
let err = verify_persisted_fingerprint_matches(index_dir, "NEWFP")
.expect_err("fingerprint mismatch must error");
let msg = err.to_string();
assert!(
msg.contains("embedder changed") || msg.contains("fingerprint"),
"got: {msg}"
);
assert!(msg.contains("OLDFP") && msg.contains("NEWFP"), "got: {msg}");
}
#[test]
fn verify_fingerprint_ok_on_match() {
let tmp = tempfile::TempDir::new().unwrap();
write_meta_with_fingerprint(tmp.path(), "coderank-hnsw", "SAME");
verify_persisted_fingerprint_matches(tmp.path(), "SAME").unwrap();
}
#[test]
fn verify_fingerprint_skips_when_meta_missing() {
let tmp = tempfile::TempDir::new().unwrap();
verify_persisted_fingerprint_matches(tmp.path(), "anything").unwrap();
}
fn write_meta_with_fingerprint(index_dir: &Path, backend: &str, fingerprint: &str) {
let meta = crate::types::IndexMeta {
schema_version: crate::types::IndexMeta::CURRENT_SCHEMA_VERSION,
project_path: index_dir.to_path_buf(),
created_at: "0".to_string(),
updated_at: "0".to_string(),
file_count: 0,
chunk_count: 0,
embedding_model: "test".to_string(),
embedding_dim: 48,
use_bm25_stemmer: true,
dense_backend: backend.to_string(),
embedder_fingerprint: fingerprint.to_string(),
};
std::fs::write(
index_dir.join("meta.json"),
serde_json::to_string(&meta).unwrap(),
)
.unwrap();
}
#[test]
fn versioned_dir_nests_fingerprint_under_backend() {
let root = Path::new("/tmp/proj/.semantex");
let p = active_dense_dir(root, DenseBackendKind::CoderankHnsw, "deadbeef");
assert_eq!(
p,
Path::new("/tmp/proj/.semantex/dense/coderank-hnsw/deadbeef")
);
}
#[test]
fn active_pointer_round_trips() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
assert_eq!(
read_active_pointer(root, DenseBackendKind::CoderankHnsw),
None
);
write_active_pointer(root, DenseBackendKind::CoderankHnsw, "abc123").unwrap();
assert_eq!(
read_active_pointer(root, DenseBackendKind::CoderankHnsw),
Some("abc123".to_string())
);
write_active_pointer(root, DenseBackendKind::CoderankHnsw, "def456").unwrap();
assert_eq!(
read_active_pointer(root, DenseBackendKind::CoderankHnsw),
Some("def456".to_string())
);
}
#[test]
fn resolve_active_dense_dir_no_pointer_returns_plain() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
let got = resolve_active_dense_dir(root, DenseBackendKind::CoderankHnsw);
assert_eq!(got, dense_subdir(root, DenseBackendKind::CoderankHnsw));
}
#[test]
fn resolve_active_dense_dir_pointer_plus_populated_returns_versioned() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
let fp = "deadbeef";
let versioned = active_dense_dir(root, DenseBackendKind::CoderankHnsw, fp);
std::fs::create_dir_all(&versioned).unwrap();
std::fs::write(
versioned.join(dense_sentinel_file(DenseBackendKind::CoderankHnsw)),
b"x",
)
.unwrap();
write_active_pointer(root, DenseBackendKind::CoderankHnsw, fp).unwrap();
let got = resolve_active_dense_dir(root, DenseBackendKind::CoderankHnsw);
assert_eq!(got, versioned);
}
#[test]
fn resolve_active_dense_dir_pointer_but_missing_sentinel_falls_back() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path();
let fp = "deadbeef";
std::fs::create_dir_all(active_dense_dir(root, DenseBackendKind::CoderankHnsw, fp))
.unwrap();
write_active_pointer(root, DenseBackendKind::CoderankHnsw, fp).unwrap();
let got = resolve_active_dense_dir(root, DenseBackendKind::CoderankHnsw);
assert_eq!(got, dense_subdir(root, DenseBackendKind::CoderankHnsw));
}
fn write_meta_with_backend(index_dir: &Path, backend: &str) {
let meta = crate::types::IndexMeta {
schema_version: crate::types::IndexMeta::CURRENT_SCHEMA_VERSION,
project_path: index_dir.to_path_buf(),
created_at: "0".to_string(),
updated_at: "0".to_string(),
file_count: 0,
chunk_count: 0,
embedding_model: "test".to_string(),
embedding_dim: 48,
use_bm25_stemmer: true,
dense_backend: backend.to_string(),
embedder_fingerprint: "test".to_string(),
};
std::fs::write(
index_dir.join("meta.json"),
serde_json::to_string(&meta).unwrap(),
)
.unwrap();
}
}