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,187 @@
//! Domain tests for the [`Memory`] entity and its value objects (LOT A, étage 1):
//! [`MemorySlug`] validation, `[[slug]]` link scanning, frontmatter serde
//! round-trip, and the derived index entry.
use domain::{
DomainError, MarkdownDoc, Memory, MemoryFrontmatter, MemorySlug, MemoryType,
};
fn fm(slug: &str, description: &str, ty: MemoryType) -> MemoryFrontmatter {
MemoryFrontmatter {
name: MemorySlug::new(slug).unwrap(),
description: description.to_string(),
r#type: ty,
}
}
fn note(slug: &str, body: &str) -> Memory {
Memory::new(
fm(slug, "a hook", MemoryType::Project),
MarkdownDoc::new(body),
)
.unwrap()
}
// ---------------------------------------------------------------------------
// MemorySlug
// ---------------------------------------------------------------------------
#[test]
fn slug_accepts_kebab_case() {
assert_eq!(
MemorySlug::new("my-note-42").unwrap().as_str(),
"my-note-42"
);
assert!(MemorySlug::new("abc").is_ok());
assert!(MemorySlug::new("a-b-c-1-2-3").is_ok());
}
#[test]
fn slug_rejects_uppercase() {
assert!(matches!(
MemorySlug::new("MyNote").unwrap_err(),
DomainError::InvalidSlug { .. }
));
}
#[test]
fn slug_rejects_spaces() {
assert!(matches!(
MemorySlug::new("my note").unwrap_err(),
DomainError::InvalidSlug { .. }
));
}
#[test]
fn slug_rejects_dot_and_traversal() {
assert!(matches!(
MemorySlug::new("..").unwrap_err(),
DomainError::InvalidSlug { .. }
));
assert!(matches!(
MemorySlug::new("a.b").unwrap_err(),
DomainError::InvalidSlug { .. }
));
}
#[test]
fn slug_rejects_empty() {
assert!(matches!(
MemorySlug::new("").unwrap_err(),
DomainError::InvalidSlug { .. }
));
}
#[test]
fn slug_rejects_underscore_and_slash() {
assert!(MemorySlug::new("a_b").is_err());
assert!(MemorySlug::new("a/b").is_err());
}
// ---------------------------------------------------------------------------
// Memory invariants
// ---------------------------------------------------------------------------
#[test]
fn memory_rejects_empty_description() {
let r = Memory::new(
fm("ok-slug", "", MemoryType::User),
MarkdownDoc::new("body"),
);
assert!(matches!(r.unwrap_err(), DomainError::EmptyField { .. }));
}
#[test]
fn memory_rejects_empty_body() {
let r = Memory::new(fm("ok-slug", "hook", MemoryType::User), MarkdownDoc::new(""));
assert!(matches!(r.unwrap_err(), DomainError::EmptyField { .. }));
}
// ---------------------------------------------------------------------------
// outgoing_links
// ---------------------------------------------------------------------------
#[test]
fn outgoing_links_none() {
assert!(note("n", "plain body, no links").outgoing_links().is_empty());
}
#[test]
fn outgoing_links_multiple() {
let m = note("n", "see [[alpha]] and also [[beta-2]] here");
let targets: Vec<_> = m
.outgoing_links()
.into_iter()
.map(|l| l.target.as_str().to_string())
.collect();
assert_eq!(targets, vec!["alpha".to_string(), "beta-2".to_string()]);
}
#[test]
fn outgoing_links_preserves_duplicates() {
let m = note("n", "[[alpha]] then again [[alpha]]");
assert_eq!(m.outgoing_links().len(), 2);
}
#[test]
fn outgoing_links_skips_invalid_targets() {
// `[[Not Valid]]` is not a kebab-case slug -> skipped.
let m = note("n", "[[Not Valid]] but [[good-one]]");
let targets: Vec<_> = m
.outgoing_links()
.into_iter()
.map(|l| l.target.as_str().to_string())
.collect();
assert_eq!(targets, vec!["good-one".to_string()]);
}
#[test]
fn outgoing_links_ignores_unterminated() {
let m = note("n", "dangling [[oops without close");
assert!(m.outgoing_links().is_empty());
}
// ---------------------------------------------------------------------------
// index_entry
// ---------------------------------------------------------------------------
#[test]
fn index_entry_carries_slug_hook_and_type() {
let m = Memory::new(
fm("topic-x", "a useful hook", MemoryType::Feedback),
MarkdownDoc::new("body"),
)
.unwrap();
let e = m.index_entry();
assert_eq!(e.slug.as_str(), "topic-x");
assert_eq!(e.hook, "a useful hook");
assert_eq!(e.title, "topic-x");
assert_eq!(e.r#type, MemoryType::Feedback);
}
// ---------------------------------------------------------------------------
// Frontmatter serde round-trip (the `type` field name + camelCase variants).
// ---------------------------------------------------------------------------
#[test]
fn frontmatter_serde_roundtrip() {
let original = fm("round-trip", "desc", MemoryType::Reference);
let json = serde_json::to_string(&original).unwrap();
// `type` field name (not `kind`), camelCase enum value.
assert!(json.contains("\"type\":\"reference\""));
assert!(json.contains("\"name\":\"round-trip\""));
let back: MemoryFrontmatter = serde_json::from_str(&json).unwrap();
assert_eq!(back, original);
}
#[test]
fn memory_type_serde_is_camel_case() {
assert_eq!(
serde_json::to_string(&MemoryType::User).unwrap(),
"\"user\""
);
assert_eq!(
serde_json::to_string(&MemoryType::Project).unwrap(),
"\"project\""
);
}