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
23 changes: 20 additions & 3 deletions crates/tinymemory-api/src/conformance/reference/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ use crate::{
Citation, ConsolidateReceipt, ConsolidateRequest, ConsolidateStatus, Consolidation,
EngineDescriptor, EngineHealth, EraseReport, EraseRequest, Error, ExportPage, Exported,
FetchMode, FetchPage, FetchRequest, ForgetReport, ForgetTarget, Hit, ItemId, ListPage,
ListRequest, MemoryEngine, MetaFilter, Reach, RecallAnswer, RecallRequest, Result, StoreItem,
StoreReceipt,
ListRequest, MemoryEngine, MetaFilter, Reach, RecallAnswer, RecallMode, RecallRequest, Result,
StoreItem, StoreReceipt,
};
use async_trait::async_trait;

Expand Down Expand Up @@ -159,7 +159,10 @@ impl MemoryEngine for ReferenceEngine {

async fn recall(&self, req: RecallRequest) -> Result<RecallAnswer> {
req.validate()?;
let hits = self.ranked(&req.question, FetchMode::Hybrid, &req.filter)?;
let mut hits = self.ranked(&req.question, FetchMode::Hybrid, &req.filter)?;
if let Some(window) = &req.exclude_thread {
hits.retain(|hit| !window.covers(hit));
}
let citations: Vec<Citation> = hits
.into_iter()
.take(req.limit)
Expand All @@ -171,6 +174,17 @@ impl MemoryEngine for ReferenceEngine {
score: Some(hit.score),
})
.collect();
if req.mode == RecallMode::Evidence {
let lines: Vec<String> = citations
.iter()
.map(|c| format!("- {}", c.snippet))
.collect();
return Ok(RecallAnswer {
answer: lines.join("\n"),
citations,
model: None,
});
}
let answer = if citations.is_empty() {
"Nothing stored answers this question.".to_string()
} else {
Expand All @@ -192,6 +206,9 @@ impl MemoryEngine for ReferenceEngine {
self.descriptor.ensure_mode(req.mode)?;
req.validate()?;
let mut hits = self.ranked(&req.query, req.mode, &req.filter)?;
if let Some(window) = &req.exclude_thread {
hits.retain(|hit| !window.covers(hit));
}
if let Some(hint) = &req.refers_to {
hint.rank(&mut hits);
}
Expand Down
122 changes: 121 additions & 1 deletion crates/tinymemory-api/src/conformance/suite/checks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use std::collections::BTreeSet;

use crate::{
Error as ApiError, FetchMode, FetchRequest, ForgetTarget, ItemId, MemoryMeta, MetaFilter,
RecallRequest,
RecallMode, RecallRequest, Role, StoreItem, ThreadWindow, Turn, TurnRange,
};

use super::{Ctx, ensure};
Expand All @@ -21,6 +21,7 @@ pub(super) async fn all(ctx: &Ctx<'_>) -> Result<()> {
super::bulk::store_many(ctx).await?;
super::lifecycle::store_with(ctx).await?;
fetch_filters(ctx).await?;
excluded_thread(ctx).await?;
unsupported_modes(ctx).await?;
super::namespaces::namespaces(ctx).await?;
empty_forget(ctx).await?;
Expand Down Expand Up @@ -181,6 +182,104 @@ async fn fetch_filters(ctx: &Ctx<'_>) -> Result<()> {
Ok(())
}

/// A read's `exclude_thread` leaves that thread's turns out of every fetch
/// mode and of recall's citations, and a window starting after a turn keeps
/// it.
async fn excluded_thread(ctx: &Ctx<'_>) -> Result<()> {
const CHECK: &str = "excluded_thread";
let turn = |thread: &str| {
let mut meta = ctx.run.meta();
meta.thread_id = Some(thread.to_string());
meta.turns = Some(TurnRange { first: 0, last: 0 });
StoreItem::Conversation {
turns: vec![Turn::new(
Role::User,
format!("{} the turn being answered in {thread}", ctx.run.marker),
)],
meta,
}
};
let (live, other) = (
format!("{}-live", ctx.run.marker),
format!("{}-other", ctx.run.marker),
);
let live_id = ctx.call(CHECK, ctx.engine.store(turn(&live))).await?.id;
let other_id = ctx.call(CHECK, ctx.engine.store(turn(&other))).await?.id;
let modes = ctx.engine.descriptor().fetch_modes.clone();
for mode in &modes {
let mut request = FetchRequest::new(ctx.run.marker.clone(), *mode, 50);
request.filter = ctx.run.filter();
request.exclude_thread = Some(ThreadWindow::whole(&live));
let page = ctx.call(CHECK, ctx.engine.fetch(request.clone())).await?;
let found: BTreeSet<&ItemId> = page.hits.iter().map(|hit| &hit.id).collect();
ensure(
CHECK,
!found.contains(&live_id) && found.contains(&other_id),
|| {
format!(
"{} fetch excluding {live} returned {found:?}: expected {other_id}, not {live_id}",
mode.as_str()
)
},
)?;
request.exclude_thread = Some(ThreadWindow {
thread_id: live.clone(),
from_turn: 1,
});
let page = ctx.call(CHECK, ctx.engine.fetch(request)).await?;
ensure(CHECK, page.hits.iter().any(|hit| hit.id == live_id), || {
format!(
"{} fetch excluding turns of {live} from 1 on dropped its turn 0",
mode.as_str()
)
})?;
}
let mut request = RecallRequest::new(ctx.run.marker.clone(), 50);
request.filter = ctx.run.filter();
request.exclude_thread = Some(ThreadWindow::whole(&live));
let answer = ctx.call(CHECK, ctx.engine.recall(request.clone())).await?;
ensure(
CHECK,
answer
.citations
.iter()
.all(|citation| citation.id != live_id),
|| format!("a recall excluding {live} cited its turn {live_id}"),
)?;
// In evidence mode the engine writes no answer of its own, so the text it
// returns is all evidence, and none of it may come from the window.
request.mode = RecallMode::Evidence;
let evidence = ctx.call(CHECK, ctx.engine.recall(request)).await?;
let live_text = format!("the turn being answered in {live}");
ensure(
CHECK,
!evidence.answer.contains(&live_text)
&& evidence
.citations
.iter()
.all(|citation| citation.id != live_id),
|| format!("an evidence recall excluding {live} returned its turn {live_id}"),
)?;
ensure(
CHECK,
evidence
.answer
.contains(&format!("the turn being answered in {other}")),
|| format!("an evidence recall left out {other}, which no window covers"),
)?;
let blank = ThreadWindow::whole(" ");
let mut request = FetchRequest::new(ctx.run.marker.clone(), FetchMode::Hybrid, 5);
request.exclude_thread = Some(blank.clone());
ensure(CHECK, request.validate().is_err(), || {
"a fetch excluding a blank thread id validated".to_string()
})?;
let mut request = RecallRequest::new(ctx.run.marker.clone(), 5);
request.exclude_thread = Some(blank);
ensure(CHECK, request.validate().is_err(), || {
"a recall excluding a blank thread id validated".to_string()
})
}

async fn unsupported_modes(ctx: &Ctx<'_>) -> Result<()> {
const CHECK: &str = "unsupported_modes";
for mode in FetchMode::ALL {
Expand Down Expand Up @@ -304,6 +403,27 @@ async fn recall(ctx: &Ctx<'_>) -> Result<()> {
ensure(CHECK, answer.citations.len() <= 5, || {
format!("{} citations exceed the limit of 5", answer.citations.len())
})?;
let mut evidence = RecallRequest::new(ctx.run.marker.clone(), 5);
evidence.filter = ctx.run.filter();
evidence.mode = RecallMode::Evidence;
let evidence = ctx.call(CHECK, ctx.engine.recall(evidence)).await?;
ensure(
CHECK,
!evidence.answer.trim().is_empty() && !evidence.citations.is_empty(),
|| "an evidence recall over matching items returned no evidence".to_string(),
)?;
ensure(CHECK, evidence.model.is_none(), || {
format!(
"an evidence recall reported answering model {:?}",
evidence.model
)
})?;
ensure(CHECK, evidence.citations.len() <= 5, || {
format!(
"{} evidence citations exceed the limit of 5",
evidence.citations.len()
)
})?;
let listed = ctx.list_all(CHECK, &ctx.run.filter()).await?;
for citation in &answer.citations {
let resolved = listed.iter().find(|hit| hit.id == citation.id);
Expand Down
4 changes: 3 additions & 1 deletion crates/tinymemory-api/src/engine/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ pub trait MemoryEngine: Send + Sync {
/// Whether the engine can serve right now.
async fn health(&self) -> EngineHealth;

/// Answers a question from stored items.
/// Answers a question from stored items, or, in
/// [`crate::RecallMode::Evidence`], returns the evidence for the caller
/// to answer from without writing an answer.
///
/// # Errors
///
Expand Down
3 changes: 2 additions & 1 deletion crates/tinymemory-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,8 @@ pub use namespace::{Namespace, Reach, Segment, SegmentKind};
pub use provenance::{ProvenancePolicy, wrap_untrusted_for_agent};
pub use query::{
Citation, EraseReport, EraseRequest, ExportPage, Exported, FetchMode, FetchPage, FetchRequest,
ForgetReport, ForgetTarget, Hit, ListPage, ListRequest, RecallAnswer, RecallRequest, TimeHint,
ForgetReport, ForgetTarget, Hit, ListPage, ListRequest, RecallAnswer, RecallMode,
RecallRequest, ThreadWindow, TimeHint,
};
pub use write::{WaitFor, WriteOptions};

Expand Down
Loading
Loading