use crate::embedding::cinder::CinderEncoder;
use crate::embedding::colbert::{ColbertEmbedder, TokenEmbeddings};
use crate::embedding::model_manager;
use crate::embedding::static_token::StaticTokenEmbedder;
use crate::search::dense_backend::{DenseBackend, DenseHit, DenseIndexBuilder};
use crate::search::plaid_search::PlaidSearcher;
use crate::types::DENSE_TOMBSTONE;
use anyhow::Result;
use ndarray::Axis;
use std::path::{Path, PathBuf};
const PLAID_BUFFER_SIZE: usize = 50;
const PLAID_BATCH: usize = 32;
const INITIAL_BUILD_CHUNKS: usize = 2_000;
const PLAID_APPEND_BATCH: usize = 512;
#[cfg(test)]
pub(crate) static CINDER_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
pub struct ColbertPlaidBackend {
plaid: PlaidSearcher,
colbert: &'static ColbertEmbedder,
}
impl ColbertPlaidBackend {
pub const NAME: &'static str = "colbert-plaid";
pub fn open(plaid_dir: &Path, mapping_path: &Path, model_dir: &Path) -> Result<Self> {
let plaid = PlaidSearcher::open(plaid_dir, mapping_path)?;
let colbert = ColbertEmbedder::global(model_dir)?;
Ok(Self { plaid, colbert })
}
pub fn plaid(&self) -> &PlaidSearcher {
&self.plaid
}
}
impl DenseBackend for ColbertPlaidBackend {
fn name(&self) -> &'static str {
Self::NAME
}
fn search(&self, query: &str, k: usize) -> Result<Vec<DenseHit>> {
self.plaid.search(self.colbert, query, k)
}
fn search_with_subset(&self, query: &str, k: usize, subset: &[u64]) -> Result<Vec<DenseHit>> {
self.plaid
.search_with_subset(self.colbert, query, k, Some(subset))
}
fn positional_chunk_ids(&self) -> Option<&[u64]> {
Some(self.plaid.doc_to_chunk())
}
fn embed_text_vector(&self, query: &str) -> Option<Vec<f32>> {
let tokens = self.colbert.encode_query(query).ok()?;
mean_pool_l2(&tokens)
}
fn embed_doc_vectors(&self, _chunk_ids: &[u64]) -> Option<Vec<(u64, Vec<f32>)>> {
None
}
}
fn mean_pool_l2(tokens: &crate::embedding::colbert::TokenEmbeddings) -> Option<Vec<f32>> {
if tokens.nrows() == 0 {
return None;
}
let mean = tokens.mean_axis(Axis(0))?; let norm = mean.dot(&mean).sqrt();
if norm <= f32::EPSILON {
return None;
}
Some(mean.iter().map(|&x| x / norm).collect())
}
fn write_mapping_atomic(path: &Path, mapping: &[u64]) -> Result<()> {
let dir = path.parent().unwrap_or_else(|| Path::new("."));
std::fs::create_dir_all(dir)?;
let tmp_path = dir.join(format!(".plaid_mapping.{}.tmp", std::process::id()));
std::fs::write(&tmp_path, postcard::to_stdvec(mapping)?)?;
std::fs::rename(&tmp_path, path)?;
Ok(())
}
enum DocEncoderKind {
Contextual(ColbertEmbedder),
Static(StaticTokenEmbedder),
}
impl DocEncoderKind {
fn encode_documents(&self, texts: &[String]) -> Result<Vec<TokenEmbeddings>> {
match self {
DocEncoderKind::Contextual(embedder) => embedder.encode_documents(texts),
DocEncoderKind::Static(embedder) => embedder.encode_documents(texts),
}
}
fn for_indexing(model_dir: &Path) -> Result<Self> {
if crate::config::env_bool("SEMANTEX_STATIC_DOC_EMBED") {
match StaticTokenEmbedder::new(model_dir) {
Ok(embedder) => return Ok(DocEncoderKind::Static(embedder)),
Err(e) => {
tracing::warn!(
"SEMANTEX_STATIC_DOC_EMBED is set but the static token table \
failed to load from {} ({e:#}); falling back to the contextual \
ColBERT encoder for this build",
model_dir.display()
);
}
}
}
Ok(DocEncoderKind::Contextual(ColbertEmbedder::for_indexing(
model_dir,
)?))
}
}
fn frozen_centroids_for_build(model_dir: &Path) -> Option<PathBuf> {
if !crate::config::env_bool("SEMANTEX_FROZEN_CENTROIDS") {
return None;
}
let path = model_manager::frozen_centroids_path(model_dir);
if path.exists() {
tracing::info!("using frozen universal centroids: {}", path.display());
Some(path)
} else {
tracing::warn!(
"SEMANTEX_FROZEN_CENTROIDS is set but {} is missing; \
falling back to per-repo k-means for this build",
path.display()
);
None
}
}
fn cinder_for_build(model_dir: &Path) -> Option<CinderEncoder> {
if !crate::config::env_bool_default_true("SEMANTEX_CINDER") {
return None;
}
match CinderEncoder::new(model_dir) {
Ok(encoder) => {
tracing::info!("using cinder compiled indexing");
Some(encoder)
}
Err(e) => {
tracing::warn!(
"SEMANTEX_CINDER is set but the Cinder artifacts failed to load from {} \
({e:#}); falling back to the existing PLAID build path for this build",
model_dir.display()
);
None
}
}
}
pub struct ColbertPlaidIndexBuilder {
plaid_dir: PathBuf,
mapping_path: PathBuf,
models_dir: PathBuf,
nbits: usize,
}
impl ColbertPlaidIndexBuilder {
pub fn new(index_dir: &Path, nbits: usize) -> Self {
Self {
plaid_dir: index_dir.to_path_buf(),
mapping_path: index_dir.join("plaid_mapping.bin"),
models_dir: crate::config::SemantexConfig::default().models_dir(),
nbits,
}
}
pub fn with_models_dir(mut self, models_dir: PathBuf) -> Self {
self.models_dir = models_dir;
self
}
fn next_plaid_configs(
&self,
frozen_centroids: Option<&Path>,
) -> (next_plaid::IndexConfig, next_plaid::UpdateConfig) {
let external_centroids_npy = frozen_centroids.map(|p| p.to_string_lossy().into_owned());
let index_config = next_plaid::IndexConfig {
nbits: self.nbits,
batch_size: 1024,
force_cpu: true,
external_centroids_npy: external_centroids_npy.clone(),
..Default::default()
};
let update_config = next_plaid::UpdateConfig {
batch_size: 1024,
buffer_size: PLAID_BUFFER_SIZE,
force_cpu: true,
external_centroids_npy,
..Default::default()
};
(index_config, update_config)
}
pub fn build_streaming_ids<F>(&mut self, chunk_ids: &[u64], mut fetch: F) -> Result<()>
where
F: FnMut(&[u64]) -> Result<Vec<(u64, String)>>,
{
use next_plaid::MmapIndex;
if chunk_ids.is_empty() {
tracing::info!("No chunks to encode for PLAID index");
return Ok(());
}
let model_dir = model_manager::ensure_colbert_model(&self.models_dir)?;
if crate::config::env_bool_default_true("SEMANTEX_CINDER")
&& let Err(e) = model_manager::ensure_ember_cinder_artifacts(&model_dir)
{
tracing::warn!(
"failed to provision Ember/Cinder artifacts from the pinned release into {} \
({e:#}); will load any already-present artifacts, else fall back to the \
existing PLAID build path",
model_dir.display()
);
}
if let Some(cinder) = cinder_for_build(&model_dir) {
let centroids_path = model_manager::frozen_centroids_path(&model_dir);
match crate::embedding::centroid_train::load_centroids_npy(¢roids_path) {
Ok(centroids) => return self.build_cinder(chunk_ids, fetch, &cinder, centroids),
Err(e) => tracing::warn!(
"SEMANTEX_CINDER is set and Cinder artifacts loaded, but the frozen \
centroids the cinder path REQUIRES failed to load from {} ({e:#}); \
falling back to the existing PLAID build path",
centroids_path.display()
),
}
}
let embedder = DocEncoderKind::for_indexing(&model_dir)?;
let frozen = frozen_centroids_for_build(&model_dir);
let (index_config, update_config) = self.next_plaid_configs(frozen.as_deref());
let plaid_dir_str = self.plaid_dir.to_string_lossy().into_owned();
if self.plaid_dir.exists() {
let _ = std::fs::remove_dir_all(&self.plaid_dir);
}
std::fs::create_dir_all(&self.plaid_dir)?;
let split = chunk_ids.len().min(INITIAL_BUILD_CHUNKS);
let (initial_ids, remainder_ids) = chunk_ids.split_at(split);
let mut full_mapping: Vec<u64> = Vec::with_capacity(chunk_ids.len());
let mut all_embeddings: Vec<_> = Vec::with_capacity(initial_ids.len());
for batch_ids in initial_ids.chunks(PLAID_BATCH) {
if let Err(e) = crate::memory::check_rss_or_abort("PLAID encode batch") {
anyhow::bail!("Indexing aborted: {e}");
}
let batch = fetch(batch_ids)?;
if batch.is_empty() {
continue;
}
let contents: Vec<String> = batch.iter().map(|(_, c)| c.clone()).collect();
let embeddings = embedder.encode_documents(&contents)?;
all_embeddings.extend(embeddings);
full_mapping.extend(batch.iter().map(|(id, _)| *id));
}
if all_embeddings.is_empty() {
tracing::info!("No chunks to encode for PLAID index");
return Ok(());
}
if let Err(e) = crate::memory::check_rss_or_abort("PLAID build (initial call)") {
anyhow::bail!("Indexing aborted: {e}");
}
let (_index, plaid_doc_ids) = MmapIndex::update_or_create(
&all_embeddings,
&plaid_dir_str,
&index_config,
&update_config,
)?;
anyhow::ensure!(
plaid_doc_ids.len() == full_mapping.len(),
"PLAID returned {} doc IDs for {} chunks — contract violated",
plaid_doc_ids.len(),
full_mapping.len(),
);
drop(all_embeddings);
crate::memory::purge_allocator();
let mut appended_so_far: usize = 0;
for batch_ids in remainder_ids.chunks(PLAID_APPEND_BATCH) {
if let Err(e) = crate::memory::check_rss_or_abort("PLAID append batch") {
anyhow::bail!("Indexing aborted: {e}");
}
let batch = fetch(batch_ids)?;
if batch.is_empty() {
continue;
}
let contents: Vec<String> = batch.iter().map(|(_, c)| c.clone()).collect();
let embeddings = embedder.encode_documents(&contents)?;
let plaid_doc_ids =
MmapIndex::update_append(&embeddings, &plaid_dir_str, &update_config)?;
anyhow::ensure!(
plaid_doc_ids.len() == batch.len(),
"PLAID returned {} doc IDs for {} chunks — contract violated",
plaid_doc_ids.len(),
batch.len(),
);
for (&doc_id, (chunk_id, _)) in plaid_doc_ids.iter().zip(batch.iter()) {
anyhow::ensure!(doc_id >= 0, "PLAID returned negative doc_id {doc_id}");
let idx = doc_id as usize;
while full_mapping.len() <= idx {
full_mapping.push(DENSE_TOMBSTONE);
}
full_mapping[idx] = *chunk_id;
}
crate::memory::purge_allocator();
appended_so_far += batch.len();
if let Some(rss_mb) = crate::memory::current_rss_mb() {
tracing::info!(
appended_so_far,
remainder_total = remainder_ids.len(),
rss_mb,
"PLAID append batch progress"
);
}
}
if !remainder_ids.is_empty() {
MmapIndex::load(&plaid_dir_str)?;
}
write_mapping_atomic(&self.mapping_path, &full_mapping)?;
tracing::info!("PLAID index built ({} chunks)", full_mapping.len());
Ok(())
}
fn build_cinder<F>(
&mut self,
chunk_ids: &[u64],
mut fetch: F,
cinder: &CinderEncoder,
centroids: ndarray::Array2<f32>,
) -> Result<()>
where
F: FnMut(&[u64]) -> Result<Vec<(u64, String)>>,
{
use crate::embedding::shortlists::shortlist_argmax;
use ndarray::Array1;
use next_plaid::{CompiledIndexWriter, IdAwareCodeAssigner, IndexConfig};
use std::time::{Duration, Instant};
if chunk_ids.is_empty() {
tracing::info!("No chunks to encode for PLAID index");
return Ok(());
}
let plaid_dir_str = self.plaid_dir.to_string_lossy().into_owned();
if self.plaid_dir.exists() {
let _ = std::fs::remove_dir_all(&self.plaid_dir);
}
std::fs::create_dir_all(&self.plaid_dir)?;
let config = IndexConfig {
nbits: self.nbits,
batch_size: 1024,
force_cpu: true,
..Default::default()
};
let split = chunk_ids.len().min(INITIAL_BUILD_CHUNKS);
let (initial_ids, remainder_ids) = chunk_ids.split_at(split);
let build_start = Instant::now();
let mut t_fetch = Duration::ZERO;
let mut t_encode = Duration::ZERO;
let mut t_add = Duration::ZERO;
let mut t_purge = Duration::ZERO;
let mut full_mapping: Vec<u64> = Vec::with_capacity(chunk_ids.len());
let mut sample_emb: Vec<TokenEmbeddings> = Vec::with_capacity(initial_ids.len());
let mut sample_windows: Vec<Vec<Vec<u32>>> = Vec::with_capacity(initial_ids.len());
for batch_ids in initial_ids.chunks(PLAID_BATCH) {
if let Err(e) = crate::memory::check_rss_or_abort("cinder encode (sample)") {
anyhow::bail!("Indexing aborted: {e}");
}
let t = Instant::now();
let batch = fetch(batch_ids)?;
t_fetch += t.elapsed();
if batch.is_empty() {
continue;
}
let contents: Vec<String> = batch.iter().map(|(_, c)| c.clone()).collect();
let t = Instant::now();
for (emb, windows) in cinder.encode_documents_with_window_ids(&contents)? {
sample_emb.push(emb);
sample_windows.push(windows);
}
t_encode += t.elapsed();
full_mapping.extend(batch.iter().map(|(id, _)| *id));
}
if sample_emb.is_empty() {
tracing::info!("No chunks to encode for PLAID index");
return Ok(());
}
let exact_assign = crate::config::env_bool("SEMANTEX_CINDER_EXACT_ASSIGN");
let assigner: Option<IdAwareCodeAssigner> = if exact_assign {
tracing::info!(
"SEMANTEX_CINDER_EXACT_ASSIGN=1: forcing exhaustive centroid assignment \
(diagnostic; Cinder's shortlist-union assigner disabled)"
);
None
} else {
let assigner_centroids = centroids.clone();
let assigner_shortlists = cinder.shortlists().clone();
Some(Box::new(
move |batch: &ndarray::Array2<f32>, window_ids: &[Vec<u32>]| {
use rayon::prelude::*;
let cview = assigner_centroids.view();
let out: Vec<usize> = (0..batch.nrows())
.into_par_iter()
.map_init(
crate::embedding::shortlists::ArgmaxScratch::new,
|scratch, r| {
let row = batch.row(r);
let e = row
.as_slice()
.expect("compiled-writer batch rows are contiguous");
shortlist_argmax(
e,
&window_ids[r],
&assigner_shortlists,
&cview,
scratch,
)
},
)
.collect();
Array1::from_vec(out)
},
))
};
let t = Instant::now();
let mut writer = CompiledIndexWriter::new(&plaid_dir_str, centroids, &config, &sample_emb)?;
let t_writer_new = t.elapsed();
if let Some(assigner) = assigner {
writer = writer.with_id_aware_assigner(assigner);
}
let t = Instant::now();
for (emb, windows) in sample_emb.iter().zip(sample_windows.iter()) {
writer.add_document_with_ids(emb, windows)?;
}
t_add += t.elapsed();
drop(sample_emb); drop(sample_windows);
let t = Instant::now();
crate::memory::purge_allocator();
t_purge += t.elapsed();
for batch_ids in remainder_ids.chunks(PLAID_APPEND_BATCH) {
if let Err(e) = crate::memory::check_rss_or_abort("cinder encode (stream)") {
anyhow::bail!("Indexing aborted: {e}");
}
let t = Instant::now();
let batch = fetch(batch_ids)?;
t_fetch += t.elapsed();
if batch.is_empty() {
continue;
}
let contents: Vec<String> = batch.iter().map(|(_, c)| c.clone()).collect();
let t = Instant::now();
let encoded = cinder.encode_documents_with_window_ids(&contents)?;
t_encode += t.elapsed();
let t = Instant::now();
for ((emb, windows), (chunk_id, _)) in encoded.iter().zip(batch.iter()) {
writer.add_document_with_ids(emb, windows)?;
full_mapping.push(*chunk_id);
}
t_add += t.elapsed();
let t = Instant::now();
crate::memory::purge_allocator();
t_purge += t.elapsed();
}
let t = Instant::now();
let meta = writer.finalize()?;
let t_finalize = t.elapsed();
tracing::info!(
"cinder dense-stage breakdown: total={:.2}s | fetch={:.2}s encode={:.2}s \
writer_new(residual_stats)={:.2}s add(buffer+flush_chunk)={:.2}s \
purge_allocator={:.2}s finalize(sort+ivf_write)={:.2}s",
build_start.elapsed().as_secs_f64(),
t_fetch.as_secs_f64(),
t_encode.as_secs_f64(),
t_writer_new.as_secs_f64(),
t_add.as_secs_f64(),
t_purge.as_secs_f64(),
t_finalize.as_secs_f64(),
);
anyhow::ensure!(
meta.num_documents == full_mapping.len(),
"cinder build wrote {} docs but mapping has {} entries — contract violated",
meta.num_documents,
full_mapping.len(),
);
write_mapping_atomic(&self.mapping_path, &full_mapping)?;
tracing::info!(
"PLAID index built via cinder ({} chunks)",
full_mapping.len()
);
Ok(())
}
pub fn insert_streaming_ids<F>(&mut self, chunk_ids: &[u64], mut fetch: F) -> Result<()>
where
F: FnMut(&[u64]) -> Result<Vec<(u64, String)>>,
{
use next_plaid::MmapIndex;
if chunk_ids.is_empty() {
return Ok(());
}
let model_dir = model_manager::ensure_colbert_model(&self.models_dir)?;
let embedder = DocEncoderKind::for_indexing(&model_dir)?;
let frozen = frozen_centroids_for_build(&model_dir);
let (index_config, update_config) = self.next_plaid_configs(frozen.as_deref());
let plaid_dir_str = self.plaid_dir.to_string_lossy().into_owned();
let mut mapping: Vec<u64> = if self.mapping_path.exists() {
postcard::from_bytes::<Vec<u64>>(&std::fs::read(&self.mapping_path)?)?
} else {
Vec::new()
};
for batch_ids in chunk_ids.chunks(PLAID_BATCH) {
if let Err(e) = crate::memory::check_rss_or_abort("PLAID incremental batch") {
anyhow::bail!("Indexing aborted: {e}");
}
let batch = fetch(batch_ids)?;
if batch.is_empty() {
continue;
}
let contents: Vec<String> = batch.iter().map(|(_, c)| c.clone()).collect();
let embeddings = embedder.encode_documents(&contents)?;
let (_index, plaid_doc_ids) = MmapIndex::update_or_create(
&embeddings,
&plaid_dir_str,
&index_config,
&update_config,
)?;
anyhow::ensure!(
plaid_doc_ids.len() == batch.len(),
"PLAID returned {} doc IDs for {} chunks — contract violated",
plaid_doc_ids.len(),
batch.len(),
);
for (&doc_id, (chunk_id, _)) in plaid_doc_ids.iter().zip(batch.iter()) {
anyhow::ensure!(doc_id >= 0, "PLAID returned negative doc_id {doc_id}");
let idx = doc_id as usize;
while mapping.len() <= idx {
mapping.push(DENSE_TOMBSTONE);
}
mapping[idx] = *chunk_id;
}
}
write_mapping_atomic(&self.mapping_path, &mapping)?;
Ok(())
}
}
impl DenseIndexBuilder for ColbertPlaidIndexBuilder {
fn name(&self) -> &'static str {
ColbertPlaidBackend::NAME
}
fn build(&mut self, chunks: &[(u64, &str)]) -> Result<()> {
let ids: Vec<u64> = chunks.iter().map(|(id, _)| *id).collect();
let by_id: std::collections::HashMap<u64, &str> =
chunks.iter().map(|(id, c)| (*id, *c)).collect();
self.build_streaming_ids(&ids, |batch_ids| {
Ok(batch_ids
.iter()
.filter_map(|id| by_id.get(id).map(|c| (*id, (*c).to_string())))
.collect())
})
}
fn insert(&mut self, chunks: &[(u64, &str)]) -> Result<()> {
let ids: Vec<u64> = chunks.iter().map(|(id, _)| *id).collect();
let by_id: std::collections::HashMap<u64, &str> =
chunks.iter().map(|(id, c)| (*id, *c)).collect();
self.insert_streaming_ids(&ids, |batch_ids| {
Ok(batch_ids
.iter()
.filter_map(|id| by_id.get(id).map(|c| (*id, (*c).to_string())))
.collect())
})
}
fn delete(&mut self, chunk_ids: &[u64]) -> Result<()> {
use next_plaid::MmapIndex;
if chunk_ids.is_empty() || !self.mapping_path.exists() {
return Ok(());
}
let mut mapping: Vec<u64> =
postcard::from_bytes::<Vec<u64>>(&std::fs::read(&self.mapping_path)?)?;
let removed_set: std::collections::HashSet<u64> = chunk_ids.iter().copied().collect();
let plaid_delete_ids: Vec<i64> = mapping
.iter()
.enumerate()
.filter_map(|(plaid_id, &cid)| {
if cid != DENSE_TOMBSTONE && removed_set.contains(&cid) {
Some(plaid_id as i64)
} else {
None
}
})
.collect();
if plaid_delete_ids.is_empty() {
return Ok(());
}
let plaid_dir_str = self.plaid_dir.to_string_lossy().into_owned();
match MmapIndex::load(&plaid_dir_str) {
Ok(mut index) => {
if let Err(e) = index.delete(&plaid_delete_ids) {
tracing::warn!("PLAID delete failed: {e}");
}
}
Err(e) => tracing::warn!("PLAID load for delete failed: {e}"),
}
for plaid_id in &plaid_delete_ids {
if let Some(slot) = mapping.get_mut(*plaid_id as usize) {
*slot = DENSE_TOMBSTONE;
}
}
write_mapping_atomic(&self.mapping_path, &mapping)?;
Ok(())
}
fn persist(&self, _dir: &Path) -> Result<()> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn backend_name_is_colbert_plaid() {
assert_eq!(ColbertPlaidBackend::NAME, "colbert-plaid");
}
#[test]
fn index_builder_name_is_colbert_plaid() {
let tmp = tempfile::TempDir::new().unwrap();
let b = ColbertPlaidIndexBuilder::new(tmp.path(), 4);
assert_eq!(DenseIndexBuilder::name(&b), "colbert-plaid");
}
#[test]
fn empty_build_writes_nothing_and_is_ok() {
let tmp = tempfile::TempDir::new().unwrap();
let mut b = ColbertPlaidIndexBuilder::new(tmp.path(), 4);
b.build(&[]).unwrap();
assert!(!tmp.path().join("plaid_mapping.bin").exists());
}
#[test]
fn plaid_batch_strictly_below_buffer_size() {
const { assert!(PLAID_BATCH < PLAID_BUFFER_SIZE) };
}
static FROZEN_CENTROIDS_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn with_frozen_centroids_env<F: FnOnce()>(val: Option<&str>, f: F) {
let _guard = FROZEN_CENTROIDS_ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let prior = std::env::var("SEMANTEX_FROZEN_CENTROIDS").ok();
unsafe {
match val {
Some(v) => std::env::set_var("SEMANTEX_FROZEN_CENTROIDS", v),
None => std::env::remove_var("SEMANTEX_FROZEN_CENTROIDS"),
}
}
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
unsafe {
match prior {
Some(v) => std::env::set_var("SEMANTEX_FROZEN_CENTROIDS", v),
None => std::env::remove_var("SEMANTEX_FROZEN_CENTROIDS"),
}
}
if let Err(e) = result {
std::panic::resume_unwind(e);
}
}
#[test]
fn frozen_centroids_off_without_env_flag() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("frozen_centroids.npy"), b"x").unwrap();
with_frozen_centroids_env(None, || {
assert!(frozen_centroids_for_build(tmp.path()).is_none());
});
}
#[test]
fn frozen_centroids_on_with_flag_and_artifact() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("frozen_centroids.npy"), b"x").unwrap();
with_frozen_centroids_env(Some("1"), || {
let p = frozen_centroids_for_build(tmp.path()).expect("flag + artifact → Some");
assert!(p.ends_with("frozen_centroids.npy"));
});
}
#[test]
fn frozen_centroids_flag_without_artifact_warns_and_returns_none() {
let tmp = tempfile::TempDir::new().unwrap();
with_frozen_centroids_env(Some("1"), || {
assert!(frozen_centroids_for_build(tmp.path()).is_none());
});
}
#[test]
fn configs_carry_the_centroid_path_on_both_structs() {
let tmp = tempfile::TempDir::new().unwrap();
let b = ColbertPlaidIndexBuilder::new(tmp.path(), 4);
let cpath = tmp.path().join("frozen_centroids.npy");
let (ic, uc) = b.next_plaid_configs(Some(&cpath));
assert_eq!(ic.external_centroids_npy.as_deref(), cpath.to_str());
assert_eq!(uc.external_centroids_npy.as_deref(), cpath.to_str());
let (ic, uc) = b.next_plaid_configs(None);
assert!(ic.external_centroids_npy.is_none() && uc.external_centroids_npy.is_none());
}
fn with_cinder_env<F: FnOnce()>(val: Option<&str>, f: F) {
let _guard = super::CINDER_ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let prior = std::env::var("SEMANTEX_CINDER").ok();
unsafe {
match val {
Some(v) => std::env::set_var("SEMANTEX_CINDER", v),
None => std::env::remove_var("SEMANTEX_CINDER"),
}
}
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
unsafe {
match prior {
Some(v) => std::env::set_var("SEMANTEX_CINDER", v),
None => std::env::remove_var("SEMANTEX_CINDER"),
}
}
if let Err(e) = result {
std::panic::resume_unwind(e);
}
}
fn write_placeholder_cinder_artifacts(dir: &Path) {
for name in [
"static_token_table.bin",
"cinder_mixer.bin",
"cinder_shortlists.bin",
] {
std::fs::write(dir.join(name), b"x").unwrap();
}
}
#[test]
fn cinder_for_build_opts_out_when_flag_is_falsy() {
let tmp = tempfile::TempDir::new().unwrap();
write_placeholder_cinder_artifacts(tmp.path());
for opt_out in ["0", "false", "False"] {
with_cinder_env(Some(opt_out), || {
assert!(
cinder_for_build(tmp.path()).is_none(),
"SEMANTEX_CINDER={opt_out} must opt out (None) before any load"
);
});
}
}
#[test]
fn cinder_for_build_default_on_attempts_load_when_flag_unset() {
let tmp = tempfile::TempDir::new().unwrap();
write_placeholder_cinder_artifacts(tmp.path());
with_cinder_env(None, || {
assert!(
cinder_for_build(tmp.path()).is_none(),
"unset (default-on) with invalid artifacts must fall back to None, never error"
);
});
}
#[test]
fn cinder_for_build_on_with_flag_but_mixer_missing_returns_none() {
let tmp = tempfile::TempDir::new().unwrap();
crate::embedding::static_table::StaticTokenTable::new(4, 2, [0.0, 0.0, 1.0, 0.0, 0.0])
.save(&model_manager::static_token_table_path(tmp.path()))
.unwrap();
with_cinder_env(Some("1"), || {
assert!(
cinder_for_build(tmp.path()).is_none(),
"a set flag with a missing mixer must fall back (None), never error"
);
});
}
#[test]
#[ignore = "requires the downloaded LateOn-Code-edge model and real Cinder artifacts"]
fn cinder_build_produces_searchable_index() {
use crate::config::SemantexConfig;
use crate::embedding::centroid_train::{
CentroidTrainOptions, save_centroids_npy, train_centroids,
};
use crate::embedding::mixer_train::{MixerTrainOptions, train_mixer};
use crate::embedding::shortlists::CentroidShortlists;
use crate::embedding::static_distill::distill;
let real_models_dir = SemantexConfig::default().models_dir();
let real_model_dir = model_manager::ensure_colbert_model(&real_models_dir)
.expect("LateOn-Code-edge must be downloaded for this ignored test");
let overlay = tempfile::TempDir::new().unwrap();
let overlay_model_dir = overlay.path().join("LateOn-Code-edge");
std::fs::create_dir_all(&overlay_model_dir).unwrap();
for file in ["model_int8.onnx", "tokenizer.json", "onnx_config.json"] {
let src = real_model_dir.join(file);
let dst = overlay_model_dir.join(file);
#[cfg(unix)]
std::os::unix::fs::symlink(&src, &dst).unwrap();
#[cfg(not(unix))]
std::fs::copy(&src, &dst).unwrap();
}
let teacher = ColbertEmbedder::for_indexing(&overlay_model_dir).unwrap();
let corpus: Vec<String> = (0..50)
.map(|i| format!("fn cinder_helper_{i}(x: i32) -> i32 {{ x * {i} + 1 }}"))
.collect();
let table = distill(&teacher, corpus.clone().into_iter(), 8).unwrap();
table
.save(&model_manager::static_token_table_path(&overlay_model_dir))
.unwrap();
let (mixer, _report) = train_mixer(
&teacher,
&table,
corpus.clone().into_iter(),
&MixerTrainOptions {
sample_capacity: 50_000,
epochs: 2,
batch: 8,
lr: 1e-3,
holdout_frac: 0.1,
},
)
.unwrap();
mixer
.save(&model_manager::cinder_mixer_path(&overlay_model_dir))
.unwrap();
let centroids = train_centroids(
&teacher,
corpus.clone().into_iter(),
&CentroidTrainOptions {
num_centroids: 16,
sample_capacity: 50_000,
batch: 8,
},
)
.unwrap();
save_centroids_npy(
&model_manager::frozen_centroids_path(&overlay_model_dir),
¢roids,
)
.unwrap();
CentroidShortlists::derive(&table, ¢roids.view(), 8)
.unwrap()
.save(&model_manager::cinder_shortlists_path(&overlay_model_dir))
.unwrap();
let chunks: Vec<(u64, String)> = (0u64..50)
.map(|i| {
(
i,
format!("fn cinder_helper_{i}(x: i32) -> i32 {{ x * {i} + 1 }}"),
)
})
.collect();
let chunk_ids: Vec<u64> = chunks.iter().map(|(id, _)| *id).collect();
let fetch = |batch_ids: &[u64]| -> Result<Vec<(u64, String)>> {
Ok(batch_ids
.iter()
.map(|&id| (id, chunks[id as usize].1.clone()))
.collect())
};
let idx_dir = tempfile::TempDir::new().unwrap();
with_cinder_env(Some("1"), || {
let mut builder = ColbertPlaidIndexBuilder::new(idx_dir.path(), 4)
.with_models_dir(overlay.path().to_path_buf());
builder
.build_streaming_ids(&chunk_ids, fetch)
.expect("cinder build must succeed");
});
let plaid_dir_str = idx_dir.path().to_string_lossy().into_owned();
let loaded = next_plaid::MmapIndex::load(&plaid_dir_str).unwrap();
assert_eq!(loaded.metadata.num_documents, chunks.len());
let mapping_path = idx_dir.path().join("plaid_mapping.bin");
let backend =
ColbertPlaidBackend::open(idx_dir.path(), &mapping_path, &overlay_model_dir).unwrap();
let hits = backend.search("cinder_helper_7", 5).unwrap();
assert!(
!hits.is_empty(),
"search must return hits after a cinder build"
);
assert_eq!(
hits[0].chunk_id, 7,
"the distinctive token must retrieve its own chunk top-1"
);
{
use crate::embedding::centroid_train::load_centroids_npy;
use crate::embedding::shortlists::{ArgmaxScratch, shortlist_argmax};
use ndarray::Array1;
use ndarray_npy::ReadNpyExt;
let cinder = CinderEncoder::new(&overlay_model_dir).unwrap();
let centroids =
load_centroids_npy(&model_manager::frozen_centroids_path(&overlay_model_dir))
.unwrap();
let shortlists = cinder.shortlists();
let cview = centroids.view();
let all_texts: Vec<String> = chunk_ids
.iter()
.map(|&id| chunks[id as usize].1.clone())
.collect();
let encoded = cinder.encode_documents_with_window_ids(&all_texts).unwrap();
let mut scratch = ArgmaxScratch::new();
let mut expected: Vec<i64> = Vec::new();
for (emb, windows) in &encoded {
for (r, row) in emb.rows().into_iter().enumerate() {
let e = row.as_slice().unwrap();
expected.push(
shortlist_argmax(e, &windows[r], shortlists, &cview, &mut scratch) as i64,
);
}
}
let mut got: Vec<i64> = Vec::new();
let mut ci = 0usize;
loop {
let p = idx_dir.path().join(format!("{ci}.codes.npy"));
if !p.exists() {
break;
}
let arr = Array1::<i64>::read_npy(std::fs::File::open(&p).unwrap()).unwrap();
got.extend(arr.iter().copied());
ci += 1;
}
assert!(!expected.is_empty(), "no codes recomputed");
assert_eq!(
got.len(),
expected.len(),
"on-disk vs recomputed token count"
);
assert_eq!(
got, expected,
"on-disk codes must equal direct shortlist_argmax output — proves \
build_cinder used the shortlist-union assigner, not the exhaustive default"
);
}
{
use crate::embedding::centroid_train::load_centroids_npy;
use ndarray::Array1;
use ndarray_npy::ReadNpyExt;
fn exhaustive_argmax(e: &[f32], centroids: &ndarray::ArrayView2<f32>) -> i64 {
let mut best_id = 0i64;
let mut best_dot = f32::NEG_INFINITY;
for (id, row) in centroids.rows().into_iter().enumerate() {
let dot: f32 = e.iter().zip(row.iter()).map(|(&a, &b)| a * b).sum();
if dot > best_dot {
best_dot = dot;
best_id = id as i64;
}
}
best_id
}
let idx_dir2 = tempfile::TempDir::new().unwrap();
let fetch2 = |batch_ids: &[u64]| -> Result<Vec<(u64, String)>> {
Ok(batch_ids
.iter()
.map(|&id| (id, chunks[id as usize].1.clone()))
.collect())
};
with_cinder_env(Some("1"), || {
unsafe { std::env::set_var("SEMANTEX_CINDER_EXACT_ASSIGN", "1") };
let mut builder = ColbertPlaidIndexBuilder::new(idx_dir2.path(), 4)
.with_models_dir(overlay.path().to_path_buf());
let build_res = builder.build_streaming_ids(&chunk_ids, fetch2);
unsafe { std::env::remove_var("SEMANTEX_CINDER_EXACT_ASSIGN") };
build_res.expect("cinder build with EXACT_ASSIGN must succeed");
});
let cinder = CinderEncoder::new(&overlay_model_dir).unwrap();
let centroids =
load_centroids_npy(&model_manager::frozen_centroids_path(&overlay_model_dir))
.unwrap();
let cview = centroids.view();
let all_texts: Vec<String> = chunk_ids
.iter()
.map(|&id| chunks[id as usize].1.clone())
.collect();
let encoded = cinder.encode_documents_with_window_ids(&all_texts).unwrap();
let mut expected_exhaustive: Vec<i64> = Vec::new();
for (emb, _windows) in &encoded {
for row in emb.rows() {
let e = row.as_slice().unwrap();
expected_exhaustive.push(exhaustive_argmax(e, &cview));
}
}
let mut got: Vec<i64> = Vec::new();
let mut ci = 0usize;
loop {
let p = idx_dir2.path().join(format!("{ci}.codes.npy"));
if !p.exists() {
break;
}
let arr = Array1::<i64>::read_npy(std::fs::File::open(&p).unwrap()).unwrap();
got.extend(arr.iter().copied());
ci += 1;
}
assert!(!expected_exhaustive.is_empty(), "no codes recomputed");
assert_eq!(
got.len(),
expected_exhaustive.len(),
"on-disk vs recomputed token count (EXACT_ASSIGN)"
);
assert_eq!(
got, expected_exhaustive,
"with SEMANTEX_CINDER_EXACT_ASSIGN=1 the on-disk codes must equal the \
EXHAUSTIVE full-scan argmax — proves the shortlist-union assigner was NOT \
installed and the writer's default exhaustive scan ran"
);
}
}
static STATIC_DOC_EMBED_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn with_static_doc_embed_env<F: FnOnce()>(val: Option<&str>, f: F) {
let _guard = STATIC_DOC_EMBED_ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let prior = std::env::var("SEMANTEX_STATIC_DOC_EMBED").ok();
unsafe {
match val {
Some(v) => std::env::set_var("SEMANTEX_STATIC_DOC_EMBED", v),
None => std::env::remove_var("SEMANTEX_STATIC_DOC_EMBED"),
}
}
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
unsafe {
match prior {
Some(v) => std::env::set_var("SEMANTEX_STATIC_DOC_EMBED", v),
None => std::env::remove_var("SEMANTEX_STATIC_DOC_EMBED"),
}
}
if let Err(e) = result {
std::panic::resume_unwind(e);
}
}
fn test_tokenizer_dir() -> Option<std::path::PathBuf> {
let dir = crate::config::SemantexConfig::default()
.models_dir()
.join("LateOn-Code-edge");
(dir.join("tokenizer.json").exists() && dir.join("onnx_config.json").exists())
.then_some(dir)
}
#[test]
fn doc_encoder_kind_defaults_to_contextual_when_flag_unset() {
let tmp = tempfile::TempDir::new().unwrap();
with_static_doc_embed_env(None, || {
let kind = DocEncoderKind::for_indexing(tmp.path())
.expect("contextual construction only requires the dir to exist");
assert!(
matches!(kind, DocEncoderKind::Contextual(_)),
"flag unset must always select the contextual encoder"
);
});
}
#[test]
fn doc_encoder_kind_falls_back_to_contextual_when_flag_set_but_table_missing() {
let tmp = tempfile::TempDir::new().unwrap();
with_static_doc_embed_env(Some("1"), || {
let kind = DocEncoderKind::for_indexing(tmp.path())
.expect("a set flag with a missing table must fall back, never error");
assert!(
matches!(kind, DocEncoderKind::Contextual(_)),
"missing table must fall back to the contextual encoder"
);
});
}
#[test]
fn doc_encoder_kind_selects_static_when_flag_set_and_table_present() {
let Some(model_dir) = test_tokenizer_dir() else {
return;
};
let vocab_size = ColbertEmbedder::new(&model_dir)
.unwrap()
.tokenizer_vocab_size()
.unwrap();
let table = crate::embedding::static_table::StaticTokenTable::new(
vocab_size,
4,
[0.1, 0.2, 0.4, 0.2, 0.1],
);
let tmp = tempfile::TempDir::new().unwrap();
table
.save(&tmp.path().join("static_token_table.bin"))
.unwrap();
std::fs::copy(
model_dir.join("tokenizer.json"),
tmp.path().join("tokenizer.json"),
)
.unwrap();
std::fs::copy(
model_dir.join("onnx_config.json"),
tmp.path().join("onnx_config.json"),
)
.unwrap();
with_static_doc_embed_env(Some("1"), || {
let kind = DocEncoderKind::for_indexing(tmp.path())
.expect("a valid table + flag must succeed");
assert!(
matches!(kind, DocEncoderKind::Static(_)),
"flag set with a loadable table must select the static encoder"
);
});
}
#[test]
fn write_mapping_atomic_leaves_no_temp_file_and_round_trips() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("plaid_mapping.bin");
let mapping: Vec<u64> = vec![10, 20, DENSE_TOMBSTONE, 30];
write_mapping_atomic(&path, &mapping).unwrap();
let back: Vec<u64> = postcard::from_bytes(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(back, mapping);
let leftovers: Vec<_> = std::fs::read_dir(tmp.path())
.unwrap()
.filter_map(std::result::Result::ok)
.filter(|e| e.file_name().to_string_lossy().contains(".tmp"))
.collect();
assert!(
leftovers.is_empty(),
"atomic write must not leave a temp file behind: {leftovers:?}"
);
}
#[test]
#[ignore = "builds a PLAID index + loads the ColBERT model; run with --ignored"]
fn embed_text_vector_returns_some_with_model_dim() {
use crate::config::SemantexConfig;
use crate::index::builder::IndexBuilder;
use crate::search::dense_backend::{DenseBackendKind, dense_subdir};
let tmp = tempfile::TempDir::new().unwrap();
let project = tmp.path().join("repo");
std::fs::create_dir_all(&project).unwrap();
std::fs::write(project.join("a.rs"), "pub fn hello() -> u32 { 41 + 1 }\n").unwrap();
let cfg = SemantexConfig::default();
IndexBuilder::new(&cfg).unwrap().build(&project).unwrap();
let index_dir = project.join(".semantex");
let dense_dir = dense_subdir(&index_dir, DenseBackendKind::ColbertPlaid);
let mapping_path = dense_dir.join("plaid_mapping.bin");
let model_dir =
crate::embedding::model_manager::ensure_colbert_model(&cfg.models_dir()).unwrap();
let backend = ColbertPlaidBackend::open(&dense_dir, &mapping_path, &model_dir).unwrap();
let v = backend
.embed_text_vector("open a database connection")
.expect("query projection must be Some for a non-empty query");
assert_eq!(
v.len(),
48,
"mean-pooled query vector must have the model dim"
);
let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
assert!(
(norm - 1.0).abs() < 1e-3,
"projection must be L2-normalized, got norm={norm}"
);
}
#[test]
#[ignore = "builds a >INITIAL_BUILD_CHUNKS PLAID index + loads the ColBERT model; run with --ignored"]
fn build_streaming_ids_covers_bounded_initial_and_streamed_remainder() {
use crate::config::SemantexConfig;
let tmp = tempfile::TempDir::new().unwrap();
let cfg = SemantexConfig::default();
let model_dir =
crate::embedding::model_manager::ensure_colbert_model(&cfg.models_dir()).unwrap();
let total = INITIAL_BUILD_CHUNKS + PLAID_APPEND_BATCH * 3 + 1;
let chunk_ids: Vec<u64> = (1..=total as u64).collect();
let mut builder = ColbertPlaidIndexBuilder::new(tmp.path(), 4);
builder
.build_streaming_ids(&chunk_ids, |batch_ids| {
Ok(batch_ids
.iter()
.map(|&id| (id, format!("fn chunk_{id}() {{ return {id}; }}")))
.collect())
})
.unwrap();
let mapping_path = tmp.path().join("plaid_mapping.bin");
let mapping: Vec<u64> =
postcard::from_bytes(&std::fs::read(&mapping_path).unwrap()).unwrap();
assert_eq!(
mapping.len(),
total,
"mapping must cover every chunk across both phases"
);
let mut seen: Vec<u64> = mapping
.iter()
.copied()
.filter(|&c| c != DENSE_TOMBSTONE)
.collect();
seen.sort_unstable();
seen.dedup();
assert_eq!(
seen.len(),
total,
"every chunk id must appear exactly once across the phased build"
);
let backend = ColbertPlaidBackend::open(tmp.path(), &mapping_path, &model_dir).unwrap();
let hits = backend.search("chunk_1", 5).unwrap();
assert!(
!hits.is_empty(),
"search must return hits after a phased build"
);
}
#[test]
#[ignore = "requires the downloaded LateOn-Code-edge model and builds a real PLAID index"]
fn frozen_centroids_build_produces_searchable_index_and_fallback_matches() {
use crate::config::SemantexConfig;
use crate::embedding::centroid_train::{
CentroidTrainOptions, save_centroids_npy, train_centroids,
};
let real_models_dir = SemantexConfig::default().models_dir();
let real_model_dir = model_manager::ensure_colbert_model(&real_models_dir)
.expect("LateOn-Code-edge must be downloaded for this ignored test");
let overlay = tempfile::TempDir::new().unwrap();
let overlay_model_dir = overlay.path().join("LateOn-Code-edge");
std::fs::create_dir_all(&overlay_model_dir).unwrap();
for file in ["model_int8.onnx", "tokenizer.json", "onnx_config.json"] {
let src = real_model_dir.join(file);
let dst = overlay_model_dir.join(file);
#[cfg(unix)]
std::os::unix::fs::symlink(&src, &dst).unwrap();
#[cfg(not(unix))]
std::fs::copy(&src, &dst).unwrap();
}
let embedder = ColbertEmbedder::for_indexing(&overlay_model_dir).unwrap();
let corpus: Vec<String> = (0..40)
.map(|i| format!("fn synthetic_helper_{i}(x: i32) -> i32 {{ x + {i} }}"))
.collect();
let k = 8usize;
let opts = CentroidTrainOptions {
num_centroids: k,
sample_capacity: 10_000,
batch: 8,
};
let centroids = train_centroids(&embedder, corpus.into_iter(), &opts).unwrap();
assert_eq!(centroids.nrows(), k, "trained k must match request");
let frozen_path = model_manager::frozen_centroids_path(&overlay_model_dir);
save_centroids_npy(&frozen_path, ¢roids).unwrap();
let chunks: Vec<(u64, String)> = (0u64..40)
.map(|i| {
(
i,
format!("fn synthetic_helper_{i}(x: i32) -> i32 {{ x + {i} }}"),
)
})
.collect();
let chunk_ids: Vec<u64> = chunks.iter().map(|(id, _)| *id).collect();
let fetch = |batch_ids: &[u64]| -> Result<Vec<(u64, String)>> {
Ok(batch_ids
.iter()
.map(|&id| (id, chunks[id as usize].1.clone()))
.collect())
};
let flagged_dir = tempfile::TempDir::new().unwrap();
with_frozen_centroids_env(Some("1"), || {
let mut builder = ColbertPlaidIndexBuilder::new(flagged_dir.path(), 4)
.with_models_dir(overlay.path().to_path_buf());
builder
.build_streaming_ids(&chunk_ids, fetch)
.expect("flagged build (external centroids) must succeed");
});
let flagged_centroids: ndarray::Array2<f32> = ndarray_npy::ReadNpyExt::read_npy(
std::fs::File::open(flagged_dir.path().join("centroids.npy")).unwrap(),
)
.unwrap();
assert_eq!(
flagged_centroids.nrows(),
k,
"flagged build's centroids.npy must carry the trained k rows, \
proving the external artifact (not per-repo k-means) was used"
);
let unflagged_dir = tempfile::TempDir::new().unwrap();
with_frozen_centroids_env(None, || {
let mut builder = ColbertPlaidIndexBuilder::new(unflagged_dir.path(), 4)
.with_models_dir(overlay.path().to_path_buf());
builder
.build_streaming_ids(&chunk_ids, fetch)
.expect("unflagged build (per-repo k-means) must succeed");
});
let flagged_mapping_path = flagged_dir.path().join("plaid_mapping.bin");
let unflagged_mapping_path = unflagged_dir.path().join("plaid_mapping.bin");
let flagged_backend = ColbertPlaidBackend::open(
flagged_dir.path(),
&flagged_mapping_path,
&overlay_model_dir,
)
.unwrap();
let unflagged_backend = ColbertPlaidBackend::open(
unflagged_dir.path(),
&unflagged_mapping_path,
&overlay_model_dir,
)
.unwrap();
let query = "synthetic_helper_7";
let flagged_hits = flagged_backend.search(query, 1).unwrap();
let unflagged_hits = unflagged_backend.search(query, 1).unwrap();
assert!(!flagged_hits.is_empty() && !unflagged_hits.is_empty());
assert_eq!(
flagged_hits[0].chunk_id, unflagged_hits[0].chunk_id,
"frozen centroids must not change what is retrieved, only how the codec is built"
);
}
}