perf(memory): drop O(n^2) position scan in episodic relevance (#3782)

Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
mysma-9403
2026-06-22 19:10:33 +05:30
committed by GitHub
co-authored by Steven Enamakel
parent b0fa43c89d
commit 62fb5beab1
2 changed files with 58 additions and 5 deletions
+1 -5
View File
@@ -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);
@@ -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<f64> = 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();