//! Tests for [`NaiveMemoryRecall`] (LOT B, étage 1): the default, dependency-free //! [`MemoryRecall`] that reads the aggregated index via a composed [`MemoryStore`] //! and truncates the entries to fit the query's token budget. //! //! Budget semantics under test (mirroring the adapter doc): each entry costs //! `ceil((title.len() + hook.len()) / 4)` tokens; entries are taken in index order, //! accumulating cost; the first entry that would exceed the budget — and every one //! after it — is dropped. `token_budget == 0` ⇒ empty; an empty/absent memory ⇒ //! empty, never an error. //! //! Two backings are exercised: //! - the **real** [`FsMemoryStore`] over an in-memory [`FileSystem`] mock (same //! `MemFs` shape as `memory_store.rs`), giving end-to-end coverage of the //! index round-trip feeding recall; //! - a tiny **counting fake** `MemoryStore` to assert the `budget == 0` //! short-circuit performs *no* `read_index` call. use std::collections::HashMap; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use async_trait::async_trait; use domain::markdown::MarkdownDoc; use domain::memory::{ Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug, MemoryType, }; use domain::ports::{ DirEntry, FileSystem, FsError, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, RemotePath, }; use domain::project::ProjectPath; use infrastructure::{FsMemoryStore, NaiveMemoryRecall}; // --------------------------------------------------------------------------- // In-memory FileSystem mock (same minimal shape as `memory_store.rs`). // --------------------------------------------------------------------------- #[derive(Default)] struct MemFs { files: Mutex>>, } impl MemFs { fn arc() -> Arc { Arc::new(Self::default()) } } #[async_trait] impl FileSystem for MemFs { async fn read(&self, path: &RemotePath) -> Result, FsError> { self.files .lock() .unwrap() .get(path.as_str()) .cloned() .ok_or_else(|| FsError::NotFound(path.as_str().to_string())) } async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> { self.files .lock() .unwrap() .insert(path.as_str().to_string(), data.to_vec()); Ok(()) } async fn exists(&self, path: &RemotePath) -> Result { Ok(self.files.lock().unwrap().contains_key(path.as_str())) } async fn create_dir_all(&self, _path: &RemotePath) -> Result<(), FsError> { Ok(()) } async fn list(&self, _path: &RemotePath) -> Result, FsError> { Ok(Vec::new()) } async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> { Ok(()) } } // --------------------------------------------------------------------------- // Builders // --------------------------------------------------------------------------- fn root() -> ProjectPath { ProjectPath::new("/proj").unwrap() } /// A note whose index `title` is its slug (length `slug.len()`) and whose `hook` /// is `desc` — so the recall cost is `ceil((slug.len() + desc.len()) / 4)`. fn note(slug: &str, desc: &str) -> Memory { Memory::new( MemoryFrontmatter { name: MemorySlug::new(slug).unwrap(), description: desc.to_string(), r#type: MemoryType::Project, }, MarkdownDoc::new("# body"), ) .unwrap() } /// Saves notes (in order) through the real store and returns a recall over it. async fn recall_over(notes: &[Memory]) -> NaiveMemoryRecall { let store = Arc::new(FsMemoryStore::new(MemFs::arc())); for n in notes { store.save(&root(), n).await.unwrap(); } NaiveMemoryRecall::new(store) } fn query(budget: usize) -> MemoryQuery { MemoryQuery { text: "anything".to_string(), token_budget: budget, } } fn slugs(entries: &[MemoryIndexEntry]) -> Vec { entries .iter() .map(|e| e.slug.as_str().to_string()) .collect() } // --------------------------------------------------------------------------- // Empty / absent memory ⇒ empty list, never an error. // --------------------------------------------------------------------------- #[tokio::test] async fn absent_index_recalls_empty_without_error() { let recall = NaiveMemoryRecall::new(Arc::new(FsMemoryStore::new(MemFs::arc()))); let out = recall.recall(&root(), &query(1000)).await.unwrap(); assert!(out.is_empty(), "absent memory must recall empty: {out:?}"); } // --------------------------------------------------------------------------- // Budget 0 ⇒ empty list (and short-circuits before touching the store). // --------------------------------------------------------------------------- #[tokio::test] async fn zero_budget_recalls_empty() { let recall = recall_over(&[note("aaaa", "bbbb"), note("cccc", "dddd")]).await; let out = recall.recall(&root(), &query(0)).await.unwrap(); assert!(out.is_empty(), "budget 0 must recall empty: {out:?}"); } /// A `MemoryStore` that counts `read_index` calls and panics on every other /// method — proving the recall path only reads the index, and that a `0` budget /// short-circuits before any read. #[derive(Default)] struct CountingStore { read_index_calls: AtomicUsize, } #[async_trait] impl MemoryStore for CountingStore { async fn list(&self, _root: &ProjectPath) -> Result, MemoryError> { panic!("recall must not call list") } async fn get(&self, _root: &ProjectPath, _slug: &MemorySlug) -> Result { panic!("recall must not call get") } async fn save(&self, _root: &ProjectPath, _memory: &Memory) -> Result<(), MemoryError> { panic!("recall must not call save") } async fn delete(&self, _root: &ProjectPath, _slug: &MemorySlug) -> Result<(), MemoryError> { panic!("recall must not call delete") } async fn read_index(&self, _root: &ProjectPath) -> Result, MemoryError> { self.read_index_calls.fetch_add(1, Ordering::SeqCst); Ok(Vec::new()) } async fn resolve_links( &self, _root: &ProjectPath, _slug: &MemorySlug, ) -> Result, MemoryError> { panic!("recall must not call resolve_links") } } #[tokio::test] async fn zero_budget_short_circuits_no_read_index() { let store = Arc::new(CountingStore::default()); let recall = NaiveMemoryRecall::new(store.clone()); let out = recall.recall(&root(), &query(0)).await.unwrap(); assert!(out.is_empty()); assert_eq!( store.read_index_calls.load(Ordering::SeqCst), 0, "budget 0 must short-circuit before reading the index" ); } #[tokio::test] async fn nonzero_budget_reads_index_once() { let store = Arc::new(CountingStore::default()); let recall = NaiveMemoryRecall::new(store.clone()); let _ = recall.recall(&root(), &query(10)).await.unwrap(); assert_eq!(store.read_index_calls.load(Ordering::SeqCst), 1); } // --------------------------------------------------------------------------- // Sufficient budget ⇒ all entries, in index order. // --------------------------------------------------------------------------- #[tokio::test] async fn ample_budget_returns_all_entries_in_index_order() { // Saved order: alpha, beta, gamma → index order is the same. let recall = recall_over(&[ note("alpha", "first hook"), note("beta", "second hook"), note("gamma", "third hook"), ]) .await; let out = recall.recall(&root(), &query(100_000)).await.unwrap(); assert_eq!(slugs(&out), vec!["alpha", "beta", "gamma"]); } // --------------------------------------------------------------------------- // Intermediate budget ⇒ exact truncation at the cumulative-cost boundary. // // Three notes, each title.len()==4 and hook.len()==4 ⇒ 8 chars ⇒ ceil(8/4)==2 // tokens apiece. Cumulative costs: 2, 4, 6. // --------------------------------------------------------------------------- #[tokio::test] async fn intermediate_budget_truncates_at_exact_boundary() { let notes = [ note("aaaa", "bbbb"), note("cccc", "dddd"), note("eeee", "ffff"), ]; let recall = recall_over(¬es).await; // Budget 1 < 2 ⇒ first entry already exceeds ⇒ none. assert!(recall.recall(&root(), &query(1)).await.unwrap().is_empty()); // Budget 2 == cost(1) ⇒ exactly the first entry; second (would reach 4) dropped. assert_eq!( slugs(&recall.recall(&root(), &query(2)).await.unwrap()), vec!["aaaa"] ); // Budget 3 < 4 ⇒ still only the first. assert_eq!( slugs(&recall.recall(&root(), &query(3)).await.unwrap()), vec!["aaaa"] ); // Budget 4 == cost(1)+cost(2) ⇒ first two; third (would reach 6) dropped. assert_eq!( slugs(&recall.recall(&root(), &query(4)).await.unwrap()), vec!["aaaa", "cccc"] ); // Budget 5 < 6 ⇒ still the first two. assert_eq!( slugs(&recall.recall(&root(), &query(5)).await.unwrap()), vec!["aaaa", "cccc"] ); // Budget 6 == total ⇒ all three. assert_eq!( slugs(&recall.recall(&root(), &query(6)).await.unwrap()), vec!["aaaa", "cccc", "eeee"] ); } // --------------------------------------------------------------------------- // ceil rounding: an odd character count rounds *up* (5 chars ⇒ 2 tokens). // --------------------------------------------------------------------------- #[tokio::test] async fn entry_cost_rounds_up_via_ceil() { // slug "ab" (2) + hook "c" (1) = 3 chars ⇒ ceil(3/4) == 1 token. // slug "de" (2) + hook "fgh" (3) = 5 chars ⇒ ceil(5/4) == 2 tokens. let recall = recall_over(&[note("ab", "c"), note("de", "fgh")]).await; // Budget 1 covers only the 1-token first entry; the 2-token second is dropped. assert_eq!( slugs(&recall.recall(&root(), &query(1)).await.unwrap()), vec!["ab"] ); // Budget 2 still cannot fit the second on top of the first (1 + 2 = 3 > 2). assert_eq!( slugs(&recall.recall(&root(), &query(2)).await.unwrap()), vec!["ab"] ); // Budget 3 fits both (1 + 2 == 3). assert_eq!( slugs(&recall.recall(&root(), &query(3)).await.unwrap()), vec!["ab", "de"] ); }