use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use super::{FragmentMetadata, FragmentStore};
const FRAGMENT_ROOT_SCHEMA_VERSION: u32 = 1;
pub(crate) const FRAGMENT_EMBEDDING_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct FragmentRootState {
#[serde(default)]
schema_version: u32,
pub(crate) root_hash: String,
pub(crate) generation: u64,
pub(crate) fragment_rows: u64,
}
pub(crate) fn compute_fragment_root_hash(store: &FragmentStore) -> String {
root_hash_from_entries(store.content_hashes())
}
pub(crate) fn compute_fragment_root_hash_from_ids(ids: &[String]) -> String {
root_hash_from_entries(ids.iter().map(String::as_str))
}
fn root_hash_from_entries<'a>(hashes: impl Iterator<Item = &'a str>) -> String {
let mut entries: Vec<String> = hashes
.map(|hash| format!("{hash}:{FRAGMENT_EMBEDDING_SCHEMA_VERSION}"))
.collect();
entries.sort();
let mut hasher = blake3::Hasher::new();
for entry in entries {
hasher.update(entry.as_bytes());
hasher.update(b"\n");
}
hasher.finalize().to_hex().to_string()
}
fn fragment_root_path(project_path: &Path) -> PathBuf {
project_path.join(".leindex").join("fragment_root.bin")
}
pub(crate) fn persist_fragment_root(
project_path: &Path,
store: &FragmentStore,
generation: u64,
) -> Result<()> {
let state = FragmentRootState {
schema_version: FRAGMENT_ROOT_SCHEMA_VERSION,
root_hash: compute_fragment_root_hash(store),
generation,
fragment_rows: store.len() as u64,
};
let path = fragment_root_path(project_path);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| {
format!(
"Failed to create fragment root directory: {}",
parent.display()
)
})?;
}
let payload = bincode::serialize(&state).context("Failed to serialize fragment root")?;
super::atomic_write(&path, &payload)
.with_context(|| format!("Failed to persist fragment root: {}", path.display()))
}
pub(crate) fn persist_fragment_root_from_ids(
project_path: &Path,
ids: &[String],
generation: u64,
) -> Result<()> {
let path = fragment_root_path(project_path);
if ids.is_empty() {
if path.exists() {
std::fs::remove_file(&path)
.map_err(|e| anyhow::anyhow!("Failed to remove stale fragment root: {e}"))?;
}
return Ok(());
}
let state = FragmentRootState {
schema_version: FRAGMENT_ROOT_SCHEMA_VERSION,
root_hash: compute_fragment_root_hash_from_ids(ids),
generation,
fragment_rows: ids.len() as u64,
};
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| {
format!(
"Failed to create fragment root directory: {}",
parent.display()
)
})?;
}
let payload = bincode::serialize(&state).context("Failed to serialize fragment root")?;
super::atomic_write(&path, &payload)
.with_context(|| format!("Failed to persist fragment root: {}", path.display()))
}
pub(crate) fn load_fragment_root(storage_path: &Path) -> Result<Option<FragmentRootState>> {
let path = storage_path.join("fragment_root.bin");
if !path.exists() {
return Ok(None);
}
let bytes = std::fs::read(&path)
.with_context(|| format!("Failed to read fragment root: {}", path.display()))?;
let state: FragmentRootState = bincode::deserialize(&bytes)
.with_context(|| format!("Failed to deserialize fragment root: {}", path.display()))?;
if state.schema_version != FRAGMENT_ROOT_SCHEMA_VERSION {
tracing::warn!(
"Persisted fragment root schema version {} != current {}; discarding",
state.schema_version,
FRAGMENT_ROOT_SCHEMA_VERSION
);
return Ok(None);
}
Ok(Some(state))
}
const FRAGMENT_SYNC_MANIFEST_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub(crate) struct FragmentExtractionIdentity {
pub(crate) model_name: String,
pub(crate) knobs_hash: String,
}
impl FragmentExtractionIdentity {
pub(crate) fn new(
model_name: &str,
max_bytes: usize,
orphan_enabled: bool,
naive_fallback: bool,
) -> Self {
let mut hasher = blake3::Hasher::new();
hasher.update(&(max_bytes as u64).to_le_bytes());
hasher.update(&[orphan_enabled as u8]);
hasher.update(&[naive_fallback as u8]);
hasher.update(&FRAGMENT_EMBEDDING_SCHEMA_VERSION.to_le_bytes());
Self {
model_name: model_name.to_string(),
knobs_hash: hasher.finalize().to_hex().to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct FragmentFileManifest {
#[serde(default)]
schema_version: u32,
pub(crate) generation: u64,
pub(crate) file_hashes: HashMap<String, String>,
#[serde(default)]
pub(crate) file_content_hashes: HashMap<String, Vec<String>>,
#[serde(default)]
pub(crate) extraction_identity: FragmentExtractionIdentity,
}
impl FragmentFileManifest {
pub(crate) fn new() -> Self {
Self {
schema_version: FRAGMENT_SYNC_MANIFEST_SCHEMA_VERSION,
generation: 0,
file_hashes: HashMap::new(),
file_content_hashes: HashMap::new(),
extraction_identity: FragmentExtractionIdentity::default(),
}
}
}
impl Default for FragmentFileManifest {
fn default() -> Self {
Self::new()
}
}
fn fragment_sync_manifest_path(storage_path: &Path) -> PathBuf {
storage_path.join("fragment_sync_manifest.bin")
}
pub(crate) fn load_fragment_sync_manifest(
storage_path: &Path,
) -> Result<Option<FragmentFileManifest>> {
let path = fragment_sync_manifest_path(storage_path);
if !path.exists() {
return Ok(None);
}
let bytes = std::fs::read(&path)
.with_context(|| format!("Failed to read fragment sync manifest: {}", path.display()))?;
let manifest: FragmentFileManifest = bincode::deserialize(&bytes).with_context(|| {
format!(
"Failed to deserialize fragment sync manifest: {}",
path.display()
)
})?;
if manifest.schema_version != FRAGMENT_SYNC_MANIFEST_SCHEMA_VERSION {
tracing::warn!(
"Persisted fragment sync manifest schema version {} != current {}; discarding",
manifest.schema_version,
FRAGMENT_SYNC_MANIFEST_SCHEMA_VERSION
);
return Ok(None);
}
Ok(Some(manifest))
}
pub(crate) fn persist_fragment_sync_manifest(
storage_path: &Path,
manifest: &FragmentFileManifest,
) -> Result<()> {
let path = fragment_sync_manifest_path(storage_path);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| {
format!(
"Failed to create fragment sync manifest directory: {}",
parent.display()
)
})?;
}
let payload =
bincode::serialize(manifest).context("Failed to serialize fragment sync manifest")?;
super::atomic_write(&path, &payload).with_context(|| {
format!(
"Failed to persist fragment sync manifest: {}",
path.display()
)
})
}
#[derive(Debug, Clone)]
pub(crate) struct FragmentCandidate {
pub(crate) content_hash: String,
pub(crate) enriched_text: String,
pub(crate) meta: FragmentMetadata,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct FragmentSyncSummary {
pub(crate) files_scanned: usize,
pub(crate) files_changed: usize,
pub(crate) fragments_total: usize,
pub(crate) embedded: usize,
pub(crate) reused: usize,
pub(crate) generation: u64,
}
fn embed_missing_batches(
missing: &mut [(String, FragmentMetadata)],
store: &mut FragmentStore,
embed_fn: &mut dyn FnMut(&[String]) -> Vec<Option<Vec<f32>>>,
new_embeddings: &mut Vec<(String, Vec<f32>)>,
summary: &mut FragmentSyncSummary,
produced: &mut HashSet<String>,
) -> (bool, usize) {
const EMBED_BATCH: usize = 256;
let mut all_embedded = true;
let mut inserted = 0;
for chunk in missing.chunks(EMBED_BATCH) {
let texts: Vec<String> = chunk.iter().map(|(t, _)| t.clone()).collect();
let results = embed_fn(&texts);
for (i, (_, meta)) in chunk.iter().enumerate() {
match results.get(i) {
Some(Some(embedding)) if !embedding.is_empty() => {
store.insert(meta.clone());
summary.embedded += 1;
inserted += 1;
new_embeddings.push((meta.content_hash.clone(), embedding.clone()));
}
_ => {
tracing::warn!(
hash = %meta.content_hash,
file = %meta.file_path,
"Fragment embedding unavailable; row skipped"
);
produced.remove(&meta.content_hash);
all_embedded = false;
}
}
}
}
(all_embedded, inserted)
}
fn remove_file_rows(store: &mut FragmentStore, file_path: &str) {
let mut to_remove: Vec<String> = Vec::new();
for hash in store
.content_hashes()
.map(str::to_string)
.collect::<Vec<_>>()
{
let Some(metas) = store.get(&hash) else {
continue;
};
if metas.iter().any(|m| m.file_path == file_path) {
let remaining: Vec<FragmentMetadata> = metas
.iter()
.filter(|m| m.file_path != file_path)
.cloned()
.collect();
if remaining.is_empty() {
to_remove.push(hash.clone());
} else {
store.remove_hash(&hash);
for meta in remaining {
store.insert(meta);
}
}
}
}
for hash in to_remove {
store.remove_hash(&hash);
}
}
struct FileSyncOutcome {
store_modified: bool,
complete: bool,
}
struct IdentityMismatch {
identity_changed: bool,
model_changed: bool,
effective_force_reembed: bool,
}
fn detect_identity_mismatch(
persisted: &FragmentExtractionIdentity,
current: &FragmentExtractionIdentity,
force_reembed: bool,
) -> IdentityMismatch {
let identity_changed = persisted != current;
let model_changed = identity_changed
&& !persisted.model_name.is_empty()
&& persisted.model_name != current.model_name;
IdentityMismatch {
identity_changed,
model_changed,
effective_force_reembed: force_reembed || model_changed,
}
}
fn should_skip_unchanged_file(
force_reembed: bool,
identity_changed: bool,
persisted_hash: Option<&String>,
current_hash: &String,
) -> bool {
!force_reembed && !identity_changed && persisted_hash == Some(current_hash)
}
fn process_changed_file(
store: &mut FragmentStore,
manifest: &mut FragmentFileManifest,
path: &Path,
file_hash: &str,
force_reembed: bool,
chunk_fn: &mut dyn FnMut(&Path, &[u8]) -> Vec<FragmentCandidate>,
embed_fn: &mut dyn FnMut(&[String]) -> Vec<Option<Vec<f32>>>,
new_embeddings: &mut Vec<(String, Vec<f32>)>,
summary: &mut FragmentSyncSummary,
) -> FileSyncOutcome {
let path_str = path.display().to_string();
let mut store_modified = false;
let bytes = match std::fs::read(path) {
Ok(b) => b,
Err(e) => {
tracing::warn!(
error = %e,
path = %path.display(),
"Failed to read source file during fragment sync; dropping its stale rows"
);
if manifest.file_content_hashes.remove(&path_str).is_some() {
remove_file_rows(store, &path_str);
store_modified = true;
}
manifest.file_hashes.remove(&path_str);
return FileSyncOutcome {
store_modified,
complete: false,
};
}
};
if manifest.file_content_hashes.remove(&path_str).is_some() {
remove_file_rows(store, &path_str);
store_modified = true;
}
let candidates = chunk_fn(path, &bytes);
summary.fragments_total += candidates.len();
let mut missing: Vec<(String, FragmentMetadata)> = Vec::new(); let mut produced: HashSet<String> = HashSet::new();
for cand in candidates {
if !force_reembed && store.get(&cand.content_hash).is_some() {
summary.reused += 1;
store.insert(cand.meta.clone());
store_modified = true;
produced.insert(cand.content_hash);
} else {
missing.push((cand.enriched_text, cand.meta));
produced.insert(cand.content_hash);
}
}
let (all_embedded, inserted) = embed_missing_batches(
&mut missing,
store,
embed_fn,
new_embeddings,
summary,
&mut produced,
);
store_modified |= inserted > 0;
manifest
.file_content_hashes
.insert(path_str.clone(), produced.into_iter().collect());
if all_embedded {
manifest.file_hashes.insert(path_str, file_hash.to_string());
}
FileSyncOutcome {
store_modified,
complete: all_embedded,
}
}
pub(crate) fn incremental_sync_fragments(
project_path: &Path,
store: &mut FragmentStore,
files: &[(PathBuf, String)],
chunk_fn: &mut dyn FnMut(&Path, &[u8]) -> Vec<FragmentCandidate>,
embed_fn: &mut dyn FnMut(&[String]) -> Vec<Option<Vec<f32>>>,
force_reembed: bool,
identity: &FragmentExtractionIdentity,
) -> Result<(FragmentSyncSummary, Vec<(String, Vec<f32>)>)> {
let storage_path = project_path.join(".leindex");
let mut manifest = load_fragment_sync_manifest(&storage_path)?.unwrap_or_default();
let mismatch = detect_identity_mismatch(&manifest.extraction_identity, identity, force_reembed);
if mismatch.identity_changed {
tracing::info!(
old_model = %manifest.extraction_identity.model_name,
new_model = %identity.model_name,
model_changed = mismatch.model_changed,
"Fragment extraction identity changed; forcing fragment re-sync"
);
}
let mut summary = FragmentSyncSummary {
files_scanned: files.len(),
..Default::default()
};
let current_paths: HashSet<String> =
files.iter().map(|(p, _)| p.display().to_string()).collect();
let mut new_embeddings: Vec<(String, Vec<f32>)> = Vec::new();
let mut store_dirty = false;
let mut manifest_dirty = false;
let mut all_files_complete = true;
let removed_paths: Vec<String> = manifest
.file_hashes
.keys()
.chain(manifest.file_content_hashes.keys())
.filter(|p| !current_paths.contains(*p))
.cloned()
.collect::<HashSet<_>>()
.into_iter()
.collect();
for path in &removed_paths {
if manifest.file_content_hashes.remove(path).is_some() {
remove_file_rows(store, path);
store_dirty = true;
}
manifest.file_hashes.remove(path);
manifest_dirty = true;
}
for (path, file_hash) in files {
let path_str = path.display().to_string();
if should_skip_unchanged_file(
force_reembed,
mismatch.identity_changed,
manifest.file_hashes.get(&path_str),
file_hash,
) {
continue;
}
summary.files_changed += 1;
manifest_dirty = true;
let outcome = process_changed_file(
store,
&mut manifest,
path,
file_hash,
mismatch.effective_force_reembed,
chunk_fn,
embed_fn,
&mut new_embeddings,
&mut summary,
);
store_dirty |= outcome.store_modified;
all_files_complete &= outcome.complete;
}
if mismatch.identity_changed && all_files_complete {
manifest.extraction_identity = identity.clone();
manifest_dirty = true;
}
if store_dirty {
manifest.generation += 1;
store.persist_to_storage(project_path)?;
persist_fragment_root(project_path, store, manifest.generation)?;
summary.generation = manifest.generation;
} else {
summary.generation = manifest.generation;
}
if manifest_dirty {
persist_fragment_sync_manifest(&storage_path, &manifest)?;
}
Ok((summary, new_embeddings))
}
pub(crate) fn fragment_layer_generation_is_consistent(
storage_path: &Path,
root: &FragmentRootState,
) -> bool {
match load_fragment_sync_manifest(storage_path) {
Ok(Some(manifest)) => manifest.generation == root.generation,
Ok(None) => true,
Err(_) => false,
}
}