use crate::adapters::lance::{EMBEDDING_DIM, LanceStore, PreparedFile};
use crate::domain::common::{CorpusConfig, HallouminateError, Result};
use crate::domain::corpus::{CorpusChunker, blake3_file};
use crate::domain::embeddings::{EmbedBatch, EmbedRole};
use super::plan::{IndexPlan, MtimeCandidate};
use super::writer::{WriteRequest, prepare_file};
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct ApplyStats {
pub files_upserted: usize,
pub files_touched: usize,
pub files_deleted: usize,
pub files_skipped_empty: usize,
pub chunks_inserted: usize,
pub embeddings_inserted: usize,
}
pub const DEFAULT_BATCH_SIZE: usize = 16;
pub async fn apply(
plan: IndexPlan,
store: &LanceStore,
mut embedder: Option<&mut dyn EmbedBatch>,
chunker: &dyn CorpusChunker,
corpus: &CorpusConfig,
batch_size: usize,
) -> Result<ApplyStats> {
let mut stats = ApplyStats::default();
let batch_size = batch_size.max(1);
let indexed_at_ms = chrono::Utc::now().timestamp_millis();
let mut upsert_reqs: Vec<WriteRequest<'_>> = Vec::with_capacity(plan.upserts.len());
for u in &plan.upserts {
upsert_reqs.push(WriteRequest {
corpus,
file: &u.file,
mtime: u.mtime,
});
}
run_in_batches(
upsert_reqs,
batch_size,
store,
embedder.as_deref_mut(),
chunker,
indexed_at_ms,
&mut stats,
)
.await?;
let mut fallthrough: Vec<MtimeCandidate> = Vec::new();
for cand in plan.mtime_touches {
let new_hash = blake3_file(cand.file.as_path())?;
if new_hash == cand.snap.content_hash {
store
.touch_mtime(&cand.snap.corpus, &cand.snap.file_ref, cand.new_mtime.0)
.await?;
stats.files_touched += 1;
} else {
fallthrough.push(cand);
}
}
let mut fallthrough_reqs: Vec<WriteRequest<'_>> = Vec::with_capacity(fallthrough.len());
for c in &fallthrough {
fallthrough_reqs.push(WriteRequest {
corpus,
file: &c.file,
mtime: c.new_mtime,
});
}
run_in_batches(
fallthrough_reqs,
batch_size,
store,
embedder,
chunker,
indexed_at_ms,
&mut stats,
)
.await?;
for snap in plan.deletes {
store.delete_file(&snap.corpus, &snap.file_ref).await?;
stats.files_deleted += 1;
}
tracing::debug!(
target: "hallouminate::indexer",
embeddings_inserted_total = stats.embeddings_inserted,
"apply finished"
);
Ok(stats)
}
async fn run_in_batches(
reqs: Vec<WriteRequest<'_>>,
batch_size: usize,
store: &LanceStore,
mut embedder: Option<&mut (dyn EmbedBatch + '_)>,
chunker: &dyn CorpusChunker,
indexed_at_ms: i64,
stats: &mut ApplyStats,
) -> Result<()> {
if reqs.is_empty() {
return Ok(());
}
for chunk_of_reqs in reqs.chunks(batch_size) {
let mut prepared: Vec<PreparedFile> = Vec::with_capacity(chunk_of_reqs.len());
for req in chunk_of_reqs {
let pf = prepare_file(
WriteRequest {
corpus: req.corpus,
file: req.file,
mtime: req.mtime,
},
chunker,
indexed_at_ms,
)?;
if pf.chunks.is_empty() {
tracing::warn!(
target: "hallouminate::indexer",
file = %req.file.as_path().display(),
"skipping empty file (no chunks generated)"
);
stats.files_skipped_empty += 1;
continue;
}
prepared.push(pf);
}
if prepared.is_empty() {
continue;
}
let mut all_texts: Vec<String> = Vec::new();
let mut splits: Vec<usize> = Vec::with_capacity(prepared.len());
for pf in &prepared {
splits.push(pf.chunks.len());
for c in &pf.chunks {
all_texts.push(c.text.clone());
}
}
match embedder.as_deref_mut() {
Some(embedder) => {
let mut vectors = if all_texts.is_empty() {
Vec::new()
} else {
embedder.embed_batch(&all_texts, EmbedRole::Passage)?
};
if vectors.len() != all_texts.len() {
return Err(HallouminateError::Indexer(format!(
"embedder returned {} vectors for {} chunks",
vectors.len(),
all_texts.len()
)));
}
let mut iter = vectors.drain(..);
for (pf, count) in prepared.iter_mut().zip(splits.iter().copied()) {
let mut buf: Vec<[f32; EMBEDDING_DIM]> = Vec::with_capacity(count);
for _ in 0..count {
let v = iter.next().ok_or_else(|| {
HallouminateError::Indexer("embedding count drained early".into())
})?;
buf.push(v);
}
stats.chunks_inserted += count;
stats.embeddings_inserted += count;
pf.embeddings = Some(buf);
}
}
None => {
for (pf, count) in prepared.iter_mut().zip(splits.iter().copied()) {
stats.chunks_inserted += count;
pf.embeddings = None;
}
}
}
let n = prepared.len();
store.apply_batch(prepared).await?;
stats.files_upserted += n;
}
Ok(())
}