Files
IdeA/crates/infrastructure/tests/memory_recall.rs
Blomios 785e9935fd feat(memory): config embedders (LOT C2) + suggestion contextuelle (LOT C3) + contexte projet partagé
- LOT C2 (§14.5.3) : use cases de configuration des embedders déclaratifs
  (List/Save/Delete + DescribeEmbedderEngines : modèles ONNX recommandés,
  environnement local détecté, stratégies compilées). UI EmbedderSettings.
- LOT C3 (§14.5.5) : suggestion contextuelle best-effort à l'activation quand la
  mémoire dépasse le budget de recall sans embedder configuré (event
  EmbedderSuggested, anti-spam 1×/session, « ne plus demander »).
- Contexte projet partagé .ideai/CONTEXT.md (model-agnostic) injecté à tous les
  agents/profils au lancement, avant la persona. UI ProjectContextPanel.

Tests : backend workspace vert (0 échec) ; frontend 306/306.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 09:24:51 +02:00

296 lines
10 KiB
Rust

//! 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<HashMap<String, Vec<u8>>>,
}
impl MemFs {
fn arc() -> Arc<Self> {
Arc::new(Self::default())
}
}
#[async_trait]
impl FileSystem for MemFs {
async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, 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<bool, FsError> {
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<Vec<DirEntry>, 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<String> {
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<Vec<Memory>, MemoryError> {
panic!("recall must not call list")
}
async fn get(&self, _root: &ProjectPath, _slug: &MemorySlug) -> Result<Memory, MemoryError> {
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<Vec<MemoryIndexEntry>, MemoryError> {
self.read_index_calls.fetch_add(1, Ordering::SeqCst);
Ok(Vec::new())
}
async fn resolve_links(
&self,
_root: &ProjectPath,
_slug: &MemorySlug,
) -> Result<Vec<MemoryLink>, 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(&notes).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"]
);
}