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>
278 lines
10 KiB
Rust
278 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(¬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"]
|
|
);
|
|
}
|