From ec95ead074274553d580fbd781d718c03c0f1bd2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 10 Jul 2026 01:17:28 +0000 Subject: [PATCH] perf(conversations): rank cross-thread hits before materializing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port OpenHuman host commit 007a99b62 (drift ledger D1). The cross-thread inverted-index search cloned the KB-sized `CrossThreadHit` (content, message_id, created_at) for *every* Phase-2 match, then sorted and truncated to `limit`. Phase 2 can leave thousands of candidates while callers ask for 3-10 results, so ~99% of those clones were discarded. Now rank on cheap borrowed keys first — `(doc_id, matched: usize, created_at: &str)` — truncate to `limit`, and materialize the heavy `CrossThreadHit` only for the survivors. Ranking by `matched` (usize) is order-equivalent to ranking by `score = matched / total_terms` since `total_terms` is a positive constant, so returned order is unchanged. Pinned by the new `ranks_by_score_then_recency_before_truncating` test. --- src/memory/conversations/inverted_index.rs | 33 ++++++++++++------- .../conversations/inverted_index_tests.rs | 29 ++++++++++++++++ 2 files changed, 51 insertions(+), 11 deletions(-) diff --git a/src/memory/conversations/inverted_index.rs b/src/memory/conversations/inverted_index.rs index 44fda34..00e4311 100644 --- a/src/memory/conversations/inverted_index.rs +++ b/src/memory/conversations/inverted_index.rs @@ -275,9 +275,29 @@ impl InvertedIndex { } let total_terms = terms.len() as f64; - let mut hits: Vec = 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"); @@ -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. diff --git a/src/memory/conversations/inverted_index_tests.rs b/src/memory/conversations/inverted_index_tests.rs index f4f7d13..c8e0b77 100644 --- a/src/memory/conversations/inverted_index_tests.rs +++ b/src/memory/conversations/inverted_index_tests.rs @@ -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); +}