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) -> 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. 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" + ); + } +}