Done the way the owner asked and the spike mapped: the crate and the manifests renamed, then what the build reported fixed. Layers: 138 "proto unresolved" errors across 169 files; cargo fmt reordering imports; six fixture paths built as strings in brokerd's and bxctl's tests, which the compiler cannot see, and comments naming crates/proto/; the crate-dependency check and its self-test. One more the spike did not reach: a `\nproto =` key inside a printf format in the self-test, which a word-boundary replacement skips because `n` and `p` are both word characters. make gate: gate: ok, 944 tests; no new entry in /tmp. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
180 lines
6.1 KiB
Rust
180 lines
6.1 KiB
Rust
//! `recall`: quoted terms only, ranked hits with source tags and labels, the result's label, and
|
|
//! the recall log (M5a spec, section 5). Do not edit.
|
|
|
|
#[path = "support/logs.rs"]
|
|
mod logs;
|
|
#[path = "support/tmp.rs"]
|
|
mod tmp;
|
|
|
|
use bxcore::{DataClass, RecallArgs, Timestamp};
|
|
use logs::{assistant, result, user, write_log};
|
|
use memoryd::files::Store;
|
|
use memoryd::index::Index;
|
|
use memoryd::recall::{recall, terms};
|
|
use tmp::TempDir;
|
|
|
|
fn now() -> Timestamp {
|
|
Timestamp::parse("2026-09-24T21:00:00.000Z").unwrap()
|
|
}
|
|
|
|
struct Setup {
|
|
dir: TempDir,
|
|
store: Store,
|
|
index: Index,
|
|
}
|
|
|
|
fn setup(tag: &str) -> Setup {
|
|
let dir = TempDir::new(tag);
|
|
let starter = dir.write("etc/soul.md", "Soul.\n");
|
|
let store = Store::new(&dir.path().join("memory"));
|
|
store.init(&starter).unwrap();
|
|
let index = Index::open(&dir.path().join("index/memory.sqlite3"), DataClass::Private).unwrap();
|
|
Setup { dir, store, index }
|
|
}
|
|
|
|
fn ask(s: &Setup, query: &str, k: u64) -> memoryd::recall::Recalled {
|
|
let args = RecallArgs {
|
|
query: query.to_string(),
|
|
k,
|
|
};
|
|
recall(&s.index, &s.store, &args, "mm-now", now()).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn terms_are_quoted_and_operators_never_pass() {
|
|
assert_eq!(
|
|
terms("launch window"),
|
|
Some("\"launch\" OR \"window\"".to_string())
|
|
);
|
|
assert_eq!(
|
|
terms(r#"a NEAR(b c) OR "x" title:y z* ^w -v"#),
|
|
Some(
|
|
r#""a" OR "NEAR" OR "b" OR "c" OR "OR" OR "x" OR "title" OR "y" OR "z" OR "w" OR "v""#
|
|
.to_string()
|
|
)
|
|
);
|
|
assert_eq!(terms("Київ 東京"), Some("\"Київ\" OR \"東京\"".to_string()));
|
|
assert_eq!(terms("*** ::: \"\""), None);
|
|
}
|
|
|
|
#[test]
|
|
fn hits_are_tagged_and_labelled() {
|
|
let s = setup("recall-tags");
|
|
write_log(
|
|
s.dir.path(),
|
|
"mm-a",
|
|
&[
|
|
user(2, "The launch window moved to Thursday."),
|
|
assistant(
|
|
3,
|
|
None,
|
|
&[("http_fetch", r#"{"url":"https://example.org/launch"}"#)],
|
|
),
|
|
result(4, "page", DataClass::Private, true),
|
|
assistant(5, Some("The page says the launch is in March."), &[]),
|
|
],
|
|
);
|
|
s.index
|
|
.catch_up(&s.dir.path().join("sessions"), &s.store.path("notes.md"))
|
|
.unwrap();
|
|
std::fs::write(
|
|
s.store.path("notes.md"),
|
|
"§ moved 2026-09-24T20:14Z from memory.md in session mm-b\nOwner cares about the launch.\n§ dropped 2026-09-24T20:15Z from user.md in session mm-b\nOld launch plan.\n§ noted 2026-09-22T18:20Z in session mm-c · private · untrusted\nexample.org says the launch moved.\n",
|
|
)
|
|
.unwrap();
|
|
s.index
|
|
.catch_up(&s.dir.path().join("sessions"), &s.store.path("notes.md"))
|
|
.unwrap();
|
|
let got = ask(&s, "launch", 10);
|
|
let headers: Vec<&str> = got.text.lines().filter(|l| l.starts_with('[')).collect();
|
|
let mut sorted: Vec<String> = headers
|
|
.iter()
|
|
.map(|h| h.split_once("] ").unwrap().1.to_string())
|
|
.collect();
|
|
sorted.sort();
|
|
assert_eq!(
|
|
sorted,
|
|
[
|
|
"identity · moved 2026-09-24 from memory.md · private",
|
|
"memory · dropped 2026-09-24 from user.md · private",
|
|
"memory · session mm-a, 2026-09-20 14:02 UTC, the owner said · private",
|
|
"memory · session mm-a, 2026-09-20 14:03 UTC, the assistant called · private",
|
|
"memory-tainted · note, 2026-09-22 18:20 UTC, session mm-c · private",
|
|
"memory-tainted · session mm-a, 2026-09-20 14:05 UTC, the assistant said · private",
|
|
]
|
|
);
|
|
assert!(got.text.starts_with("[1] "));
|
|
assert!(got.text.contains("\nThe launch window moved to Thursday."));
|
|
assert!(!got.text.contains("page\n"), "tool results are not indexed");
|
|
assert_eq!((got.class, got.untrusted), (DataClass::Private, true));
|
|
}
|
|
|
|
#[test]
|
|
fn a_clean_result_is_trusted_and_k_limits_the_hits() {
|
|
let s = setup("recall-clean");
|
|
write_log(
|
|
s.dir.path(),
|
|
"mm-a",
|
|
&[
|
|
user(1, "alpha one"),
|
|
user(2, "alpha two"),
|
|
user(3, "alpha three"),
|
|
],
|
|
);
|
|
s.index
|
|
.catch_up(&s.dir.path().join("sessions"), &s.store.path("notes.md"))
|
|
.unwrap();
|
|
let got = ask(&s, "alpha", 2);
|
|
assert_eq!(got.text.lines().filter(|l| l.starts_with('[')).count(), 2);
|
|
assert_eq!((got.class, got.untrusted), (DataClass::Private, false));
|
|
let none = ask(&s, "zebra", 5);
|
|
assert_eq!(none.text, "(no matches for \"zebra\")");
|
|
assert_eq!(ask(&s, "NEAR(", 5).text, "(no matches for \"NEAR(\")");
|
|
}
|
|
|
|
#[test]
|
|
fn long_hits_are_cut() {
|
|
let s = setup("recall-long");
|
|
// 64 words of 40 letters: longer than 600 characters even after `snippet` keeps 64 words.
|
|
let long = format!("needle {}", format!("{} ", "w".repeat(40)).repeat(100));
|
|
write_log(s.dir.path(), "mm-a", &[user(1, &long)]);
|
|
s.index
|
|
.catch_up(&s.dir.path().join("sessions"), &s.store.path("notes.md"))
|
|
.unwrap();
|
|
let got = ask(&s, "needle", 1);
|
|
let body = got.text.split_once('\n').unwrap().1;
|
|
assert!(body.chars().count() <= 600, "{}", body.chars().count());
|
|
}
|
|
|
|
#[test]
|
|
fn every_recall_is_logged_with_its_hits() {
|
|
let s = setup("recall-log");
|
|
write_log(s.dir.path(), "mm-a", &[user(1, "found me")]);
|
|
s.index
|
|
.catch_up(&s.dir.path().join("sessions"), &s.store.path("notes.md"))
|
|
.unwrap();
|
|
ask(&s, "found", 5);
|
|
ask(&s, "missing", 5);
|
|
let log = std::fs::read_to_string(s.store.path("recalls/2026-09.jsonl")).unwrap();
|
|
let lines: Vec<serde_json::Value> = log
|
|
.lines()
|
|
.map(|l| serde_json::from_str(l).unwrap())
|
|
.collect();
|
|
assert_eq!(lines.len(), 2);
|
|
assert_eq!(lines[0]["query"], "found");
|
|
assert_eq!(lines[0]["session"], "mm-now");
|
|
assert_eq!(lines[0]["hits"][0]["session"], "mm-a");
|
|
assert_eq!(lines[0]["hits"][0]["record"], 0);
|
|
assert_eq!(lines[1]["hits"], serde_json::json!([]));
|
|
}
|
|
|
|
#[test]
|
|
fn bad_arguments_are_refused() {
|
|
let s = setup("recall-bad");
|
|
let args = RecallArgs {
|
|
query: String::new(),
|
|
k: 5,
|
|
};
|
|
assert!(recall(&s.index, &s.store, &args, "mm-now", now()).is_err());
|
|
}
|