diff --git a/src/openhuman/memory_store/unified/query.rs b/src/openhuman/memory_store/unified/query.rs index 23e2c7812..77a9000c4 100644 --- a/src/openhuman/memory_store/unified/query.rs +++ b/src/openhuman/memory_store/unified/query.rs @@ -252,14 +252,10 @@ impl UnifiedMemory { } } - for entry in &episodic_hits { + for (position_idx, entry) in episodic_hits.iter().enumerate() { let freshness = Self::recency_score(entry.timestamp, now); // Episodic FTS5 returns results ordered by rank (best first). // Normalize position to a 0-1 relevance score. - let position_idx = episodic_hits - .iter() - .position(|e| e.id == entry.id) - .unwrap_or(0); let fts_relevance = 1.0 - (position_idx as f64 / episodic_hits.len().max(1) as f64); let episodic_score = (fts_relevance * 0.7) + (freshness * 0.3); diff --git a/src/openhuman/memory_store/unified/query_tests.rs b/src/openhuman/memory_store/unified/query_tests.rs index 81e1164a7..a868f079b 100644 --- a/src/openhuman/memory_store/unified/query_tests.rs +++ b/src/openhuman/memory_store/unified/query_tests.rs @@ -264,6 +264,63 @@ async fn query_episodic_hits_have_correct_kind() { } } +/// Episodic FTS relevance is derived from each hit's rank position +/// (`1.0 - idx / len`). With two equally-fresh matches the only +/// differentiator is rank, so the relevance scores must be exactly the +/// per-position values {1.0, 0.5}. This pins the position-indexing math +/// for n > 1 — the single-entry tests above cannot, since idx is always 0. +#[tokio::test] +async fn query_episodic_relevance_tracks_rank_position() { + use crate::openhuman::memory_store::fts5::{self, EpisodicEntry}; + + let tmp = TempDir::new().unwrap(); + let memory = UnifiedMemory::new(tmp.path(), Arc::new(NoopEmbedding), None).unwrap(); + + // Two distinct entries, identical timestamp (equal freshness), both + // matching the query so episodic_hits has len == 2. + for content in [ + "I have been using Tokio for async Rust development", + "Tokio async runtime powers our backend services", + ] { + fts5::episodic_insert( + &memory.conn, + &EpisodicEntry { + id: None, + session_id: "sess-rank".into(), + timestamp: 1000.0, + role: "user".into(), + content: content.into(), + lesson: None, + tool_calls_json: None, + cost_microdollars: 0, + }, + ) + .unwrap(); + } + + let hits = memory + .query_namespace_hits("global", "Tokio async", 10) + .await + .unwrap(); + + let mut relevances: Vec = hits + .iter() + .filter(|h| h.kind == crate::openhuman::memory_store::MemoryItemKind::Episodic) + .map(|h| h.score_breakdown.episodic_relevance) + .collect(); + relevances.sort_by(|a, b| a.partial_cmp(b).unwrap()); + + assert_eq!( + relevances.len(), + 2, + "expected exactly two episodic hits, got {relevances:?}" + ); + assert!( + (relevances[0] - 0.5).abs() < 1e-9 && (relevances[1] - 1.0).abs() < 1e-9, + "episodic relevance must be {{0.5, 1.0}} for two-element rank order, got {relevances:?}" + ); +} + #[tokio::test] async fn query_supporting_relations_contain_entity_types() { let tmp = TempDir::new().unwrap();