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:
183
crates/domain/tests/embedder_profile.rs
Normal file
183
crates/domain/tests/embedder_profile.rs
Normal file
@ -0,0 +1,183 @@
|
||||
//! Tests for the declarative [`EmbedderProfile`] / [`EmbedderStrategy`] (LOT C,
|
||||
//! étage 2 vectoriel): serde camelCase round-trip, the validated `new`
|
||||
//! constructor, and the dependency-free `none()` default.
|
||||
|
||||
use domain::{DomainError, EmbedderProfile, EmbedderStrategy};
|
||||
|
||||
fn roundtrip<T>(value: &T) -> T
|
||||
where
|
||||
T: serde::Serialize + serde::de::DeserializeOwned + PartialEq + std::fmt::Debug,
|
||||
{
|
||||
let json = serde_json::to_string(value).expect("serialize");
|
||||
let back: T = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(&back, value, "round-trip mismatch via {json}");
|
||||
back
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EmbedderStrategy: camelCase wire form (`localOnnx` / `localServer` / `api` /
|
||||
// `none`).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn strategy_serializes_camel_case() {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&EmbedderStrategy::LocalOnnx).unwrap(),
|
||||
"\"localOnnx\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&EmbedderStrategy::LocalServer).unwrap(),
|
||||
"\"localServer\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&EmbedderStrategy::Api).unwrap(),
|
||||
"\"api\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&EmbedderStrategy::None).unwrap(),
|
||||
"\"none\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strategy_deserializes_camel_case() {
|
||||
assert_eq!(
|
||||
serde_json::from_str::<EmbedderStrategy>("\"localOnnx\"").unwrap(),
|
||||
EmbedderStrategy::LocalOnnx
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<EmbedderStrategy>("\"localServer\"").unwrap(),
|
||||
EmbedderStrategy::LocalServer
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<EmbedderStrategy>("\"api\"").unwrap(),
|
||||
EmbedderStrategy::Api
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<EmbedderStrategy>("\"none\"").unwrap(),
|
||||
EmbedderStrategy::None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strategy_roundtrips() {
|
||||
for s in [
|
||||
EmbedderStrategy::LocalOnnx,
|
||||
EmbedderStrategy::LocalServer,
|
||||
EmbedderStrategy::Api,
|
||||
EmbedderStrategy::None,
|
||||
] {
|
||||
roundtrip(&s);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EmbedderProfile: camelCase fields and optional-field skipping.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn profile_roundtrips_with_all_fields() {
|
||||
let p = EmbedderProfile::new(
|
||||
"local-onnx-minilm",
|
||||
"Local MiniLM",
|
||||
EmbedderStrategy::LocalOnnx,
|
||||
Some("all-MiniLM-L6-v2".to_owned()),
|
||||
Some("http://localhost:1234".to_owned()),
|
||||
Some("OPENAI_API_KEY".to_owned()),
|
||||
384,
|
||||
)
|
||||
.unwrap();
|
||||
let back = roundtrip(&p);
|
||||
assert_eq!(back.dimension, 384);
|
||||
assert_eq!(back.strategy, EmbedderStrategy::LocalOnnx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_uses_camel_case_keys_and_skips_none_options() {
|
||||
let p = EmbedderProfile::new(
|
||||
"id-1",
|
||||
"Name",
|
||||
EmbedderStrategy::Api,
|
||||
None,
|
||||
Some("https://api.example/embed".to_owned()),
|
||||
Some("MY_KEY".to_owned()),
|
||||
16,
|
||||
)
|
||||
.unwrap();
|
||||
let json = serde_json::to_string(&p).unwrap();
|
||||
assert!(json.contains("\"apiKeyEnv\":\"MY_KEY\""), "camelCase apiKeyEnv: {json}");
|
||||
assert!(json.contains("\"strategy\":\"api\""), "camelCase strategy: {json}");
|
||||
// `model` is None ⇒ skipped from the wire form.
|
||||
assert!(!json.contains("\"model\""), "None model must be skipped: {json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_deserializes_from_camel_case_document() {
|
||||
let json = r#"{
|
||||
"id": "srv",
|
||||
"name": "Server",
|
||||
"strategy": "localServer",
|
||||
"endpoint": "http://127.0.0.1:9000",
|
||||
"dimension": 512
|
||||
}"#;
|
||||
let p: EmbedderProfile = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(p.id, "srv");
|
||||
assert_eq!(p.strategy, EmbedderStrategy::LocalServer);
|
||||
assert_eq!(p.endpoint.as_deref(), Some("http://127.0.0.1:9000"));
|
||||
assert_eq!(p.dimension, 512);
|
||||
assert!(p.model.is_none());
|
||||
assert!(p.api_key_env.is_none());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// none(): the dependency-free default.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn none_profile_has_none_strategy() {
|
||||
let p = EmbedderProfile::none();
|
||||
assert_eq!(p.strategy, EmbedderStrategy::None);
|
||||
assert_eq!(p.id, "none");
|
||||
assert!(p.dimension > 0, "even the none profile keeps a non-zero dimension");
|
||||
// And it round-trips like any other profile.
|
||||
roundtrip(&p);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// new(): validation.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn new_rejects_empty_id() {
|
||||
assert!(matches!(
|
||||
EmbedderProfile::new("", "Name", EmbedderStrategy::None, None, None, None, 8),
|
||||
Err(DomainError::EmptyField { field: "embedder.id" })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rejects_empty_name() {
|
||||
assert!(matches!(
|
||||
EmbedderProfile::new("id", "", EmbedderStrategy::None, None, None, None, 8),
|
||||
Err(DomainError::EmptyField {
|
||||
field: "embedder.name"
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rejects_zero_dimension() {
|
||||
assert!(matches!(
|
||||
EmbedderProfile::new("id", "Name", EmbedderStrategy::None, None, None, None, 0),
|
||||
Err(DomainError::EmptyField {
|
||||
field: "embedder.dimension"
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_accepts_valid_minimal_profile() {
|
||||
let p = EmbedderProfile::new("id", "Name", EmbedderStrategy::None, None, None, None, 1).unwrap();
|
||||
assert_eq!(p.dimension, 1);
|
||||
assert_eq!(p.strategy, EmbedderStrategy::None);
|
||||
}
|
||||
187
crates/domain/tests/memory.rs
Normal file
187
crates/domain/tests/memory.rs
Normal 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\""
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user