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
35 changes: 30 additions & 5 deletions crates/tinymemory-tools/src/recall/gather.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<Hit> = Vec::new();
let mut first_beliefs = Vec::new();
let mut cursor: Option<String> = 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
Expand Down
54 changes: 54 additions & 0 deletions crates/tinymemory-tools/src/recall/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}
Loading