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:
2026-06-08 08:47:23 +02:00
parent 3ed0f6b45f
commit 98a8b7292a
30 changed files with 4978 additions and 14 deletions

View 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(&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"]
);
}