feat(memory): système de mémoire projet model-agnostic (L14, LOT A+B+C)
Base de connaissance persistante par projet, indépendante de tout modèle/CLU
et de git. Cadrage archi en §14.5 (ARCHITECTURE.md), cycle Archi→Dev→Test.
LOT A — étage 1 (.md, source de vérité)
- domaine: entité Memory (+ MemorySlug, MemoryType, MemoryFrontmatter,
MemoryLink, MemoryIndexEntry), liens [[slug]], index MEMORY.md dérivé
- port MemoryStore + MemoryError, adapter FsMemoryStore (.ideai/memory/)
- application: 7 use cases (Create/Update/List/Get/Delete/ReadIndex/
ResolveLinks), From<MemoryError> for AppError
- app-tauri: commandes + DTO, events MemorySaved/MemoryDeleted
- suppression de la variante morte DomainError::MalformedFrontmatter
LOT B — rappel adaptatif (étage 1)
- port MemoryRecall + MemoryQuery, adapter NaiveMemoryRecall (troncature
au budget de tokens, court-circuit budget-0), use case RecallMemory
LOT C — étage 2 vectoriel (structure complète, zéro dépendance lourde)
- port Embedder + EmbedderError, profils déclaratifs EmbedderProfile/
EmbedderStrategy (embedder.json)
- VectorMemoryRecall (cosinus, cache .ideai/memory/.index/ gitignoré)
- AdaptiveMemoryRecall (bascule pure should_use_vector), défaut none
- HashEmbedder (déterministe, tests), StubEmbedder (onnx/server/api)
Tests: 57 binaires verts, build + clippy --workspace sans warning.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
277
crates/infrastructure/tests/memory_recall.rs
Normal file
277
crates/infrastructure/tests/memory_recall.rs
Normal file
@ -0,0 +1,277 @@
|
||||
//! 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(¬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"]
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user