From aa3e058766c8934e28002f7eaf0dbbabf7a6faa6 Mon Sep 17 00:00:00 2001 From: Ghost Scripter Date: Wed, 7 Oct 2026 09:08:09 +0530 Subject: [PATCH] fix(recall): page a ranked section past hits its exclusions drop Per review: a fetch section took one ranked page of `wanted` hits and let settle drop the excluded ones (the prompt's thread window, shown ids), so a page made of in-window turns left the section empty even when eligible hits sat on the next page. Exclusions now apply page by page, and only when a page lost hits to them and too few remain is the next page read (cap 5), so the common case is still one request. Revert-checked test. --- crates/tinymemory-tools/src/recall/gather.rs | 35 ++++++++++-- .../tinymemory-tools/src/recall/mod_tests.rs | 54 +++++++++++++++++++ 2 files changed, 84 insertions(+), 5 deletions(-) diff --git a/crates/tinymemory-tools/src/recall/gather.rs b/crates/tinymemory-tools/src/recall/gather.rs index c8bc9cd9..fbe721e8 100644 --- a/crates/tinymemory-tools/src/recall/gather.rs +++ b/crates/tinymemory-tools/src/recall/gather.rs @@ -48,6 +48,10 @@ const LATEST_PAGE: usize = 100; /// Most listing pages read before ranking; a ceiling, not a target. const LATEST_MAX_PAGES: usize = 50; +/// Most ranked pages a fetch section reads to replace hits its exclusions +/// dropped. +const FETCH_MAX_PAGES: usize = 5; + /// What one section read. pub(super) enum Gathered { /// An answer and its citations. @@ -329,11 +333,32 @@ async fn fetch( let Some(mode) = preferred_mode(engine) else { return Ok((latest(engine, filter, limit, keep).await?, Vec::new())); }; - let mut request = FetchRequest::new(query, mode, limit); - request.filter = filter.clone(); - request.beliefs = beliefs; - let page = engine.fetch(request).await?; - Ok((page.hits, page.beliefs)) + // Exclusions (the prompt's thread window, shown ids) are dropped page by + // page. Only when a page lost hits to them and too few remain is the next + // page read, so the common case is still one request (each page is a + // round trip on a hosted engine), and the walk is capped. + let mut hits: Vec = Vec::new(); + let mut first_beliefs = Vec::new(); + let mut cursor: Option = None; + for page_no in 0..FETCH_MAX_PAGES { + let mut request = FetchRequest::new(query, mode, limit); + request.filter = filter.clone(); + request.beliefs = if page_no == 0 { beliefs } else { 0 }; + request.cursor = cursor.take(); + let page = engine.fetch(request).await?; + if page_no == 0 { + first_beliefs = page.beliefs; + } + let fetched = page.hits.len(); + let before = hits.len(); + hits.extend(page.hits.into_iter().filter(|hit| keep(hit))); + let lost_some = hits.len() - before < fetched; + match page.next_cursor { + Some(next) if hits.len() < limit && lost_some => cursor = Some(next), + _ => break, + } + } + Ok((hits, first_beliefs)) } /// The newest hits, then the most confident, then the latest turn; ties diff --git a/crates/tinymemory-tools/src/recall/mod_tests.rs b/crates/tinymemory-tools/src/recall/mod_tests.rs index 2ffbfd02..e762dc22 100644 --- a/crates/tinymemory-tools/src/recall/mod_tests.rs +++ b/crates/tinymemory-tools/src/recall/mod_tests.rs @@ -631,3 +631,57 @@ fn a_recall_future_can_cross_threads() { let future = holistic_recall(&engine, &request); send(&future); } + +#[tokio::test] +async fn a_ranked_section_reads_past_a_page_the_thread_window_empties() { + // Eight turns of one thread all match the query, and the prompt still + // holds turns 2..7, which rank first. The section wants 1 hit, so its + // first page (limit plus window allowance = 2) is entirely in-window, as + // are the next two. The section must read on (within its page cap) and + // find turn 0 or 1, not come back empty. + let engine = ReferenceEngine::new(); + for turn in 0..8u32 { + let meta = MemoryMeta { + thread_id: Some("tw".into()), + turns: Some(TurnRange { + first: turn, + last: turn, + }), + ..MemoryMeta::default() + }; + engine + .store(StoreItem::Conversation { + turns: vec![Turn::new( + Role::User, + if turn >= 2 { + // In-window turns rank first: the query terms, repeated. + format!("turn {turn}: Porto refund, Porto refund, Porto refund") + } else { + format!("turn {turn} asks about the Porto refund") + }, + )], + meta, + }) + .await + .unwrap(); + } + let mut request = HolisticRecall::new( + Some("Porto refund".into()), + vec![ScopeSection::fetch( + "History", + MetaFilter::kinds([ItemKind::Conversation]), + 1, + )], + ); + request.exclude_thread = Some(ThreadWindow { + thread_id: "tw".into(), + from_turn: 2, + }); + let pack = holistic_recall(&engine, &request).await.unwrap(); + assert!(!pack.is_empty(), "skipped: {:?}", pack.skipped); + assert!( + pack.markdown.contains("turn 0 ") || pack.markdown.contains("turn 1 "), + "{}", + pack.markdown + ); +}