//! Hybrid search combining FTS5 BM25 + sqlite-vec KNN + temporal decay + source weighting + MMR. //! //! The search pipeline: //! 1. FTS5 keyword search (always available) //! 2. Vector KNN search (when sqlite-vec + embeddings are available) //! 3. Merge results by chunk_id, normalize scores to [0,1] //! 4. Skip content-free chunks: empty/boilerplate templates (the //! auto-generated `MEMORY.md` stub) never appear in results / injection //! 5. Apply temporal decay: evergreen sources (global, workspace) are exempt; //! session chunks decay with exponential half-life: //! `decayed = base × e^(-λ × age_days)` where `λ = ln(2) / half_life_days` //! 6. Apply source weights + access-frequency boost, filter by `min_score`, //! rank on the unclamped score, then clamp the stored display score to [0,1] //! 7. MMR diversity re-ranking (opt-in, penalizes redundant results) //! 8. Limit to `max_results` //! //! Graceful degradation: if vector search is unavailable, falls back to FTS-only //! with `text_weight = 1.0`. use std::collections::HashMap; use super::embedding::EmbeddingProvider; use super::index::MemoryIndex; use kigi_config_types::MemorySearchConfig; /// A search result with merged scoring from FTS and vector search. #[derive(Debug, Clone)] pub struct SearchResult { pub chunk_id: String, pub path: String, pub start_line: usize, pub end_line: usize, pub score: f64, pub snippet: String, pub source: String, pub created_at: i64, } /// Returns `true` for sources that contain curated long-term knowledge /// and should not be penalized by temporal decay. /// /// Evergreen: `"global"` (MEMORY.md), `"workspace"` (project MEMORY.md). /// Decaying: `"session"` (auto-generated session logs). fn is_evergreen_source(source: &str) -> bool { matches!(source, "global" | "workspace") } /// Returns `true` when a chunk is an empty/boilerplate template (e.g. the /// auto-generated `MEMORY.md` stub) and should be filtered out. /// /// True when the chunk is structurally empty, or matches a known scaffold /// template via [`super::dream::is_scaffold_template`]. The marker branch is /// scoped to evergreen sources, where scaffold templates live, so a session /// chunk that merely quotes a marker phrase is kept. fn is_content_free(text: &str, source: &str) -> bool { is_structurally_empty(text) || (is_evergreen_source(source) && super::dream::is_scaffold_template(text)) } /// Returns `true` when `text` has no substantive content after stripping ATX /// headings, HTML comments, and whitespace. Blockquotes are NOT stripped — /// they are real user content. fn is_structurally_empty(text: &str) -> bool { // Fast path: no comment marker means no multi-line span to strip. if !text.contains("") { Some(end) => { without_comments.push_str(&rest[..start]); let after = start + "".len(); rest = &rest[after..]; } None => { // Unterminated comment: keep the remainder as literal text so a // comment split across a chunk boundary can't drop real content. without_comments.push_str(rest); rest = ""; break; } } } without_comments.push_str(rest); lines_are_scaffolding(&without_comments) } /// Returns `true` when every non-blank line is an ATX heading (per /// [`super::chunker::header_level`]). Any other non-blank line is content. fn lines_are_scaffolding(text: &str) -> bool { for line in text.lines() { if line.trim().is_empty() { continue; } if super::chunker::header_level(line).is_some() { continue; } return false; } true } /// Compute the temporal decay multiplier for a chunk. /// /// - Evergreen sources → `1.0` (no decay). /// - Session sources → exponential decay: `e^(-λ × age_days)` where /// `λ = ln(2) / half_life_days`. Score halves every `half_life_days`. /// - `half_life = None` → decay disabled, returns `1.0` for all sources. fn temporal_decay_multiplier( source: &str, created_at: i64, now_secs: i64, half_life_days: Option, ) -> f64 { let Some(half_life) = half_life_days else { return 1.0; }; if is_evergreen_source(source) { return 1.0; } if half_life <= 0.0 { return 1.0; } // No upper clamp on age: with exponential decay a 2-year-old chunk at // 30-day half-life scores ~6e-8, well below any reasonable min_score. let age_days = ((now_secs - created_at.max(0)) as f64 / 86400.0).max(0.0); let lambda = f64::ln(2.0) / half_life; (-lambda * age_days).exp() } /// Run a hybrid search across the memory index. /// /// Combines FTS5 keyword search with optional vector KNN similarity. /// Falls back to FTS-only when vector search is unavailable. /// /// Structured so that `&MemoryIndex` is never held across `.await` points, /// allowing the caller's future to be `Send` even though `MemoryIndex` is `!Sync`. #[tracing::instrument(name = "memory.hybrid_search", skip_all, fields( max_results = config.max_results, ))] pub async fn hybrid_search( index: &MemoryIndex, embedding_provider: Option<&dyn EmbeddingProvider>, query: &str, config: &MemorySearchConfig, ) -> Result, Box> { let candidate_limit = config.max_results * 3; // Phase 1 (sync): FTS search + supplemental evergreen query so // global/workspace chunks aren't crowded out by session volume. let mut fts_results = index.search_fts(query, candidate_limit).unwrap_or_default(); let evergreen = index .search_fts_by_sources(query, candidate_limit, &["global", "workspace"]) .unwrap_or_default(); let existing: std::collections::HashSet = fts_results.iter().map(|r| r.chunk_id.clone()).collect(); for r in evergreen { if !existing.contains(&r.chunk_id) { fts_results.push(r); } } let vec_available = index.vec_available(); // Phase 2 (async): embed query — no &index borrow here let query_embedding = if vec_available { if let Some(provider) = embedding_provider { match provider.embed_batch(&[query]).await { Ok(embeddings) if !embeddings.is_empty() => { Some(embeddings.into_iter().next().unwrap()) } Ok(_) => None, Err(e) => { tracing::warn!(error = %e, "embedding query failed, falling back to FTS-only"); None } } } else { None } } else { None }; // Phase 3 (sync): vector search + scoring + merge hybrid_search_merge(index, fts_results, query_embedding.as_deref(), config) } /// Synchronous merge phase: vector search (if embedding provided), score /// normalization, temporal decay, source weighting, MMR, and truncation. pub(super) fn hybrid_search_merge( index: &MemoryIndex, fts_results: Vec, query_embedding: Option<&[f32]>, config: &MemorySearchConfig, ) -> Result, Box> { let candidate_limit = config.max_results * 3; let vec_results = if let Some(embedding) = query_embedding { index .vector_search(embedding, candidate_limit) .unwrap_or_default() } else { vec![] }; // Normalize and merge scores. // // Per-chunk scoring strategy: // - Chunks with BOTH FTS and vector matches: weighted combination // (text_weight × fts_score + vector_weight × vec_score) // - Chunks with ONLY FTS matches: score = fts_score (full weight, not // penalized to text_weight just because other chunks have vectors) // - Chunks with ONLY vector matches: score = vector_weight × vec_score // // This ensures FTS-only chunks (e.g., global MEMORY.md with no embedding // match) can still score high enough to pass min_score. let mut fts_scores: HashMap = HashMap::new(); let mut vec_scores: HashMap = HashMap::new(); // Normalize FTS BM25 scores to [0,1] (BM25 scores are negative in FTS5, // more negative = better match) if !fts_results.is_empty() { let min_rank = fts_results .iter() .map(|r| r.rank) .fold(f64::INFINITY, f64::min); let max_rank = fts_results .iter() .map(|r| r.rank) .fold(f64::NEG_INFINITY, f64::max); // When there's only 1 FTS result, min_rank == max_rank, so range = EPSILON // and normalized = 1.0. This is correct: a single result gets full score. let range = (max_rank - min_rank).max(f64::EPSILON); for r in &fts_results { // FTS5 rank: more negative = better. Normalize so best = 1.0 let normalized = 1.0 - (r.rank - min_rank) / range; fts_scores.insert(r.chunk_id.clone(), normalized); } } // Normalize vector distances to [0,1] similarity using absolute scale. // // For normalized embeddings, L2 distance ranges from 0 (identical) to 2 // (opposite). Using `similarity = 1.0 - distance / 2.0` maps this to // [0, 1] on an absolute scale, avoiding the compression problem where // relative normalization (`1 - d/max_d`) collapses all scores to near-zero // when candidates cluster in a narrow distance band (common for // high-dimensional embeddings due to concentration of measure). // // The constant `2.0` is the theoretical maximum L2 distance between two // unit-norm vectors: ||u - v||₂ = sqrt(2 - 2·cos(θ)) ≤ sqrt(4) = 2. const MAX_L2_DISTANCE: f64 = 2.0; for (chunk_id, distance) in &vec_results { let similarity = (1.0 - (*distance as f64 / MAX_L2_DISTANCE)).clamp(0.0, 1.0); vec_scores.insert(chunk_id.clone(), similarity); } // Merge per-chunk scores: use max(fts_only, hybrid) so FTS-only chunks // are never penalized by the existence of unrelated vector results. let mut scores: HashMap = HashMap::new(); let text_weight = config.text_weight as f64; let vector_weight = config.vector_weight as f64; // Collect all unique chunk IDs across both result sets. let all_chunk_ids: std::collections::HashSet<&String> = fts_scores.keys().chain(vec_scores.keys()).collect(); for chunk_id in all_chunk_ids { let fts = fts_scores.get(chunk_id).copied().unwrap_or(0.0); let vec = vec_scores.get(chunk_id).copied().unwrap_or(0.0); let score = if fts > 0.0 && vec > 0.0 { // Both signals available: weighted combination, but never worse // than the FTS score alone (since text_weight < 1.0 would otherwise // penalize a strong keyword match). let hybrid = text_weight * fts + vector_weight * vec; hybrid.max(fts) } else if fts > 0.0 { // FTS-only: full FTS score (not penalized to text_weight) fts } else { // Vector-only: weighted vector score vector_weight * vec }; scores.insert(chunk_id.clone(), score); } // Apply temporal decay and source weights, build results let now_secs = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs() as i64; let half_life = config.effective_half_life_days(); // Pairs the unclamped ranking score with each result (see the raw_score / // display_score split below). let mut ranked: Vec<(f64, SearchResult)> = Vec::new(); for (chunk_id, base_score) in &scores { let Some(chunk) = index.get_chunk(chunk_id).ok().flatten() else { continue; }; // Filter at search time (not index time) so already-indexed stubs are // excluded without requiring a reindex. if is_content_free(&chunk.text, &chunk.source) { continue; } let decay_multiplier = temporal_decay_multiplier(&chunk.source, chunk.created_at, now_secs, half_life); let source_weight = config .source_weights .get(&chunk.source) .copied() .unwrap_or(1.0) as f64; // Access-frequency boost: chunks retrieved before score slightly higher. // // Uses ln(1 + access_count) so: // - 0 accesses → boost = 1.0 (no penalty) // - 1 access → boost ≈ 1.035 // - 10 accesses → boost ≈ 1.120 // - 100 accesses → boost ≈ 1.230 // // The 0.05 scale factor keeps the boost modest so retrieval relevance // (BM25 / vector similarity) remains the primary ranking signal. let access_boost = 1.0 + (chunk.access_count as f64).ln_1p() * 0.05; // access_boost is an unbounded multiplier (> 1.0), so the product can // exceed 1.0 for top evergreen chunks. Rank on the unclamped raw_score // (so the boost still orders chunks that would otherwise both clamp to // 1.0), but store the clamped display_score so it reads as a [0,1] // similarity. Gating on display_score keeps the threshold and the // stored value in agreement. let raw_score = base_score * decay_multiplier * source_weight * access_boost; let display_score = raw_score.clamp(0.0, 1.0); if display_score >= config.min_score as f64 { ranked.push(( raw_score, SearchResult { chunk_id: chunk_id.clone(), path: chunk.path.clone(), start_line: chunk.start_line, end_line: chunk.end_line, score: display_score, snippet: chunk.text.clone(), source: chunk.source.clone(), created_at: chunk.created_at, }, )); } } ranked.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)); // Split into aligned `relevance` (unclamped) + `results` (clamped) vectors // so MMR can rank on relevance. Only build `relevance` when MMR is enabled; // otherwise `mmr_rerank` early-returns before reading it. let mmr_enabled = config.mmr.enabled; let mut relevance: Vec = if mmr_enabled { Vec::with_capacity(ranked.len()) } else { Vec::new() }; let mut results: Vec = Vec::with_capacity(ranked.len()); for (raw_score, result) in ranked { if mmr_enabled { relevance.push(raw_score); } results.push(result); } // MMR diversity re-ranking (opt-in, applied before truncation) super::mmr::mmr_rerank(&mut results, &relevance, &config.mmr); results.truncate(config.max_results); Ok(results) } #[cfg(test)] mod tests { use super::*; use crate::embedding::MockEmbeddingProvider; use crate::index::{MemoryIndex, init_sqlite_vec}; use crate::storage::MemoryStorage; use kigi_config_types::{MemoryIndexConfig, MemorySearchConfig}; use tempfile::TempDir; fn test_index(tmp: &TempDir) -> MemoryIndex { init_sqlite_vec(); let global = tmp.path().join("memory"); let workspace = global.join("test_ws"); let storage = MemoryStorage::with_paths(global, workspace); let db_path = tmp.path().join("test.sqlite"); MemoryIndex::open_or_create(&db_path, storage, MemoryIndexConfig::default(), 4).unwrap() } #[tokio::test] async fn test_hybrid_search_fts_only() { let tmp = TempDir::new().unwrap(); let mut idx = test_index(&tmp); let file_path = tmp.path().join("test.md"); std::fs::write(&file_path, "# Guide\n\nRust programming language tutorial.").unwrap(); idx.reindex_file(&file_path, "workspace").unwrap(); let config = MemorySearchConfig::default(); let results = hybrid_search(&idx, None, "rust programming", &config) .await .unwrap(); assert!(!results.is_empty(), "should find results via FTS"); assert!(results[0].snippet.contains("Rust")); assert!( results[0].created_at > 0, "created_at must propagate from ChunkRecord (got {})", results[0].created_at, ); } #[tokio::test] async fn test_hybrid_search_no_match() { let tmp = TempDir::new().unwrap(); let mut idx = test_index(&tmp); let file_path = tmp.path().join("test.md"); std::fs::write(&file_path, "# Guide\n\nPython tutorial.").unwrap(); idx.reindex_file(&file_path, "workspace").unwrap(); let config = MemorySearchConfig::default(); let results = hybrid_search(&idx, None, "haskell monads", &config) .await .unwrap(); assert!(results.is_empty(), "should not find unrelated content"); } #[tokio::test] async fn test_hybrid_search_respects_max_results() { let tmp = TempDir::new().unwrap(); let mut idx = test_index(&tmp); // Create multiple matching files for i in 0..10 { let file_path = tmp.path().join(format!("test_{i}.md")); std::fs::write(&file_path, format!("# Doc {i}\n\nRust content {i}.")).unwrap(); idx.reindex_file(&file_path, "workspace").unwrap(); } let config = MemorySearchConfig { max_results: 3, min_score: 0.0, ..Default::default() }; let results = hybrid_search(&idx, None, "rust content", &config) .await .unwrap(); assert!( results.len() <= 3, "should respect max_results, got {}", results.len() ); } #[tokio::test] async fn test_hybrid_search_empty_index() { let tmp = TempDir::new().unwrap(); let idx = test_index(&tmp); let config = MemorySearchConfig::default(); let results = hybrid_search(&idx, None, "anything", &config) .await .unwrap(); assert!(results.is_empty()); } #[tokio::test] async fn test_hybrid_search_source_weights() { let tmp = TempDir::new().unwrap(); let mut idx = test_index(&tmp); let ws_file = tmp.path().join("ws.md"); std::fs::write(&ws_file, "# WS\n\nRust workspace content.").unwrap(); idx.reindex_file(&ws_file, "workspace").unwrap(); let gl_file = tmp.path().join("gl.md"); std::fs::write(&gl_file, "# GL\n\nRust global content.").unwrap(); idx.reindex_file(&gl_file, "global").unwrap(); let config = MemorySearchConfig { min_score: 0.0, ..Default::default() }; let results = hybrid_search(&idx, None, "rust content", &config) .await .unwrap(); // Both should be found; workspace should score higher due to source_weight if results.len() >= 2 { let ws_result = results.iter().find(|r| r.source == "workspace"); let gl_result = results.iter().find(|r| r.source == "global"); if let (Some(ws), Some(gl)) = (ws_result, gl_result) { assert!( (ws.score - gl.score).abs() < 0.01, "workspace and global should score equally (both weight=1.0)" ); } } } #[tokio::test] async fn test_hybrid_search_with_vector_and_fts() { let tmp = TempDir::new().unwrap(); let mut idx = test_index(&tmp); let mock = MockEmbeddingProvider { dimensions: 4 }; // Index a file and embed its chunks let file_path = tmp.path().join("test.md"); std::fs::write(&file_path, "# Guide\n\nRust programming language tutorial.").unwrap(); idx.reindex_file(&file_path, "workspace").unwrap(); // Embed the chunk let path_str = file_path.to_string_lossy().to_string(); let chunk_id = format!("{path_str}:0"); let chunk = idx.get_chunk(&chunk_id).unwrap().unwrap(); let embeddings = mock.embed_batch(&[&chunk.text]).await.unwrap(); idx.upsert_embedding(&chunk_id, &embeddings[0]).unwrap(); // Search — should use both FTS and vector paths let config = MemorySearchConfig { min_score: 0.0, ..Default::default() }; let results = hybrid_search( &idx, Some(&mock as &dyn EmbeddingProvider), "rust programming", &config, ) .await .unwrap(); assert!(!results.is_empty(), "hybrid search should find results"); assert!(results[0].snippet.contains("Rust")); // With both FTS and vector results, score should combine both weights assert!(results[0].score > 0.0, "score should be positive"); } // Temporal decay unit tests #[test] fn test_is_evergreen_source() { assert!(is_evergreen_source("global")); assert!(is_evergreen_source("workspace")); assert!(!is_evergreen_source("session")); assert!(!is_evergreen_source("unknown")); assert!(!is_evergreen_source("")); } #[test] fn test_decay_disabled_returns_one() { assert_eq!( temporal_decay_multiplier("session", 0, 86400 * 90, None), 1.0 ); assert_eq!( temporal_decay_multiplier("global", 0, 86400 * 90, None), 1.0 ); } #[test] fn test_evergreen_sources_never_decay() { let now = 86400 * 365; // created at epoch let created = 0; let half_life = Some(30.0); assert_eq!( temporal_decay_multiplier("global", created, now, half_life), 1.0 ); assert_eq!( temporal_decay_multiplier("workspace", created, now, half_life), 1.0 ); } #[test] fn test_session_chunks_decay_with_half_life() { let half_life = Some(30.0); // 30 days after epoch let now = 86400 * 30; let created = 0; let multiplier = temporal_decay_multiplier("session", created, now, half_life); // After exactly one half-life, multiplier should be ~0.5 assert!( (multiplier - 0.5).abs() < 0.01, "30-day-old session chunk with 30-day half-life should score ~0.5, got {multiplier}" ); } #[test] fn test_decay_at_two_half_lives() { let half_life = Some(30.0); let now = 86400 * 60; let created = 0; let multiplier = temporal_decay_multiplier("session", created, now, half_life); assert!( (multiplier - 0.25).abs() < 0.01, "60-day-old session chunk should score ~0.25, got {multiplier}" ); } #[test] fn test_fresh_session_chunk_no_decay() { let half_life = Some(30.0); let now = 1_000_000; // just created let created = now; let multiplier = temporal_decay_multiplier("session", created, now, half_life); assert!( (multiplier - 1.0).abs() < f64::EPSILON, "brand-new session chunk should have multiplier ~1.0, got {multiplier}" ); } #[test] fn test_zero_half_life_returns_one() { let multiplier = temporal_decay_multiplier("session", 0, 86400 * 30, Some(0.0)); assert_eq!(multiplier, 1.0, "zero half-life should disable decay"); } #[test] fn test_negative_half_life_returns_one() { let multiplier = temporal_decay_multiplier("session", 0, 86400 * 30, Some(-5.0)); assert_eq!(multiplier, 1.0, "negative half-life should disable decay"); } #[test] fn test_future_created_at_no_negative_age() { let now = 1_000_000; // 1 day in the future (clock skew) let created = now + 86400; let half_life = Some(30.0); let multiplier = temporal_decay_multiplier("session", created, now, half_life); assert!( (multiplier - 1.0).abs() < f64::EPSILON, "future created_at should clamp age to 0, got {multiplier}" ); } #[tokio::test] async fn test_hybrid_search_old_session_ranks_below_evergreen() { let tmp = TempDir::new().unwrap(); let mut idx = test_index(&tmp); // Index a workspace file (evergreen) and a session file (decays) let ws_file = tmp.path().join("ws.md"); std::fs::write(&ws_file, "# WS\n\nRust workspace content about memory.").unwrap(); idx.reindex_file(&ws_file, "workspace").unwrap(); let sess_file = tmp.path().join("sess.md"); std::fs::write(&sess_file, "# Sess\n\nRust session content about memory.").unwrap(); idx.reindex_file(&sess_file, "session").unwrap(); // Backdate the session chunk's created_at by 60 days (2 half-lives) let sixty_days_ago = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs() as i64 - 86400 * 60; let sess_path = sess_file.to_string_lossy().to_string(); idx.db() .execute( "UPDATE chunks SET created_at = ?1 WHERE path = ?2", rusqlite::params![sixty_days_ago, sess_path], ) .unwrap(); // Equal source weights to isolate temporal decay let mut source_weights = std::collections::HashMap::new(); source_weights.insert("workspace".to_string(), 1.0); source_weights.insert("session".to_string(), 1.0); let config = MemorySearchConfig { min_score: 0.0, source_weights, ..Default::default() }; let results = hybrid_search(&idx, None, "rust memory", &config) .await .unwrap(); assert!(results.len() >= 2, "should find both chunks"); let ws = results.iter().find(|r| r.source == "workspace").unwrap(); let sess = results.iter().find(|r| r.source == "session").unwrap(); // Workspace (evergreen) should rank above the 60-day-old session chunk. // At 2 half-lives the session chunk decays to ~0.25× its base score, // while the workspace chunk stays at 1.0×. assert!( ws.score > sess.score, "evergreen workspace ({:.4}) should outscore 60-day-old session ({:.4})", ws.score, sess.score, ); } // PR-8: access-frequency boost tests /// A chunk with access_count > 0 scores higher than an identical chunk /// with access_count = 0, all else equal. #[tokio::test] async fn test_access_boost_raises_frequently_accessed_chunks() { let tmp = TempDir::new().unwrap(); let mut idx = test_index(&tmp); // Two files with nearly identical content; chunk B is accessed once. let fa = tmp.path().join("chunk_a.md"); let fb = tmp.path().join("chunk_b.md"); std::fs::write(&fa, "# Rust\n\nRust ownership model explained.").unwrap(); std::fs::write(&fb, "# Rust\n\nRust ownership model explained.").unwrap(); idx.reindex_file(&fa, "workspace").unwrap(); idx.reindex_file(&fb, "workspace").unwrap(); // Record one access for chunk B. let chunk_b_id = format!("{}:0", fb.to_string_lossy()); idx.record_access(&chunk_b_id).unwrap(); // Use the DEFAULT config (all source_weights = 1.0). Both chunks // normalize to base_score = 1.0 as the top FTS matches, so their // display scores both clamp to 1.0 — but ranking is performed on the // UNCLAMPED score, so the access boost still orders the accessed chunk // first. This exercises the common default-config path where the clamp // would otherwise make the boost inert. let config = MemorySearchConfig::default(); let results = hybrid_search_merge( &idx, idx.search_fts("rust ownership", 10).unwrap(), None, &config, ) .unwrap(); // Both chunks must be returned (no vacuous "inconclusive → pass" path). let pos_a = results .iter() .position(|r| r.path == fa.to_string_lossy().as_ref()) .expect("chunk A must be returned"); let pos_b = results .iter() .position(|r| r.path == fb.to_string_lossy().as_ref()) .expect("chunk B must be returned"); // The accessed chunk (B) must rank ahead of the unaccessed chunk (A), // even though both display scores clamp to 1.0 under default weights. assert!( pos_b < pos_a, "accessed chunk (rank {pos_b}) should rank ahead of unaccessed (rank {pos_a})", ); // Pin the premise that makes rank-on-unclamped necessary: BOTH display // scores are exactly 1.0 (the collision the split resolves). The rank // ordering above therefore can only come from the unclamped score. assert!( (results[pos_a].score - 1.0).abs() < 1e-9, "unaccessed display score ({:.6}) must clamp to exactly 1.0", results[pos_a].score, ); assert!( (results[pos_b].score - 1.0).abs() < 1e-9, "accessed display score ({:.6}) must clamp to exactly 1.0", results[pos_b].score, ); } /// Covers the MMR-enabled handoff through `hybrid_search_merge` — the /// construction and alignment of the `relevance`/`results` vectors — which /// no other search test exercises (MMR is off by default). /// /// This is NOT the raw-vs-clamped regression guard: `results` enters MMR /// pre-sorted by `raw_score`, so the boosted chunk would stay first even on /// a buggy `.score` read. That guarantee lives in the unit test /// `test_mmr_ranks_on_relevance_not_clamped_score`. #[tokio::test] async fn test_hybrid_search_merge_with_mmr_enabled() { let tmp = TempDir::new().unwrap(); let mut idx = test_index(&tmp); // Two identical (redundant) chunks + one diverse chunk, all matching. let fa = tmp.path().join("a.md"); let fb = tmp.path().join("b.md"); let fc = tmp.path().join("c.md"); std::fs::write(&fa, "# Rust\n\nRust ownership model explained.").unwrap(); std::fs::write(&fb, "# Rust\n\nRust ownership model explained.").unwrap(); std::fs::write(&fc, "# Borrow\n\nRust borrowing and lifetimes guide.").unwrap(); idx.reindex_file(&fa, "workspace").unwrap(); idx.reindex_file(&fb, "workspace").unwrap(); idx.reindex_file(&fc, "workspace").unwrap(); // Boost chunk B so its unclamped relevance exceeds chunk A's. let chunk_b_id = format!("{}:0", fb.to_string_lossy()); idx.record_access(&chunk_b_id).unwrap(); // Enable MMR (relevance-leaning lambda) to drive the aligned handoff. let mut config = MemorySearchConfig { min_score: 0.0, ..Default::default() }; config.mmr.enabled = true; config.mmr.lambda = 0.7; let results = hybrid_search_merge( &idx, idx.search_fts("rust ownership", 10).unwrap(), None, &config, ) .unwrap(); assert!( !results.is_empty(), "MMR-enabled search must return results" ); let pos_a = results .iter() .position(|r| r.path == fa.to_string_lossy().as_ref()) .expect("chunk A must be returned"); let pos_b = results .iter() .position(|r| r.path == fb.to_string_lossy().as_ref()) .expect("chunk B must be returned"); // Through the MMR handoff, the access-boosted chunk (B) ranks ahead of // its identical twin (A) because MMR's relevance term reads the // unclamped `relevance` slice (both share a clamped display score of 1.0). assert!( pos_b < pos_a, "boosted chunk (rank {pos_b}) should rank ahead of its twin (rank {pos_a}) with MMR on", ); } /// access_boost never penalises zero-access chunks (boost = 1.0 for access_count = 0). #[test] fn test_access_boost_zero_access_is_neutral() { let boost = 1.0 + (0_f64).ln_1p() * 0.05; assert!( (boost - 1.0).abs() < f64::EPSILON, "zero accesses must yield a boost factor of exactly 1.0" ); } // PR: scoring normalization fix tests /// FTS-only results (no vector search) should score well above a /// reasonable min_score threshold (e.g., 0.3). Before the fix, /// FTS-only chunks in hybrid mode had their scores capped at /// text_weight (0.3), making them impossible to retrieve at default /// min_score = 0.35. #[tokio::test] async fn test_fts_only_scores_above_reasonable_threshold() { let tmp = TempDir::new().unwrap(); let mut idx = test_index(&tmp); let file_path = tmp.path().join("test.md"); std::fs::write( &file_path, "# Rust Guide\n\nRust programming language ownership and borrowing tutorial.", ) .unwrap(); idx.reindex_file(&file_path, "workspace").unwrap(); let config = MemorySearchConfig { min_score: 0.3, ..Default::default() }; let results = hybrid_search(&idx, None, "rust programming", &config) .await .unwrap(); assert!( !results.is_empty(), "FTS-only results must pass min_score=0.3 threshold" ); assert!( results[0].score > 0.3, "FTS-only score ({:.4}) must exceed 0.3", results[0].score, ); } /// Global MEMORY.md chunks (source_weight = 0.7) should still be /// retrievable with a reasonable threshold. Before the fix, global /// chunks were capped at text_weight × source_weight = 0.21, making /// them invisible at any threshold above 0.2. #[tokio::test] async fn test_global_source_scores_above_min_threshold() { let tmp = TempDir::new().unwrap(); let mut idx = test_index(&tmp); let file_path = tmp.path().join("global.md"); std::fs::write( &file_path, "# Project Conventions\n\nAlways use graphite for PRs. Never commit without review.", ) .unwrap(); idx.reindex_file(&file_path, "global").unwrap(); let config = MemorySearchConfig { min_score: 0.25, ..Default::default() }; let results = hybrid_search(&idx, None, "graphite PRs review", &config) .await .unwrap(); assert!( !results.is_empty(), "global source results must pass min_score=0.25 threshold" ); assert!( results[0].score > 0.25, "global chunk score ({:.4}) must exceed 0.25", results[0].score, ); } /// When vector results exist for some chunks but not others, FTS-only /// chunks should NOT be penalized. Their FTS score should remain at /// full weight (1.0 × normalized), not capped at text_weight. #[tokio::test] async fn test_fts_only_chunks_not_penalized_by_vec_existence() { let tmp = TempDir::new().unwrap(); let mut idx = test_index(&tmp); let mock = MockEmbeddingProvider { dimensions: 4 }; // File A: has both FTS and vector embedding let file_a = tmp.path().join("embedded.md"); std::fs::write( &file_a, "# Rust\n\nRust programming language ownership tutorial.", ) .unwrap(); idx.reindex_file(&file_a, "workspace").unwrap(); // Embed chunk A let path_a = file_a.to_string_lossy().to_string(); let chunk_a_id = format!("{path_a}:0"); let chunk_a = idx.get_chunk(&chunk_a_id).unwrap().unwrap(); let embeddings = mock.embed_batch(&[&chunk_a.text]).await.unwrap(); idx.upsert_embedding(&chunk_a_id, &embeddings[0]).unwrap(); // File B: FTS only (no embedding) let file_b = tmp.path().join("unembedded.md"); std::fs::write( &file_b, "# Rust\n\nRust programming language borrowing tutorial.", ) .unwrap(); idx.reindex_file(&file_b, "workspace").unwrap(); let config = MemorySearchConfig { min_score: 0.0, ..Default::default() }; // Use mock provider so hybrid path runs vector search let results = hybrid_search( &idx, Some(&mock as &dyn EmbeddingProvider), "rust programming", &config, ) .await .unwrap(); // Both chunks must be found let result_b = results .iter() .find(|r| r.path == file_b.to_string_lossy().as_ref()); assert!( result_b.is_some(), "FTS-only chunk must appear in results even when other chunks have vectors" ); let score_b = result_b.unwrap().score; assert!( score_b > 0.3, "FTS-only chunk score ({:.4}) must not be penalized below 0.3 by vec existence", score_b, ); } /// Vector normalization should use absolute L2 distance scale (max = 2.0) /// instead of relative normalization. This ensures that even when all /// candidates have similar distances, vector scores still contribute /// meaningfully. /// /// Note: `dimensions: 4` is chosen deliberately. The mock provider /// (blake3 bytes / 255.0) does NOT produce unit-norm vectors. At low /// dimensions the L2 distances stay within `MAX_L2_DISTANCE = 2.0`, so /// the absolute normalization works. At production dimensions (1024), /// mock distances could exceed 2.0 and clamp to 0 — use real embeddings /// or normalize the mock output for high-dimensional tests. #[tokio::test] async fn test_vector_absolute_normalization() { let tmp = TempDir::new().unwrap(); let mut idx = test_index(&tmp); let mock = MockEmbeddingProvider { dimensions: 4 }; let file = tmp.path().join("test.md"); std::fs::write(&file, "# Test\n\nContent for vector search test.").unwrap(); idx.reindex_file(&file, "workspace").unwrap(); let path_str = file.to_string_lossy().to_string(); let chunk_id = format!("{path_str}:0"); // Use the mock to get a consistent embedding let embedding = mock.embed_batch(&["test"]).await.unwrap(); idx.upsert_embedding(&chunk_id, &embedding[0]).unwrap(); // Search with vector — the mock returns deterministic embeddings let fts_results = idx.search_fts("content test", 10).unwrap_or_default(); let query_embedding = mock.embed_batch(&["content test"]).await.unwrap(); let config = MemorySearchConfig { min_score: 0.0, ..Default::default() }; let results = hybrid_search_merge(&idx, fts_results, Some(&query_embedding[0]), &config).unwrap(); assert!(!results.is_empty(), "should find at least one result"); // With absolute normalization, the combined score should be // substantially above zero (mock embeddings produce deterministic // but varying values). assert!( results[0].score > 0.1, "hybrid score ({:.4}) should be meaningful with absolute normalization", results[0].score, ); } // Empty-template filter + score clamp tests /// The auto-generated global MEMORY.md stub, written verbatim by /// `MemoryStorage::ensure_initialized` (storage.rs), including the trailing /// newline. Kept in sync with that source. const GLOBAL_STUB: &str = "# Global Memory\n\ \n\ > This file is automatically managed by Kigi's memory system.\n\ > You can also edit it manually — changes will be indexed on next session.\n\ \n\ ## Preferences\n\ \n\ \n"; /// The shorter workspace stub variant from `dream.rs`. const WORKSPACE_STUB: &str = "# Project Memory — /test\n\n> Auto-populated by dream consolidation. Edit freely.\n"; #[test] fn test_is_content_free_global_stub() { // Caught via the marker-based scaffold predicate (it has blockquote // disclaimer lines, so it is NOT structurally empty) — only on // evergreen sources, where the stubs live. assert!( is_content_free(GLOBAL_STUB, "global"), "the unedited global MEMORY.md stub must be content-free" ); assert!( is_content_free(WORKSPACE_STUB, "workspace"), "the auto-generated workspace stub must be content-free" ); } #[test] fn test_is_content_free_scaffolding_only() { // The structural branch applies to ALL sources — use "session" here. assert!( is_content_free("# Heading\n## Subheading", "session"), "headings only" ); assert!( is_content_free("", "session"), "comment only (single line)" ); assert!( is_content_free("", "session"), "comment only (multi-line)" ); assert!(is_content_free("", "session"), "empty string"); assert!(is_content_free(" \n\t\n ", "session"), "whitespace only"); assert!( is_content_free("# Heading\n\n\n\n## Another", "session"), "headings + comments only" ); assert!( is_content_free(" # Indented Heading", "session"), "indented ATX heading is still a heading" ); } #[test] fn test_is_content_free_real_content() { // Use "global" (evergreen) so both filter branches are active; real // content must survive regardless. assert!( !is_content_free("## Preferences\n\n- Use tabs", "global"), "heading with a following bullet has real content" ); assert!( !is_content_free("Use C# for this", "global"), "a `#` mid-line is real content, not a heading" ); assert!( !is_content_free("#hashtag not a heading", "global"), "`#` with no following space is not an ATX heading (per header_level)" ); assert!( !is_content_free("# Title\n\nSome actual prose here.", "global"), "prose after a heading is real content" ); assert!( !is_content_free("\nactual content", "global"), "content after a comment counts" ); assert!( !is_content_free("- a\n- b", "global"), "list-only chunk is content" ); assert!( !is_content_free("Title\n=====", "global"), "setext heading underline counts as content" ); assert!( !is_content_free("```\nlet x = 1;\n```", "global"), "code-fence chunk is content" ); } /// The scaffold-marker branch is scoped to evergreen sources: a short /// non-evergreen chunk that merely quotes a marker phrase must be kept, /// while the same text on an evergreen source is filtered. #[test] fn test_is_content_free_marker_branch_scoped_to_evergreen() { // A short session note that happens to quote a scaffold marker phrase. let quotes_marker = "Reminder: the template says \"Add any cross-project preferences here\"."; assert!( !is_content_free(quotes_marker, "session"), "non-evergreen chunk quoting a marker phrase must NOT be filtered" ); assert!( is_content_free(quotes_marker, "global"), "the same short text on an evergreen source is treated as scaffold" ); } /// Blockquotes are real user content and must NOT be filtered (the user's /// spec listed only headings/comments/whitespace as scaffolding). #[test] fn test_is_content_free_preserves_blockquotes() { assert!( !is_content_free("> a quote\n> another quote", "global"), "blockquote-only user notes must be preserved" ); assert!( !is_content_free( "## Important\n> Always run migrations before deploy", "global" ), "heading + blockquote with real guidance must be preserved" ); } /// An unterminated `