diff --git a/examples/embed_batch_parity.rs b/examples/embed_batch_parity.rs new file mode 100644 index 0000000..95e6ab0 --- /dev/null +++ b/examples/embed_batch_parity.rs @@ -0,0 +1,69 @@ +//! Compare shared-context document embeddings with fresh-context embeddings. +//! +//! Usage: `embed_batch_parity ` + +use std::path::PathBuf; +use std::time::Instant; + +use engraph::llm::{EmbedModel, LlamaEmbed}; + +fn main() -> anyhow::Result<()> { + let models_dir = PathBuf::from( + std::env::args() + .nth(1) + .expect("models_dir argument is required"), + ); + let config = engraph::config::Config::default(); + let mut embedder = LlamaEmbed::new(&models_dir, &config)?; + + let texts: Vec = (0..32) + .map(|index| { + format!( + "Note {index}: this chunk discusses retrieval pipelines, wikilink graphs, \ + and reciprocal rank fusion in a knowledge vault. Sample {index} varies \ + the content slightly to avoid identical sequences." + ) + }) + .collect(); + let text_refs: Vec<&str> = texts.iter().map(String::as_str).collect(); + + let started = Instant::now(); + let shared_context = embedder.embed_batch(&text_refs)?; + let shared_context_ms = started.elapsed().as_millis(); + + let started = Instant::now(); + let mut fresh_context = Vec::with_capacity(text_refs.len()); + for text in &text_refs { + fresh_context.extend(embedder.embed_batch(&[text])?); + } + let fresh_context_ms = started.elapsed().as_millis(); + + let mut max_abs_diff = 0.0_f32; + let mut vectors_beyond_tolerance = 0_usize; + for (shared, fresh) in shared_context.iter().zip(&fresh_context) { + assert_eq!(shared.len(), fresh.len()); + let difference = shared + .iter() + .zip(fresh) + .map(|(left, right)| (left - right).abs()) + .fold(0.0_f32, f32::max); + max_abs_diff = max_abs_diff.max(difference); + if difference > 1e-6 { + vectors_beyond_tolerance += 1; + } + } + + println!( + "{{\"n\":{},\"shared_context_ms\":{},\"fresh_context_ms\":{},\"max_abs_diff\":{:e},\"vectors_beyond_1e-6\":{}}}", + text_refs.len(), + shared_context_ms, + fresh_context_ms, + max_abs_diff, + vectors_beyond_tolerance + ); + + if vectors_beyond_tolerance > 0 { + anyhow::bail!("shared-context embeddings exceeded the 1e-6 parity tolerance"); + } + Ok(()) +} diff --git a/src/fusion.rs b/src/fusion.rs index 8bd026f..f3f0134 100644 --- a/src/fusion.rs +++ b/src/fusion.rs @@ -115,11 +115,13 @@ pub fn rrf_fuse(lanes: &[(&str, &[RankedResult], f64)], k: usize) -> Vec Result { let start = Instant::now(); + let defaults = ModelDefaults::default(); + let embedding_uri = config + .models + .embed + .as_deref() + .unwrap_or(&defaults.embed_uri); + store.set_meta("embedding_model_uri", embedding_uri)?; + store.set_meta("embedding_dim", &embedder.dim().to_string())?; + let cleaned = crate::writer::cleanup_temp_files(vault_path)?; if cleaned > 0 { info!(cleaned, "cleaned up incomplete writes from previous run"); @@ -1159,4 +1168,25 @@ mod tests { assert_eq!(mentions.len(), 1); assert_eq!(mentions[0].0, person); } + + #[test] + fn test_index_records_embedding_metadata() { + let vault = TempDir::new().unwrap(); + write_file(vault.path(), "note.md", "# Metadata fixture\n"); + + let store = Store::open_memory().unwrap(); + let mut embedder = crate::llm::MockLlm::new(256); + let config = Config::default(); + + run_index_shared(vault.path(), &config, &store, &mut embedder, false, None).unwrap(); + + assert_eq!( + store.get_meta("embedding_model_uri").unwrap(), + Some(ModelDefaults::default().embed_uri) + ); + assert_eq!( + store.get_meta("embedding_dim").unwrap().as_deref(), + Some("256") + ); + } } diff --git a/src/llm.rs b/src/llm.rs index 4c417c3..2fae219 100644 --- a/src/llm.rs +++ b/src/llm.rs @@ -527,32 +527,47 @@ impl FlexTokenizer { /// Load tokenizer for a model. Tries external tokenizer.json first, falls back to GGUF-embedded. fn load_tokenizer_for_model(uri: &HfModelUri, models_dir: &Path) -> Result { - // First try: external tokenizer.json from candidate repos. - if let Some(tok) = try_external_tokenizer(uri, models_dir) { + // 1. Already-cached external tokenizer.json (no network). + if let Some(tok) = try_external_tokenizer(uri, models_dir, false) { return Ok(FlexTokenizer::HuggingFace(Box::new(tok))); } - // Fallback: load tokenizer from GGUF file metadata. + // 2. Tokenizer embedded in the GGUF we already have (no network). + // The model file is guaranteed present here: callers run ensure_model() + // before loading the tokenizer. Trying the network first meant every + // process start paid seconds of doomed HTTP requests (404/401 on all + // candidate repos) before landing on this exact fallback. let model_path = uri.cache_path(models_dir); - if model_path.exists() { - tracing::info!( - "no external tokenizer found, loading from GGUF: {}", + if model_path.exists() + && let Ok(tok) = shimmytok::Tokenizer::from_gguf_file(&model_path) + { + tracing::debug!( + "loaded tokenizer from GGUF metadata: {}", model_path.display() ); - let tok = shimmytok::Tokenizer::from_gguf_file(&model_path) - .map_err(|e| anyhow::anyhow!("loading tokenizer from GGUF metadata: {e}"))?; return Ok(FlexTokenizer::Gguf(Box::new(tok))); } + // 3. Last resort: download tokenizer.json from candidate repos. + if let Some(tok) = try_external_tokenizer(uri, models_dir, true) { + return Ok(FlexTokenizer::HuggingFace(Box::new(tok))); + } + bail!( - "could not find tokenizer for model '{}': no external tokenizer.json \ - and GGUF file not yet downloaded", + "could not find tokenizer for model '{}': no cached tokenizer.json, \ + no GGUF-embedded tokenizer, and no candidate repo served one", uri.repo ) } -/// Try downloading tokenizer.json from candidate HuggingFace repos. -fn try_external_tokenizer(uri: &HfModelUri, models_dir: &Path) -> Option { +/// Try loading tokenizer.json from candidate HuggingFace repos. +/// With `download = false`, only already-cached files are considered — no +/// network I/O. With `download = true`, missing candidates are fetched. +fn try_external_tokenizer( + uri: &HfModelUri, + models_dir: &Path, + download: bool, +) -> Option { let mut candidates: Vec = vec![uri.repo.clone()]; // Non-GGUF variant: "org/model-GGUF" → "org/model" @@ -594,7 +609,8 @@ fn try_external_tokenizer(uri: &HfModelUri, models_dir: &Path) -> Option Result> { - // Tokenize using llama.cpp's built-in tokenizer. - // Use AddBos::Never because PromptFormat already adds for embeddinggemma. + /// Tokenize with llama.cpp's built-in tokenizer. + /// AddBos::Never because PromptFormat already adds for embeddinggemma. + fn tokenize(&self, text: &str) -> Result> { let tokens = self .model .str_to_token(text, AddBos::Never) @@ -711,28 +726,38 @@ impl LlamaEmbed { if tokens.is_empty() { bail!("tokenizer returned empty token sequence"); } + Ok(tokens) + } - // Create a context with embeddings enabled (per-call, since LlamaContext is !Send). - // n_ubatch must be >= n_tokens for the encoder, and n_ctx must fit all tokens. - let n_tokens = tokens.len() as u32; - let n_ctx = std::num::NonZeroU32::new(n_tokens.max(64) + 16); + /// Create an embeddings context that can encode sequences up to `max_tokens`. + /// n_ubatch must be >= n_tokens for the encoder, and n_ctx must fit all tokens. + fn make_context(&self, max_tokens: u32) -> Result> { + let n_ctx = std::num::NonZeroU32::new(max_tokens.max(64) + 16); let ctx_params = LlamaContextParams::default() .with_embeddings(true) .with_n_ctx(n_ctx) - .with_n_ubatch(n_tokens.max(512)) - .with_n_batch(n_tokens.max(512)); - let mut ctx = self - .model + .with_n_ubatch(max_tokens.max(512)) + .with_n_batch(max_tokens.max(512)); + self.model .new_context(llama_backend()?, ctx_params) - .map_err(|e| anyhow::anyhow!("creating embedding context: {e}"))?; + .map_err(|e| anyhow::anyhow!("creating embedding context: {e}")) + } + /// Encode one token sequence in `ctx` and return the truncated, + /// L2-normalized embedding. + fn encode_tokens( + &self, + ctx: &mut llama_cpp_2::context::LlamaContext<'_>, + tokens: &[llama_cpp_2::token::LlamaToken], + ) -> Result> { // Create batch and add tokens — mark all as outputs for embedding. let mut batch = LlamaBatch::new(tokens.len() + 16, 1); batch - .add_sequence(&tokens, 0, true) + .add_sequence(tokens, 0, true) .map_err(|e| anyhow::anyhow!("adding sequence to batch: {e}"))?; // Encode (compute embeddings). Use encode() for embedding models. + ctx.clear_kv_cache(); ctx.encode(&mut batch) .map_err(|e| anyhow::anyhow!("embedding encode failed: {e}"))?; @@ -759,18 +784,38 @@ impl LlamaEmbed { Ok(normalized) } + + /// Run embedding inference and return the truncated, L2-normalized embedding. + fn embed_text(&self, text: &str) -> Result> { + let tokens = self.tokenize(text)?; + // Context is created per-call (LlamaContext is !Send, so it can't be + // stored in this Send struct). + let mut ctx = self.make_context(tokens.len() as u32)?; + self.encode_tokens(&mut ctx, &tokens) + } } impl EmbedModel for LlamaEmbed { fn embed_batch(&mut self, texts: &[&str]) -> Result>> { - // Process texts sequentially — llama.cpp context is per-call. + if texts.is_empty() { + return Ok(Vec::new()); + } // Apply document prompt format for indexing (asymmetric models need this). - texts + // Tokenize everything first, then share ONE context sized for the longest + // sequence: context creation costs llama.cpp graph planning + Metal + // pipeline setup per call, which dominated indexing when paid per chunk. + let token_seqs: Vec> = texts .iter() .map(|t| { let formatted = self.prompt_format.format_document("", t); - self.embed_text(&formatted) + self.tokenize(&formatted) }) + .collect::>()?; + let max_tokens = token_seqs.iter().map(|t| t.len()).max().unwrap_or(0) as u32; + let mut ctx = self.make_context(max_tokens)?; + token_seqs + .iter() + .map(|tokens| self.encode_tokens(&mut ctx, tokens)) .collect() } diff --git a/src/search.rs b/src/search.rs index 4a78481..71283f7 100644 --- a/src/search.rs +++ b/src/search.rs @@ -268,6 +268,7 @@ pub fn search_with_intelligence( b.score .partial_cmp(&a.score) .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.file_path.cmp(&b.file_path)) }); true } else { @@ -311,6 +312,7 @@ pub fn search_with_intelligence( b.score .partial_cmp(&a.score) .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.file_path.cmp(&b.file_path)) }); // 5-lane RRF (rerank_results is empty when reranker absent, weight 0) @@ -374,10 +376,14 @@ fn dedup_by_file(results: Vec) -> Vec { } } let mut deduped: Vec = by_file.into_values().collect(); + // Tiebreak on file_path: the map's iteration order is random per process, + // and equal scores otherwise land in random rank order (nondeterministic + // RRF ranks downstream). deduped.sort_by(|a, b| { b.score .partial_cmp(&a.score) .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.file_path.cmp(&b.file_path)) }); deduped } @@ -393,7 +399,15 @@ fn merge_seeds(semantic: &[RankedResult], fts: &[RankedResult]) -> Vec = by_file.into_values().collect(); + // Deterministic seed order (map iteration order is random per process). + seeds.sort_by(|a, b| { + b.score + .partial_cmp(&a.score) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.file_path.cmp(&b.file_path)) + }); + seeds } /// Run a search query and print results. @@ -497,7 +511,13 @@ pub fn run_status(json: bool, data_dir: &Path) -> Result<()> { // Compute index size on disk (sqlite db file). let index_size = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0); - let model_name = "all-MiniLM-L6-v2"; + let model_uri = store + .get_meta("embedding_model_uri")? + .unwrap_or_else(|| "".to_string()); + let model_dim = store + .get_meta("embedding_dim")? + .unwrap_or_else(|| "unknown".to_string()); + let model_name = format!("{model_uri} (dim {model_dim})"); let config = crate::config::Config::load().unwrap_or_default(); let intelligence = if config.intelligence_enabled() { @@ -509,7 +529,7 @@ pub fn run_status(json: bool, data_dir: &Path) -> Result<()> { let output = format_status( &stats, index_size, - model_name, + &model_name, intelligence, date_count, json, diff --git a/src/serve.rs b/src/serve.rs index 691cafb..ea01d1b 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -977,6 +977,54 @@ pub struct HttpServeOpts { // Entry point // --------------------------------------------------------------------------- +/// Return `(latest_indexed_at, newest_vault_mtime)` when a read-only serve +/// process would be serving an index older than the vault on disk. +/// +/// This deliberately performs only metadata and SQLite reads. Read-only +/// servers do not start a watcher, so the caller can surface the stale state +/// without silently mutating the index. +pub fn read_only_index_staleness( + store: &Store, + vault_path: &Path, + config: &Config, +) -> Result> { + let latest_indexed_at = store + .get_all_files()? + .iter() + .filter_map(|file| file.indexed_at.parse::().ok()) + .max(); + let newest_vault_mtime = + crate::indexer::walk_vault(vault_path, &config.exclude, config.respect_gitignore)? + .iter() + .filter_map(|path| { + std::fs::metadata(path) + .ok()? + .modified() + .ok()? + .duration_since(std::time::UNIX_EPOCH) + .ok() + .map(|duration| duration.as_secs() as i64) + }) + .max(); + + match (latest_indexed_at, newest_vault_mtime) { + (Some(indexed), Some(mtime)) if mtime > indexed => Ok(Some((indexed, mtime))), + _ => Ok(None), + } +} + +/// Reconcile crash leftovers and orphaned index rows only for writable serve +/// processes. Read-only processes must not mutate either the vault or SQLite. +fn reconcile_startup(store: &Store, vault_path: &Path, read_only: bool) -> Result<(usize, usize)> { + if read_only { + return Ok((0, 0)); + } + + let cleaned = crate::writer::cleanup_temp_files(vault_path)?; + let orphans = crate::writer::verify_index_integrity(store, vault_path)?; + Ok((cleaned, orphans)) +} + pub async fn run_serve( data_dir: &Path, http_opts: Option, @@ -1004,7 +1052,16 @@ pub async fn run_serve( })?; let vault_path = PathBuf::from(&vault_path_str); - let cleaned = crate::writer::cleanup_temp_files(&vault_path)?; + if read_only + && let Some((latest_indexed_at, newest_vault_mtime)) = + read_only_index_staleness(&store, &vault_path, &config)? + { + eprintln!( + "Warning: read-only index is stale (latest indexed_at={latest_indexed_at}, newest vault mtime={newest_vault_mtime}); no file watcher will self-heal it." + ); + } + + let (cleaned, orphans) = reconcile_startup(&store, &vault_path, read_only)?; if cleaned > 0 { eprintln!( "Cleaned up {} incomplete write(s) from previous run", @@ -1012,7 +1069,6 @@ pub async fn run_serve( ); } - let orphans = crate::writer::verify_index_integrity(&store, &vault_path)?; if orphans > 0 { eprintln!("Cleaned up {} orphan DB entries for missing files", orphans); } @@ -1066,28 +1122,37 @@ pub async fn run_serve( let http_reranker = reranker.as_ref().map(Arc::clone); let http_recent_writes = recent_writes.clone(); - // Start file watcher for real-time index updates - let mut exclude = config.exclude.clone(); - if let Some(ref prof) = *profile_arc - && let Some(ref archive) = prof.structure.folders.archive - { - let pattern = format!("{}/", archive); - if !exclude.contains(&pattern) { - exclude.push(pattern); + // Start file watcher for real-time index updates — writable servers only. + // A read-only server must not mutate the index: with N concurrent servers, + // an unconditional watcher meant every vault save was re-chunked, + // re-embedded, and re-written N times by N processes racing on one SQLite + // file (and N startup reconciliations racing at spawn). Read-only servers + // see index updates anyway: each query reads the shared WAL-mode DB. + let watcher = if read_only { + None + } else { + let mut exclude = config.exclude.clone(); + if let Some(ref prof) = *profile_arc + && let Some(ref archive) = prof.structure.folders.archive + { + let pattern = format!("{}/", archive); + if !exclude.contains(&pattern) { + exclude.push(pattern); + } } - } - let (watcher_handle, watcher_shutdown) = crate::watcher::start_watcher( - store_arc.clone(), - embedder_arc.clone(), - vault_path_arc.clone(), - profile_arc.clone(), - config, - exclude, - recent_writes.clone(), - )?; + Some(crate::watcher::start_watcher( + store_arc.clone(), + embedder_arc.clone(), + vault_path_arc.clone(), + profile_arc.clone(), + config, + exclude, + recent_writes.clone(), + )?) + }; if read_only { - eprintln!("Read-only mode: write tools disabled"); + eprintln!("Read-only mode: write tools disabled, file watcher not started"); } let server = EngraphServer { @@ -1155,9 +1220,11 @@ pub async fn run_serve( cancel_token.cancel(); // triggers HTTP graceful shutdown // Shut down watcher cleanly after MCP transport exits - let _ = watcher_shutdown.send(()); - if let Err(e) = watcher_handle.join() { - tracing::warn!("Watcher thread panicked: {:?}", e); + if let Some((watcher_handle, watcher_shutdown)) = watcher { + let _ = watcher_shutdown.send(()); + if let Err(e) = watcher_handle.join() { + tracing::warn!("Watcher thread panicked: {:?}", e); + } } Ok(()) @@ -1217,4 +1284,72 @@ mod tests { "unknown op variant should fail deserialization" ); } + + #[test] + fn read_only_index_staleness_detects_newer_vault_file() { + let vault = tempfile::tempdir().unwrap(); + let note = vault.path().join("note.md"); + std::fs::write(¬e, "# Fresh note\n").unwrap(); + + let store = Store::open_memory().unwrap(); + let file_id = store + .insert_file("note.md", "hash", 0, &[], "abc123", None, None) + .unwrap(); + store + .conn() + .execute( + "UPDATE files SET indexed_at = '0' WHERE id = ?1", + rusqlite::params![file_id], + ) + .unwrap(); + + let config = Config::default(); + let stale = read_only_index_staleness(&store, vault.path(), &config) + .unwrap() + .expect("newer vault file should be reported stale"); + assert_eq!(stale.0, 0); + assert!(stale.1 > stale.0); + + store + .conn() + .execute( + "UPDATE files SET indexed_at = ?1 WHERE id = ?2", + rusqlite::params![stale.1 + 1, file_id], + ) + .unwrap(); + assert!( + read_only_index_staleness(&store, vault.path(), &config) + .unwrap() + .is_none(), + "index newer than vault should not warn" + ); + } + + #[test] + fn read_only_startup_skips_reconciliation_writes() { + let vault = tempfile::tempdir().unwrap(); + let temp_file = vault.path().join("crash.md.tmp"); + std::fs::write(&temp_file, "incomplete").unwrap(); + + let store = Store::open_memory().unwrap(); + store + .insert_file("missing.md", "hash", 0, &[], "abc123", None, None) + .unwrap(); + + let (cleaned, orphans) = reconcile_startup(&store, vault.path(), true).unwrap(); + assert_eq!((cleaned, orphans), (0, 0)); + assert!(temp_file.exists(), "read-only startup removed a temp file"); + assert!( + store.get_file("missing.md").unwrap().is_some(), + "read-only startup removed an orphan row" + ); + + let (cleaned, orphans) = reconcile_startup(&store, vault.path(), false).unwrap(); + assert_eq!((cleaned, orphans), (1, 1)); + assert!(!temp_file.exists(), "writable startup left a temp file"); + assert!( + store.get_file("missing.md").unwrap().is_none(), + "writable startup left an orphan row" + ); + } } diff --git a/src/store.rs b/src/store.rs index 0e621ef..1075188 100644 --- a/src/store.rs +++ b/src/store.rs @@ -131,6 +131,8 @@ CREATE TABLE IF NOT EXISTS chunks ( vector BLOB ); +CREATE INDEX IF NOT EXISTS idx_chunks_file_id ON chunks(file_id); + CREATE TABLE IF NOT EXISTS tombstones ( id INTEGER PRIMARY KEY, vector_id INTEGER UNIQUE NOT NULL, @@ -1001,7 +1003,7 @@ impl Store { /// Get the best (highest token_count) chunk for a file. pub fn get_best_chunk_for_file(&self, file_id: i64) -> Result> { let mut stmt = self.conn.prepare( - "SELECT heading, snippet FROM chunks WHERE file_id = ?1 ORDER BY token_count DESC LIMIT 1", + "SELECT heading, snippet FROM chunks WHERE file_id = ?1 ORDER BY token_count DESC, id ASC LIMIT 1", )?; let mut rows = stmt.query_map(params![file_id], |row| { Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) @@ -2619,6 +2621,9 @@ mod tests { store .insert_chunk(f1, "Big heading", "big snippet", 2, 100) .unwrap(); + store + .insert_chunk(f1, "Later tie", "later snippet", 3, 100) + .unwrap(); let best = store.get_best_chunk_for_file(f1).unwrap().unwrap(); assert_eq!(best.0, "Big heading"); diff --git a/tests/golden_search.rs b/tests/golden_search.rs new file mode 100644 index 0000000..e043c08 --- /dev/null +++ b/tests/golden_search.rs @@ -0,0 +1,68 @@ +//! Deterministic, model-free search regression battery. +//! +//! The old integration suites exercised deleted HNSW/Embedder modules. This +//! fixture uses the current Store + MockLlm path and repeats the same queries +//! to catch ranking nondeterminism without downloading a GGUF model. + +use engraph::docid::generate_docid; +use engraph::llm::MockLlm; +use engraph::search::search_internal; +use engraph::store::Store; + +fn fixture_store() -> Store { + let store = Store::open_memory().unwrap(); + let embedder = MockLlm::new(256); + let documents = [ + ( + "notes/alpha.md", + "retrieval ranking architecture", + "retrieval ranking", + ), + ( + "notes/beta.md", + "retrieval ranking operations", + "retrieval ranking", + ), + ("notes/gamma.md", "unrelated gardening notes", "unrelated"), + ]; + + for (path, snippet, vector_seed) in documents { + let file_id = store + .insert_file(path, path, 0, &[], &generate_docid(path), None, None) + .unwrap(); + let vector_id = store.next_vector_id().unwrap(); + let vector = embedder.hash_to_vector(vector_seed); + store + .insert_chunk_with_vector(file_id, "# Fixture", snippet, vector_id, 3, &vector) + .unwrap(); + store.insert_vec(vector_id, &vector).unwrap(); + store.insert_fts_chunk(file_id, 0, snippet).unwrap(); + } + store +} + +#[test] +fn golden_search_battery_is_stable() { + let store = fixture_store(); + let mut embedder = MockLlm::new(256); + + for _ in 0..10 { + let retrieval = search_internal("retrieval ranking", 2, &store, &mut embedder).unwrap(); + let retrieval_paths: Vec<&str> = retrieval + .results + .iter() + .map(|result| result.file_path.as_str()) + .collect(); + assert_eq!( + retrieval_paths, + vec!["notes/alpha.md", "notes/beta.md"], + "retrieval golden result changed" + ); + + let unrelated = search_internal("unrelated", 1, &store, &mut embedder).unwrap(); + assert_eq!( + unrelated.results[0].file_path, "notes/gamma.md", + "unrelated golden result changed" + ); + } +} diff --git a/tests/integration.rs b/tests/integration.rs index 049e5c9..a0ea6ca 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1,456 +1,127 @@ -//! Integration tests for engraph. -//! -//! All tests are `#[ignore]` because they require the ONNX model download (~23MB). -//! Run with: `cargo test --test integration -- --ignored` +//! Model-free integration tests for indexing and search. use std::path::{Path, PathBuf}; -use engraph::chunker::chunk_markdown; use engraph::config::Config; -use engraph::docid::generate_docid; -use engraph::embedder::Embedder; -use engraph::hnsw::HnswIndex; -use engraph::indexer::{compute_file_hash, diff_vault, walk_vault}; +use engraph::indexer::run_index_shared; +use engraph::llm::MockLlm; +use engraph::search::search_internal; use engraph::store::Store; - use tempfile::TempDir; -/// Fixture directory relative to the project root. fn fixtures_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures") } -/// Copy the fixture vault into a temp directory so tests can mutate it. fn copy_fixtures_to(dest: &Path) { - copy_dir_recursive(&fixtures_dir(), dest); -} - -fn copy_dir_recursive(src: &Path, dst: &Path) { - std::fs::create_dir_all(dst).unwrap(); - for entry in std::fs::read_dir(src).unwrap() { + std::fs::create_dir_all(dest).unwrap(); + for entry in std::fs::read_dir(fixtures_dir()).unwrap() { let entry = entry.unwrap(); - let src_path = entry.path(); - let dst_path = dst.join(entry.file_name()); - if src_path.is_dir() { - copy_dir_recursive(&src_path, &dst_path); - } else { - std::fs::copy(&src_path, &dst_path).unwrap(); - } - } -} - -/// Write a file at the given relative path inside a directory. -fn write_file(dir: &Path, rel_path: &str, content: &str) { - let full = dir.join(rel_path); - if let Some(parent) = full.parent() { - std::fs::create_dir_all(parent).unwrap(); + copy_entry(&entry.path(), &dest.join(entry.file_name())); } - std::fs::write(&full, content).unwrap(); } -/// Index a vault directory into the given data_dir using lower-level APIs. -/// Returns the number of files indexed. -fn index_vault(vault_path: &Path, data_dir: &Path, config: &Config, rebuild: bool) -> usize { - let db_path = data_dir.join("engraph.db"); - let store = Store::open(&db_path).unwrap(); - let hnsw_dir = data_dir.join("hnsw"); - - let files = walk_vault(vault_path, &config.exclude).unwrap(); - - let (new_files, changed_files, deleted_files) = if rebuild { - (files.clone(), Vec::new(), Vec::new()) - } else { - diff_vault(&files, vault_path, &store).unwrap() - }; - - // Handle deletes. - for record in &deleted_files { - store.delete_file(record.id).unwrap(); - } - - // Handle updates (delete old record, then treat as new). - let mut files_to_index: Vec = new_files.clone(); - for file_path in &changed_files { - let rel = file_path.strip_prefix(vault_path).unwrap_or(file_path); - let rel_str = rel.to_string_lossy().to_string(); - if let Some(record) = store.get_file(&rel_str).unwrap() { - store.delete_file(record.id).unwrap(); - } - files_to_index.push(file_path.clone()); - } - - // Embed and store with vectors. - let models_dir = data_dir.join("models"); - let mut embedder = Embedder::new(&models_dir).unwrap(); - - let mut next_vid: u64 = store - .get_all_vectors() - .unwrap_or_default() - .iter() - .map(|(id, _)| *id) - .max() - .map_or(0, |m| m + 1); - - for file_path in &files_to_index { - let content = std::fs::read_to_string(file_path).unwrap(); - let rel = file_path.strip_prefix(vault_path).unwrap_or(file_path); - let rel_str = rel.to_string_lossy().to_string(); - let hash = compute_file_hash(file_path).unwrap(); - - let parsed = chunk_markdown(&content); - let tags = parsed.tags; - let chunks = parsed.chunks; - - let docid = generate_docid(&rel_str); - let file_id = store - .insert_file(&rel_str, &hash, 0, &tags, &docid, None, None) - .unwrap(); - - for chunk in &chunks { - let heading = chunk.heading.clone().unwrap_or_default(); - let vec = embedder.embed_one(&chunk.text).unwrap(); - let token_count = embedder.token_count(&chunk.text) as i64; - let vector_id = next_vid; - next_vid += 1; - store - .insert_chunk_with_vector( - file_id, - &heading, - &chunk.snippet, - vector_id, - token_count, - &vec, - ) - .unwrap(); +fn copy_entry(source: &Path, destination: &Path) { + if source.is_dir() { + std::fs::create_dir_all(destination).unwrap(); + for entry in std::fs::read_dir(source).unwrap() { + let entry = entry.unwrap(); + copy_entry(&entry.path(), &destination.join(entry.file_name())); } + } else { + std::fs::copy(source, destination).unwrap(); } - - store - .set_meta("vault_path", &vault_path.to_string_lossy()) - .unwrap(); - store - .set_meta( - "last_indexed_at", - &format!( - "{}", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() - ), - ) - .unwrap(); - - // Rebuild HNSW from all vectors in SQLite (hnsw_rs doesn't support append after load). - let all_vectors = store.get_all_vectors().unwrap(); - let mut hnsw = HnswIndex::new(all_vectors.len().max(1000)); - for (vid, vector) in &all_vectors { - hnsw.insert_with_id(vector, *vid); - } - hnsw.save(&hnsw_dir).unwrap(); - - files_to_index.len() } -/// Search using lower-level APIs and return results as (file_path, score) pairs. -fn search_vault(query: &str, top_n: usize, data_dir: &Path) -> Vec<(String, f32)> { - let models_dir = data_dir.join("models"); - let mut embedder = Embedder::new(&models_dir).unwrap(); - - let hnsw_dir = data_dir.join("hnsw"); - let index = HnswIndex::load(&hnsw_dir).unwrap(); - - let db_path = data_dir.join("engraph.db"); - let store = Store::open(&db_path).unwrap(); - - let query_vec = embedder.embed_one(query).unwrap(); - let tombstones = store.get_tombstones().unwrap(); - let raw_results = index.search(&query_vec, top_n, &tombstones); +struct Harness { + _data_dir: TempDir, + vault_dir: TempDir, + store: Store, + embedder: MockLlm, + config: Config, +} - let mut results = Vec::new(); - for (vector_id, distance) in raw_results { - if let Some(chunk) = store.get_chunk_by_vector_id(vector_id).unwrap() { - let file_path = store - .get_file_path_by_id(chunk.file_id) - .unwrap() - .unwrap_or_else(|| "".to_string()); - let score = 1.0 - distance; - results.push((file_path, score)); +impl Harness { + fn new() -> Self { + let data_dir = TempDir::new().unwrap(); + let vault_dir = TempDir::new().unwrap(); + copy_fixtures_to(vault_dir.path()); + + Self { + store: Store::open(&data_dir.path().join("engraph.db")).unwrap(), + embedder: MockLlm::new(256), + config: Config::default(), + _data_dir: data_dir, + vault_dir, } } - results -} - -// ── Tests ──────────────────────────────────────────────────────── - -#[test] -#[ignore] -fn test_full_index_and_search() { - let vault_dir = TempDir::new().unwrap(); - let data_dir = TempDir::new().unwrap(); - copy_fixtures_to(vault_dir.path()); - - let config = Config::default(); - let indexed = index_vault(vault_dir.path(), data_dir.path(), &config, false); - assert!(indexed > 0, "should have indexed some files"); - - let results = search_vault("error handling", 5, data_dir.path()); - assert!(!results.is_empty(), "search should return results"); - - let file_paths: Vec<&str> = results.iter().map(|(p, _)| p.as_str()).collect(); - assert!( - file_paths.iter().any(|p| p.contains("note1")), - "results should contain note1.md (Rust Error Handling), got: {:?}", - file_paths - ); -} - -#[test] -#[ignore] -fn test_incremental_add() { - let vault_dir = TempDir::new().unwrap(); - let data_dir = TempDir::new().unwrap(); - copy_fixtures_to(vault_dir.path()); - - let config = Config::default(); - index_vault(vault_dir.path(), data_dir.path(), &config, false); - - // Check initial file count. - let db_path = data_dir.path().join("engraph.db"); - let store = Store::open(&db_path).unwrap(); - let initial_count = store.stats().unwrap().file_count; - drop(store); - - // Add a new file. - write_file( - vault_dir.path(), - "note5.md", - "---\ntags: [kubernetes]\n---\n\n# Kubernetes Pods\n\nPods are the smallest deployable units.", - ); - - // Re-index incrementally. - index_vault(vault_dir.path(), data_dir.path(), &config, false); - - let store = Store::open(&db_path).unwrap(); - let new_count = store.stats().unwrap().file_count; - assert_eq!( - new_count, - initial_count + 1, - "file count should increase by 1 after adding a file" - ); - - // Search for the new content. - let results = search_vault("kubernetes pods", 5, data_dir.path()); - let file_paths: Vec<&str> = results.iter().map(|(p, _)| p.as_str()).collect(); - assert!( - file_paths.iter().any(|p| p.contains("note5")), - "results should contain newly added note5.md, got: {:?}", - file_paths - ); -} - -#[test] -#[ignore] -fn test_incremental_delete() { - let vault_dir = TempDir::new().unwrap(); - let data_dir = TempDir::new().unwrap(); - copy_fixtures_to(vault_dir.path()); - let config = Config::default(); - index_vault(vault_dir.path(), data_dir.path(), &config, false); - - let db_path = data_dir.path().join("engraph.db"); - let store = Store::open(&db_path).unwrap(); - let initial_count = store.stats().unwrap().file_count; - drop(store); - - // Delete note2.md from the vault. - std::fs::remove_file(vault_dir.path().join("note2.md")).unwrap(); - - // Re-index incrementally. - index_vault(vault_dir.path(), data_dir.path(), &config, false); - - let store = Store::open(&db_path).unwrap(); - let new_count = store.stats().unwrap().file_count; - assert_eq!( - new_count, - initial_count - 1, - "file count should decrease by 1 after deleting a file" - ); - - // Verify the deleted file is gone from the store. - assert!( - store.get_file("note2.md").unwrap().is_none(), - "deleted file should not be in the store" - ); -} - -#[test] -#[ignore] -fn test_incremental_update() { - let vault_dir = TempDir::new().unwrap(); - let data_dir = TempDir::new().unwrap(); - copy_fixtures_to(vault_dir.path()); - - let config = Config::default(); - index_vault(vault_dir.path(), data_dir.path(), &config, false); - - // Modify note3.md to contain completely different content. - write_file( - vault_dir.path(), - "note3.md", - "# Quantum Computing\n\nQuantum computers use qubits for parallel computation.", - ); - - // Re-index incrementally. - index_vault(vault_dir.path(), data_dir.path(), &config, false); - - // Search for the updated content. - let results = search_vault("quantum computing qubits", 5, data_dir.path()); - let file_paths: Vec<&str> = results.iter().map(|(p, _)| p.as_str()).collect(); - assert!( - file_paths.iter().any(|p| p.contains("note3")), - "results should contain updated note3.md with quantum computing content, got: {:?}", - file_paths - ); -} - -#[test] -#[ignore] -fn test_obsidian_dir_excluded() { - let vault_dir = TempDir::new().unwrap(); - let data_dir = TempDir::new().unwrap(); - copy_fixtures_to(vault_dir.path()); - - let config = Config::default(); // default excludes .obsidian/ - index_vault(vault_dir.path(), data_dir.path(), &config, false); - - let db_path = data_dir.path().join("engraph.db"); - let store = Store::open(&db_path).unwrap(); - let stats = store.stats().unwrap(); - - // Fixtures have 4 .md files (note1-4) plus .obsidian/config.md. - // .obsidian should be excluded, so we expect exactly 4 files. - assert_eq!( - stats.file_count, 4, - "should index exactly 4 files (excluding .obsidian/), got {}", - stats.file_count - ); - - // Verify .obsidian/config.md is not in the store. - assert!( - store.get_file(".obsidian/config.md").unwrap().is_none(), - ".obsidian/config.md should not be indexed" - ); -} - -#[test] -#[ignore] -fn test_rebuild_flag() { - let vault_dir = TempDir::new().unwrap(); - let data_dir = TempDir::new().unwrap(); - copy_fixtures_to(vault_dir.path()); - - let config = Config::default(); - - // Initial index. - index_vault(vault_dir.path(), data_dir.path(), &config, false); - - let db_path = data_dir.path().join("engraph.db"); - let store = Store::open(&db_path).unwrap(); - let initial_stats = store.stats().unwrap(); - drop(store); - - // Rebuild from scratch. - // First clear the store to simulate rebuild behavior. - std::fs::remove_file(&db_path).unwrap(); - let hnsw_dir = data_dir.path().join("hnsw"); - if hnsw_dir.exists() { - std::fs::remove_dir_all(&hnsw_dir).unwrap(); + fn index(&mut self) -> engraph::indexer::IndexResult { + run_index_shared( + self.vault_dir.path(), + &self.config, + &self.store, + &mut self.embedder, + false, + None, + ) + .unwrap() } - - index_vault(vault_dir.path(), data_dir.path(), &config, true); - - let store = Store::open(&db_path).unwrap(); - let rebuild_stats = store.stats().unwrap(); - - assert_eq!( - rebuild_stats.file_count, initial_stats.file_count, - "rebuild should index the same number of files" - ); - assert_eq!( - rebuild_stats.tombstone_count, 0, - "rebuild should have no tombstones" - ); } #[test] -#[ignore] -fn test_clear_preserves_model() { - let vault_dir = TempDir::new().unwrap(); - let data_dir = TempDir::new().unwrap(); - copy_fixtures_to(vault_dir.path()); - - let config = Config::default(); - index_vault(vault_dir.path(), data_dir.path(), &config, false); - - // Verify model files exist. - let models_dir = data_dir.path().join("models"); - assert!( - models_dir.join("model.onnx").exists(), - "model.onnx should exist before clear" - ); - - // Simulate `engraph clear` (without --all): remove db and hnsw, keep models. - let db_path = data_dir.path().join("engraph.db"); - let hnsw_dir = data_dir.path().join("hnsw"); - let _ = std::fs::remove_file(&db_path); - if hnsw_dir.exists() { - let _ = std::fs::remove_dir_all(&hnsw_dir); - } - - // Model directory should still exist. - assert!( - models_dir.join("model.onnx").exists(), - "model.onnx should survive clear (without --all)" - ); +fn full_index_is_searchable() { + let mut harness = Harness::new(); + let indexed = harness.index(); + + assert_eq!(indexed.new_files, 4); + assert_eq!(harness.store.stats().unwrap().file_count, 4); + + let results = search_internal( + "Rust error handling", + 5, + &harness.store, + &mut harness.embedder, + ) + .unwrap(); assert!( - models_dir.join("tokenizer.json").exists(), - "tokenizer.json should survive clear (without --all)" + results + .results + .iter() + .any(|result| result.file_path == "note1.md") ); } #[test] -#[ignore] -fn test_status_output() { - let vault_dir = TempDir::new().unwrap(); - let data_dir = TempDir::new().unwrap(); - copy_fixtures_to(vault_dir.path()); - - let config = Config::default(); - index_vault(vault_dir.path(), data_dir.path(), &config, false); - - let db_path = data_dir.path().join("engraph.db"); - let store = Store::open(&db_path).unwrap(); - let stats = store.stats().unwrap(); - - assert_eq!(stats.file_count, 4, "expected 4 indexed files"); - assert!(stats.chunk_count > 0, "expected some chunks"); - assert_eq!( - stats.tombstone_count, 0, - "expected no tombstones on fresh index" - ); - assert!( - stats.last_indexed_at.is_some(), - "last_indexed_at should be set" - ); - assert!(stats.vault_path.is_some(), "vault_path should be set"); +fn incremental_index_tracks_add_change_and_delete() { + let mut harness = Harness::new(); + harness.index(); + + std::fs::write( + harness.vault_dir.path().join("note4.md"), + "# Kubernetes Pods\n\nPods are deployable units.\n", + ) + .unwrap(); + std::fs::write( + harness.vault_dir.path().join("note2.md"), + "# Python Basics\n\nPython has dynamic typing and pattern matching.\n", + ) + .unwrap(); + std::fs::remove_file(harness.vault_dir.path().join("note3.md")).unwrap(); + + let indexed = harness.index(); + assert_eq!(indexed.new_files, 1); + assert_eq!(indexed.updated_files, 1); + assert_eq!(harness.store.stats().unwrap().file_count, 4); + assert!(harness.store.get_file("note4.md").unwrap().is_some()); + assert!(harness.store.get_file("note3.md").unwrap().is_none()); + + let results = + search_internal("Kubernetes Pods", 5, &harness.store, &mut harness.embedder).unwrap(); assert!( - stats - .vault_path - .as_ref() - .unwrap() - .contains(vault_dir.path().to_str().unwrap()), - "vault_path should point to the test vault" + results + .results + .iter() + .any(|result| result.file_path == "note4.md") ); } diff --git a/tests/write_pipeline.rs b/tests/write_pipeline.rs index 7e84a78..521ddfb 100644 --- a/tests/write_pipeline.rs +++ b/tests/write_pipeline.rs @@ -1,165 +1,128 @@ -//! Integration tests for the write pipeline. -//! Run with: cargo test --test write_pipeline -- --ignored +//! Model-free integration tests for the write pipeline. -use std::path::Path; - -use engraph::embedder::Embedder; +use engraph::llm::MockLlm; +use engraph::search::search_internal; use engraph::store::Store; -use engraph::vecstore; use engraph::writer::{AppendInput, CreateNoteInput, append_to_note, create_note}; -fn setup(vault_dir: &Path) -> (Store, Embedder) { - // Register sqlite-vec extension - vecstore::init_sqlite_vec(); - - // Create minimal vault structure - std::fs::create_dir_all(vault_dir.join("00-Inbox")).unwrap(); - std::fs::create_dir_all(vault_dir.join("03-Resources/People")).unwrap(); - std::fs::write( - vault_dir.join("03-Resources/People/Steve Barbera.md"), - "# Steve Barbera\n\nRole: VP Engineering\n", - ) - .unwrap(); - - // Open store and set vault path - let data_dir = tempfile::TempDir::new().unwrap(); - let db_path = data_dir.path().join("engraph.db"); - let store = Store::open(&db_path).unwrap(); - store - .set_meta("vault_path", &vault_dir.to_string_lossy()) - .unwrap(); +struct Harness { + vault_dir: tempfile::TempDir, + store: Store, + embedder: MockLlm, +} - // Index the existing file so it's in the store - let docid = engraph::docid::generate_docid("03-Resources/People/Steve Barbera.md"); - store - .insert_file( - "03-Resources/People/Steve Barbera.md", - "hash1", - 0, - &[], - &docid, - None, +impl Harness { + fn new() -> Self { + let vault_dir = tempfile::TempDir::new().unwrap(); + std::fs::create_dir_all(vault_dir.path().join("00-Inbox")).unwrap(); + + Self { + store: Store::open_memory().unwrap(), + embedder: MockLlm::new(256), + vault_dir, + } + } + + fn create_note(&mut self, title: &str, body: &str) -> engraph::writer::WriteResult { + create_note( + CreateNoteInput { + content: format!("# {title}\n\n{body}"), + filename: Some(title.to_string()), + type_hint: None, + tags: vec!["engraph".to_string()], + folder: Some("00-Inbox".to_string()), + created_by: "integration-test".to_string(), + auto_link: Some(false), + }, + &self.store, + &mut self.embedder, + self.vault_dir.path(), None, ) - .unwrap(); - - // Load embedder - let models_dir = engraph::config::Config::data_dir().unwrap().join("models"); - let embedder = Embedder::new(&models_dir).unwrap(); - - (store, embedder) + .unwrap() + } } #[test] -#[ignore] // requires model download -fn test_create_note_is_immediately_searchable() { - let vault_dir = tempfile::TempDir::new().unwrap(); - let (store, mut embedder) = setup(vault_dir.path()); - - let input = CreateNoteInput { - content: - "# RRF Tuning Notes\n\nWe tested reciprocal rank fusion with k=60 and got good results." - .into(), - filename: Some("RRF Tuning".into()), - type_hint: None, - tags: vec!["engraph".into(), "search".into()], - folder: Some("00-Inbox".into()), - created_by: "test".into(), - auto_link: None, - }; - - let result = create_note(input, &store, &mut embedder, vault_dir.path(), None).unwrap(); - assert!(result.path.starts_with("00-Inbox/")); - assert!(result.path.ends_with(".md")); - assert!(!result.docid.is_empty()); - - // Verify the file exists on disk - assert!(vault_dir.path().join(&result.path).exists()); - - // Verify it's immediately searchable via sqlite-vec - let search = - engraph::search::search_internal("reciprocal rank fusion", 5, &store, &mut embedder) - .unwrap(); - assert!( - !search.results.is_empty(), - "created note should be searchable immediately" +fn create_note_is_immediately_searchable() { + let mut harness = Harness::new(); + let created = harness.create_note( + "RRF Tuning", + "Reciprocal rank fusion uses a stable rank constant.", ); + + assert!(harness.vault_dir.path().join(&created.path).is_file()); + let results = search_internal( + "reciprocal rank fusion", + 5, + &harness.store, + &mut harness.embedder, + ) + .unwrap(); assert!( - search.results.iter().any(|r| r.file_path == result.path), - "created note should appear in search results" + results + .results + .iter() + .any(|result| result.file_path == created.path) ); } #[test] -#[ignore] -fn test_append_updates_index() { - let vault_dir = tempfile::TempDir::new().unwrap(); - let (store, mut embedder) = setup(vault_dir.path()); - - // Create a note first - let input = CreateNoteInput { - content: "# Meeting Notes\n\nDiscussed the roadmap for Q2.".into(), - filename: Some("Meeting 2026-03-25".into()), - type_hint: None, - tags: vec![], - folder: Some("00-Inbox".into()), - created_by: "test".into(), - auto_link: None, - }; - let created = create_note(input, &store, &mut embedder, vault_dir.path(), None).unwrap(); - - // Append new content - let append_input = AppendInput { - file: created.path.clone(), - content: "## Action Items\n\n- Ship sqlite-vec migration by Friday\n- Review PR #42".into(), - modified_by: "test".into(), - }; - let _appended = append_to_note(append_input, &store, &mut embedder, vault_dir.path()).unwrap(); +fn append_updates_the_search_index() { + let mut harness = Harness::new(); + let created = harness.create_note("Meeting Notes", "Discussed the roadmap."); + + append_to_note( + AppendInput { + file: created.path.clone(), + content: "## Action Items\n\nShip sqlite vector migration by Friday.".to_string(), + modified_by: "integration-test".to_string(), + }, + &harness.store, + &mut harness.embedder, + harness.vault_dir.path(), + ) + .unwrap(); - // Verify appended content is searchable - let search = - engraph::search::search_internal("sqlite-vec migration", 5, &store, &mut embedder).unwrap(); + let results = search_internal( + "sqlite vector migration", + 5, + &harness.store, + &mut harness.embedder, + ) + .unwrap(); assert!( - search.results.iter().any(|r| r.file_path == created.path), - "appended content should be searchable" + results + .results + .iter() + .any(|result| result.file_path == created.path) ); } #[test] -#[ignore] -fn test_conflict_detection() { - let vault_dir = tempfile::TempDir::new().unwrap(); - let (store, mut embedder) = setup(vault_dir.path()); - - let input = CreateNoteInput { - content: "# Test Note\n\nOriginal content.".into(), - filename: Some("conflict-test".into()), - type_hint: None, - tags: vec![], - folder: Some("00-Inbox".into()), - created_by: "test".into(), - auto_link: None, - }; - let created = create_note(input, &store, &mut embedder, vault_dir.path(), None).unwrap(); - - // Modify file externally (simulates Obsidian edit) - let abs_path = vault_dir.path().join(&created.path); - // Wait a moment so mtime changes - std::thread::sleep(std::time::Duration::from_millis(1100)); - std::fs::write(&abs_path, "# Modified externally\n\nNew content.").unwrap(); +fn append_rejects_an_mtime_conflict() { + let mut harness = Harness::new(); + let created = harness.create_note("Conflict Test", "Original content."); + let record = harness.store.get_file(&created.path).unwrap().unwrap(); + harness + .store + .conn() + .execute( + "UPDATE files SET mtime = ?1 WHERE id = ?2", + rusqlite::params![record.mtime - 1, record.id], + ) + .unwrap(); - // Attempt append — should fail with conflict - let append_input = AppendInput { - file: created.path, - content: "appended content".into(), - modified_by: "test".into(), - }; - let result = append_to_note(append_input, &store, &mut embedder, vault_dir.path()); - assert!(result.is_err(), "should detect mtime conflict"); - let err_msg = result.unwrap_err().to_string(); - assert!( - err_msg.contains("mtime conflict") || err_msg.contains("CONFLICT"), - "error should mention conflict, got: {}", - err_msg + let result = append_to_note( + AppendInput { + file: created.path, + content: "Unexpected append".to_string(), + modified_by: "integration-test".to_string(), + }, + &harness.store, + &mut harness.embedder, + harness.vault_dir.path(), ); + + assert!(result.unwrap_err().to_string().contains("mtime conflict")); }