Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 22 additions & 11 deletions src/memory/conversations/inverted_index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -275,9 +275,29 @@ impl InvertedIndex {
}

let total_terms = terms.len() as f64;
let mut hits: Vec<CrossThreadHit> = hit_counts
// Rank on cheap keys first — the match count and a borrowed `created_at`
// — then materialize the heavy CrossThreadHit (which clones the KB-sized
// `content`) only for the `limit` survivors. Phase 2 can leave thousands
// of candidates in `hit_counts` while callers ask for 3-10 results, so
// cloning every candidate's content before truncating is ~99% wasted.
// Ranking by `matched` (usize) is order-equivalent to ranking by
// `score = matched / total_terms` since `total_terms` is a positive
// constant, so the returned order is unchanged.
let mut ranked: Vec<(u32, usize, &str)> = hit_counts
.into_iter()
.map(|(doc_id, matched)| {
let entry = self.docs[doc_id as usize]
.as_ref()
.expect("doc_id from hit_counts must be live");
(doc_id, matched, entry.created_at.as_str())
})
.collect();
ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| b.2.cmp(a.2)));
ranked.truncate(limit);

ranked
.into_iter()
.map(|(doc_id, matched, _)| {
let entry = self.docs[doc_id as usize]
.as_ref()
.expect("doc_id from hit_counts must be live");
Expand All @@ -290,16 +310,7 @@ impl InvertedIndex {
score: matched as f64 / total_terms,
}
})
.collect();

hits.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| b.created_at.cmp(&a.created_at))
});
hits.truncate(limit);
hits
.collect()
}

/// Build the Phase 1 candidate set for one query term.
Expand Down
29 changes: 29 additions & 0 deletions src/memory/conversations/inverted_index_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,3 +195,32 @@ fn intersect_sorted_with_btreeset_empty_other() {
intersect_sorted_with_btreeset(&mut acc, &other);
assert!(acc.is_empty());
}

#[test]
fn ranks_by_score_then_recency_before_truncating() {
// More matches than `limit`, so truncation must keep the top-ranked hits by
// (score desc, created_at desc) — this pins that ranking still happens
// before the result set is cut, not after (the rank-before-materialize
// refactor must stay order-equivalent to the old clone-then-rank path).
let mut idx = InvertedIndex::new();
// Both terms → score 1.0, but oldest.
idx.insert(
"t1",
msg("both", "alpha beta gamma", "2026-04-10T10:00:00Z"),
);
// One term → score 0.5, newest of the 0.5 group.
idx.insert("t1", msg("newest", "alpha delta", "2026-04-10T10:03:00Z"));
// One term → score 0.5, middle.
idx.insert("t1", msg("middle", "alpha epsilon", "2026-04-10T10:02:00Z"));
// One term → score 0.5, oldest of the 0.5 group.
idx.insert("t1", msg("oldest", "beta zeta", "2026-04-10T10:01:00Z"));

let hits = idx.search("alpha beta", 2, None);
assert_eq!(hits.len(), 2, "must respect the limit");
// Highest score wins outright; the recency tiebreak then picks the newest of
// the equal-score remainder. "middle"/"oldest" are dropped.
assert_eq!(hits[0].message_id, "both");
assert!((hits[0].score - 1.0).abs() < 1e-9, "score = {}", hits[0].score);
assert_eq!(hits[1].message_id, "newest");
assert!((hits[1].score - 0.5).abs() < 1e-9, "score = {}", hits[1].score);
}
Loading