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:
@ -40,5 +40,8 @@ pub use pty::PortablePtyAdapter;
|
||||
pub use remote::{remote_host, LocalHost};
|
||||
pub use runtime::CliAgentRuntime;
|
||||
pub use store::{
|
||||
FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore, IdeaiContextStore,
|
||||
embedder_from_profile, index_token_size, should_use_vector, AdaptiveMemoryRecall,
|
||||
FsEmbedderProfileStore, FsMemoryStore, FsProfileStore, FsProjectStore, FsSkillStore,
|
||||
FsTemplateStore, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall, StubEmbedder,
|
||||
VectorMemoryRecall,
|
||||
};
|
||||
|
||||
173
crates/infrastructure/src/store/embedder.rs
Normal file
173
crates/infrastructure/src/store/embedder.rs
Normal file
@ -0,0 +1,173 @@
|
||||
//! [`Embedder`] adapters (LOT C, étage 2 vectoriel — §14.5.3).
|
||||
//!
|
||||
//! The étage-2 semantic recall is driven by a declarative
|
||||
//! [`EmbedderProfile`]; this module turns such a profile into a concrete
|
||||
//! [`Embedder`] via [`embedder_from_profile`].
|
||||
//!
|
||||
//! ## Zero heavy dependency by design
|
||||
//!
|
||||
//! IdeA's founding posture is **default `none`, nothing imposed, zero
|
||||
//! dependency** (ARCHITECTURE §14.5.3). The concrete engines therefore ship as
|
||||
//! **documented stubs**:
|
||||
//!
|
||||
//! - [`EmbedderStrategy::LocalOnnx`] / [`EmbedderStrategy::LocalServer`] /
|
||||
//! [`EmbedderStrategy::Api`] map to [`StubEmbedder`], which returns a clean
|
||||
//! [`EmbedderError::Unsupported`] (never a panic) — the real ONNX/HTTP
|
||||
//! integration is an explicit follow-up that would add the heavy crate behind a
|
||||
//! feature, here and only here;
|
||||
//! - [`EmbedderStrategy::None`] yields no embedder (`None`): recall stays the
|
||||
//! dependency-free naïve étage 1.
|
||||
//!
|
||||
//! [`HashEmbedder`] is a small **deterministic, dependency-free** embedder used to
|
||||
//! exercise the whole vector pipeline (it is not a stub: it produces stable,
|
||||
//! repeatable vectors from a hashing bag-of-words, good enough for tests and for a
|
||||
//! trivial offline fallback).
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use domain::ports::{Embedder, EmbedderError};
|
||||
use domain::profile::{EmbedderProfile, EmbedderStrategy};
|
||||
|
||||
/// Builds the concrete [`Embedder`] for a profile, or `None` when the strategy is
|
||||
/// [`EmbedderStrategy::None`] (recall stays naïve).
|
||||
///
|
||||
/// The concrete engines are stubs ([`StubEmbedder`]) until their real ONNX/HTTP
|
||||
/// integration lands; they fail cleanly with [`EmbedderError::Unsupported`] rather
|
||||
/// than panic, so a composing recall degrades to naïve.
|
||||
#[must_use]
|
||||
pub fn embedder_from_profile(profile: &EmbedderProfile) -> Option<Box<dyn Embedder>> {
|
||||
match profile.strategy {
|
||||
EmbedderStrategy::None => None,
|
||||
EmbedderStrategy::LocalOnnx => Some(Box::new(StubEmbedder::new(
|
||||
profile.id.clone(),
|
||||
profile.dimension,
|
||||
"localOnnx",
|
||||
))),
|
||||
EmbedderStrategy::LocalServer => Some(Box::new(StubEmbedder::new(
|
||||
profile.id.clone(),
|
||||
profile.dimension,
|
||||
"localServer",
|
||||
))),
|
||||
EmbedderStrategy::Api => Some(Box::new(StubEmbedder::new(
|
||||
profile.id.clone(),
|
||||
profile.dimension,
|
||||
"api",
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// A placeholder [`Embedder`] for a not-yet-implemented concrete strategy.
|
||||
///
|
||||
/// Every [`embed`](Embedder::embed) call returns [`EmbedderError::Unsupported`]
|
||||
/// (never a panic), naming the strategy. It exists so the wiring, profiles, and
|
||||
/// adapters are all in place and testable today; swapping in the real engine is a
|
||||
/// localised follow-up.
|
||||
pub struct StubEmbedder {
|
||||
id: String,
|
||||
dimension: usize,
|
||||
strategy: &'static str,
|
||||
}
|
||||
|
||||
impl StubEmbedder {
|
||||
/// Builds a stub for `strategy` advertising `dimension`.
|
||||
#[must_use]
|
||||
pub fn new(id: impl Into<String>, dimension: usize, strategy: &'static str) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
dimension,
|
||||
strategy,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Embedder for StubEmbedder {
|
||||
fn id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
|
||||
async fn embed(&self, _texts: &[String]) -> Result<Vec<Vec<f32>>, EmbedderError> {
|
||||
Err(EmbedderError::Unsupported(format!(
|
||||
"embedder strategy `{}` is not implemented yet (follow-up: real ONNX/HTTP backend)",
|
||||
self.strategy
|
||||
)))
|
||||
}
|
||||
|
||||
fn dimension(&self) -> usize {
|
||||
self.dimension
|
||||
}
|
||||
}
|
||||
|
||||
/// A deterministic, dependency-free [`Embedder`] backed by a hashing
|
||||
/// bag-of-words. Pure std (FNV-1a hashing of whitespace tokens into `dimension`
|
||||
/// buckets, then L2-normalised). Stable across runs and platforms.
|
||||
///
|
||||
/// It is **not** a stub: it lets the étage-2 pipeline run end-to-end with no heavy
|
||||
/// dependency. Vector quality is crude (lexical overlap only), but the
|
||||
/// [`Embedder`] contract is fully honoured, so it is a valid offline embedder and
|
||||
/// the substrate for deterministic tests of [`crate::store::VectorMemoryRecall`].
|
||||
#[derive(Clone)]
|
||||
pub struct HashEmbedder {
|
||||
id: String,
|
||||
dimension: usize,
|
||||
}
|
||||
|
||||
impl HashEmbedder {
|
||||
/// Builds a hashing embedder producing `dimension`-length vectors.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if `dimension` is `0` (an embedder must produce fixed-length
|
||||
/// non-empty vectors).
|
||||
#[must_use]
|
||||
pub fn new(id: impl Into<String>, dimension: usize) -> Self {
|
||||
assert!(dimension > 0, "HashEmbedder dimension must be non-zero");
|
||||
Self {
|
||||
id: id.into(),
|
||||
dimension,
|
||||
}
|
||||
}
|
||||
|
||||
/// Embeds one text into an L2-normalised `dimension`-length vector.
|
||||
fn embed_one(&self, text: &str) -> Vec<f32> {
|
||||
let mut v = vec![0f32; self.dimension];
|
||||
for token in text.split_whitespace() {
|
||||
let lower = token.to_ascii_lowercase();
|
||||
let bucket = (fnv1a(lower.as_bytes()) as usize) % self.dimension;
|
||||
v[bucket] += 1.0;
|
||||
}
|
||||
let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
if norm > 0.0 {
|
||||
for x in &mut v {
|
||||
*x /= norm;
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Embedder for HashEmbedder {
|
||||
fn id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
|
||||
async fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbedderError> {
|
||||
Ok(texts.iter().map(|t| self.embed_one(t)).collect())
|
||||
}
|
||||
|
||||
fn dimension(&self) -> usize {
|
||||
self.dimension
|
||||
}
|
||||
}
|
||||
|
||||
/// FNV-1a 64-bit hash of a byte slice (deterministic, dependency-free).
|
||||
fn fnv1a(bytes: &[u8]) -> u64 {
|
||||
const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
const PRIME: u64 = 0x0000_0100_0000_01b3;
|
||||
let mut hash = OFFSET;
|
||||
for &b in bytes {
|
||||
hash ^= u64::from(b);
|
||||
hash = hash.wrapping_mul(PRIME);
|
||||
}
|
||||
hash
|
||||
}
|
||||
492
crates/infrastructure/src/store/memory.rs
Normal file
492
crates/infrastructure/src/store/memory.rs
Normal file
@ -0,0 +1,492 @@
|
||||
//! [`FsMemoryStore`] — file implementation of the [`MemoryStore`] port
|
||||
//! (LOT A, étage 1).
|
||||
//!
|
||||
//! Memory notes are the project's persistent, model-agnostic knowledge base. Each
|
||||
//! note is a single Markdown file with a YAML frontmatter header, stored under the
|
||||
//! project's `.ideai/memory/`:
|
||||
//!
|
||||
//! ```text
|
||||
//! <project_root>/.ideai/memory/
|
||||
//! ├── MEMORY.md # aggregated index: one `- [Title](slug.md) — hook` line per note
|
||||
//! └── <slug>.md # a note: YAML frontmatter + Markdown body
|
||||
//! ```
|
||||
//!
|
||||
//! A note file looks like:
|
||||
//!
|
||||
//! ```text
|
||||
//! ---
|
||||
//! name: my-note
|
||||
//! description: A one-line hook
|
||||
//! metadata:
|
||||
//! type: project
|
||||
//! ---
|
||||
//! # Body
|
||||
//! ...
|
||||
//! ```
|
||||
//!
|
||||
//! The `.md` files are the **single source of truth**; `MEMORY.md` is derived and
|
||||
//! kept in sync on every [`save`](MemoryStore::save)/[`delete`](MemoryStore::delete)
|
||||
//! (idempotent upsert / removal of the note's line). All I/O goes through the
|
||||
//! [`FileSystem`] port, so the adapter is location-neutral (SSH/WSL work unchanged)
|
||||
//! and Tauri-agnostic.
|
||||
//!
|
||||
//! Like the sibling stores, [`delete`](MemoryStore::delete) drops the note's line
|
||||
//! from `MEMORY.md` and leaves the orphaned `<slug>.md` on disk (the [`FileSystem`]
|
||||
//! port exposes no remove); since listing is index-driven, the note is effectively
|
||||
//! gone.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::memory::{
|
||||
Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug, MemoryType,
|
||||
};
|
||||
use domain::ports::{
|
||||
FileSystem, FsError, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, RemotePath,
|
||||
};
|
||||
use domain::project::ProjectPath;
|
||||
|
||||
/// The `.ideai/` directory name inside a project root.
|
||||
const IDEAI_DIR: &str = ".ideai";
|
||||
|
||||
/// Sub-path of the memory store inside `.ideai/`.
|
||||
const MEMORY_DIR: &str = "memory";
|
||||
|
||||
/// Aggregated index file name inside the memory dir.
|
||||
const INDEX_FILE: &str = "MEMORY.md";
|
||||
|
||||
/// First line of the aggregated index.
|
||||
const INDEX_HEADER: &str = "# Memory Index";
|
||||
|
||||
/// File-backed [`MemoryStore`], composing a [`FileSystem`] port. The project root
|
||||
/// is supplied **per call**, so a single instance serves every open project
|
||||
/// (mirroring [`crate::store::FsSkillStore`]).
|
||||
#[derive(Clone)]
|
||||
pub struct FsMemoryStore {
|
||||
fs: Arc<dyn FileSystem>,
|
||||
}
|
||||
|
||||
impl FsMemoryStore {
|
||||
/// Builds the store from an injected [`FileSystem`]. Directories are created
|
||||
/// on first write.
|
||||
#[must_use]
|
||||
pub fn new(fs: Arc<dyn FileSystem>) -> Self {
|
||||
Self { fs }
|
||||
}
|
||||
|
||||
/// `<root>/.ideai/memory`.
|
||||
fn dir(&self, root: &ProjectPath) -> String {
|
||||
let base = root.as_str().trim_end_matches(['/', '\\']);
|
||||
format!("{base}/{IDEAI_DIR}/{MEMORY_DIR}")
|
||||
}
|
||||
|
||||
/// `<memory-dir>/<slug>.md`.
|
||||
fn md_path(&self, root: &ProjectPath, slug: &MemorySlug) -> RemotePath {
|
||||
RemotePath::new(format!("{}/{}.md", self.dir(root), slug.as_str()))
|
||||
}
|
||||
|
||||
/// `<memory-dir>/MEMORY.md`.
|
||||
fn index_path(&self, root: &ProjectPath) -> RemotePath {
|
||||
RemotePath::new(format!("{}/{INDEX_FILE}", self.dir(root)))
|
||||
}
|
||||
|
||||
/// Reads and parses a note by slug.
|
||||
async fn load(&self, root: &ProjectPath, slug: &MemorySlug) -> Result<Memory, MemoryError> {
|
||||
let bytes = self
|
||||
.fs
|
||||
.read(&self.md_path(root, slug))
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
FsError::NotFound(_) => MemoryError::NotFound,
|
||||
other => MemoryError::Io(other.to_string()),
|
||||
})?;
|
||||
let text = String::from_utf8(bytes).map_err(|e| MemoryError::Io(e.to_string()))?;
|
||||
parse_note(&text)
|
||||
}
|
||||
|
||||
/// Reads the raw `MEMORY.md` text, or `None` if it does not exist yet.
|
||||
async fn read_index_text(&self, root: &ProjectPath) -> Result<Option<String>, MemoryError> {
|
||||
match self.fs.read(&self.index_path(root)).await {
|
||||
Ok(bytes) => String::from_utf8(bytes)
|
||||
.map(Some)
|
||||
.map_err(|e| MemoryError::Io(e.to_string())),
|
||||
Err(FsError::NotFound(_)) => Ok(None),
|
||||
Err(e) => Err(MemoryError::Io(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewrites `MEMORY.md` from the given entries, ensuring the dir exists.
|
||||
async fn write_index(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
entries: &[MemoryIndexEntry],
|
||||
) -> Result<(), MemoryError> {
|
||||
self.fs
|
||||
.create_dir_all(&RemotePath::new(self.dir(root)))
|
||||
.await
|
||||
.map_err(|e| MemoryError::Io(e.to_string()))?;
|
||||
let text = render_index(entries);
|
||||
self.fs
|
||||
.write(&self.index_path(root), text.as_bytes())
|
||||
.await
|
||||
.map_err(|e| MemoryError::Io(e.to_string()))
|
||||
}
|
||||
|
||||
/// Lists the slugs known to the index (index-driven listing).
|
||||
async fn index_slugs(&self, root: &ProjectPath) -> Result<Vec<MemorySlug>, MemoryError> {
|
||||
Ok(self
|
||||
.read_index(root)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|e| e.slug)
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MemoryStore for FsMemoryStore {
|
||||
async fn list(&self, root: &ProjectPath) -> Result<Vec<Memory>, MemoryError> {
|
||||
let slugs = self.index_slugs(root).await?;
|
||||
let mut out = Vec::with_capacity(slugs.len());
|
||||
for slug in &slugs {
|
||||
out.push(self.load(root, slug).await?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn get(&self, root: &ProjectPath, slug: &MemorySlug) -> Result<Memory, MemoryError> {
|
||||
self.load(root, slug).await
|
||||
}
|
||||
|
||||
async fn save(&self, root: &ProjectPath, memory: &Memory) -> Result<(), MemoryError> {
|
||||
// (1) Write the note file.
|
||||
self.fs
|
||||
.create_dir_all(&RemotePath::new(self.dir(root)))
|
||||
.await
|
||||
.map_err(|e| MemoryError::Io(e.to_string()))?;
|
||||
let text = render_note(memory);
|
||||
self.fs
|
||||
.write(&self.md_path(root, memory.slug()), text.as_bytes())
|
||||
.await
|
||||
.map_err(|e| MemoryError::Io(e.to_string()))?;
|
||||
|
||||
// (2) Upsert the index line idempotently (same slug => one line).
|
||||
let mut entries = self.read_index(root).await?;
|
||||
let row = memory.index_entry();
|
||||
if let Some(slot) = entries.iter_mut().find(|e| e.slug == row.slug) {
|
||||
*slot = row;
|
||||
} else {
|
||||
entries.push(row);
|
||||
}
|
||||
self.write_index(root, &entries).await
|
||||
}
|
||||
|
||||
async fn delete(&self, root: &ProjectPath, slug: &MemorySlug) -> Result<(), MemoryError> {
|
||||
let mut entries = self.read_index(root).await?;
|
||||
let before = entries.len();
|
||||
entries.retain(|e| &e.slug != slug);
|
||||
if entries.len() == before {
|
||||
return Err(MemoryError::NotFound);
|
||||
}
|
||||
// The orphaned `<slug>.md` is left on disk (no FileSystem delete); the
|
||||
// index no longer references it, so it is effectively gone.
|
||||
self.write_index(root, &entries).await
|
||||
}
|
||||
|
||||
async fn read_index(&self, root: &ProjectPath) -> Result<Vec<MemoryIndexEntry>, MemoryError> {
|
||||
match self.read_index_text(root).await? {
|
||||
Some(text) => Ok(parse_index(&text)),
|
||||
None => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_links(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
slug: &MemorySlug,
|
||||
) -> Result<Vec<MemoryLink>, MemoryError> {
|
||||
let memory = self.load(root, slug).await?;
|
||||
let known = self.index_slugs(root).await?;
|
||||
// Keep only links whose target resolves to a known note (ignore broken).
|
||||
Ok(memory
|
||||
.outgoing_links()
|
||||
.into_iter()
|
||||
.filter(|link| known.contains(&link.target))
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NaiveMemoryRecall — the default, dependency-free MemoryRecall (LOT B).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Heuristic divisor turning a character count into an approximate token count
|
||||
/// (~4 characters per token, the usual rule of thumb). Shared by every
|
||||
/// [`MemoryRecall`] adapter so the budget semantics stay identical (DRY).
|
||||
pub(crate) const CHARS_PER_TOKEN: usize = 4;
|
||||
|
||||
/// Approximate token cost of an index entry's textual payload — the single
|
||||
/// budget-cost function shared by every [`MemoryRecall`] adapter (naïve, vector,
|
||||
/// adaptive), so truncation semantics are identical across them (Liskov / DRY).
|
||||
pub(crate) fn entry_cost(entry: &MemoryIndexEntry) -> usize {
|
||||
let chars = entry.title.len() + entry.hook.len();
|
||||
chars.div_ceil(CHARS_PER_TOKEN)
|
||||
}
|
||||
|
||||
/// Greedily takes entries in the given order while their accumulated
|
||||
/// [`entry_cost`] stays within `budget`; stops at the first entry that would
|
||||
/// exceed it (and drops every entry after). A `budget` of `0` yields an empty
|
||||
/// vec. This is the shared truncation used by every recall adapter.
|
||||
pub(crate) fn truncate_to_budget(
|
||||
entries: impl IntoIterator<Item = MemoryIndexEntry>,
|
||||
budget: usize,
|
||||
) -> Vec<MemoryIndexEntry> {
|
||||
if budget == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut spent = 0usize;
|
||||
let mut out = Vec::new();
|
||||
for entry in entries {
|
||||
let cost = entry_cost(&entry);
|
||||
if spent + cost > budget {
|
||||
break;
|
||||
}
|
||||
spent += cost;
|
||||
out.push(entry);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Total approximate token cost of an index — a **pure** function of the entries,
|
||||
/// used by [`crate::store::AdaptiveMemoryRecall`] to decide naïve vs. vector
|
||||
/// recall without any I/O.
|
||||
#[must_use]
|
||||
pub fn index_token_size(entries: &[MemoryIndexEntry]) -> usize {
|
||||
entries.iter().map(entry_cost).sum()
|
||||
}
|
||||
|
||||
/// The default [`MemoryRecall`]: dependency-free, ignores semantic relevance.
|
||||
///
|
||||
/// It composes an [`Arc<dyn MemoryStore>`], reads the aggregated index via
|
||||
/// [`MemoryStore::read_index`], and returns the entries **in index order**,
|
||||
/// truncated to fit the query's token budget. It is the baseline against which a
|
||||
/// future `VectorMemoryRecall` (LOT C) is substitutable.
|
||||
///
|
||||
/// ## Budget semantics
|
||||
/// `token_budget` is an *approximate* budget. Each entry's cost is estimated as
|
||||
/// `ceil((title.len() + hook.len()) / 4)` tokens (≈ 4 chars/token, counting only
|
||||
/// the index line's textual payload). Entries are taken in order, accumulating
|
||||
/// their cost; the first entry whose inclusion would exceed the budget — and every
|
||||
/// entry after it — is dropped. A budget of `0` therefore yields an empty list;
|
||||
/// an empty or missing memory yields an empty list without error.
|
||||
#[derive(Clone)]
|
||||
pub struct NaiveMemoryRecall {
|
||||
store: Arc<dyn MemoryStore>,
|
||||
}
|
||||
|
||||
impl NaiveMemoryRecall {
|
||||
/// Builds the recall adapter from a composed [`MemoryStore`].
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn MemoryStore>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MemoryRecall for NaiveMemoryRecall {
|
||||
async fn recall(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
query: &MemoryQuery,
|
||||
) -> Result<Vec<MemoryIndexEntry>, MemoryError> {
|
||||
// Budget-0 short-circuits before any I/O: a zero budget can hold no entry,
|
||||
// so there is nothing to read (homogeneous with every recall adapter).
|
||||
if query.token_budget == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let entries = self.store.read_index(root).await?;
|
||||
Ok(truncate_to_budget(entries, query.token_budget))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// On-disk format: YAML frontmatter + body, and the MEMORY.md index.
|
||||
//
|
||||
// We hand-roll a tiny, well-scoped YAML reader/writer for exactly the frontmatter
|
||||
// shape we own (`name`, `description`, `metadata.type`). This keeps the crate free
|
||||
// of a YAML dependency for a fixed, simple schema; any deviation surfaces as a
|
||||
// `MemoryError::Frontmatter`.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Renders a note to its on-disk `---`-fenced frontmatter + body form.
|
||||
fn render_note(memory: &Memory) -> String {
|
||||
let fm = &memory.frontmatter;
|
||||
format!(
|
||||
"---\nname: {}\ndescription: {}\nmetadata:\n type: {}\n---\n{}",
|
||||
fm.name.as_str(),
|
||||
fm.description,
|
||||
type_to_str(fm.r#type),
|
||||
memory.body.as_str(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Parses a note from its on-disk form.
|
||||
fn parse_note(text: &str) -> Result<Memory, MemoryError> {
|
||||
let rest = text
|
||||
.strip_prefix("---\n")
|
||||
.or_else(|| text.strip_prefix("---\r\n"))
|
||||
.ok_or_else(|| MemoryError::Frontmatter("missing opening `---` fence".to_string()))?;
|
||||
|
||||
// Find the closing `---` fence at the start of a line.
|
||||
let (fm_block, body) = split_frontmatter(rest)
|
||||
.ok_or_else(|| MemoryError::Frontmatter("missing closing `---` fence".to_string()))?;
|
||||
|
||||
let frontmatter = parse_frontmatter(fm_block)?;
|
||||
Memory::new(frontmatter, MarkdownDoc::new(body))
|
||||
.map_err(|e| MemoryError::Frontmatter(e.to_string()))
|
||||
}
|
||||
|
||||
/// Splits the post-opening-fence text into `(frontmatter_block, body)` at the
|
||||
/// closing `---` line. Returns `None` if no closing fence is present.
|
||||
fn split_frontmatter(rest: &str) -> Option<(&str, &str)> {
|
||||
let mut offset = 0;
|
||||
for line in rest.split_inclusive('\n') {
|
||||
let trimmed = line.trim_end_matches(['\n', '\r']);
|
||||
if trimmed == "---" {
|
||||
let fm = &rest[..offset];
|
||||
let body = &rest[offset + line.len()..];
|
||||
return Some((fm, body));
|
||||
}
|
||||
offset += line.len();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Parses the frontmatter key/values into a validated [`MemoryFrontmatter`].
|
||||
fn parse_frontmatter(block: &str) -> Result<MemoryFrontmatter, MemoryError> {
|
||||
let err = |reason: &str| MemoryError::Frontmatter(reason.to_string());
|
||||
|
||||
let mut name: Option<String> = None;
|
||||
let mut description: Option<String> = None;
|
||||
let mut type_str: Option<String> = None;
|
||||
let mut in_metadata = false;
|
||||
|
||||
for raw in block.lines() {
|
||||
if raw.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let indented = raw.starts_with(' ') || raw.starts_with('\t');
|
||||
let (key, value) = raw
|
||||
.split_once(':')
|
||||
.ok_or_else(|| err("frontmatter line missing `:`"))?;
|
||||
let key = key.trim();
|
||||
let value = value.trim();
|
||||
|
||||
if !indented {
|
||||
in_metadata = false;
|
||||
match key {
|
||||
"name" => name = Some(value.to_string()),
|
||||
"description" => description = Some(value.to_string()),
|
||||
"metadata" => {
|
||||
in_metadata = true;
|
||||
if !value.is_empty() {
|
||||
return Err(err("`metadata` must be a nested block"));
|
||||
}
|
||||
}
|
||||
_ => return Err(err("unknown frontmatter key")),
|
||||
}
|
||||
} else if in_metadata && key == "type" {
|
||||
type_str = Some(value.to_string());
|
||||
} else {
|
||||
return Err(err("unexpected indented frontmatter line"));
|
||||
}
|
||||
}
|
||||
|
||||
let name = name.ok_or_else(|| err("missing `name`"))?;
|
||||
let description = description.ok_or_else(|| err("missing `description`"))?;
|
||||
let type_str = type_str.ok_or_else(|| err("missing `metadata.type`"))?;
|
||||
|
||||
let name = MemorySlug::new(name).map_err(|e| MemoryError::Frontmatter(e.to_string()))?;
|
||||
let r#type = str_to_type(&type_str).ok_or_else(|| err("unknown `metadata.type` value"))?;
|
||||
|
||||
Ok(MemoryFrontmatter {
|
||||
name,
|
||||
description,
|
||||
r#type,
|
||||
})
|
||||
}
|
||||
|
||||
/// Renders the aggregated `MEMORY.md` index.
|
||||
fn render_index(entries: &[MemoryIndexEntry]) -> String {
|
||||
let mut out = String::from(INDEX_HEADER);
|
||||
out.push('\n');
|
||||
if !entries.is_empty() {
|
||||
out.push('\n');
|
||||
for e in entries {
|
||||
out.push_str(&format!(
|
||||
"- [{}]({}.md) — {}\n",
|
||||
e.title,
|
||||
e.slug.as_str(),
|
||||
e.hook
|
||||
));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Parses the `MEMORY.md` index lines back into structured entries. Lines that do
|
||||
/// not match the `- [Title](slug.md) — hook` shape are skipped (tolerant read);
|
||||
/// the `type` is not stored in the index line and defaults to
|
||||
/// [`MemoryType::Reference`].
|
||||
fn parse_index(text: &str) -> Vec<MemoryIndexEntry> {
|
||||
let mut out = Vec::new();
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if !line.starts_with("- [") {
|
||||
continue;
|
||||
}
|
||||
if let Some(entry) = parse_index_line(line) {
|
||||
out.push(entry);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Parses one `- [Title](slug.md) — hook` line.
|
||||
fn parse_index_line(line: &str) -> Option<MemoryIndexEntry> {
|
||||
let rest = line.strip_prefix("- [")?;
|
||||
let (title, rest) = rest.split_once("](")?;
|
||||
let (target, rest) = rest.split_once(')')?;
|
||||
let slug_str = target.strip_suffix(".md").unwrap_or(target);
|
||||
let slug = MemorySlug::new(slug_str).ok()?;
|
||||
let hook = rest.trim_start().strip_prefix('—').unwrap_or(rest).trim();
|
||||
Some(MemoryIndexEntry {
|
||||
slug,
|
||||
title: title.to_string(),
|
||||
hook: hook.to_string(),
|
||||
r#type: MemoryType::Reference,
|
||||
})
|
||||
}
|
||||
|
||||
/// Maps a [`MemoryType`] to its YAML/string form (matches serde camelCase).
|
||||
fn type_to_str(t: MemoryType) -> &'static str {
|
||||
match t {
|
||||
MemoryType::User => "user",
|
||||
MemoryType::Feedback => "feedback",
|
||||
MemoryType::Project => "project",
|
||||
MemoryType::Reference => "reference",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a [`MemoryType`] from its string form.
|
||||
fn str_to_type(s: &str) -> Option<MemoryType> {
|
||||
match s {
|
||||
"user" => Some(MemoryType::User),
|
||||
"feedback" => Some(MemoryType::Feedback),
|
||||
"project" => Some(MemoryType::Project),
|
||||
"reference" => Some(MemoryType::Reference),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@ -5,13 +5,19 @@
|
||||
//! JSON files in the app data directory (machine-local, outside any project).
|
||||
|
||||
mod context;
|
||||
mod embedder;
|
||||
mod memory;
|
||||
mod profile;
|
||||
mod project;
|
||||
mod skill;
|
||||
mod template;
|
||||
mod vector;
|
||||
|
||||
pub use context::IdeaiContextStore;
|
||||
pub use profile::FsProfileStore;
|
||||
pub use embedder::{embedder_from_profile, HashEmbedder, StubEmbedder};
|
||||
pub use memory::{index_token_size, FsMemoryStore, NaiveMemoryRecall};
|
||||
pub use profile::{FsEmbedderProfileStore, FsProfileStore};
|
||||
pub use project::FsProjectStore;
|
||||
pub use skill::FsSkillStore;
|
||||
pub use template::FsTemplateStore;
|
||||
pub use vector::{should_use_vector, AdaptiveMemoryRecall, VectorMemoryRecall};
|
||||
|
||||
@ -24,7 +24,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use domain::ids::ProfileId;
|
||||
use domain::ports::{FileSystem, ProfileStore, RemotePath, StoreError};
|
||||
use domain::profile::AgentProfile;
|
||||
use domain::profile::{AgentProfile, EmbedderProfile};
|
||||
|
||||
/// File name of the profiles store inside the app-data dir.
|
||||
const PROFILES_FILE: &str = "profiles.json";
|
||||
@ -147,3 +147,126 @@ impl ProfileStore for FsProfileStore {
|
||||
self.write_doc(&doc).await
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FsEmbedderProfileStore — declarative embedder profiles (`embedder.json`, LOT C).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// File name of the embedder-profiles store inside the app-data dir.
|
||||
const EMBEDDER_FILE: &str = "embedder.json";
|
||||
|
||||
/// Current schema version of the embedder-profiles file.
|
||||
const EMBEDDER_VERSION: u32 = 1;
|
||||
|
||||
/// On-disk shape of `embedder.json` (LOT C, §14.5.3), mirroring [`ProfilesDoc`].
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct EmbedderDoc {
|
||||
/// Schema version.
|
||||
version: u32,
|
||||
/// All configured embedder profiles.
|
||||
profiles: Vec<EmbedderProfile>,
|
||||
}
|
||||
|
||||
impl Default for EmbedderDoc {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
version: EMBEDDER_VERSION,
|
||||
profiles: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON-file store for declarative [`EmbedderProfile`]s (LOT C, étage 2 vectoriel),
|
||||
/// calqué on [`FsProfileStore`]: `embedder.json` in the global IDE app-data dir,
|
||||
/// mirroring `profiles.json`.
|
||||
///
|
||||
/// No domain `EmbedderProfileStore` port exists yet — embedder profiles are pure
|
||||
/// configuration loaded at the composition root, so this is a concrete loader. A
|
||||
/// dedicated port (parallel to [`ProfileStore`]) is an easy follow-up the day the
|
||||
/// UI needs CRUD over embedder profiles.
|
||||
///
|
||||
/// Cheap to clone (everything behind `Arc`).
|
||||
#[derive(Clone)]
|
||||
pub struct FsEmbedderProfileStore {
|
||||
fs: Arc<dyn FileSystem>,
|
||||
app_data_dir: String,
|
||||
}
|
||||
|
||||
impl FsEmbedderProfileStore {
|
||||
/// Builds the store from an injected [`FileSystem`] and the app-data dir.
|
||||
#[must_use]
|
||||
pub fn new(fs: Arc<dyn FileSystem>, app_data_dir: impl Into<String>) -> Self {
|
||||
Self {
|
||||
fs,
|
||||
app_data_dir: app_data_dir.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `<app-data-dir>/embedder.json`.
|
||||
fn path(&self) -> RemotePath {
|
||||
let base = self.app_data_dir.trim_end_matches(['/', '\\']);
|
||||
RemotePath::new(format!("{base}/{EMBEDDER_FILE}"))
|
||||
}
|
||||
|
||||
async fn read_doc(&self) -> Result<EmbedderDoc, StoreError> {
|
||||
match self.fs.read(&self.path()).await {
|
||||
Ok(bytes) => {
|
||||
serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string()))
|
||||
}
|
||||
Err(domain::ports::FsError::NotFound(_)) => Ok(EmbedderDoc::default()),
|
||||
Err(e) => Err(StoreError::Io(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_doc(&self, doc: &EmbedderDoc) -> Result<(), StoreError> {
|
||||
let dir = RemotePath::new(self.app_data_dir.trim_end_matches(['/', '\\']).to_owned());
|
||||
self.fs
|
||||
.create_dir_all(&dir)
|
||||
.await
|
||||
.map_err(|e| StoreError::Io(e.to_string()))?;
|
||||
let bytes =
|
||||
serde_json::to_vec_pretty(doc).map_err(|e| StoreError::Serialization(e.to_string()))?;
|
||||
self.fs
|
||||
.write(&self.path(), &bytes)
|
||||
.await
|
||||
.map_err(|e| StoreError::Io(e.to_string()))
|
||||
}
|
||||
|
||||
/// Lists all configured embedder profiles (empty when `embedder.json` is
|
||||
/// absent — the default `none` posture).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`StoreError`] on an I/O or deserialisation failure.
|
||||
pub async fn list(&self) -> Result<Vec<EmbedderProfile>, StoreError> {
|
||||
Ok(self.read_doc().await?.profiles)
|
||||
}
|
||||
|
||||
/// Saves (creates or replaces by id) an embedder profile.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`StoreError`] on failure.
|
||||
pub async fn save(&self, profile: &EmbedderProfile) -> Result<(), StoreError> {
|
||||
let mut doc = self.read_doc().await?;
|
||||
if let Some(slot) = doc.profiles.iter_mut().find(|p| p.id == profile.id) {
|
||||
*slot = profile.clone();
|
||||
} else {
|
||||
doc.profiles.push(profile.clone());
|
||||
}
|
||||
self.write_doc(&doc).await
|
||||
}
|
||||
|
||||
/// Deletes an embedder profile by id.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`StoreError::NotFound`] if no profile carries that id.
|
||||
pub async fn delete(&self, id: &str) -> Result<(), StoreError> {
|
||||
let mut doc = self.read_doc().await?;
|
||||
let before = doc.profiles.len();
|
||||
doc.profiles.retain(|p| p.id != id);
|
||||
if doc.profiles.len() == before {
|
||||
return Err(StoreError::NotFound);
|
||||
}
|
||||
self.write_doc(&doc).await
|
||||
}
|
||||
}
|
||||
|
||||
347
crates/infrastructure/src/store/vector.rs
Normal file
347
crates/infrastructure/src/store/vector.rs
Normal file
@ -0,0 +1,347 @@
|
||||
//! Étage-2 semantic recall (LOT C, §14.5.3): [`VectorMemoryRecall`] and the
|
||||
//! [`AdaptiveMemoryRecall`] switch.
|
||||
//!
|
||||
//! [`VectorMemoryRecall`] composes an [`Embedder`] + a [`MemoryStore`] + a small
|
||||
//! **derived vector store** under `.ideai/memory/.index/`, ranks the memory index
|
||||
//! entries by cosine similarity to the query, and truncates to the token budget —
|
||||
//! the **same** budget/emptiness semantics as [`super::NaiveMemoryRecall`]
|
||||
//! (Liskov), only the relevance strategy differs.
|
||||
//!
|
||||
//! [`AdaptiveMemoryRecall`] composes both and routes between them by an
|
||||
//! **objective, pure, I/O-free** decision ([`should_use_vector`]): tiny memories
|
||||
//! or a `none` strategy go to the naïve recall; otherwise to the vector recall,
|
||||
//! with an automatic **fallback to naïve** whenever the embedder is unavailable.
|
||||
//! No path of this module ever fails hard on a missing/unavailable embedder.
|
||||
//!
|
||||
//! ## Derived vector store format
|
||||
//!
|
||||
//! `.ideai/memory/.index/vectors.json` holds, per slug, the embedding of that
|
||||
//! note's index line (title + hook), tagged with the producing embedder id and
|
||||
//! its dimension:
|
||||
//!
|
||||
//! ```json
|
||||
//! {
|
||||
//! "version": 1,
|
||||
//! "embedderId": "hash-embedder",
|
||||
//! "dimension": 64,
|
||||
//! "vectors": { "my-note": [0.1, 0.0, ...] }
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! It is **derived data, fully reconstructible** from the `.md` source of truth,
|
||||
//! and is **gitignored** (`.ideai/memory/.index/`). A stale or absent file is not
|
||||
//! an error: missing vectors are recomputed on demand and the file is refreshed
|
||||
//! best-effort. A change of embedder id/dimension invalidates the whole file.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use domain::memory::MemoryIndexEntry;
|
||||
use domain::ports::{
|
||||
Embedder, EmbedderError, FileSystem, FsError, MemoryError, MemoryQuery, MemoryRecall,
|
||||
MemoryStore, RemotePath,
|
||||
};
|
||||
use domain::profile::EmbedderStrategy;
|
||||
use domain::project::ProjectPath;
|
||||
|
||||
use super::memory::{index_token_size, truncate_to_budget};
|
||||
|
||||
/// `.ideai/` directory name (mirrors [`super::memory`]).
|
||||
const IDEAI_DIR: &str = ".ideai";
|
||||
/// Memory sub-dir.
|
||||
const MEMORY_DIR: &str = "memory";
|
||||
/// Derived vector-store sub-dir (gitignored).
|
||||
const INDEX_DIR: &str = ".index";
|
||||
/// Derived vector-store file.
|
||||
const VECTORS_FILE: &str = "vectors.json";
|
||||
/// Schema version of the derived vector store.
|
||||
const VECTORS_VERSION: u32 = 1;
|
||||
|
||||
/// On-disk shape of `.ideai/memory/.index/vectors.json`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct VectorDoc {
|
||||
/// Schema version.
|
||||
version: u32,
|
||||
/// Id of the embedder that produced these vectors (a mismatch invalidates).
|
||||
embedder_id: String,
|
||||
/// Vector length (a mismatch invalidates).
|
||||
dimension: usize,
|
||||
/// Per-slug embedding of the note's index line.
|
||||
vectors: HashMap<String, Vec<f32>>,
|
||||
}
|
||||
|
||||
impl VectorDoc {
|
||||
fn new(embedder_id: String, dimension: usize) -> Self {
|
||||
Self {
|
||||
version: VECTORS_VERSION,
|
||||
embedder_id,
|
||||
dimension,
|
||||
vectors: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this doc was produced by the given embedder (id + dimension).
|
||||
fn matches(&self, embedder_id: &str, dimension: usize) -> bool {
|
||||
self.version == VECTORS_VERSION
|
||||
&& self.embedder_id == embedder_id
|
||||
&& self.dimension == dimension
|
||||
}
|
||||
}
|
||||
|
||||
/// Semantic [`MemoryRecall`] (étage 2): ranks the memory index by cosine
|
||||
/// similarity of each note's index line to the query text, truncated to the token
|
||||
/// budget (identical budget semantics to [`super::NaiveMemoryRecall`]).
|
||||
///
|
||||
/// Composes an [`Embedder`], a [`MemoryStore`] (the index source of truth), and a
|
||||
/// [`FileSystem`] for the derived vector cache. **Best-effort**: any
|
||||
/// [`EmbedderError`] surfaces as [`EmbedderError`] to the caller
|
||||
/// ([`AdaptiveMemoryRecall`] turns it into a naïve fallback); a missing/empty
|
||||
/// memory yields an empty list, never an error.
|
||||
#[derive(Clone)]
|
||||
pub struct VectorMemoryRecall {
|
||||
embedder: Arc<dyn Embedder>,
|
||||
store: Arc<dyn MemoryStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
}
|
||||
|
||||
impl VectorMemoryRecall {
|
||||
/// Builds the vector recall from its composed ports.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
embedder: Arc<dyn Embedder>,
|
||||
store: Arc<dyn MemoryStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
) -> Self {
|
||||
Self {
|
||||
embedder,
|
||||
store,
|
||||
fs,
|
||||
}
|
||||
}
|
||||
|
||||
/// `<root>/.ideai/memory/.index`.
|
||||
fn index_dir(root: &ProjectPath) -> String {
|
||||
let base = root.as_str().trim_end_matches(['/', '\\']);
|
||||
format!("{base}/{IDEAI_DIR}/{MEMORY_DIR}/{INDEX_DIR}")
|
||||
}
|
||||
|
||||
/// `<index-dir>/vectors.json`.
|
||||
fn vectors_path(root: &ProjectPath) -> RemotePath {
|
||||
RemotePath::new(format!("{}/{VECTORS_FILE}", Self::index_dir(root)))
|
||||
}
|
||||
|
||||
/// The text embedded for an index entry (its index-line payload).
|
||||
fn entry_text(entry: &MemoryIndexEntry) -> String {
|
||||
format!("{} {}", entry.title, entry.hook)
|
||||
}
|
||||
|
||||
/// Loads the derived vector doc, or a fresh empty one when absent, malformed,
|
||||
/// or produced by a different embedder/dimension (best-effort: never errors on
|
||||
/// a stale cache — it is simply rebuilt).
|
||||
async fn load_doc(&self, root: &ProjectPath) -> VectorDoc {
|
||||
let empty = || VectorDoc::new(self.embedder.id().to_owned(), self.embedder.dimension());
|
||||
match self.fs.read(&Self::vectors_path(root)).await {
|
||||
Ok(bytes) => match serde_json::from_slice::<VectorDoc>(&bytes) {
|
||||
Ok(doc) if doc.matches(self.embedder.id(), self.embedder.dimension()) => doc,
|
||||
_ => empty(),
|
||||
},
|
||||
Err(FsError::NotFound(_)) | Err(_) => empty(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists the derived vector doc best-effort (a write failure is swallowed:
|
||||
/// the cache is reconstructible, recall already has its result).
|
||||
async fn save_doc(&self, root: &ProjectPath, doc: &VectorDoc) {
|
||||
let dir = RemotePath::new(Self::index_dir(root));
|
||||
if self.fs.create_dir_all(&dir).await.is_err() {
|
||||
return;
|
||||
}
|
||||
if let Ok(bytes) = serde_json::to_vec_pretty(doc) {
|
||||
let _ = self.fs.write(&Self::vectors_path(root), &bytes).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MemoryRecall for VectorMemoryRecall {
|
||||
async fn recall(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
query: &MemoryQuery,
|
||||
) -> Result<Vec<MemoryIndexEntry>, MemoryError> {
|
||||
if query.token_budget == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let entries = self.store.read_index(root).await?;
|
||||
if entries.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// Load the derived cache and compute any missing note vectors.
|
||||
let mut doc = self.load_doc(root).await;
|
||||
let missing: Vec<&MemoryIndexEntry> = entries
|
||||
.iter()
|
||||
.filter(|e| !doc.vectors.contains_key(e.slug.as_str()))
|
||||
.collect();
|
||||
if !missing.is_empty() {
|
||||
let texts: Vec<String> = missing.iter().map(|e| Self::entry_text(e)).collect();
|
||||
let vectors = self.recall_embed(&texts).await?;
|
||||
for (entry, vector) in missing.iter().zip(vectors) {
|
||||
doc.vectors.insert(entry.slug.as_str().to_owned(), vector);
|
||||
}
|
||||
// Drop vectors of notes that no longer exist, then persist best-effort.
|
||||
let live: std::collections::HashSet<&str> =
|
||||
entries.iter().map(|e| e.slug.as_str()).collect();
|
||||
doc.vectors.retain(|slug, _| live.contains(slug.as_str()));
|
||||
self.save_doc(root, &doc).await;
|
||||
}
|
||||
|
||||
// Embed the query and rank by cosine similarity (descending).
|
||||
let query_vec = self
|
||||
.recall_embed(std::slice::from_ref(&query.text))
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut ranked: Vec<(f32, MemoryIndexEntry)> = entries
|
||||
.into_iter()
|
||||
.map(|entry| {
|
||||
let score = doc
|
||||
.vectors
|
||||
.get(entry.slug.as_str())
|
||||
.map_or(0.0, |v| cosine_similarity(&query_vec, v));
|
||||
(score, entry)
|
||||
})
|
||||
.collect();
|
||||
// Stable, deterministic order: score desc, then slug asc for ties.
|
||||
ranked.sort_by(|a, b| {
|
||||
b.0.partial_cmp(&a.0)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
.then_with(|| a.1.slug.cmp(&b.1.slug))
|
||||
});
|
||||
|
||||
let ordered = ranked.into_iter().map(|(_, e)| e);
|
||||
Ok(truncate_to_budget(ordered, query.token_budget))
|
||||
}
|
||||
}
|
||||
|
||||
impl VectorMemoryRecall {
|
||||
/// Embeds `texts`, mapping an [`EmbedderError`] into a [`MemoryError`] only so
|
||||
/// the `?` plumbing compiles; in practice [`AdaptiveMemoryRecall`] guards this
|
||||
/// path and falls back to naïve before any such error reaches a use case.
|
||||
async fn recall_embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, MemoryError> {
|
||||
self.embedder
|
||||
.embed(texts)
|
||||
.await
|
||||
.map_err(map_embedder_error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps an [`EmbedderError`] to a [`MemoryError`] for the (guarded) `?` path.
|
||||
fn map_embedder_error(e: EmbedderError) -> MemoryError {
|
||||
match e {
|
||||
EmbedderError::Io(m) => MemoryError::Io(m),
|
||||
other => MemoryError::Serialization(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Cosine similarity of two equal-length vectors; `0.0` when a length mismatches
|
||||
/// or either vector is zero (defensive — never panics).
|
||||
fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
||||
if a.len() != b.len() {
|
||||
return 0.0;
|
||||
}
|
||||
let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
|
||||
let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
if na == 0.0 || nb == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
dot / (na * nb)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AdaptiveMemoryRecall — the étage-1/étage-2 switch.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// **Pure, I/O-free** routing decision for [`AdaptiveMemoryRecall`].
|
||||
///
|
||||
/// Returns `true` (use the vector recall) **iff** the strategy is not
|
||||
/// [`EmbedderStrategy::None`] **and** the memory is larger than the budget
|
||||
/// (i.e. there is something to rank/prune semantically). Otherwise the naïve
|
||||
/// recall is sufficient and cheaper. This is the single objective rule, unit
|
||||
/// testable without any store.
|
||||
#[must_use]
|
||||
pub fn should_use_vector(memory_size: usize, budget: usize, strategy: EmbedderStrategy) -> bool {
|
||||
if strategy == EmbedderStrategy::None {
|
||||
return false;
|
||||
}
|
||||
memory_size > budget
|
||||
}
|
||||
|
||||
/// Adaptive [`MemoryRecall`]: routes between a naïve étage-1 recall and a vector
|
||||
/// étage-2 recall by [`should_use_vector`], with an automatic **fallback to
|
||||
/// naïve** whenever the vector path fails (embedder unavailable/unsupported).
|
||||
///
|
||||
/// Substitutable for either composed recall (Liskov): same emptiness/budget
|
||||
/// guarantees, and — by construction — it **never fails hard** on an embedder
|
||||
/// problem. With strategy [`EmbedderStrategy::None`] it is behaviourally identical
|
||||
/// to the naïve recall (the default product posture, zero dependency).
|
||||
#[derive(Clone)]
|
||||
pub struct AdaptiveMemoryRecall {
|
||||
naive: Arc<dyn MemoryRecall>,
|
||||
vector: Arc<dyn MemoryRecall>,
|
||||
store: Arc<dyn MemoryStore>,
|
||||
strategy: EmbedderStrategy,
|
||||
}
|
||||
|
||||
impl AdaptiveMemoryRecall {
|
||||
/// Builds the switch from the two recalls, the index store (for the pure size
|
||||
/// measure), and the active embedder strategy.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
naive: Arc<dyn MemoryRecall>,
|
||||
vector: Arc<dyn MemoryRecall>,
|
||||
store: Arc<dyn MemoryStore>,
|
||||
strategy: EmbedderStrategy,
|
||||
) -> Self {
|
||||
Self {
|
||||
naive,
|
||||
vector,
|
||||
store,
|
||||
strategy,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MemoryRecall for AdaptiveMemoryRecall {
|
||||
async fn recall(
|
||||
&self,
|
||||
root: &ProjectPath,
|
||||
query: &MemoryQuery,
|
||||
) -> Result<Vec<MemoryIndexEntry>, MemoryError> {
|
||||
// Budget-0 short-circuits before any I/O, homogeneously with the naïve and
|
||||
// vector recalls (a zero budget can hold no entry: nothing to read/measure).
|
||||
if query.token_budget == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
// Pure measure from the index (the only I/O is reading the index once;
|
||||
// the decision itself is pure via `should_use_vector`).
|
||||
let memory_size = index_token_size(&self.store.read_index(root).await?);
|
||||
if !should_use_vector(memory_size, query.token_budget, self.strategy) {
|
||||
return self.naive.recall(root, query).await;
|
||||
}
|
||||
// Vector path with naïve fallback — never fail hard on the embedder.
|
||||
match self.vector.recall(root, query).await {
|
||||
Ok(entries) => Ok(entries),
|
||||
Err(_) => self.naive.recall(root, query).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
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"]
|
||||
);
|
||||
}
|
||||
278
crates/infrastructure/tests/memory_store.rs
Normal file
278
crates/infrastructure/tests/memory_store.rs
Normal file
@ -0,0 +1,278 @@
|
||||
//! Tests for [`FsMemoryStore`] against an **in-memory mock** [`FileSystem`]:
|
||||
//! `save` writes the `<slug>.md` AND the `MEMORY.md` index line; get/list/delete;
|
||||
//! upsert idempotence (same slug => one index line); malformed frontmatter
|
||||
//! surfaces as [`MemoryError::Frontmatter`]; and `resolve_links` ignores broken
|
||||
//! links.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::memory::{Memory, MemoryFrontmatter, MemorySlug, MemoryType};
|
||||
use domain::ports::{DirEntry, FileSystem, FsError, MemoryError, MemoryStore, RemotePath};
|
||||
use domain::project::ProjectPath;
|
||||
use infrastructure::FsMemoryStore;
|
||||
|
||||
/// A minimal in-memory [`FileSystem`] mock: a path -> bytes map behind a mutex.
|
||||
/// `create_dir_all`/`symlink`/`list` are no-ops or derived; only read/write/exists
|
||||
/// matter for the memory store.
|
||||
#[derive(Default)]
|
||||
struct MemFs {
|
||||
files: Mutex<HashMap<String, Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl MemFs {
|
||||
fn arc() -> Arc<Self> {
|
||||
Arc::new(Self::default())
|
||||
}
|
||||
fn raw(&self, path: &str) -> Option<String> {
|
||||
self.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(path)
|
||||
.map(|b| String::from_utf8(b.clone()).unwrap())
|
||||
}
|
||||
/// Directly seeds a file (to inject a malformed note bypassing the store).
|
||||
fn put(&self, path: &str, content: &str) {
|
||||
self.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(path.to_string(), content.as_bytes().to_vec());
|
||||
}
|
||||
}
|
||||
|
||||
#[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(())
|
||||
}
|
||||
}
|
||||
|
||||
fn root() -> ProjectPath {
|
||||
ProjectPath::new("/proj").unwrap()
|
||||
}
|
||||
|
||||
fn slug(s: &str) -> MemorySlug {
|
||||
MemorySlug::new(s).unwrap()
|
||||
}
|
||||
|
||||
fn note(slug_str: &str, hook: &str, ty: MemoryType, body: &str) -> Memory {
|
||||
Memory::new(
|
||||
MemoryFrontmatter {
|
||||
name: MemorySlug::new(slug_str).unwrap(),
|
||||
description: hook.to_string(),
|
||||
r#type: ty,
|
||||
},
|
||||
MarkdownDoc::new(body),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_index_lists_empty() {
|
||||
let store = FsMemoryStore::new(MemFs::arc());
|
||||
assert!(store.list(&root()).await.unwrap().is_empty());
|
||||
assert!(store.read_index(&root()).await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_writes_md_and_index_line() {
|
||||
let fs = MemFs::arc();
|
||||
let store = FsMemoryStore::new(fs.clone());
|
||||
store
|
||||
.save(&root(), ¬e("alpha", "the hook", MemoryType::Project, "# Body"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// The note `.md` exists with frontmatter + body.
|
||||
let md = fs.raw("/proj/.ideai/memory/alpha.md").expect("note written");
|
||||
assert!(md.starts_with("---\n"));
|
||||
assert!(md.contains("name: alpha"));
|
||||
assert!(md.contains("description: the hook"));
|
||||
assert!(md.contains("type: project"));
|
||||
assert!(md.contains("# Body"));
|
||||
|
||||
// The index has the header + the line.
|
||||
let index = fs.raw("/proj/.ideai/memory/MEMORY.md").expect("index written");
|
||||
assert!(index.starts_with("# Memory Index"));
|
||||
assert!(index.contains("- [alpha](alpha.md) — the hook"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_roundtrips_the_saved_note() {
|
||||
let store = FsMemoryStore::new(MemFs::arc());
|
||||
let m = note("topic-x", "hook", MemoryType::Feedback, "content [[other]]");
|
||||
store.save(&root(), &m).await.unwrap();
|
||||
|
||||
let got = store.get(&root(), &slug("topic-x")).await.unwrap();
|
||||
assert_eq!(got, m);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_missing_is_not_found() {
|
||||
let store = FsMemoryStore::new(MemFs::arc());
|
||||
assert!(matches!(
|
||||
store.get(&root(), &slug("nope")).await.unwrap_err(),
|
||||
MemoryError::NotFound
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_returns_all_saved_notes() {
|
||||
let store = FsMemoryStore::new(MemFs::arc());
|
||||
store
|
||||
.save(&root(), ¬e("a", "ha", MemoryType::User, "A"))
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.save(&root(), ¬e("b", "hb", MemoryType::Project, "B"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut slugs: Vec<_> = store
|
||||
.list(&root())
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|m| m.slug().as_str().to_string())
|
||||
.collect();
|
||||
slugs.sort();
|
||||
assert_eq!(slugs, vec!["a".to_string(), "b".to_string()]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_is_idempotent_upsert_single_index_line() {
|
||||
let fs = MemFs::arc();
|
||||
let store = FsMemoryStore::new(fs.clone());
|
||||
store
|
||||
.save(&root(), ¬e("dup", "hook v1", MemoryType::Project, "v1"))
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.save(&root(), ¬e("dup", "hook v2", MemoryType::Project, "v2"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Content was replaced.
|
||||
assert_eq!(
|
||||
store
|
||||
.get(&root(), &slug("dup"))
|
||||
.await
|
||||
.unwrap()
|
||||
.body
|
||||
.as_str(),
|
||||
"v2"
|
||||
);
|
||||
// Exactly one index entry for the slug.
|
||||
let entries = store.read_index(&root()).await.unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].hook, "hook v2");
|
||||
// And the raw index has only one matching line.
|
||||
let index = fs.raw("/proj/.ideai/memory/MEMORY.md").unwrap();
|
||||
assert_eq!(index.matches("- [dup]").count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_removes_index_line_and_is_not_found_twice() {
|
||||
let store = FsMemoryStore::new(MemFs::arc());
|
||||
store
|
||||
.save(&root(), ¬e("gone", "h", MemoryType::User, "x"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
store.delete(&root(), &slug("gone")).await.unwrap();
|
||||
assert!(store.list(&root()).await.unwrap().is_empty());
|
||||
assert!(matches!(
|
||||
store.delete(&root(), &slug("gone")).await.unwrap_err(),
|
||||
MemoryError::NotFound
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn malformed_frontmatter_surfaces_as_error() {
|
||||
let fs = MemFs::arc();
|
||||
// Seed the index so the slug is listed, plus a malformed note file.
|
||||
fs.put(
|
||||
"/proj/.ideai/memory/MEMORY.md",
|
||||
"# Memory Index\n\n- [bad](bad.md) — h\n",
|
||||
);
|
||||
fs.put(
|
||||
"/proj/.ideai/memory/bad.md",
|
||||
"no frontmatter fence here\njust body",
|
||||
);
|
||||
let store = FsMemoryStore::new(fs);
|
||||
|
||||
assert!(matches!(
|
||||
store.get(&root(), &slug("bad")).await.unwrap_err(),
|
||||
MemoryError::Frontmatter(_)
|
||||
));
|
||||
// list propagates the same error.
|
||||
assert!(matches!(
|
||||
store.list(&root()).await.unwrap_err(),
|
||||
MemoryError::Frontmatter(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_links_ignores_broken_links() {
|
||||
let store = FsMemoryStore::new(MemFs::arc());
|
||||
// `target` exists; `ghost` does not.
|
||||
store
|
||||
.save(&root(), ¬e("target", "h", MemoryType::Project, "T"))
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.save(
|
||||
&root(),
|
||||
¬e(
|
||||
"src",
|
||||
"h",
|
||||
MemoryType::Project,
|
||||
"links to [[target]] and [[ghost]]",
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let resolved = store.resolve_links(&root(), &slug("src")).await.unwrap();
|
||||
let targets: Vec<_> = resolved
|
||||
.into_iter()
|
||||
.map(|l| l.target.as_str().to_string())
|
||||
.collect();
|
||||
assert_eq!(targets, vec!["target".to_string()]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_links_missing_source_is_not_found() {
|
||||
let store = FsMemoryStore::new(MemFs::arc());
|
||||
assert!(matches!(
|
||||
store.resolve_links(&root(), &slug("nope")).await.unwrap_err(),
|
||||
MemoryError::NotFound
|
||||
));
|
||||
}
|
||||
626
crates/infrastructure/tests/vector_recall.rs
Normal file
626
crates/infrastructure/tests/vector_recall.rs
Normal file
@ -0,0 +1,626 @@
|
||||
//! Tests for the étage-2 vector recall (LOT C, §14.5.3):
|
||||
//! - [`should_use_vector`] — the pure routing decision (full truth table);
|
||||
//! - [`AdaptiveMemoryRecall`] — the étage-1/étage-2 switch, with naïve equivalence
|
||||
//! on strategy `none`, routing by size/budget, and the never-fail-hard fallback;
|
||||
//! - [`VectorMemoryRecall`] — semantic ranking with the deterministic
|
||||
//! [`HashEmbedder`], budget truncation, the derived `vectors.json` cache and its
|
||||
//! embedder-id/dimension invalidation, and `StubEmbedder` best-effort behaviour;
|
||||
//! - [`HashEmbedder`] / [`StubEmbedder`] embedder contracts;
|
||||
//! - [`embedder_from_profile`] strategy → embedder mapping.
|
||||
//!
|
||||
//! Everything is in-memory and deterministic (`MemFs` + `HashEmbedder`); no real
|
||||
//! I/O, no network.
|
||||
|
||||
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, MemorySlug, MemoryType};
|
||||
use domain::ports::{
|
||||
DirEntry, Embedder, EmbedderError, FileSystem, FsError, MemoryQuery, MemoryRecall, MemoryStore,
|
||||
RemotePath,
|
||||
};
|
||||
use domain::profile::{EmbedderProfile, EmbedderStrategy};
|
||||
use domain::project::ProjectPath;
|
||||
use infrastructure::{
|
||||
embedder_from_profile, should_use_vector, AdaptiveMemoryRecall, FsMemoryStore, HashEmbedder,
|
||||
NaiveMemoryRecall, StubEmbedder, VectorMemoryRecall,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-memory FileSystem mock (same shape as `memory_recall.rs` / `memory_store.rs`),
|
||||
// with raw read access to assert on the derived `vectors.json`.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Default)]
|
||||
struct MemFs {
|
||||
files: Mutex<HashMap<String, Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl MemFs {
|
||||
fn arc() -> Arc<Self> {
|
||||
Arc::new(Self::default())
|
||||
}
|
||||
fn raw(&self, path: &str) -> Option<String> {
|
||||
self.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(path)
|
||||
.map(|b| String::from_utf8(b.clone()).unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
#[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(())
|
||||
}
|
||||
}
|
||||
|
||||
const VECTORS_PATH: &str = "/proj/.ideai/memory/.index/vectors.json";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// A counting wrapper embedder: delegates to an inner embedder and counts the
|
||||
// number of *texts* it was asked to embed — to assert the derived cache avoids
|
||||
// recomputing note vectors on a second recall.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct SpyEmbedder {
|
||||
inner: HashEmbedder,
|
||||
embedded_texts: AtomicUsize,
|
||||
}
|
||||
|
||||
impl SpyEmbedder {
|
||||
fn new(id: &str, dim: usize) -> Self {
|
||||
Self {
|
||||
inner: HashEmbedder::new(id, dim),
|
||||
embedded_texts: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Embedder for SpyEmbedder {
|
||||
fn id(&self) -> &str {
|
||||
self.inner.id()
|
||||
}
|
||||
async fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbedderError> {
|
||||
self.embedded_texts.fetch_add(texts.len(), Ordering::SeqCst);
|
||||
self.inner.embed(texts).await
|
||||
}
|
||||
fn dimension(&self) -> usize {
|
||||
self.inner.dimension()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Builders.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn root() -> ProjectPath {
|
||||
ProjectPath::new("/proj").unwrap()
|
||||
}
|
||||
|
||||
/// A note whose index line is `<slug> <hook>` (title == slug). The vector recall
|
||||
/// embeds exactly `"{slug} {hook}"`, so picking distinct vocabulary words in
|
||||
/// `slug`/`hook` makes cosine similarity to a chosen query deterministic.
|
||||
fn note(slug: &str, hook: &str) -> Memory {
|
||||
Memory::new(
|
||||
MemoryFrontmatter {
|
||||
name: MemorySlug::new(slug).unwrap(),
|
||||
description: hook.to_string(),
|
||||
r#type: MemoryType::Project,
|
||||
},
|
||||
MarkdownDoc::new("# body"),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn query(text: &str, budget: usize) -> MemoryQuery {
|
||||
MemoryQuery {
|
||||
text: text.to_string(),
|
||||
token_budget: budget,
|
||||
}
|
||||
}
|
||||
|
||||
fn slugs(entries: &[MemoryIndexEntry]) -> Vec<String> {
|
||||
entries.iter().map(|e| e.slug.as_str().to_string()).collect()
|
||||
}
|
||||
|
||||
async fn store_with(notes: &[Memory], fs: Arc<MemFs>) -> Arc<FsMemoryStore> {
|
||||
let store = Arc::new(FsMemoryStore::new(fs));
|
||||
for n in notes {
|
||||
store.save(&root(), n).await.unwrap();
|
||||
}
|
||||
store
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 1. should_use_vector — pure truth table.
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn should_use_vector_none_strategy_is_always_false() {
|
||||
// Even when size > budget, a `none` strategy never routes to the vector path.
|
||||
assert!(!should_use_vector(0, 0, EmbedderStrategy::None));
|
||||
assert!(!should_use_vector(100, 10, EmbedderStrategy::None));
|
||||
assert!(!should_use_vector(10, 100, EmbedderStrategy::None));
|
||||
assert!(!should_use_vector(usize::MAX, 0, EmbedderStrategy::None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_use_vector_non_none_iff_size_exceeds_budget() {
|
||||
for strategy in [
|
||||
EmbedderStrategy::LocalOnnx,
|
||||
EmbedderStrategy::LocalServer,
|
||||
EmbedderStrategy::Api,
|
||||
] {
|
||||
// size > budget ⇒ true.
|
||||
assert!(should_use_vector(11, 10, strategy), "{strategy:?}: 11>10");
|
||||
assert!(should_use_vector(1, 0, strategy), "{strategy:?}: 1>0");
|
||||
// size == budget ⇒ false (exact boundary).
|
||||
assert!(!should_use_vector(10, 10, strategy), "{strategy:?}: 10==10");
|
||||
assert!(!should_use_vector(0, 0, strategy), "{strategy:?}: 0==0");
|
||||
// size < budget ⇒ false.
|
||||
assert!(!should_use_vector(9, 10, strategy), "{strategy:?}: 9<10");
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 2. HashEmbedder — determinism, dimension, L2 normalisation.
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn hash_embedder_is_deterministic() {
|
||||
let e = HashEmbedder::new("h", 64);
|
||||
let a = e.embed(&["hello world".to_string()]).await.unwrap();
|
||||
let b = e.embed(&["hello world".to_string()]).await.unwrap();
|
||||
assert_eq!(a, b, "same text must give the same vector");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hash_embedder_dimension_is_consistent() {
|
||||
let e = HashEmbedder::new("h", 48);
|
||||
assert_eq!(e.dimension(), 48);
|
||||
let out = e
|
||||
.embed(&["alpha beta".to_string(), "gamma".to_string()])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(out.len(), 2, "one vector per input, order preserved");
|
||||
assert!(out.iter().all(|v| v.len() == 48), "every vector is dimension-long");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hash_embedder_vectors_are_l2_normalised() {
|
||||
let e = HashEmbedder::new("h", 64);
|
||||
let out = e
|
||||
.embed(&["one two three four".to_string()])
|
||||
.await
|
||||
.unwrap();
|
||||
let norm = out[0].iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
assert!((norm - 1.0).abs() < 1e-5, "norm ≈ 1, got {norm}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hash_embedder_empty_text_yields_zero_vector_no_panic() {
|
||||
// No tokens ⇒ zero vector (norm 0, left unnormalised). Must not panic / NaN.
|
||||
let e = HashEmbedder::new("h", 16);
|
||||
let out = e.embed(&["".to_string()]).await.unwrap();
|
||||
assert_eq!(out[0].len(), 16);
|
||||
assert!(out[0].iter().all(|x| *x == 0.0));
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 3. StubEmbedder — always Unsupported, never panics.
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn stub_embedder_returns_unsupported() {
|
||||
let e = StubEmbedder::new("stub", 32, "localOnnx");
|
||||
assert_eq!(e.dimension(), 32);
|
||||
let err = e.embed(&["x".to_string()]).await.unwrap_err();
|
||||
assert!(matches!(err, EmbedderError::Unsupported(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 4. embedder_from_profile — strategy → embedder mapping.
|
||||
// ===========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn embedder_from_profile_maps_each_strategy() {
|
||||
// none ⇒ no embedder (recall stays naïve).
|
||||
assert!(embedder_from_profile(&EmbedderProfile::none()).is_none());
|
||||
|
||||
// localOnnx / localServer / api ⇒ a StubEmbedder (Unsupported), dimension kept.
|
||||
for strategy in [
|
||||
EmbedderStrategy::LocalOnnx,
|
||||
EmbedderStrategy::LocalServer,
|
||||
EmbedderStrategy::Api,
|
||||
] {
|
||||
let profile =
|
||||
EmbedderProfile::new("e", "E", strategy, None, None, None, 24).unwrap();
|
||||
let embedder = embedder_from_profile(&profile)
|
||||
.unwrap_or_else(|| panic!("{strategy:?} must yield an embedder"));
|
||||
assert_eq!(embedder.dimension(), 24);
|
||||
let err = embedder.embed(&["t".to_string()]).await.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, EmbedderError::Unsupported(_)),
|
||||
"{strategy:?} stub must be Unsupported, got {err:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 5. VectorMemoryRecall — semantic ranking, budget, cache.
|
||||
// ===========================================================================
|
||||
|
||||
/// Builds a vector recall over `notes` with the given embedder, sharing one fs.
|
||||
fn vector_recall(
|
||||
embedder: Arc<dyn Embedder>,
|
||||
store: Arc<FsMemoryStore>,
|
||||
fs: Arc<MemFs>,
|
||||
) -> VectorMemoryRecall {
|
||||
VectorMemoryRecall::new(embedder, store, fs)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn vector_recall_ranks_closest_note_first() {
|
||||
// Notes have disjoint vocabularies; the query shares all tokens with `beta`,
|
||||
// so `beta` must rank first (cosine == 1 for it, ~0 for the others).
|
||||
let fs = MemFs::arc();
|
||||
let notes = [
|
||||
note("alpha", "apple orange grape"),
|
||||
note("beta", "kiwi mango papaya"),
|
||||
note("gamma", "carrot potato onion"),
|
||||
];
|
||||
let store = store_with(¬es, fs.clone()).await;
|
||||
let embedder = Arc::new(HashEmbedder::new("hash", 256));
|
||||
let recall = vector_recall(embedder, store, fs);
|
||||
|
||||
// Query identical to beta's index line ("beta kiwi mango papaya").
|
||||
let out = recall
|
||||
.recall(&root(), &query("beta kiwi mango papaya", 100_000))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(out.first().map(|e| e.slug.as_str()), Some("beta"), "ranked: {:?}", slugs(&out));
|
||||
// All three notes fit the ample budget.
|
||||
assert_eq!(out.len(), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn vector_recall_truncates_to_budget() {
|
||||
// The closest note ("beta apricot apricot") must survive a tight budget while
|
||||
// the rest are pruned. Cost of an entry = ceil((title+hook chars)/4).
|
||||
let fs = MemFs::arc();
|
||||
let notes = [
|
||||
note("beta", "kiwi"), // "beta kiwi" → 8 chars → 2 tokens
|
||||
note("alpha", "apple orange grape"), // larger
|
||||
note("gamma", "carrot potato onion"), // larger
|
||||
];
|
||||
let store = store_with(¬es, fs.clone()).await;
|
||||
let embedder = Arc::new(HashEmbedder::new("hash", 256));
|
||||
let recall = vector_recall(embedder, store, fs);
|
||||
|
||||
// Budget 2 fits only the top-ranked `beta` (cost 2); the next would exceed.
|
||||
let out = recall.recall(&root(), &query("beta kiwi", 2)).await.unwrap();
|
||||
assert_eq!(slugs(&out), vec!["beta"], "budget must keep only the closest");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn vector_recall_zero_budget_and_empty_memory_are_empty() {
|
||||
let fs = MemFs::arc();
|
||||
let embedder = Arc::new(HashEmbedder::new("hash", 64));
|
||||
|
||||
// Empty memory ⇒ empty, no error.
|
||||
let empty_store = Arc::new(FsMemoryStore::new(fs.clone()));
|
||||
let recall = vector_recall(embedder.clone(), empty_store, fs.clone());
|
||||
assert!(recall
|
||||
.recall(&root(), &query("anything", 1000))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
|
||||
// Zero budget ⇒ empty, even with notes present.
|
||||
let store = store_with(&[note("a", "x"), note("b", "y")], fs.clone()).await;
|
||||
let recall = vector_recall(embedder, store, fs);
|
||||
assert!(recall
|
||||
.recall(&root(), &query("a x", 0))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn vector_recall_writes_and_reuses_the_derived_cache() {
|
||||
let fs = MemFs::arc();
|
||||
let notes = [note("alpha", "apple"), note("beta", "banana")];
|
||||
let store = store_with(¬es, fs.clone()).await;
|
||||
let spy = Arc::new(SpyEmbedder::new("spy", 128));
|
||||
let recall = VectorMemoryRecall::new(spy.clone(), store, fs.clone());
|
||||
|
||||
// First recall: embeds 2 note texts + 1 query text = 3.
|
||||
let _ = recall.recall(&root(), &query("alpha apple", 100_000)).await.unwrap();
|
||||
let after_first = spy.embedded_texts.load(Ordering::SeqCst);
|
||||
assert_eq!(after_first, 3, "2 notes + 1 query embedded on a cold cache");
|
||||
|
||||
// The derived cache file now exists with both slugs and the embedder id.
|
||||
let doc = fs.raw(VECTORS_PATH).expect("vectors.json written");
|
||||
assert!(doc.contains("\"embedderId\": \"spy\""), "tagged with embedder id: {doc}");
|
||||
assert!(doc.contains("\"alpha\""), "alpha vector cached: {doc}");
|
||||
assert!(doc.contains("\"beta\""), "beta vector cached: {doc}");
|
||||
|
||||
// Second recall: note vectors come from the cache; only the query is embedded.
|
||||
let _ = recall.recall(&root(), &query("beta banana", 100_000)).await.unwrap();
|
||||
let delta = spy.embedded_texts.load(Ordering::SeqCst) - after_first;
|
||||
assert_eq!(delta, 1, "cache reuse ⇒ only the query is re-embedded, got {delta}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn vector_recall_invalidates_cache_on_embedder_id_change() {
|
||||
let fs = MemFs::arc();
|
||||
let notes = [note("alpha", "apple"), note("beta", "banana")];
|
||||
let store = store_with(¬es, fs.clone()).await;
|
||||
|
||||
// Warm the cache with embedder "first".
|
||||
let first = Arc::new(SpyEmbedder::new("first", 128));
|
||||
VectorMemoryRecall::new(first.clone(), store.clone(), fs.clone())
|
||||
.recall(&root(), &query("alpha apple", 100_000))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(fs.raw(VECTORS_PATH).unwrap().contains("\"embedderId\": \"first\""));
|
||||
|
||||
// A different embedder id must invalidate ⇒ recompute all note vectors.
|
||||
let second = Arc::new(SpyEmbedder::new("second", 128));
|
||||
VectorMemoryRecall::new(second.clone(), store, fs.clone())
|
||||
.recall(&root(), &query("beta banana", 100_000))
|
||||
.await
|
||||
.unwrap();
|
||||
// 2 notes recomputed + 1 query embedded against the new embedder.
|
||||
assert_eq!(
|
||||
second.embedded_texts.load(Ordering::SeqCst),
|
||||
3,
|
||||
"id change must invalidate and recompute note vectors"
|
||||
);
|
||||
// The file is now tagged with the new embedder id.
|
||||
assert!(fs.raw(VECTORS_PATH).unwrap().contains("\"embedderId\": \"second\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn vector_recall_invalidates_cache_on_dimension_change() {
|
||||
let fs = MemFs::arc();
|
||||
let notes = [note("alpha", "apple"), note("beta", "banana")];
|
||||
let store = store_with(¬es, fs.clone()).await;
|
||||
|
||||
// Warm the cache at dimension 64.
|
||||
let dim64 = Arc::new(SpyEmbedder::new("same-id", 64));
|
||||
VectorMemoryRecall::new(dim64, store.clone(), fs.clone())
|
||||
.recall(&root(), &query("alpha apple", 100_000))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Same id, different dimension ⇒ invalidation, all note vectors recomputed.
|
||||
let dim128 = Arc::new(SpyEmbedder::new("same-id", 128));
|
||||
VectorMemoryRecall::new(dim128.clone(), store, fs.clone())
|
||||
.recall(&root(), &query("beta banana", 100_000))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
dim128.embedded_texts.load(Ordering::SeqCst),
|
||||
3,
|
||||
"dimension change must invalidate and recompute"
|
||||
);
|
||||
assert!(fs.raw(VECTORS_PATH).unwrap().contains("\"dimension\": 128"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn vector_recall_with_stub_embedder_errors_without_panic() {
|
||||
// The vector recall surfaces the embedder error as a MemoryError (the Adaptive
|
||||
// switch turns that into a naïve fallback — covered below). Here we just pin
|
||||
// that StubEmbedder is best-effort: an Err, never a panic.
|
||||
let fs = MemFs::arc();
|
||||
let store = store_with(&[note("alpha", "apple")], fs.clone()).await;
|
||||
let stub = Arc::new(StubEmbedder::new("stub", 64, "localOnnx"));
|
||||
let recall = VectorMemoryRecall::new(stub, store, fs);
|
||||
let res = recall.recall(&root(), &query("alpha apple", 1000)).await;
|
||||
assert!(res.is_err(), "stub embedder ⇒ Err on the vector path, got {res:?}");
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 6. AdaptiveMemoryRecall — the étage-1/étage-2 switch.
|
||||
// ===========================================================================
|
||||
|
||||
/// Assembles an [`AdaptiveMemoryRecall`] over `notes` for the given strategy and
|
||||
/// embedder, sharing one in-memory fs / store.
|
||||
async fn adaptive_over(
|
||||
notes: &[Memory],
|
||||
strategy: EmbedderStrategy,
|
||||
embedder: Arc<dyn Embedder>,
|
||||
) -> (Arc<MemFs>, AdaptiveMemoryRecall) {
|
||||
let fs = MemFs::arc();
|
||||
let store = store_with(notes, fs.clone()).await;
|
||||
let naive: Arc<dyn MemoryRecall> = Arc::new(NaiveMemoryRecall::new(store.clone()));
|
||||
let vector: Arc<dyn MemoryRecall> =
|
||||
Arc::new(VectorMemoryRecall::new(embedder, store.clone(), fs.clone()));
|
||||
let adaptive = AdaptiveMemoryRecall::new(naive, vector, store, strategy);
|
||||
(fs, adaptive)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn adaptive_none_strategy_matches_naive_exactly() {
|
||||
let notes = [
|
||||
note("alpha", "apple orange grape"),
|
||||
note("beta", "kiwi mango papaya"),
|
||||
note("gamma", "carrot potato onion"),
|
||||
];
|
||||
let fs = MemFs::arc();
|
||||
let store = store_with(¬es, fs.clone()).await;
|
||||
let naive = NaiveMemoryRecall::new(store.clone());
|
||||
|
||||
// Adaptive with strategy `none` (embedder present but never used).
|
||||
let naive_dyn: Arc<dyn MemoryRecall> = Arc::new(NaiveMemoryRecall::new(store.clone()));
|
||||
let vector_dyn: Arc<dyn MemoryRecall> = Arc::new(VectorMemoryRecall::new(
|
||||
Arc::new(HashEmbedder::new("hash", 64)),
|
||||
store.clone(),
|
||||
fs.clone(),
|
||||
));
|
||||
let adaptive = AdaptiveMemoryRecall::new(naive_dyn, vector_dyn, store, EmbedderStrategy::None);
|
||||
|
||||
// For several budgets, adaptive(none) == naive, byte-for-byte.
|
||||
for budget in [0usize, 3, 6, 100, 100_000] {
|
||||
let q = query("kiwi mango papaya", budget);
|
||||
let expected = naive.recall(&root(), &q).await.unwrap();
|
||||
let got = adaptive.recall(&root(), &q).await.unwrap();
|
||||
assert_eq!(got, expected, "strategy none must equal naive at budget {budget}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn adaptive_small_memory_routes_to_naive_order() {
|
||||
// Memory smaller than the budget ⇒ naïve path ⇒ index order, even with a
|
||||
// non-none strategy. The query would, on the vector path, reorder by
|
||||
// similarity; index order proves naïve was used.
|
||||
let notes = [
|
||||
note("alpha", "apple"),
|
||||
note("beta", "banana"),
|
||||
note("gamma", "grape"),
|
||||
];
|
||||
let (_fs, adaptive) = adaptive_over(
|
||||
¬es,
|
||||
EmbedderStrategy::LocalServer,
|
||||
Arc::new(HashEmbedder::new("hash", 256)),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Huge budget ⇒ memory_size <= budget ⇒ naïve ⇒ index order alpha,beta,gamma.
|
||||
let out = adaptive
|
||||
.recall(&root(), &query("gamma grape", 100_000))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(slugs(&out), vec!["alpha", "beta", "gamma"], "naïve keeps index order");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn adaptive_large_memory_routes_to_vector_ranking() {
|
||||
// Memory larger than the budget + non-none strategy ⇒ vector path ⇒ the note
|
||||
// closest to the query ranks first (not index order).
|
||||
let notes = [
|
||||
note("alpha", "apple orange grape"),
|
||||
note("beta", "kiwi mango papaya"),
|
||||
note("gamma", "carrot potato onion"),
|
||||
];
|
||||
let (_fs, adaptive) = adaptive_over(
|
||||
¬es,
|
||||
EmbedderStrategy::LocalOnnx,
|
||||
Arc::new(HashEmbedder::new("hash", 256)),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Budget smaller than the total memory size forces the vector route; the query
|
||||
// matches `gamma`, so `gamma` must be first (≠ index order, where it is last).
|
||||
let total = {
|
||||
let store = FsMemoryStore::new(MemFs::arc());
|
||||
for n in ¬es {
|
||||
store.save(&root(), n).await.unwrap();
|
||||
}
|
||||
infrastructure::index_token_size(&store.read_index(&root()).await.unwrap())
|
||||
};
|
||||
assert!(total > 4, "sanity: memory must exceed the chosen budget");
|
||||
|
||||
let out = adaptive
|
||||
.recall(&root(), &query("carrot potato onion", total - 1))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
out.first().map(|e| e.slug.as_str()),
|
||||
Some("gamma"),
|
||||
"vector path ranks the closest note first: {:?}",
|
||||
slugs(&out)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn adaptive_falls_back_to_naive_when_embedder_fails() {
|
||||
// Large memory + non-none strategy routes to the vector path; with a
|
||||
// StubEmbedder that path errors, and Adaptive must degrade to naïve — never a
|
||||
// hard error. Result therefore equals the naïve recall (index order, truncated).
|
||||
let notes = [
|
||||
note("alpha", "apple orange grape"),
|
||||
note("beta", "kiwi mango papaya"),
|
||||
note("gamma", "carrot potato onion"),
|
||||
];
|
||||
let fs = MemFs::arc();
|
||||
let store = store_with(¬es, fs.clone()).await;
|
||||
let naive_ref = NaiveMemoryRecall::new(store.clone());
|
||||
|
||||
let naive_dyn: Arc<dyn MemoryRecall> = Arc::new(NaiveMemoryRecall::new(store.clone()));
|
||||
let vector_dyn: Arc<dyn MemoryRecall> = Arc::new(VectorMemoryRecall::new(
|
||||
Arc::new(StubEmbedder::new("stub", 64, "localOnnx")),
|
||||
store.clone(),
|
||||
fs.clone(),
|
||||
));
|
||||
let adaptive =
|
||||
AdaptiveMemoryRecall::new(naive_dyn, vector_dyn, store.clone(), EmbedderStrategy::Api);
|
||||
|
||||
let total = infrastructure::index_token_size(&store.read_index(&root()).await.unwrap());
|
||||
let q = query("carrot potato onion", total - 1);
|
||||
|
||||
let got = adaptive.recall(&root(), &q).await.expect("fallback must not error");
|
||||
let expected = naive_ref.recall(&root(), &q).await.unwrap();
|
||||
assert_eq!(got, expected, "embedder failure must degrade to the naïve result");
|
||||
assert!(!got.is_empty(), "the degraded result still returns entries");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn adaptive_empty_memory_and_zero_budget_are_empty() {
|
||||
// Liskov common contract: empty list, never an error, both paths.
|
||||
let fs = MemFs::arc();
|
||||
let empty_store = Arc::new(FsMemoryStore::new(fs.clone()));
|
||||
let naive: Arc<dyn MemoryRecall> = Arc::new(NaiveMemoryRecall::new(empty_store.clone()));
|
||||
let vector: Arc<dyn MemoryRecall> = Arc::new(VectorMemoryRecall::new(
|
||||
Arc::new(HashEmbedder::new("hash", 64)),
|
||||
empty_store.clone(),
|
||||
fs.clone(),
|
||||
));
|
||||
let adaptive =
|
||||
AdaptiveMemoryRecall::new(naive, vector, empty_store, EmbedderStrategy::LocalOnnx);
|
||||
|
||||
// Empty memory.
|
||||
assert!(adaptive
|
||||
.recall(&root(), &query("anything", 1000))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
// Zero budget.
|
||||
assert!(adaptive
|
||||
.recall(&root(), &query("anything", 0))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
}
|
||||
Reference in New Issue
Block a user