From 4af6808b618eed188f1f990e02fde845b2745388 Mon Sep 17 00:00:00 2001 From: MikeRoss27 Date: Wed, 8 Jul 2026 08:47:07 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat!:=20native-only=20migration=20?= =?UTF-8?q?=E2=80=94=20remove=20libSQL/V1=20dual-backend=20(ADR-033)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop Store/LibsqlMemoryStore, libSQL crypto CI paths, and legacy maintenance (gc/forgetting). Add CLI UI layer, crate READMEs, and update docs/CI/xtask for the sole native backend. Co-authored-by: Cursor --- .cargo/config.toml | 2 +- .claude/agents/architecture.agent.yaml | 25 +- .claude/agents/feature-dev.agent.yaml | 4 +- .claude/agents/rust-expert.agent.yaml | 4 +- .claude/agents/security.agent.yaml | 4 +- .claude/skills/architecture/content.md | 111 +-- .claude/skills/feature-dev/content.md | 4 +- .claude/skills/rust-expert/content.md | 2 +- .claude/skills/security/content.md | 2 +- .github/labeler.yml | 2 +- .github/workflows/ci.yml | 63 +- .github/workflows/node-prebuilds.yml | 25 +- .github/workflows/python-wheels.yml | 19 +- .gitignore | 3 + AGENTS.md | 3 + CHANGELOG.md | 12 + CLAUDE.md | 205 +--- README.md | 127 ++- basemyai-branding/README.md | 2 +- bindings/basemyai-node/Cargo.toml | 3 +- bindings/basemyai-node/README.md | 139 +++ .../basemyai-node/__tests__/roundtrip.test.js | 28 - bindings/basemyai-node/package.json | 2 +- bindings/basemyai-node/src/memory.rs | 24 +- bindings/basemyai-py/Cargo.toml | 3 +- bindings/basemyai-py/README.md | 150 +++ bindings/basemyai-py/pyproject.toml | 3 +- bindings/basemyai-py/src/memory.rs | 41 +- bindings/basemyai-py/tests/test_roundtrip.py | 29 - crates/basemyai-cli/Cargo.toml | 16 +- crates/basemyai-cli/src/cli.rs | 35 +- crates/basemyai-cli/src/commands/config.rs | 27 +- crates/basemyai-cli/src/commands/container.rs | 146 +-- crates/basemyai-cli/src/commands/graph.rs | 23 +- .../basemyai-cli/src/commands/maintenance.rs | 67 +- crates/basemyai-cli/src/commands/memory.rs | 175 ++-- crates/basemyai-cli/src/commands/mod.rs | 36 +- crates/basemyai-cli/src/commands/provision.rs | 124 ++- crates/basemyai-cli/src/context.rs | 37 +- crates/basemyai-cli/src/error.rs | 7 - crates/basemyai-cli/src/main.rs | 30 +- crates/basemyai-cli/src/output.rs | 7 +- crates/basemyai-cli/src/ui/color.rs | 50 + crates/basemyai-cli/src/ui/error.rs | 34 + crates/basemyai-cli/src/ui/mod.rs | 39 + crates/basemyai-cli/src/ui/progress.rs | 81 ++ crates/basemyai-cli/src/ui/render.rs | 48 + crates/basemyai-cli/src/ui/table.rs | 37 + crates/basemyai-cli/src/ui/theme.rs | 60 ++ crates/basemyai-cli/tests/cli.rs | 34 +- crates/basemyai-core/Cargo.toml | 25 +- crates/basemyai-core/README.md | 146 +++ .../basemyai-core/benches/knn_scalability.rs | 149 --- crates/basemyai-core/src/embed/candle.rs | 2 +- crates/basemyai-core/src/lib.rs | 32 +- crates/basemyai-core/src/maintenance.rs | 19 +- crates/basemyai-core/src/storage/engine.rs | 28 +- crates/basemyai-core/src/storage/key.rs | 33 + crates/basemyai-core/src/storage/mod.rs | 8 +- crates/basemyai-core/src/storage/store.rs | 714 ------------- crates/basemyai-core/src/storage/vector.rs | 73 +- crates/basemyai-core/tests/agnosticity.rs | 2 +- crates/basemyai-core/tests/contracts.rs | 36 - crates/basemyai-core/tests/libsql_smoke.rs | 34 - crates/basemyai-core/tests/store.rs | 371 ------- crates/basemyai-engine/Cargo.toml | 11 +- crates/basemyai-engine/src/idx/graph/mod.rs | 5 +- .../src/idx/graph/persistent.rs | 32 + .../basemyai-engine/src/idx/graph/traverse.rs | 7 +- .../src/idx/memory/persistent.rs | 9 + crates/basemyai-engine/src/key/mod.rs | 19 + crates/basemyai-mcp/Cargo.toml | 3 +- crates/basemyai-mcp/src/lib.rs | 3 +- crates/basemyai-mcp/src/main.rs | 31 +- crates/basemyai-mcp/src/provider.rs | 77 +- crates/basemyai-rest/Cargo.toml | 5 +- crates/basemyai-rest/src/lib.rs | 3 +- crates/basemyai-rest/src/main.rs | 27 +- crates/basemyai-rest/src/provider.rs | 70 +- crates/basemyai/Cargo.toml | 21 +- crates/basemyai/README.md | 146 +++ crates/basemyai/examples/llm_consolidation.rs | 7 +- crates/basemyai/examples/memory_basic.rs | 7 +- .../basemyai/examples/temporal_replacement.rs | 7 +- crates/basemyai/src/lib.rs | 8 +- crates/basemyai/src/maintenance/forgetting.rs | 83 -- crates/basemyai/src/maintenance/gc.rs | 42 - crates/basemyai/src/maintenance/mod.rs | 31 +- crates/basemyai/src/memory/mod.rs | 308 +++--- crates/basemyai/src/memory/porting.rs | 285 ++---- crates/basemyai/src/memory/schema.rs | 187 ---- crates/basemyai/src/provision/embedder.rs | 2 +- crates/basemyai/src/storage/libsql_store.rs | 522 ---------- crates/basemyai/src/storage/mod.rs | 67 +- crates/basemyai/src/storage/native_store.rs | 379 ++++++- crates/basemyai/src/temporal.rs | 20 +- crates/basemyai/tests/consolidation.rs | 127 +-- crates/basemyai/tests/consolidation_e2e.rs | 7 +- crates/basemyai/tests/contracts.rs | 48 +- crates/basemyai/tests/events.rs | 7 +- crates/basemyai/tests/forgetting.rs | 182 ---- crates/basemyai/tests/format.rs | 39 +- crates/basemyai/tests/gc.rs | 58 -- crates/basemyai/tests/graph.rs | 169 ---- crates/basemyai/tests/key_rotation.rs | 152 --- crates/basemyai/tests/maintenance_worker.rs | 60 +- crates/basemyai/tests/memory.rs | 229 ++--- crates/basemyai/tests/memory_tests.rs | 91 +- crates/basemyai/tests/memory_tests/mod.rs | 9 +- .../tests/native_memory_store_bench.rs | 2 +- .../tests/p1_isolation_adversarial.rs | 122 +-- crates/basemyai/tests/pool_concurrency.rs | 204 ---- crates/basemyai/tests/porting.rs | 7 +- crates/basemyai/tests/storage_contract.rs | 15 +- crates/basemyai/tests/support/mod.rs | 31 + docs/ADR.md | 10 +- docs/ARCHITECTURE.md | 133 ++- docs/PLAN.md | 8 +- docs/PRD.md | 65 +- docs/TODO-NATIVE-ENGINE.md | 940 +++++++++--------- docs/VISION.md | 2 +- docs/adr/ADR-011-libsql-pivot.md | 2 +- ...agent-memory-database-format-and-engine.md | 4 +- docs/adr/ADR-020-memory-store-trait.md | 8 +- docs/adr/ADR-021-libsql-reader-pool.md | 7 +- docs/adr/ADR-032-native-engine-default.md | 179 ++++ docs/adr/ADR-033-native-only.md | 38 + docs/archive/TODO-2026-06.md | 2 +- docs/cli.md | 69 +- docs/format/bmai-v1.md | 171 ++-- docs/status.md | 177 ++-- ...26-06-18-agent-memory-database-research.md | 2 +- xtask/src/main.rs | 73 +- 133 files changed, 3925 insertions(+), 5774 deletions(-) create mode 100644 bindings/basemyai-node/README.md create mode 100644 bindings/basemyai-py/README.md create mode 100644 crates/basemyai-cli/src/ui/color.rs create mode 100644 crates/basemyai-cli/src/ui/error.rs create mode 100644 crates/basemyai-cli/src/ui/mod.rs create mode 100644 crates/basemyai-cli/src/ui/progress.rs create mode 100644 crates/basemyai-cli/src/ui/render.rs create mode 100644 crates/basemyai-cli/src/ui/table.rs create mode 100644 crates/basemyai-cli/src/ui/theme.rs create mode 100644 crates/basemyai-core/README.md delete mode 100644 crates/basemyai-core/benches/knn_scalability.rs create mode 100644 crates/basemyai-core/src/storage/key.rs delete mode 100644 crates/basemyai-core/src/storage/store.rs delete mode 100644 crates/basemyai-core/tests/contracts.rs delete mode 100644 crates/basemyai-core/tests/libsql_smoke.rs delete mode 100644 crates/basemyai-core/tests/store.rs create mode 100644 crates/basemyai/README.md delete mode 100644 crates/basemyai/src/maintenance/forgetting.rs delete mode 100644 crates/basemyai/src/maintenance/gc.rs delete mode 100644 crates/basemyai/src/memory/schema.rs delete mode 100644 crates/basemyai/src/storage/libsql_store.rs delete mode 100644 crates/basemyai/tests/forgetting.rs delete mode 100644 crates/basemyai/tests/gc.rs delete mode 100644 crates/basemyai/tests/graph.rs delete mode 100644 crates/basemyai/tests/key_rotation.rs delete mode 100644 crates/basemyai/tests/pool_concurrency.rs create mode 100644 crates/basemyai/tests/support/mod.rs create mode 100644 docs/adr/ADR-032-native-engine-default.md create mode 100644 docs/adr/ADR-033-native-only.md diff --git a/.cargo/config.toml b/.cargo/config.toml index 8117e07..2abc922 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -11,7 +11,7 @@ [env] RUST_TEST_THREADS = "1" -# `cargo xtask ` : reproduit la matrice +# `cargo xtask ` : reproduit la matrice # CI exacte (par-crate + features) — voir xtask/src/main.rs. [alias] xtask = "run --package xtask --" diff --git a/.claude/agents/architecture.agent.yaml b/.claude/agents/architecture.agent.yaml index 5ee5306..58792e9 100644 --- a/.claude/agents/architecture.agent.yaml +++ b/.claude/agents/architecture.agent.yaml @@ -1,30 +1,30 @@ name: architecture model: claude-opus-4-8 description: > - Expert en architecture de l'écosystème BaseMyAI / ForgeMyAI. - Valide les décisions cross-project, rédige les ADRs, maintient + Expert en architecture BaseMyAI. + Valide les décisions, rédige les ADRs, maintient les invariants d'agnosticité et de dépendance entre crates. system: | - Tu es l'architecte référent de l'écosystème BaseMyAI / ForgeMyAI. - Tu connais toutes les décisions prises (ADR-001 à ADR-013+) et leurs motivations. + Tu es l'architecte référent du workspace BaseMyAI. + Tu connais toutes les décisions prises (ADR-001+) et leurs motivations. ## Ton rôle - Valider les décisions architecturales avant implémentation - Détecter les violations de dépendance entre crates - Rédiger de nouveaux ADRs quand une décision change - - Maintenir la cohérence basemyai-core / basemyai / forgemyai-app + - Maintenir la cohérence basemyai-core / basemyai / surfaces ## Règles de dépendance (jamais violer) ``` - basemyai-core ← basemyai ← [PyO3, NAPI, REST] - basemyai-core ← forgemyai-app + basemyai-engine ← basemyai-core ← basemyai ← [PyO3, NAPI, REST, CLI] + basemyai-core ← consommateurs Rust tiers (hors repo) ``` - `basemyai-core` ne dépend de rien au-dessus - - `forgemyai-app` importe `basemyai-core` JAMAIS `basemyai` + - Les consommateurs tiers importent `basemyai-core` JAMAIS `basemyai` - Les SDKs wrappent `basemyai`, jamais le core directement ## Invariant d'agnosticité (ADR-001) @@ -32,13 +32,12 @@ system: | `basemyai-core` ne connaît JAMAIS : `agent_id`, `valid_until`, `episodic`, `Symbol`, `Edge`, LLM. Toute sémantique métier est injectée via traits (`Filter`, `MaintenanceTask`, `LlmInference`). - ## Stack non-négociable (ADR-011, ADR-013) + ## Stack non-négociable (ADR-032, natif-only) - - **DB** : libSQL async (pas rusqlite, pas sqlx, pas de DB externe) - - **Vecteur** : natif libSQL (pas d'extension, pas pgvector) + - **Stockage** : moteur natif `basemyai-engine` (pas libSQL, pas sqlx, pas DB externe) + - **Vecteur / FTS / graphe** : index natifs in-process - **Embeddings** : Candle, baseline `all-MiniLM-L6-v2` 384d - - **Chiffrement** : libSQL feature `crypto` - - **Futur** : Turso DB (pur Rust) + - **Chiffrement** : enveloppe native ADR-030 ## Workflow ADR diff --git a/.claude/agents/feature-dev.agent.yaml b/.claude/agents/feature-dev.agent.yaml index db30ba0..2817485 100644 --- a/.claude/agents/feature-dev.agent.yaml +++ b/.claude/agents/feature-dev.agent.yaml @@ -1,12 +1,12 @@ name: feature-dev model: claude-opus-4-8 description: > - Agent de développement de features pour BaseMyAI / ForgeMyAI. + Agent de développement de features pour BaseMyAI. Scaffolde les nouvelles fonctionnalités end-to-end : module, trait, test, migration SQL, en respectant les patterns du projet. system: | - Tu es un développeur senior chargé d'implémenter des features dans le workspace BaseMyAI / ForgeMyAI. + Tu es un développeur senior chargé d'implémenter des features dans le workspace BaseMyAI. Tu produis du code complet, testable, et conforme aux invariants du projet. ## Ton rôle diff --git a/.claude/agents/rust-expert.agent.yaml b/.claude/agents/rust-expert.agent.yaml index 7fd804e..87b0cda 100644 --- a/.claude/agents/rust-expert.agent.yaml +++ b/.claude/agents/rust-expert.agent.yaml @@ -1,12 +1,12 @@ name: rust-expert model: claude-opus-4-8 description: > - Senior Rust 2026 expert pour l'écosystème BaseMyAI / ForgeMyAI. + Senior Rust 2026 expert pour le workspace BaseMyAI. Révise le code, détecte les violations d'invariants, propose des corrections conformes au style édition 2024 du workspace. system: | - Tu es un expert Rust senior pour le workspace BaseMyAI / ForgeMyAI. + Tu es un expert Rust senior pour le workspace BaseMyAI. Tu connais parfaitement le codebase, ses invariants et ses contraintes. ## Ton rôle diff --git a/.claude/agents/security.agent.yaml b/.claude/agents/security.agent.yaml index 8d081ce..4f3d676 100644 --- a/.claude/agents/security.agent.yaml +++ b/.claude/agents/security.agent.yaml @@ -1,12 +1,12 @@ name: security model: claude-opus-4-8 description: > - Agent de review sécurité pour BaseMyAI / ForgeMyAI. + Agent de review sécurité pour BaseMyAI. Détecte les injections SQL, timing attacks, fuites de clés, violations d'isolation agent, et logging de contenu sensible. system: | - Tu es un expert sécurité spécialisé sur le codebase BaseMyAI / ForgeMyAI. + Tu es un expert sécurité spécialisé sur le codebase BaseMyAI. Tu identifies les vulnérabilités spécifiques à ce projet : injection SQL via Filter, timing attacks sur l'auth MCP, isolation multi-agent, et fuites de contenu en logs. diff --git a/.claude/skills/architecture/content.md b/.claude/skills/architecture/content.md index 7582d60..3cc46a9 100644 --- a/.claude/skills/architecture/content.md +++ b/.claude/skills/architecture/content.md @@ -1,78 +1,78 @@ -# Skill: architecture — Écosystème BaseMyAI / ForgeMyAI +# Skill: architecture — BaseMyAI -## Vue d'ensemble de l'écosystème +## Vue d'ensemble du workspace ``` ┌──────────────────────────────┐ │ basemyai-core │ │ (crate Rust, agnostique) │ - │ Store libSQL · sqlite-vec │ + │ moteur natif · vecteur · FTS │ │ Candle · MaintenanceWorker │ - └──────┬────────────────┬───────┘ - │ │ - ┌───────────────▼──────┐ ┌────▼──────────────┐ - │ basemyai │ │ ForgeMyAI │ - │ (sémantique mémoire) │ │ (contexte code) │ - │ 4 couches · RAG │ │ graphe · RRF │ - │ agent_id · valid_* │ │ MCP/LSP │ - └──┬────────┬──────┬────┘ └───────────────────┘ - │ │ │ - ┌────▼──┐ ┌──▼──┐ ┌▼────┐ - │SDK Py │ │SDK │ │REST │ - │(PyO3) │ │Node │ │side │ - └───────┘ │NAPI │ │ car │ - └─────┘ └─────┘ + └──────────────┬───────────────┘ + │ + ┌──────────────▼───────────────┐ + │ basemyai │ + │ (sémantique mémoire) │ + │ 4 couches · RAG temporel │ + │ agent_id · valid_* · graphe │ + └──┬────────┬──────┬───────────┘ + │ │ │ + ┌────▼──┐ ┌──▼──┐ ┌▼────┐ + │SDK Py │ │SDK │ │REST │ + │(PyO3) │ │Node │ │side │ + └───────┘ │NAPI │ │ car │ + └─────┘ └─────┘ ``` +Des crates Rust tiers peuvent aussi consommer `basemyai-core` directement (sémantique propre, hors de ce repo). Voir [`../../ECOSYSTEM_ARCHITECTURE.md`](../../ECOSYSTEM_ARCHITECTURE.md) pour le contexte écosystème. + ## Règle de dépendance (JAMAIS violer) -- **`basemyai-core`** ne dépend de rien au-dessus : ni `basemyai`, ni `forge-*`. -- **`basemyai`** importe `basemyai-core` (jamais `forge-*`). -- **`forgemyai-app`** importe `basemyai-core` — **PAS `basemyai`** (sinon hérite du RAG temporel / `agent_id` inutiles pour du code). -- Les SDKs PyO3/NAPI/REST wrappent `basemyai` (la sémantique), jamais le core directement. +- **`basemyai-engine`** : moteur interne, non publié — consommé par `basemyai-core` uniquement. +- **`basemyai-core`** ne dépend de rien au-dessus : ni `basemyai`, ni produit tiers. +- **`basemyai`** importe `basemyai-core` (jamais l'inverse). +- Les SDKs PyO3/NAPI/REST/CLI wrappent `basemyai` (la sémantique), jamais le core directement. ## Invariant d'agnosticité de `basemyai-core` (ADR-001) `basemyai-core` ne connaît **jamais** : - `agent_id`, `valid_from`, `valid_until` — sémantique mémoire de `basemyai` -- `Symbol`, `Edge`, call graph, FTS — sémantique code de ForgeMyAI +- `Symbol`, `Edge`, call graph — sémantique code d'un consommateur tiers - Couches mémoire (episodic, semantic, procedural, short-term) - LLM, inférence, consolidation **Test d'agnosticité** (doit retourner zéro) : ```bash -grep -rE 'agent_id|valid_until|episodic|Symbol|Edge|semantic|graph|entity' \ - crates/basemyai-core/src +grep -rE 'agent_id|valid_until|episodic|Symbol|Edge' crates/basemyai-core/src ``` ## Principe fondateur : mécanisme au core, sens au consommateur -| Brique core | Sens ajouté par basemyai | Sens ajouté par ForgeMyAI | -|-------------|--------------------------|---------------------------| -| `Store.knn(q, k, filter?)` | filter = `agent_id = ? AND valid_until > now()` | filter = `file_path = ? AND lang = ?` | -| `MaintenanceWorker.register(task)` | task = GC `valid_until` expiré | task = purge vieilles versions MVCC | -| `Filter { sql, params }` | params = `[agent_id, timestamp]` | params = `[symbol_id, version]` | +| Brique core | Sens ajouté par `basemyai` | +|-------------|----------------------------| +| recherche vectorielle + filtres | isolation `agent_id`, validité temporelle | +| `MaintenanceWorker.register(task)` | GC expiration, oubli adaptatif, consolidation | +| graphe / FTS | entités mémoire, relations entre faits | ## Workflow ADR **Un ADR ne se modifie JAMAIS.** Une décision qui change = **un nouvel ADR**. Fichiers de décision : -- `basemyai/ADR.md` — décisions BaseMyAI (001 à 013+) -- `basemyai/PRD.md` — product requirements -- `forgemyai-app/ADR (1).md` — décisions ForgeMyAI -- `forgemyai-app/ADR-013-basemyai-core.md` — décision d'adopter basemyai-core -- `ECOSYSTEM_ARCHITECTURE.md` — relation entre les deux produits - -## Stack technique actée (ADR-011, ADR-013) - -| Composant | Choix | Interdit | -|-----------|-------|---------| -| Base de données | **libSQL async** | rusqlite, sqlx, DB externe | -| Vecteur | **natif libSQL** (`vector_top_k`, `F32_BLOB`) | extension externe, pgvector | -| Embeddings | **Candle** (`all-MiniLM-L6-v2`, 384d) | ONNX, fastembed | -| Chiffrement | **libSQL feature `crypto`** | sqlcipher séparé, AES manuel | -| Futur | Turso DB (pur Rust) | cloud-only DBs | +- `docs/ADR.md` — index des décisions BaseMyAI +- `docs/PRD.md` — product requirements +- `../ECOSYSTEM_ARCHITECTURE.md` — relation avec les autres produits de l'écosystème + +## Stack technique actée (ADR-032, natif-only) + +| Composant | Choix | +|-----------|-------| +| Stockage | **moteur natif** `basemyai-engine` (WAL + LSM + SST) | +| Vecteur | **LM-DiskANN / Vamana** in-process | +| FTS | index inversé natif (BM25) | +| Graphe | layout KV préfixé par agent | +| Embeddings | **Candle** (`all-MiniLM-L6-v2`, 384d) | +| Chiffrement | enveloppe native ADR-030 (XChaCha20-Poly1305) | ## Provisioning hardware-aware (ADR-010) @@ -84,34 +84,23 @@ Fichiers de décision : ## Modèle en V1 - **Baseline unique** : `all-MiniLM-L6-v2` (384d, Candle, pur Rust) -- Garantit la compatibilité `.idx` entre basemyai et ForgeMyAI - Multi-modèles = V2 ## Gouvernance des releases - **Semver strict** sur `basemyai-core` : tout changement d'API = bump de version -- ForgeMyAI **pin** la version de `basemyai-core` dans son `Cargo.toml` -- `basemyai-core` testé Linux + Windows dès son premier commit (il porte du code C via bindgen) +- Les consommateurs tiers **pin** la version de `basemyai-core` dans leur `Cargo.toml` -## Surfaces SDK de basemyai (4 surfaces) +## Surfaces de basemyai | Surface | Consommateur | Couche | |---------|-------------|--------| | SDK Python (PyO3) | builders d'agents Python | `basemyai` | | SDK Node (NAPI-RS) | builders d'agents JS/TS | `basemyai` | | Sidecar REST (axum) | Go, Ruby, etc. | `basemyai` | -| Crate Rust natif | ForgeMyAI | `basemyai-core` | - -## Statut juin 2026 - -**BaseMyAI** : workspace scaffoldé, Phases 1+2 implémentées : -- KNN cosine + oversampling ×8 (ADR-012) -- Graphe entités/relations CTE récursive -- RRF (`rrf_fuse`, k=60) -- Oubli adaptatif hyperbolique -- Consolidation épisodes → faits (`LlmInference` injecté) -- LLM provision 20 modèles, 8 backends +| CLI (`basemyai`) | ops, scripting | `basemyai` | +| Crate Rust natif | programmes custom | `basemyai-core` | -**Reste ouvert** : wiring `ConsolidationTask` dans `MaintenanceWorker`, publication SDKs. +## Statut juillet 2026 -**ForgeMyAI** : docs/ADRs seulement — pas encore scaffoldé. +Workspace **100 % moteur natif** (ADR-032). Phases 1+2 implémentées ; surfaces MCP/REST/bindings/CLI en place. Voir `docs/status.md`. diff --git a/.claude/skills/feature-dev/content.md b/.claude/skills/feature-dev/content.md index e0aa96f..cf1eea8 100644 --- a/.claude/skills/feature-dev/content.md +++ b/.claude/skills/feature-dev/content.md @@ -1,4 +1,4 @@ -# Skill: feature-dev — Scaffolding de features BaseMyAI / ForgeMyAI +# Skill: feature-dev — Scaffolding de features BaseMyAI ## Checklist avant de coder @@ -21,7 +21,7 @@ | Tool MCP (remember, recall…) | `crates/basemyai-mcp/` | | Binding Python | `bindings/basemyai-py/` | | Binding Node | `bindings/basemyai-node/` | -| Contexte de code (symboles, graphe d'appel) | ForgeMyAI (pas encore scaffoldé) | +| Sémantique code (symboles, graphe d'appel) | hors scope — consommateur tiers de `basemyai-core` | ## Pattern : ajouter une feature à `basemyai` (sémantique) diff --git a/.claude/skills/rust-expert/content.md b/.claude/skills/rust-expert/content.md index 3beb0d7..066e345 100644 --- a/.claude/skills/rust-expert/content.md +++ b/.claude/skills/rust-expert/content.md @@ -1,4 +1,4 @@ -# Skill: rust-expert — BaseMyAI / ForgeMyAI +# Skill: rust-expert — BaseMyAI ## Stack et édition diff --git a/.claude/skills/security/content.md b/.claude/skills/security/content.md index 8336942..c9aef7c 100644 --- a/.claude/skills/security/content.md +++ b/.claude/skills/security/content.md @@ -1,4 +1,4 @@ -# Skill: security — BaseMyAI / ForgeMyAI +# Skill: security — BaseMyAI ## Surface d'attaque principale diff --git a/.github/labeler.yml b/.github/labeler.yml index 86c8f25..7941838 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -21,7 +21,7 @@ # ── Infra / storage ────────────────────────────────────────────────────────── "storage": - - '/(\blibsql\b|\bsqlite\b|\bvector.top.k\b|\bannf?\b)/i' + - '/(\bbasemyai.engine\b|\bnative\s+engine\b|\bwal\b|\bsst\b|\blsm\b|\bdiskann\b|\bvamana\b|\bannf?\b)/i' "embeddings": - '/(\bcandle\b|\bembedd?ing\b|\bminilm\b|\ball.minilm\b)/i' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f0669c..0814435 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,9 +45,9 @@ jobs: - run: cargo fmt --all --check # ── Gate qualité : clippy + tests, features légères ───────────────────────── - # Ni CMake (crypto) ni Candle (embed) : couvre tout le workspace rapidement. + # Ni Candle (embed) ni tests lourds `#[ignore]` : couvre vite le workspace. # `--features` interdit à la racine d'un workspace virtuel → on cible chaque - # crate avec `-p`. Les features `crypto`/`embed` ont leurs jobs dédiés. + # crate avec `-p`. Le job `embed` reste dédié. gate: name: clippy + test (${{ matrix.os }}) @@ -71,12 +71,11 @@ jobs: run: | set -euxo pipefail cargo clippy -p basemyai-core --all-targets -- -D warnings - cargo clippy -p basemyai-core --features engine-native --all-targets -- -D warnings cargo clippy -p basemyai-engine --all-targets -- -D warnings cargo clippy -p basemyai --features test-util --all-targets -- -D warnings - cargo clippy -p basemyai --features test-util,engine-native --all-targets -- -D warnings cargo clippy -p basemyai-mcp --no-default-features --features stdio,http,test-util --all-targets -- -D warnings cargo clippy -p basemyai-rest --no-default-features --features test-util --all-targets -- -D warnings + cargo clippy -p basemyai-cli --all-targets -- -D warnings cargo clippy -p basemyai-py --no-default-features --features test-util --all-targets -- -D warnings cargo clippy -p basemyai-node --no-default-features --features test-util --all-targets -- -D warnings @@ -84,7 +83,6 @@ jobs: run: | set -euxo pipefail cargo test -p basemyai-core - cargo test -p basemyai-core --features engine-native # --lib --bins --test basic --test vector_recall --test vector_persistence --test vector_churn --test graph_parity --test format_lock : # tests du moteur natif + harnais recall + persistance KV + churn # insert/delete (tombstones + consolidation FreshDiskANN, recall APRÈS @@ -93,12 +91,13 @@ jobs: # persistant), SANS crash_consistency (kill-loop lent, job dédié ci-dessous) cargo test -p basemyai-engine --lib --bins --test basic --test vector_recall --test vector_persistence --test vector_churn --test graph_parity --test format_lock cargo test -p basemyai --features test-util - # --test memory_tests : le runner déclaratif multi-backend (N2) avec le - # backend Native branché (N5.1, ADR-027) — mêmes scénarios rejoués - # contre Libsql ET le moteur natif, zéro divergence tolérée - cargo test -p basemyai --features test-util,engine-native --test memory_tests + # --test memory_tests : runner déclaratif du contrat MemoryStore sur + # le backend natif (clair + chiffré), zéro divergence tolérée. + cargo test -p basemyai --features test-util --test memory_tests + cargo test -p basemyai --features test-util --test p1_isolation_adversarial cargo test -p basemyai-mcp --no-default-features --features stdio,http,test-util cargo test -p basemyai-rest --no-default-features --features test-util + cargo test -p basemyai-cli # ── Feature `embed` : Candle ML (compile + tests réels #[ignore]) ──────────── @@ -125,50 +124,6 @@ jobs: cargo test -p basemyai-core --features embed cargo test -p basemyai --features embed - # ── Feature `crypto` : chiffrement libSQL — exige CMake ───────────────────── - # Bug libsql-ffi 0.9.30 : son build.rs appelle `cp`, absent du PATH Windows. - # Le `usr/bin` de Git (présent sur les runners GitHub) le fournit. - - crypto: - name: crypto (${{ matrix.os }}) - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - # macOS retiré (×10). Windows conservé : le quirk libsql-ffi `cp`/CMake - # documenté ci-dessous est spécifique à Windows. - os: [ubuntu-latest, windows-latest] - steps: - - uses: actions/checkout@v7 - - - name: Install CMake (Ubuntu) - if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y cmake - - - name: Install CMake (macOS) - if: runner.os == 'macOS' - run: brew install cmake - - - name: Install CMake (Windows) - if: runner.os == 'Windows' - run: pip install cmake - - - name: Add Git usr/bin to PATH (Windows) - if: runner.os == 'Windows' - run: echo "C:/Program Files/Git/usr/bin" >> "$GITHUB_PATH" - - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - with: - key: crypto - save-if: ${{ github.ref == 'refs/heads/main' }} - - - name: Test (crypto) - run: | - set -euxo pipefail - cargo test -p basemyai-core --features crypto - cargo test -p basemyai --features crypto - # ── `basemyai-engine` crash-consistency harness (N2, TODO-NATIVE-ENGINE.md) ─ # Kill/reopen/verify loop: spawns `crash_writer`, force-kills it mid-write # (`taskkill /F` on Windows, `kill -9` on Linux), reopens the Engine, checks @@ -208,7 +163,6 @@ jobs: - format - gate - embed - - crypto - crash-consistency steps: - name: Check for failures @@ -217,7 +171,6 @@ jobs: "${{ needs.format.result }}" "${{ needs.gate.result }}" "${{ needs.embed.result }}" - "${{ needs.crypto.result }}" "${{ needs.crash-consistency.result }}" ) diff --git a/.github/workflows/node-prebuilds.yml b/.github/workflows/node-prebuilds.yml index 7a4c46a..4ac654c 100644 --- a/.github/workflows/node-prebuilds.yml +++ b/.github/workflows/node-prebuilds.yml @@ -5,10 +5,9 @@ on: tags: ["v*"] workflow_dispatch: -# Construit les binaires natifs NAPI (.node, features crypto + embed) par +# Construit les binaires natifs NAPI (.node, feature embed) par # plateforme. napi9 est stable au niveau ABI : un prebuild par OS/arch tourne # dès Node 18.17 (testé ci-dessous sur 20/22 — le CLI de build exige 20+). -# crypto exige CMake. defaults: run: working-directory: bindings/basemyai-node @@ -58,28 +57,10 @@ jobs: - name: Add Rust target to pinned toolchain run: rustup target add ${{ matrix.target }} - - name: Install CMake (Ubuntu) - if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y cmake - - - name: Install CMake (macOS) - if: runner.os == 'macOS' - run: brew install cmake - - - name: Install CMake (Windows) - if: runner.os == 'Windows' - run: pip install cmake - - # libsql-ffi 0.9.30 appelle `cp`, absent du PATH Windows — Git le fournit. - - name: Add Git usr/bin to PATH (Windows) - if: runner.os == 'Windows' - shell: bash - run: echo "C:/Program Files/Git/usr/bin" >> "$GITHUB_PATH" - - run: npm install --include=dev - name: Build native addon - run: npx napi build --platform --release --target ${{ matrix.target }} --features crypto,embed + run: npx napi build --platform --release --target ${{ matrix.target }} --features embed - uses: actions/upload-artifact@v4 with: @@ -128,7 +109,7 @@ jobs: - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - run: npm install --include=dev - # Build debug sans crypto/embed (rapide) pour valider l'ABI sur chaque Node. + # Build debug test-util (rapide) pour valider l'ABI sur chaque Node. - run: npx napi build --platform --no-default-features --features test-util - run: npx jest diff --git a/.github/workflows/python-wheels.yml b/.github/workflows/python-wheels.yml index 114479f..dddefbe 100644 --- a/.github/workflows/python-wheels.yml +++ b/.github/workflows/python-wheels.yml @@ -5,8 +5,8 @@ on: tags: ["v*"] workflow_dispatch: -# Construit les wheels PyO3 (features crypto + embed, cf. pyproject.toml) pour -# Linux/macOS/Windows × CPython 3.10–3.13. crypto exige CMake ; embed tire Candle. +# Construit les wheels PyO3 (feature embed) pour Linux/macOS/Windows × CPython +# 3.10–3.13. jobs: validate-secrets: name: validate publish credentials @@ -40,16 +40,6 @@ jobs: 3.12 3.13 - - name: Install CMake (Windows) - if: runner.os == 'Windows' - run: pip install cmake - - # libsql-ffi 0.9.30 appelle `cp` (absent du PATH Windows) — Git le fournit. - - name: Add Git usr/bin to PATH (Windows) - if: runner.os == 'Windows' - shell: bash - run: echo "C:/Program Files/Git/usr/bin" >> "$GITHUB_PATH" - - name: Build wheels uses: PyO3/maturin-action@v1 with: @@ -60,11 +50,6 @@ jobs: # PyPI-acceptable manylinux tag (a bare host build tags linux_x86_64, # which PyPI rejects). The container runs the script below as root. manylinux: auto - # crypto needs a MODERN CMake. EL7's `yum install cmake` is 2.8 (too - # old for libsql-ffi), so install via pipx/pip which ships a recent - # cmake binary regardless of the base image. - before-script-linux: | - command -v cmake >/dev/null 2>&1 || pipx install cmake || pip install cmake || pip3 install cmake - uses: actions/upload-artifact@v4 with: diff --git a/.gitignore b/.gitignore index 3b0db61..32ceeaf 100644 --- a/.gitignore +++ b/.gitignore @@ -79,6 +79,9 @@ iai/ *.swp *.orig +# Smoke tests CLI locaux (facts, export JSONL, conteneur de test) +.tmp-cli-smoke/ + # ── Fichiers de plan temporaires (Claude) ──────────────────────────────────── cr*.md diff --git a/AGENTS.md b/AGENTS.md index d49cb38..066025f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,9 @@ Le guide agent canonique de ce repo est **[`CLAUDE.md`](CLAUDE.md)** : commandes (`cargo xtask` reproduit la matrice CI), invariants à ne jamais violer, style Rust, layout et statut. Lis-le en entier avant de travailler ici. +Note 2026-07-08 : le workspace est désormais **natif-only** (ADR-033) : +libSQL/V1/`crypto`/double-backend sont retirés du code actif. + Ce fichier n'est qu'un pointeur — ne pas dupliquer le contenu ici (les deux copies avaient déjà divergé ; la double maintenance est volontairement abandonnée, audit 2026-07-02). diff --git a/CHANGELOG.md b/CHANGELOG.md index da29be6..8d7bcb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Migrated the active workspace to **native-only** storage: removed libSQL/V1 + compatibility paths from runtime code (`Store`, `LibsqlMemoryStore`, + SQL-leaky surface, dual-backend feature flags). +- Removed legacy libSQL `crypto` build paths from CI and DX workflows + (`cargo xtask`, GitHub Actions wheels/prebuilds, binding packaging configs). +- Updated integration tests to open native stores through production APIs (temp + directories) instead of test-only ephemeral helpers. +- Set `MAX_TEXT_LEN` to `65535` (`u16::MAX`) so the public limit is consistent + with native FTS docterm encoding. + ## [0.1.0] - 2026-06-21 First public release. Published to crates.io (`basemyai-core`, `basemyai`). diff --git a/CLAUDE.md b/CLAUDE.md index 861535b..e4a1b26 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,6 +8,10 @@ stockage maison, ADR-024/025, non publié) + **2 bindings** (`bindings/basemyai-py`, `bindings/basemyai-node`) + l'outil DX `xtask/`. Décisions : `docs/ADR.md` (index, un fichier par ADR sous `docs/adr/`). Relation d'écosystème : `../ECOSYSTEM_ARCHITECTURE.md`. +**État 2026-07-08 (ADR-033)** : workspace **100 % moteur natif**. +libSQL/V1, `Store`, `LibsqlMemoryStore`, feature `crypto` libSQL et +double-backend sont retirés du code actif. + ## Commandes **LE point d'entrée qui reproduit la CI est `cargo xtask`** (matrice exacte de @@ -15,10 +19,9 @@ Décisions : `docs/ADR.md` (index, un fichier par ADR sous `docs/adr/`). Relatio ```bash cargo xtask check # fmt --check + clippy par crate (features CI), -D warnings -cargo xtask test # tests par crate, config légère (sans embed/crypto) +cargo xtask test # tests par crate, config légère (sans embed) cargo xtask ci # check + test — LE gate avant commit cargo xtask test-embed # job CI `embed` (Candle, lourd) -cargo xtask test-crypto # job CI `crypto` — EXIGE CMake installé cargo xtask format-lock # anti-drift basemyai-engine/format.lock (inclus dans check/ci) cargo xtask test-crash-consistency # kill-loop réel basemyai-engine (~20 cycles, job CI dédié) ``` @@ -29,14 +32,13 @@ crate avec des features précises, ex. `-p basemyai-mcp --no-default-features brutes, gardées en référence : ```bash -cargo test --workspace # async (tokio) ; libSQL compile via bindgen/cc +cargo test --workspace # async (tokio) cargo clippy --workspace --all-targets -- -D warnings # utile en local, mais ≠ matrice CI cargo build -p basemyai-core --features embed # active Candle (lourd) -cargo build -p basemyai-core --features crypto # chiffrement libSQL — EXIGE CMake installé cargo build --profile profiling -p basemyai-rest # binaire optimisé MAIS symbolisable (perf/flamegraph) ``` -Le **vecteur est natif libSQL** (compile sans CMake). Seule la feature `crypto` (chiffrement au repos) exige CMake. `embed` tire Candle (lourd). +Le backend est **natif BaseMyAI** (`basemyai-engine`) ; `embed` tire Candle (lourd). **Profils** (définis dans le `Cargo.toml` racine) : `dev` est allégé (`debug = "line-tables-only"`) pour itérer vite malgré libSQL+Candle — backtraces panic conservées ; pour debugger sous gdb/lldb : `cargo build --config 'profile.dev.debug=2'`. `profiling` = release symbolisable (perf, flamegraphs). @@ -45,11 +47,11 @@ Le **vecteur est natif libSQL** (compile sans CMake). Seule la feature `crypto` ## Invariants — NE JAMAIS violer - **`basemyai-core` est agnostique métier.** Aucun `agent_id`, `valid_from/until`, couche mémoire, ni `Symbol/Edge` dedans. Ces concepts vivent dans `basemyai`. Un `grep -rE 'agent_id|valid_until|episodic|Symbol|Edge' crates/basemyai-core/src` doit retourner **zéro** (test d'agnosticité, ADR-001). -- **Mécanisme au core, sens au consommateur.** Le core expose `knn(q, k, filter?)` et un worker de tâches *injectées*. Le sens (temps, agent) se passe via `Filter` paramétré, jamais en dur dans le core. +- **Mécanisme au core, sens au consommateur.** Le core expose les primitives moteur (`StorageEngine`, capacités, chiffrement natif) et un worker de tâches *injectées*. Le sens (temps, agent) reste côté `basemyai`. - **L'`Embedder` ne télécharge jamais** et ne détecte jamais le matériel. Il reçoit chemin + `Device` résolus par `setup` (ADR-010). Zéro réseau hors setup explicite. -- **`Filter` est paramétré** : fragment SQL + valeurs liées (`?`). Les inputs d'agent vont dans `params`, jamais interpolés (anti-injection, ADR-006). -- **Chiffrement obligatoire dans `basemyai`** (libSQL feature `crypto`), optionnel dans `basemyai-core`. -- **Backend = libSQL** (ADR-011), **async**. Vecteur **natif** (`vector_top_k`, pas d'extension), chiffrement intégré. `Store` est async ; l'`Embedder` reste **sync** (CPU-bound). Pas de DB externe. **Candle**, pas ONNX/fastembed. Chemin futur : **moteur natif BaseMyAI** (ADR-024, remplace le chemin Turso — voir `docs/PLAN-NATIVE-ENGINE.md`) ; libSQL reste le défaut jusqu'à parité prouvée. +- **Pas de surface SQL-leaky dans le produit** : ni `Filter`, ni `Value`, ni `Store`, ni `LibsqlMemoryStore`. +- **Chiffrement obligatoire dans `basemyai`** via l'enveloppe native ADR-030 (pas de dépendance CMake). +- **Backend unique = moteur natif BaseMyAI** (ADR-024/025/026/027/028/030/032). Pas de fallback libSQL. ## Style Rust (2026, édition 2024) @@ -61,15 +63,17 @@ Le **vecteur est natif libSQL** (compile sans CMake). Seule la feature `crypto` **Politique de lints** : curée et commentée dans `[workspace.lints]` du `Cargo.toml` racine ; chaque crate l'hérite via `[lints] workspace = true`. Elle **encode ces règles dans le compilateur** : `unwrap_used`, `await_holding_lock` (= « pas de `Mutex` à travers `.await` »), `todo`, `clone_on_ref_ptr` + famille clonage/perf. Les tests sont exemptés de `unwrap_used` via `clippy.toml` (`allow-unwrap-in-tests`). **`expect_used` n'est volontairement PAS activé** : la règle autorise `expect("message")`, or ce lint interdit tout `expect`. Ne pas l'ajouter. -## Layout (restructuré 12 juin 2026) +## Layout (restructuré 12 juin 2026, mis à jour ADR-033) ### `basemyai-core/src/` ```text -storage/ ← Recherche vectorielle + store libSQL +storage/ ← Primitives moteur natif (ADR-024/025/030) ├─ mod.rs ← re-exports - ├─ store.rs ← Store async, migrations, chiffrement - └─ vector.rs ← Filter, Value, Neighbor (types paramétrables) + ├─ engine.rs ← StorageEngine, EngineCapabilities, EngineKind::Native + ├─ native.rs ← NativeEngine (wrapper capability-only) + ├─ key.rs ← EncryptionKey + └─ vector.rs ← Metric (cosine/euclidean/hamming — mécanisme pur) embed/ ← Embeddings in-process ├─ mod.rs ← Device enum, trait Embedder (object-safe) @@ -77,7 +81,18 @@ embed/ ← Embeddings in-process error.rs ← CoreError (thiserror, #[non_exhaustive]) maintenance.rs ← MaintenanceTask, MaintenanceWorker (injection) -lib.rs ← re-exports + libsql +lib.rs ← re-exports publics +``` + +### `basemyai-engine/src/` (non publié, BUSL-1.1) + +```text +store/ ← WAL + memtable + SST, apply_batch +idx/vector/ ← LM-DiskANN (ADR-026) +idx/graph/ ← entités/arêtes + BFS (ADR-027/N4) +idx/memory/ ← records mémoire + vecmap +idx/fts/ ← BM25 inversé (ADR-028) +format/ ← wire formats versionnés + format.lock ``` ### `basemyai/src/` @@ -86,24 +101,26 @@ lib.rs ← re-exports + libsql memory/ ← Domaine mémoire (4 couches, isolation, validité) ├─ mod.rs ← façade Memory (remember, recall, recall_by_layer, invalidate, forget, stats, search_graph) ├─ layer.rs ← MemoryLayer enum, Record, AgentStats - ├─ schema.rs ← Migrations SQL V1/V2/V3 (memory + graph), EMBEDDING_DIM - └─ isolation.rs ← AgentId newtype (isolation SQL ADR-006) + ├─ porting.rs ← export/import JSONL + ├─ event.rs ← MemoryEvent (ADR-022) + └─ isolation.rs ← AgentId newtype (isolation structurelle ADR-006) cognition/ ← Pipeline Phase 2 (graphe + consolidation + inférence) ├─ mod.rs ├─ inference.rs ← trait LlmInference (object-safe, injecté) ├─ consolidation.rs ← consolidate(memory, llm) → faits + graphe - └─ graph.rs ← Graph {entity, edge}, traverse (CTE récursive) + └─ graph.rs ← Graph {entity, edge}, traverse (BFS natif) + +storage/ ← Contrat MemoryStore + └─ native_store.rs ← NativeMemoryStore (unique implémentation) provision/ ← Provisioning hardware-aware ├─ mod.rs ← re-exports ├─ embedder.rs ← detect_hardware(), provision(), SHA256 verification - └─ llm.rs ← KNOWN_MODELS, detect_llm_options(), choose_llm(), OpenAiCompatBackend (alias OllamaBackend) + └─ llm.rs ← KNOWN_MODELS, detect_llm_options(), choose_llm() -maintenance/ ← Tâches de fond - ├─ mod.rs ← ConsolidationTask (Arc + Arc) - ├─ gc.rs ← ExpiredMemoryGc (ADR-005) - └─ forgetting.rs ← AdaptiveForgetting (importance × récence hyperbolique) +maintenance/ ← Tâches de fond injectées + └─ mod.rs ← ConsolidationTask uniquement (gc/oubli adaptatif retirés ADR-033) retrieval.rs ← Racine : Ranking, Fused, rrf_fuse (pur méchanisme) temporal.rs ← Racine : Validity (valid_from/until), temporal_filter() @@ -114,132 +131,18 @@ lib.rs ← re-exports **Organisation par domaine sémantique, pas par artefact.** Chaque dossier regroupe un concept métier et expose un API cohérent via `mod.rs`. -`crates/basemyai/tests/` : TBD (sera restructuré en accord avec src/). - -## Statut (juin 2026) - -Phase 1 (socle) ✅ et Phase 2 (Cognition) ✅ implémentées : - -- **KNN réel** : distance cosine, oversampling ×8 quand filtre présent (ADR-012). -- **Graphe** : entités/relations, CTE récursive `UNION` (cycle-safe), scopée `agent_id` + profondeur (ADR-012). -- **RRF** : `rrf_fuse` multi-signal, k=60, déterministe (ADR-012). -- **Oubli adaptatif** : `AdaptiveForgetting`, décroissance hyperbolique `H/(H+age)` (pas exponentielle — libSQL n'a pas `pow`/`exp`) (ADR-012). -- **Consolidation** épisodes→faits : `consolidate(memory, llm)`, idempotente, `LlmInference` injecté (ADR-012). -- **LLM provision** : `KNOWN_MODELS` 20 modèles juin 2026, 8 backends détectés, `choose_llm()` hardware-aware (ADR-013). - -Wiring consolidation dans `MaintenanceWorker` ✅ (`ConsolidationTask`, `maintenance/mod.rs`), -bindings PyO3/NAPI ✅ (`bindings/basemyai-py`, `bindings/basemyai-node`), sidecar MCP ✅ -(`crates/basemyai-mcp`) et REST ✅ (`crates/basemyai-rest`) : tous implémentés et testés. - -**État réel : voir `docs/status.md` (source de vérité, 2026-07-04).** Le moteur (Phase 1 + 2) -et les surfaces (MCP/REST/bindings/CLI) sont en place ; **crates.io et PyPI sont publiés** -(`0.1.0` confirmé le 2026-06-22), tandis que la publication npm de `basemyai` reste à re-vérifier -depuis cette machine (`npm view basemyai` renvoie `404`). Pas de binaire CLI distribué. CLI -`basemyai-cli` ✅ : cycle de vie mémoire complet -(`remember/recall/list/forget/invalidate/purge/export/import`), graphe, maintenance (`gc`/ -`forget-adaptive`/`consolidate`), `config`, `completions` — voir `docs/cli.md`. Reste ouvert (M5) : -distribution binaire (cargo-dist), tests CLI en CI. `StorageEngine` : trait d'opérations mémoire -`basemyai::storage::MemoryStore` + `LibsqlMemoryStore` fait (ADR-020, 2026-06-20), `Filter` confiné, -tests de contrat ajoutés. Hardening (M6, 2026-07-02) : pool lecteur ✅ (ADR-021), bench KNN ✅ -(10k/100k réels archivés, `docs/benchmarks/m6-knn-results-2026-07-01.md` — 1M documenté comme -non exécuté, coût de build de l'index natif ~78-79 ms/ligne), stress Candle 1h ✅ (stable, pas de -fuite, `docs/benchmarks/m6-candle-stress-results-2026-07-01.md`), key rotation ✅ (`PRAGMA rekey`, -`Store::rotate_key`/`Memory::rotate_key`). Live subscriptions vague 2 (ADR-022) : REST SSE ✅, MCP -notifications ✅, PyO3 callback ✅, NAPI/Node reporté (pas d'équivalent direct de l'itérateur async -Python en napi-rs). Reste ouvert : CUDA/NVML réel (M6), résultats KNN 1M, NAPI live subscriptions. - -**Moteur natif (ADR-024/025, `docs/TODO-NATIVE-ENGINE.md`)** : N1 (spike LSM vs B-tree) ✅ et -**N2 (store durable) ✅ clos 2026-07-04** — `crates/basemyai-engine` (WAL+memtable+SST+recovery, -batches atomiques `apply_batch`, `WAL_RECORD_VERSION` 2), harnais crash-consistency kill réel en CI, -fuzzing (1 panic réel trouvé/corrigé), `format.lock` anti-drift, `EngineKind::Native` + -`NativeEngine` capability-only dans `basemyai-core` (feature `engine-native`), runner déclaratif -multi-backend `crates/basemyai/tests/memory_tests/` (vert sur Libsql, prêt pour Native). -**N3 (index vectoriel natif) ✅ clos 2026-07-05** : LM-DiskANN/Vamana pur Rust -(`idx/vector/{node,distance,graph,meta,persistent}.rs`, ADR-026), insert/delete/consolidate en -`apply_batch` atomiques, tombstones + réparation FreshDiskANN, rebuild depuis la donnée. Recall@10 = -1.0 partout (RAM, persistant, après churn, 10k/100k). Bench de parité M6 -(`docs/benchmarks/n3-vector-parity-2026-07-05.md`) : les 3 seuils ADR-026 tenus avec grande marge — -requête 7.5 ms (10k) / 12.7 ms (100k) vs plafond ~48-49 ms libSQL ; build incrémental réel 5.7 ms/ligne -(10k) / 17.3 ms/ligne (100k) vs 78-79 ms/ligne libSQL (qui n'a jamais fini son build incrémental 100k -en 3h+, ce chiffre étant son taux de bulk-load, pas incrémental). Crash harness étendu aux deletes/ -consolidate : 0 violation sur plusieurs runs de 20 cycles. **N4 (graphe natif) ✅ clos 2026-07-05** : -`idx/graph/{entity,edge,traverse,ram,persistent}.rs` — un nœud/une arête = un enregistrement KV -(`relation`/`dst` dans la clé, pas la valeur, pour un scan préfixé par nœud source à chaque saut -BFS), isolation par agent structurelle dans le layout de clé, `GraphEntity:1`/`GraphEdge:1` dans -`format.lock`. Traversée = portage littéral 1:1 de la CTE récursive SQL, les 5 scénarios de -`tests/graph.rs` rejoués fidèlement contre RAM et persistant (`tests/graph_parity.rs`), même code BFS -partagé entre les deux (zéro dérive possible). Pas de méta/rebuild (design assumé : aucun état de -navigation global à mettre en cache). Crash harness étendu mode `graph`, 20 cycles réels, 0 violation. -**N5.1 (`NativeMemoryStore` hors FTS/crypto) ✅ clos 2026-07-05** — découpage N5 acté par ADR-027 : -`idx/memory/` moteur (`MemoryRecord:1`/`MemoryVecMap:1`/`MemoryIndexMeta:1` dans `format.lock`, -allocateur `vec_id` monotone auto-guérissant), `PersistentVectorIndex::insert_with`/`delete_with` -(les enregistrements du consommateur montent dans le même `apply_batch` que l'index — un `remember` -natif = UN enregistrement WAL, atomicité transaction-libSQL retrouvée), `NativeMemoryStore` dans -`basemyai` (feature `engine-native`, jamais défaut) : parité requête par requête avec -`LibsqlMemoryStore` (oversampling ×8 ADR-012, non-filtres préservés), FTS/BM25 et métriques -non-cosinus en erreur franche (N5.2/N5.3). **Le diff multi-backend du runner N2 est prouvé** : -`backend_suite!` vert sur Libsql ET Native (`cargo test -p basemyai --features -test-util,engine-native --test memory_tests`), matrice xtask/CI étendue en miroir strict, -capacités `NativeEngine` honnêtes (`vectors`/`recursive_queries` → true). -**N5.2 (FTS/BM25 natif) ✅ clos 2026-07-06** (ADR-028) : troisième index moteur `idx/fts/` -(inversé `FtsPosting:1` + direct `FtsDocTerms:1` + stats par agent healables `FtsStats:1`), -tokenizer casefold+pliage d'accents (racinisation Porter différée, gap assumé et documenté) ; -`PersistentFts::stage_insert`/`stage_delete` composent dans le `Batch` de l'appelant, fusionnés -par `PersistentMemoryIndex::put`/`forget`/`purge_agent` dans le même `extra` batch que -vecteur+mémoire — un `remember` natif reste UN enregistrement WAL étendu au troisième index. -Scoring Okapi (`k1=1.2`/`b=0.75`, défauts FTS5), `df` dérivé du scan des postings. -`NativeMemoryStore::keyword_ranking_ids` branché (fin de l'erreur franche) — un bug de parité -(filtre de validité temporelle absent) trouvé et corrigé pendant l'implémentation. Deux -scénarios `backend_suite!` (classement par pertinence, validité temporelle + forget) rejoués -Libsql/Native, zéro divergence ; `EngineCapabilities::native().full_text` → `true` ; aucune -extension xtask/CI nécessaire (nouveau module sous des entrées déjà couvertes) ; `cargo xtask ci` -vert (18 étapes) + crash-consistency re-exécuté (4 modes, 0 violation). -**N5.3 (100 % des contrats sur Native) ✅ clos 2026-07-06** : les 12 scénarios -`storage_contract.rs` restants (isolation multi-agent recall/hydrate/purge/exact-fact, batch -atomique+vide, filtre de couche, expiration/pas-encore-valide, stats par couche, classement -vecteur+mot-clé isolé, traversée graphe scopée agent, épisodes récents) portés dans le runner -déclaratif (`memory_tests/scenarios.rs`, 7→19 scénarios) ; `Step`/`Scenario` étendus d'un champ -`agent: Option<&'static str>` par étape (séquences multi-agent dans un seul scénario), 6 -nouvelles variantes (`RememberBatch`/`PurgeAgent`/`ExpectVectorRankingIds`/`ExpectHydrate`/ -`ExpectAgentStatsByLayer`/`ExpectRecentEpisodes`/`ExpectExactFactExists`). 16/16 tests de -`storage_contract.rs` rejoués verbatim contre Libsql ET Native, zéro divergence -(`cargo test -p basemyai --features test-util,engine-native --test memory_tests`) ; -`contracts.rs` hors scope (teste `Memory`/`Store` libSQL directement, aucune variation par -backend). `cargo xtask check`/`test` verts (seul incident : le flake connu et pré-existant -`basemyai-mcp --test server::isolation_between_agents`, sans rapport avec ce travail). -**N5.4 (chiffrement au repos natif) ✅ clos 2026-07-06** (ADR-030) : AEAD XChaCha20-Poly1305 -pur Rust (aucune feature gate — contrairement au `crypto` libSQL qui exige CMake), enveloppe -DEK/KEK — la clé utilisateur dérive une KEK (SHA-256 salée+domaine) qui scelle une DEK -aléatoire dans `crypto.meta` ; WAL scellé par enregistrement (`WalEnvelope:1`, torn-tail -préservé, un batch = une enveloppe), SST scellé fichier entier (`SstEnvelope:1`) — tous les -index transitent par WAL+SST donc couverts. Erreurs franches typées (mauvaise clé, clé -absente, clé sur store en clair). `Engine::rotate_key` = re-scellement O(1), commit atomique -un-fichier, instance utilisable après rotation (mieux que `PRAGMA rekey`) ; écart assumé : -DEK inchangée (ADR-030 §4). Surfaces : `EngineCapabilities::native(encrypted)` (dernière -capacité `false` levée), `NativeEngine::open_encrypted`, -`NativeMemoryStore::{open_encrypted,rotate_key}`. `format.lock` +3 (`CryptoMeta:1`/ -`WalEnvelope:1`/`SstEnvelope:1`). Vérifié : 19 scénarios `backend_suite!` rejoués contre un -3e backend `native_encrypted` (zéro divergence), rotation roundtrip `MemoryStore`, crash -harness 5e mode `encrypted_batch` (5 modes × 20 cycles kill réels, 0 violation). -**N5.5 (barre hardening M6) ✅ clos 2026-07-07** : `put_memory_batch` tout-ou-rien -(`PersistentVectorIndex::insert_many_with` — un `OverlayProvider` planifie chaque insert du -groupe contre l'état que les précédents produiront, pending par-dessus cache par-dessus store) -plus `PersistentFts::stage_insert_many` (une seule mise à jour BM25 agrégée) plus -`PersistentMemoryIndex::put_many` qui vérifie tous les doublons — store et intra-lot — avant -d'écrire quoi que ce soit, -un seul enregistrement WAL pour tout le groupe ; résorbe l'écart initial d'ADR-027 §6, `put` -devient un `put_many` à un item). Mode `memory` du harnais crash-consistency (schéma -déterministe put/forget exerçant le triplet record+vecteur+FTS sous kill réel, + variante -chiffrée) : 20 cycles × 2 (clair/chiffré), 0 violation. Concurrence mesurée : cache de -`PersistentVectorIndex` devenu interior-mutable (`Mutex`, `search`/`search_scored` -passent à `&self`), `NativeMemoryStore` d'`Arc` à `Arc` — lectures pures sous -verrou de lecture concurrent, chemins hybrides (`recall_vector`/`hydrate`) en deux passes -(recherche en lecture, `touch` en écriture brève séparée), écritures sérialisées entre elles -inchangé (`Engine` mono-écrivain sync) ; mesuré ~3× plus rapide en concurrent sur 64 lectures -mixtes. Bench KNN via le chemin `MemoryStore` complet -(`docs/benchmarks/n5.5-memorystore-knn-bench-2026-07-07.md`) : à N=10 000 `--release`, insert -~12.96 ms/`put_memory` (cohérent avec la fourchette N3 sur l'index nu) et `recall_vector` -~17.03 ms/requête contre ~48.98 ms pour le `vector_top_k` nu libSQL — ~2.9× plus rapide malgré -plus de travail par requête. `cargo xtask check`/`test`/`test-crash-consistency` verts. Reste -N5.6 : ADR de bascule du défaut libSQL→Native — décision humaine séparée, jamais prise en -passant ; stress long à 100k+ resté hors scope de cette passe. +## Statut (juillet 2026) + +**Source de vérité détaillée : `docs/status.md`.** + +- **Moteur natif (N0→N5.6 + ADR-033) : ✅ clos** — `basemyai-engine` est le backend + unique (LSM, vecteur LM-DiskANN, graphe, FTS/BM25, chiffrement ADR-030). +- **Phase 1 + 2 mémoire/cognition : ✅** — API `Memory`, graphe, RRF, consolidation, + oubli adaptatif, provisioning LLM. +- **Surfaces : ✅** — MCP, REST, CLI, bindings PyO3/NAPI. +- **CI :** `cargo xtask ci` (+ `test-embed`, `test-crash-consistency` séparés). +- **Publication :** `0.1.0` crates.io/PyPI (libSQL). **Prochaine : `0.2.0` native-only** + (breaking). npm à vérifier ; cargo-dist CLI ouvert. +- **Reste ouvert :** release 0.2.0, doc format `.bmai` natif, NAPI live watch, + CUDA/NVML, bench Mem0+Qdrant, image Docker REST. +- **V2 :** sync P2P, langage de requête, multi-modèles, Studio/Tauri. diff --git a/README.md b/README.md index db7932f..55327ac 100644 --- a/README.md +++ b/README.md @@ -79,13 +79,13 @@

  What is BaseMyAI?

-BaseMyAI is a **local memory engine** built in Rust that gives AI agents persistent, temporal, multi-layered memory — vector search, knowledge graph, and time-aware retrieval — inside a single encrypted local file. Zero cloud. Zero data leaks. Zero silent downloads. +BaseMyAI is a **local memory engine** built in Rust that gives AI agents persistent, temporal, multi-layered memory — vector search, knowledge graph, and time-aware retrieval — inside a single encrypted `.bmai` container. Zero cloud. Zero data leaks. Zero silent downloads. AI agents have no memory by default. Every session starts from zero. Worse — the few solutions that *do* add memory route your conversations and embeddings to a cloud vector database. For anything sensitive — personal assistants, internal tools, regulated industries — that is a non-starter. And almost none of them handle **time**: a fact that was true last quarter is treated identically to a fact that is true right now. BaseMyAI solves all three problems in a single Rust binary: -- **Privacy-first** — everything stays on-device, in one local libSQL file, AES-256 encrypted at rest +- **Privacy-first** — everything stays on-device, in one encrypted `.bmai` container (native engine, XChaCha20-Poly1305 at rest — no CMake, no cloud) - **Temporal** — every memory carries `valid_from` / `valid_until`; retrieval returns only what is *currently* true - **Multi-signal** — vector similarity + knowledge graph + Reciprocal Rank Fusion in one query @@ -98,7 +98,7 @@ forgetting, and encryption are part of the product contract. See - [What is BaseMyAI?](#what-is-basemyai) - [Features](#features) -- [Architecture: core + semantics](#architecture-core--semantics) +- [Architecture: core + engine + semantics](#architecture-core--engine--semantics) - [The 4 memory layers](#the-4-memory-layers) - [Phase 2 — Cognition](#phase-2--cognition) - [Temporal RAG](#temporal-rag) @@ -116,18 +116,19 @@ forgetting, and encryption are part of the product contract. See

  Features

- [x] 100 % local — no data leaves your machine, no telemetry by default +- [x] Pure Rust native storage engine (`basemyai-engine`) — LSM WAL+SST, no external database - [x] Four memory layers: short-term, episodic, procedural, semantic - [x] Temporal RAG — retrieval filtered by `valid_until`, never returns stale facts -- [x] Vector search natively inside libSQL (`vector_top_k` ANN, no extension required) -- [x] Knowledge graph — entities, relations, multi-hop traversal via recursive SQL CTE -- [x] Hybrid search — vector similarity **+** BM25 full-text (FTS5), fused with Reciprocal Rank Fusion +- [x] Native vector search (LM-DiskANN / Vamana ANN, recall@10 = 1.0 at 10k/100k) +- [x] Knowledge graph — entities, relations, multi-hop BFS traversal +- [x] Hybrid search — vector similarity **+** native BM25 full-text, fused with Reciprocal Rank Fusion - [x] Multi-signal retrieval with Reciprocal Rank Fusion (vector + graph, k = 60) - [x] Adaptive forgetting — hyperbolic importance × recency, capacity-bounded GC - [x] Episode-to-fact consolidation via injected LLM (any local runner, no hard dependency) - [x] Hardware-aware provisioning — no silent model downloads, explicit setup command -- [x] Encryption at rest via libSQL `crypto` feature (key never stored, key never sent) -- [x] Per-agent isolation enforced at the SQL level — cross-agent leakage is a security invariant -- [x] Python SDK (PyO3 wheel), Node SDK (NAPI-RS prebuild), REST sidecar (axum), native Rust crate +- [x] Encryption at rest via native envelope (ADR-030), key never stored/sent +- [x] Per-agent isolation enforced structurally by key layout — cross-agent leakage is a security invariant +- [x] MCP server (stdio + HTTP), CLI (`basemyai`), REST sidecar (axum), Python SDK (PyO3), Node SDK (NAPI-RS), native Rust crate

  P1 Public Proofs

@@ -139,29 +140,38 @@ forgetting, and encryption are part of the product contract. See BaseMyAI memory engine -

  Architecture: core + semantics

+

  Architecture: core + engine + semantics

-BaseMyAI is **two crates in one Cargo workspace**, publishable independently: +BaseMyAI is a **Cargo workspace** with two publishable crates (`basemyai-core`, `basemyai`) and an internal native engine (`basemyai-engine`, ADR-024/032): ``` ┌────────────────────────────────────────────────────────┐ │ basemyai (the memory semantics) │ │ 4 memory layers · temporal RAG · per-agent isolation │ -│ adaptive forgetting · cognition · Python/Node/REST │ +│ adaptive forgetting · cognition · MCP/CLI/REST/SDKs │ └───────────────────────┬────────────────────────────────┘ │ built on top of ┌───────────────────────▼────────────────────────────────┐ │ basemyai-core (business-agnostic foundation) │ -│ hardened libSQL · native vectors · Candle embeddings │ -│ optional libSQL crypto · MaintenanceWorker │ -└─────────────────────────────────────────────────────────┘ +│ StorageEngine trait · Candle embedder · maintenance │ +└───────────────────────┬────────────────────────────────┘ + │ backed by +┌───────────────────────▼────────────────────────────────┐ +│ basemyai-engine (native storage engine) │ +│ LSM (WAL+SST) · ANN vector index · native FTS/BM25 │ +│ knowledge graph · encryption envelope (ADR-030) │ +└────────────────────────────────────────────────────────┘ ``` -**`basemyai-core`** is deliberately business-agnostic. It knows libSQL pooling, native vector KNN/ANN, Candle in-process embeddings, optional encryption, and a background maintenance loop — nothing about agents, time windows, or memory layers. It provides **mechanism**; the consumer provides **meaning**. +**`basemyai-core`** is deliberately business-agnostic. It exposes engine capabilities, Candle in-process embeddings, encryption primitives, and a background maintenance loop — nothing about agents, time windows, or memory layers. It provides **mechanism**; the consumer provides **meaning**. + +**`basemyai-engine`** is the durable storage layer: crash-consistent LSM, LM-DiskANN vector index, inverted FTS index, graph traversal, and at-rest encryption — all in pure Rust, no libSQL/SQLite dependency. + +**`basemyai`** is the memory product built on top: the four layers, temporal RAG, per-agent isolation enforced structurally in the key layout, and all language binding surfaces. -**`basemyai`** is the memory product built on top: the four layers, temporal RAG, per-agent isolation scoped at the SQL level, and all language binding surfaces. +Since **[ADR-032](docs/adr/ADR-032-native-only.md)** (2026-07-08), the native engine is the **only** active backend. libSQL/V1 compatibility paths have been removed from the workspace. -This split powers more than one product. **[ForgeMyAI](../forgemyai-app/)** — the local code-context engine — consumes `basemyai-core` directly as a native Rust crate (no FFI, no HTTP), building its own code-specific semantics (symbols, call graph, FTS) on the same foundation. See [`../ECOSYSTEM_ARCHITECTURE.md`](../ECOSYSTEM_ARCHITECTURE.md). +`basemyai-core` is also designed for third-party Rust consumers that build their own semantics on the same foundation (no FFI, no HTTP). See [`../ECOSYSTEM_ARCHITECTURE.md`](../ECOSYSTEM_ARCHITECTURE.md) for the wider ecosystem.

  The 4 memory layers

@@ -182,7 +192,7 @@ Beyond storage and vector search, BaseMyAI implements a full five-ingredient mem ### Knowledge graph -Entities and relations live in the same libSQL file alongside vectors. Multi-hop traversal via recursive SQL CTE (`UNION`, cycle-safe by construction). Scoped per `agent_id` and depth-bounded. +Entities and relations live in the same `.bmai` container alongside vectors. Multi-hop traversal via native BFS in `basemyai-engine` (cycle-safe, depth-bounded). Scoped per `agent_id` through structural key isolation. ```rust graph.add_entity("alice", "person", "Alice")?; @@ -205,7 +215,7 @@ let fused = rrf_fuse(&[ ### Adaptive forgetting -A capacity-bounded GC that keeps the most *important and recent* memories. Importance decays over time using a **hyperbolic curve** `H / (H + age)` — not exponential, which underflows to zero at real Unix timestamps. +A capacity-bounded GC that keeps the most *important and recent* memories. Importance decays over time using a **hyperbolic curve** `H / (H + age)` — stable at real Unix timestamps without floating-point underflow. ```rust let gc = AdaptiveForgetting { @@ -226,20 +236,22 @@ consolidate(&memory, &llm_backend).await?;

  Temporal RAG

-A retrieval that ignores time is a retrieval that lies. BaseMyAI's core query is **hybrid**: cosine similarity via libSQL native vectors **AND** a time filter in the same SQL statement. +A retrieval that ignores time is a retrieval that lies. BaseMyAI's core query is **hybrid**: native vector similarity **AND** temporal validity filtering in the same retrieval pipeline. ``` retrieve("what is the user's billing plan?") - → ANN cosine match (vector_top_k, native libSQL, no extension) + → ANN cosine match (native index) → AND valid_until > now() → returns only memories that are both relevant AND still true ``` -Vectors live **inside** libSQL via its native `F32_BLOB` support (`libsql_vector_idx`, `vector_top_k` ANN). There is no second system to sync — no Qdrant, no LanceDB, no external vector database. One file. +Vectors, graph edges, and FTS all live in BaseMyAI's **native engine** (`basemyai-engine`): no external vector database, no SQL surface, no sync layer. + +A `.bmai` file is a native engine container (WAL + SST + metadata). It is **not** a SQLite/libSQL database and is not interchangeable with V1 libSQL artifacts.

  Encryption at rest

-`basemyai` requires encryption at rest via libSQL's built-in **`crypto`** feature. The database is instantiated with an `encryption_key`; the file on disk is unreadable without it. The key is supplied at open time and never stored, never transmitted. In `basemyai-core`, encryption is optional; `basemyai` makes it mandatory. +`basemyai` requires encryption at rest via the native envelope scheme (ADR-030, XChaCha20-Poly1305). The store is instantiated with an `encryption_key`; data on disk is unreadable without it. The key is supplied at open time and never stored, never transmitted.

Database plugin architecture @@ -301,17 +313,24 @@ const hits = await mem.recall("ui preferences", 5); `open_in_memory` (Python) and `openInMemory` (Node) intentionally stay test-only. They are compiled only with the `test-util` feature, use an -ephemeral unencrypted `:memory:` store plus a deterministic fake embedder, and -are not part of the documented production SDK surface. Production code should -use `Memory.open(...)` with an encrypted file store and a local model path. +ephemeral native temp store plus a deterministic fake embedder, and are not +part of the documented production SDK surface. Production code should use +`Memory.open(...)` with an encrypted file store and a local model path. **Rust (native)** ```rust -use basemyai::{Memory, MemoryLayer}; +use basemyai::{AgentId, Memory, MemoryLayer}; +use basemyai_core::{CandleEmbedder, Device, EncryptionKey, Embedder}; + +let key = EncryptionKey::new("…"); +let agent = AgentId::new("agent-42").unwrap(); +let model_path = dirs::home_dir().unwrap().join(".basemyai/models/all-MiniLM-L6-v2"); +let embedder: Box = + Box::new(CandleEmbedder::load(&model_path, Device::Cpu)?); -let mem = Memory::open("./agent.bmai", "agent-42", &key, model_path).await?; -mem.remember("User is on Pro plan.", MemoryLayer::Semantic, None).await?; +let mem = Memory::open_native("./agent.bmai", &key, embedder, agent).await?; +mem.remember("User is on Pro plan.", MemoryLayer::Semantic).await?; let hits = mem.recall("billing plan", 5).await?; ``` @@ -328,7 +347,7 @@ pip install basemyai

  Node / TypeScript

The public npm package is not live yet: `npm view basemyai` returned `404` from -the public registry on 2026-06-22. Use this command only after the npm release +the public registry as of 2026-07-08. Use this command only after the npm release workflow has published and verified `basemyai`. ```bash @@ -341,7 +360,7 @@ npm install basemyai # Full memory product basemyai = "0.1" -# Business-agnostic foundation only (e.g. for ForgeMyAI) +# Business-agnostic foundation only (custom Rust consumers) basemyai-core = "0.1" ``` @@ -394,19 +413,34 @@ command that opens a `.bmai` container requires the encryption key via the basemyai setup --fetch # provision the baseline embedder (explicit consent) basemyai status # detected hardware + persisted provisioning config basemyai init ./agent.bmai # create an encrypted .bmai container -basemyai inspect ./agent.bmai # container metadata + memory count -basemyai verify ./agent.bmai # validate container + expected format version -basemyai migrate ./agent.bmai # apply pending schema migrations (idempotent) - -basemyai remember ./agent.bmai --agent assistant-42 --layer semantic "User is on Pro plan." -basemyai recall ./agent.bmai --agent assistant-42 "billing plan" -k 5 --hybrid -basemyai stats ./agent.bmai --agent assistant-42 -basemyai forget ./agent.bmai --agent assistant-42 # physical delete — GDPR right to erasure +basemyai inspect # container metadata + memory count +basemyai verify # validate container + expected format version + +basemyai remember "User is on Pro plan." --layer semantic +basemyai recall "billing plan" -k 5 --hybrid +basemyai list --layer semantic +basemyai stats +basemyai invalidate # soft-delete: valid_until = now +basemyai forget # physical delete — GDPR right to erasure +basemyai export --out backup.jsonl +basemyai import --file backup.jsonl + +basemyai graph add-entity alice person "Alice" +basemyai graph add-edge alice works_at acme +basemyai graph traverse alice --depth 2 + +basemyai maintenance gc +basemyai maintenance forget-adaptive --capacity 10000 +basemyai consolidate # episodes → facts + graph (requires local LLM) basemyai llm detect # discover local LLM servers + best model basemyai llm suggest # installable models matched to your hardware ``` +Set `BASEMYAI_DB_KEY`, `BASEMYAI_DB_PATH`, and `BASEMYAI_AGENT` (or use `--db` / +`--agent` flags) so commands resolve the container and agent without repeating +paths. Full reference: [docs/cli.md](docs/cli.md). +

  Quick look

Store an episodic memory — what happened and when. @@ -469,15 +503,16 @@ procedures = await mem.recall_by_layer("how do I deploy?", "procedural", k=5)

  Consumption surfaces

-The same Rust core, five ways to consume it: +The same Rust core, six ways to consume it: | Surface | For | Crate consumed | Tech | |---|---|---|---| +| **MCP server** | AI agents (Claude Code, Cursor, custom MCP clients) | `basemyai` | stdio + HTTP, 8 tools — see [MCP install guide](docs/mcp-install.md) | | **Python SDK** | Python agent builders (LangChain, LlamaIndex, custom) | `basemyai` | PyO3 + precompiled wheel | | **Node SDK** | JS / TS agent builders | `basemyai` | NAPI-RS + prebuild | -| **REST sidecar** | Go, Ruby, any HTTP client | `basemyai` | Single self-contained binary (axum) | -| **Native Rust crate** | Rust programs — e.g. ForgeMyAI | `basemyai-core` | Direct link, zero FFI overhead | +| **REST sidecar** | Go, Ruby, any HTTP client | `basemyai` | axum, `/v1` routes + SSE live subscriptions | | **CLI** (`basemyai`) | Scripting, ops, ad-hoc inspection, agent-as-tool (`--format json`) | `basemyai` | Single binary (clap) — see [CLI reference](docs/cli.md) | +| **Native Rust crate** | Rust programs on the agnostic core | `basemyai-core` | Direct link, zero FFI overhead |

  Community

@@ -495,7 +530,7 @@ Join our growing community around the world, for help, ideas, and discussions re We would love for you to get involved with BaseMyAI development! If you wish to help, you can learn more about how you can contribute to this project in the [contribution guide](CONTRIBUTING.md). -Architecture decisions are documented in [docs/ADR.md](docs/ADR.md) (index) with each decision in its own file under [docs/adr/](docs/adr/). A decision that changes always produces a **new ADR** — existing ADRs are never edited. Read the ADR before touching cross-cutting concerns. +Architecture decisions are documented in [docs/ADR.md](docs/ADR.md) (index) with each decision in its own file under [docs/adr/](docs/adr/). A decision that changes always produces a **new ADR** — existing ADRs are never edited. Read the ADR before touching cross-cutting concerns. Implementation status: [docs/status.md](docs/status.md). Rust gate before every commit — `cargo xtask ci` (fmt + per-crate clippy/test with the exact feature combinations CI uses; plain `cargo clippy --workspace`/`cargo test --workspace` do **not** reproduce CI): @@ -508,8 +543,8 @@ cargo xtask ci For security issues, kindly email us at [security@basemyai.com](mailto:security@basemyai.com) instead of posting a public issue on GitHub. - **100 % local** — no data leaves your machine, no telemetry by default -- **Per-agent isolation** — every query is filtered by `agent_id` at the SQL level; cross-agent leakage is a security invariant, not a config option -- **Encrypted at rest** — libSQL `crypto` feature, AES-256, key never stored +- **Per-agent isolation** — every access is scoped structurally by `agent_id`; cross-agent leakage is a security invariant, not a config option +- **Encrypted at rest** — native envelope (ADR-030), key never stored - **No silent network** — the embedder receives a local model path and never auto-downloads See [SECURITY.md](SECURITY.md) for the full threat model and vulnerability reporting process. diff --git a/basemyai-branding/README.md b/basemyai-branding/README.md index d896432..1087fa7 100644 --- a/basemyai-branding/README.md +++ b/basemyai-branding/README.md @@ -212,4 +212,4 @@ The default `:root` applies the **Blacksite dark** theme. Wrap any section in `. ## License -Assets in this branding kit are proprietary to the BaseMyAI project. Do not redistribute, resell, or use outside of the BaseMyAI / ForgeMyAI ecosystem without explicit written permission. +Assets in this branding kit are proprietary to the BaseMyAI project. Do not redistribute, resell, or use outside of the BaseMyAI ecosystem without explicit written permission. diff --git a/bindings/basemyai-node/Cargo.toml b/bindings/basemyai-node/Cargo.toml index 14066aa..1c2b2f8 100644 --- a/bindings/basemyai-node/Cargo.toml +++ b/bindings/basemyai-node/Cargo.toml @@ -12,8 +12,7 @@ repository.workspace = true crate-type = ["cdylib"] [features] -default = ["crypto", "embed"] -crypto = ["basemyai/crypto"] +default = ["embed"] embed = ["basemyai/embed"] # `test-util` : expose Memory.openInMemory (base :memory:, embedder déterministe) # pour les tests/spikes sans CMake ni modèle. diff --git a/bindings/basemyai-node/README.md b/bindings/basemyai-node/README.md new file mode 100644 index 0000000..987f105 --- /dev/null +++ b/bindings/basemyai-node/README.md @@ -0,0 +1,139 @@ +# basemyai — Node.js bindings + +[![npm](https://img.shields.io/npm/v/basemyai?color=cb3837&label=npm)](https://www.npmjs.com/package/basemyai) +[![Node](https://img.shields.io/node/v/basemyai)](https://www.npmjs.com/package/basemyai) +[![License](https://img.shields.io/npm/l/basemyai)](https://github.com/basemyai/basemyai/blob/main/LICENSE) + +**Local memory engine for AI agents** — Node.js / TypeScript SDK built with [NAPI-RS](https://napi.rs/) and distributed as precompiled native addons (no Rust toolchain required on the client). + +BaseMyAI gives agents persistent, temporal, multi-layered memory: vector search, knowledge graph, hybrid retrieval, and per-agent isolation — all in one encrypted local `.bmai` file. Zero cloud. Zero silent downloads. + +> This package wraps the Rust [`basemyai`](https://crates.io/crates/basemyai) crate. For the full product overview, architecture, and CLI, see the [main repository](https://github.com/basemyai/basemyai). + +## Features + +- Four memory layers: `short_term`, `episodic`, `procedural`, `semantic` +- Temporal RAG — only memories that are still valid are returned +- Hybrid recall — vector similarity + BM25 full-text, fused with Reciprocal Rank Fusion +- Knowledge graph — entities, relations, multi-hop traversal +- Per-agent isolation enforced structurally +- Encryption at rest (native envelope, XChaCha20-Poly1305) — key supplied at open time +- Fully async API (`Promise`-based, backed by an internal Tokio runtime) +- TypeScript definitions included (`index.d.ts`) + +## Requirements + +- **Node.js 18+** (Node-API v9) +- A local embedding model (`all-MiniLM-L6-v2`, 384d) — provisioned once via the CLI or an explicit path + +There is **no silent download at first run**. Fetch the model explicitly: + +```bash +basemyai setup --fetch +``` + +## Installation + +```bash +npm install basemyai +``` + +Prebuilds are provided for: + +- `x86_64-pc-windows-msvc` +- `x86_64-unknown-linux-gnu` +- `x86_64-apple-darwin` +- `aarch64-apple-darwin` + +## Quick start + +```ts +import { Memory } from "basemyai"; + +const mem = await Memory.open({ + path: "./agent.bmai", + agentId: "assistant-42", + encryptionKey: "your-secret-key", + modelPath: "~/.basemyai/models/all-MiniLM-L6-v2", +}); + +// Store a procedural skill +const id = await mem.remember( + "To deploy: run `make release`, tag, push.", + "procedural", +); + +// Temporal RAG +const hits = await mem.recall("how do I deploy?", 5); +for (const hit of hits) { + console.log(hit.text, hit.score); +} + +// Hybrid recall (vector + full-text) +const hybrid = await mem.recallHybrid("deploy runbook", 10); + +// Knowledge graph +await mem.addGraphEntity("alice", "person", "Alice"); +await mem.addGraphEntity("acme", "organization", "Acme"); +await mem.addGraphEdge("alice", "works_at", "acme"); +const reachable = await mem.recallGraph("alice", 2); + +// Physical delete (GDPR right to erasure) +await mem.forget(id); +``` + +### Memory layers + +| Layer | Purpose | +|---|---| +| `short_term` | Working context for the active session | +| `episodic` | Events and interactions (what happened, when) | +| `procedural` | Learned workflows and skills | +| `semantic` | Facts and knowledge (vector-searchable) | + +### API overview + +| Method | Description | +|---|---| +| `Memory.open(options)` | Open an encrypted `.bmai` store with a local embedder | +| `remember(text, layer?)` | Store a memory; returns its UUID | +| `recall(query, k?)` | Temporal semantic recall | +| `recallByLayer(query, layer, k?)` | Recall scoped to one layer | +| `recallHybrid(query, k?)` | Vector + BM25 fused with RRF | +| `invalidate(id)` | Soft-delete (sets `valid_until` to now) | +| `forget(id)` | Physical delete | +| `stats()` | Count of valid memories per layer | +| `addGraphEntity` / `addGraphEdge` | Insert graph facts | +| `recallGraph(start, maxDepth?)` | Multi-hop graph traversal | + +### `Memory.open` options + +| Option | Required | Description | +|---|---|---| +| `path` | yes | Path to the `.bmai` container file | +| `agentId` | yes | Tenant identifier (per-agent isolation) | +| `encryptionKey` | yes | Encryption key (never stored or transmitted) | +| `modelPath` | no | Path to `all-MiniLM-L6-v2` model files | +| `allowModelDownload` | no | Allow explicit model download if `modelPath` is omitted (default: `false`) | + +## Test-only API + +`Memory.openInMemory(agentId)` is compiled **only** with the `test-util` feature. It uses an ephemeral store and a deterministic fake embedder — not part of the production SDK surface. Production code should always use `Memory.open(...)`. + +## Related packages + +| Package | Surface | +|---|---| +| [`basemyai`](https://crates.io/crates/basemyai) (Rust) | Native crate — full memory semantics | +| [`basemyai`](https://pypi.org/project/basemyai/) (Python) | PyO3 bindings | +| [`basemyai-core`](https://crates.io/crates/basemyai-core) | Business-agnostic foundation (for custom engines) | + +## Documentation + +- [Main README](https://github.com/basemyai/basemyai) +- [CLI reference](https://github.com/basemyai/basemyai/blob/main/docs/cli.md) +- [Architecture decisions (ADR)](https://github.com/basemyai/basemyai/blob/main/docs/ADR.md) + +## License + +Source-available under the [Business Source License 1.1](https://github.com/basemyai/basemyai/blob/main/LICENSE) (converts to Apache-2.0 four years after each version's release). See [ADR-031](https://github.com/basemyai/basemyai/blob/main/docs/adr/ADR-031-unified-busl-license.md). diff --git a/bindings/basemyai-node/__tests__/roundtrip.test.js b/bindings/basemyai-node/__tests__/roundtrip.test.js index 9c40fc4..9daecda 100644 --- a/bindings/basemyai-node/__tests__/roundtrip.test.js +++ b/bindings/basemyai-node/__tests__/roundtrip.test.js @@ -119,34 +119,6 @@ test('graph data does not leak between in-memory agents', async () => { expect(reachedB).toEqual([]); }); -test('same store isolates memory and graph by agent', async () => { - const dbPath = tempDbPath('basemyai-node-shared-'); - const a = await Memory.openTestFile(dbPath, 'agent-a'); - const b = await Memory.openTestFile(dbPath, 'agent-b'); - - await a.remember('secret of agent A', 'semantic'); - await b.remember('public note of agent B', 'semantic'); - - const hitsB = await b.recall('secret of agent A', 5); - expect(hitsB.every((h) => h.text !== 'secret of agent A')).toBe(true); - const statsB = await b.stats(); - expect(statsB.total).toBe(1); - - await a.addGraphEntity('alice', 'person', 'Alice A'); - await a.addGraphEntity('acme', 'organization', 'Acme A'); - await a.addGraphEdge('alice', 'works_at', 'acme'); - - await b.addGraphEntity('alice', 'person', 'Alice B'); - await b.addGraphEntity('acme', 'organization', 'Acme B'); - await b.addGraphEdge('alice', 'works_at', 'acme'); - - expect(await a.recallGraph('alice', 1)).toEqual([ - { id: 'acme', kind: 'organization', label: 'Acme A', depth: 1 }, - ]); - expect(await b.recallGraph('alice', 1)).toEqual([ - { id: 'acme', kind: 'organization', label: 'Acme B', depth: 1 }, - ]); -}); const productionOpenEnabled = process.env.BASEMYAI_RUN_PRODUCTION_OPEN === '1' && diff --git a/bindings/basemyai-node/package.json b/bindings/basemyai-node/package.json index f81127f..130d699 100644 --- a/bindings/basemyai-node/package.json +++ b/bindings/basemyai-node/package.json @@ -5,7 +5,7 @@ "main": "index.js", "types": "index.d.ts", "scripts": { - "build": "napi build --platform --release --features crypto,embed --dts ../../target/basemyai-node-default.d.ts", + "build": "napi build --platform --release --features embed --dts ../../target/basemyai-node-default.d.ts", "build:debug": "napi build --platform --features embed --dts ../../target/basemyai-node-debug.d.ts", "build:test": "napi build --platform --no-default-features --features test-util --dts ../../target/basemyai-node-test-util.d.ts", "typecheck": "tsc --noEmit -p tsconfig.examples.json", diff --git a/bindings/basemyai-node/src/memory.rs b/bindings/basemyai-node/src/memory.rs index a2182f8..f6296e3 100644 --- a/bindings/basemyai-node/src/memory.rs +++ b/bindings/basemyai-node/src/memory.rs @@ -2,7 +2,7 @@ //! Façade `Memory` exposée à Node. Les méthodes `async` deviennent des `Promise` //! JS, exécutées sur le runtime tokio interne de NAPI-RS. Moteur 100 % local. -#[cfg(any(feature = "embed", feature = "test-util"))] +#[cfg(feature = "embed")] use std::path::PathBuf; use std::sync::Arc; @@ -13,9 +13,9 @@ use napi_derive::napi; use basemyai::MemoryLayer; #[cfg(feature = "embed")] -use basemyai_core::Store; +use basemyai_core::EncryptionKey; #[cfg(feature = "embed")] -use basemyai_core::{CandleEmbedder, Device, EncryptionKey}; +use basemyai_core::{CandleEmbedder, Device}; use crate::errors::to_napi; use crate::types::{AgentStats, Entity, MemoryOpenOptions, Record}; @@ -163,11 +163,8 @@ async fn open_production(options: MemoryOpenOptions) -> Result { .map_err(basemyai::MemoryError::from) .map_err(to_napi)?; let db_path = PathBuf::from(path); - let store = Store::open(&db_path, Some(EncryptionKey::new(encryption_key))) - .await - .map_err(basemyai::MemoryError::from) - .map_err(to_napi)?; - let mem = basemyai::Memory::open(store, Box::new(embedder), agent) + let key = EncryptionKey::new(encryption_key); + let mem = basemyai::Memory::open_native(db_path, &key, Box::new(embedder), agent) .await .map_err(to_napi)?; Ok(Memory { inner: Arc::new(mem) }) @@ -195,15 +192,4 @@ impl Memory { let mem = basemyai::Memory::open_in_memory(&agent_id).await.map_err(to_napi)?; Ok(Memory { inner: Arc::new(mem) }) } - - /// Ouvre un fichier libSQL partagé avec l'embedder déterministe de test. - /// Test-only : permet de vérifier l'isolation SQL réelle entre deux agents - /// sans compiler Candle ni embarquer de modèle. - #[napi(factory, js_name = "openTestFile")] - pub async fn open_test_file(path: String, agent_id: String) -> Result { - let mem = basemyai::Memory::open_test_file(&PathBuf::from(path), &agent_id) - .await - .map_err(to_napi)?; - Ok(Memory { inner: Arc::new(mem) }) - } } diff --git a/bindings/basemyai-py/Cargo.toml b/bindings/basemyai-py/Cargo.toml index cfeb891..fae8ce2 100644 --- a/bindings/basemyai-py/Cargo.toml +++ b/bindings/basemyai-py/Cargo.toml @@ -17,8 +17,7 @@ crate-type = ["cdylib", "rlib"] doctest = false [features] -default = ["crypto", "embed"] -crypto = ["basemyai/crypto"] +default = ["embed"] embed = ["basemyai/embed"] # `test-util` : expose Memory.open_in_memory (base :memory:, embedder déterministe) # pour les tests/spikes sans CMake ni modèle. diff --git a/bindings/basemyai-py/README.md b/bindings/basemyai-py/README.md new file mode 100644 index 0000000..54acb46 --- /dev/null +++ b/bindings/basemyai-py/README.md @@ -0,0 +1,150 @@ +# basemyai — Python bindings + +[![PyPI](https://img.shields.io/pypi/v/basemyai?color=3776ab&label=PyPI)](https://pypi.org/project/basemyai/) +[![Python](https://img.shields.io/pypi/pyversions/basemyai)](https://pypi.org/project/basemyai/) +[![License](https://img.shields.io/pypi/l/basemyai)](https://github.com/basemyai/basemyai/blob/main/LICENSE) + +**Local memory engine for AI agents** — Python SDK built with [PyO3](https://pyo3.rs/) and distributed as precompiled wheels (no Rust toolchain required on the client). + +BaseMyAI gives agents persistent, temporal, multi-layered memory: vector search, knowledge graph, hybrid retrieval, and per-agent isolation — all in one encrypted local `.bmai` file. Zero cloud. Zero silent downloads. + +> This package wraps the Rust [`basemyai`](https://crates.io/crates/basemyai) crate. For the full product overview, architecture, and CLI, see the [main repository](https://github.com/basemyai/basemyai). + +## Features + +- Four memory layers: `short_term`, `episodic`, `procedural`, `semantic` +- Temporal RAG — only memories that are still valid are returned +- Hybrid recall — vector similarity + BM25 full-text, fused with Reciprocal Rank Fusion +- Knowledge graph — entities, relations, multi-hop traversal +- Per-agent isolation enforced structurally +- Encryption at rest (native envelope, XChaCha20-Poly1305) — key supplied at open time +- Fully async API (`asyncio` coroutines backed by an internal Tokio runtime) + +## Requirements + +- Python **3.10+** +- A local embedding model (`all-MiniLM-L6-v2`, 384d) — provisioned once via the CLI or an explicit path + +There is **no silent download at first run**. Fetch the model explicitly: + +```bash +# Install the CLI from the main repo, or use basemyai setup after distribution +basemyai setup --fetch +``` + +## Installation + +```bash +pip install basemyai +``` + +Precompiled wheels are provided for Linux, Windows, and macOS (x86_64 and Apple Silicon). + +## Quick start + +```python +import asyncio +from basemyai import Memory + +async def main(): + mem = await Memory.open( + path="./agent.bmai", + agent_id="assistant-42", + encryption_key="your-secret-key", + model_dir="~/.basemyai/models/all-MiniLM-L6-v2", + ) + + # Store a semantic fact + memory_id = await mem.remember( + "The user is on the Pro plan.", + layer="semantic", + ) + + # Temporal RAG: relevant AND still valid + hits = await mem.recall("which plan is the user on?", k=5) + for hit in hits: + print(hit.text, hit.score) + + # Hybrid recall (vector + full-text) + hybrid = await mem.recall_hybrid("billing plan", k=10) + + # Knowledge graph + await mem.add_graph_entity("alice", "person", "Alice") + await mem.add_graph_entity("acme", "organization", "Acme") + await mem.add_graph_edge("alice", "works_at", "acme") + reachable = await mem.recall_graph("alice", max_depth=2) + + # Invalidate a fact that is no longer true + await mem.invalidate(memory_id) + +asyncio.run(main()) +``` + +### Memory layers + +| Layer | Purpose | +|---|---| +| `short_term` | Working context for the active session | +| `episodic` | Events and interactions (what happened, when) | +| `procedural` | Learned workflows and skills | +| `semantic` | Facts and knowledge (vector-searchable) | + +### API overview + +| Method | Description | +|---|---| +| `Memory.open(...)` | Open an encrypted `.bmai` store with a local embedder | +| `remember(text, layer)` | Store a memory; returns its UUID | +| `recall(query, k)` | Temporal semantic recall | +| `recall_by_layer(query, layer, k)` | Recall scoped to one layer | +| `recall_hybrid(query, k)` | Vector + BM25 fused with RRF | +| `invalidate(id)` | Soft-delete (sets `valid_until` to now) | +| `forget(id)` | Physical delete (GDPR right to erasure) | +| `stats()` | Count of valid memories per layer | +| `add_graph_entity` / `add_graph_edge` | Insert graph facts | +| `recall_graph(start, max_depth)` | Multi-hop graph traversal | + +### `Memory.open` parameters + +| Parameter | Required | Description | +|---|---|---| +| `path` | yes | Path to the `.bmai` container file | +| `agent_id` | yes | Tenant identifier (per-agent isolation) | +| `encryption_key` | yes | Encryption key (never stored or transmitted) | +| `model_dir` | no | Path to `all-MiniLM-L6-v2` model files | +| `device` | no | `"auto"`, `"cpu"`, `"cuda"`, or `"metal"` (default: `"auto"`) | +| `consent_to_fetch` | no | If `model_dir` is omitted, allow explicit model download (default: `false`) | + +## Test-only API + +`Memory.open_in_memory(agent_id)` is compiled **only** with the `test-util` feature. It uses an ephemeral store and a deterministic fake embedder — not part of the production SDK surface. Production code should always use `Memory.open(...)`. + +## Error types + +```python +from basemyai import ( + BasemyaiError, + ValidationError, + StorageError, + EncryptionError, + InferenceError, +) +``` + +## Related packages + +| Package | Surface | +|---|---| +| [`basemyai`](https://crates.io/crates/basemyai) (Rust) | Native crate — full memory semantics | +| [`basemyai-node`](https://www.npmjs.com/package/basemyai) | Node.js / TypeScript bindings (NAPI-RS) | +| [`basemyai-core`](https://crates.io/crates/basemyai-core) | Business-agnostic foundation (for custom engines) | + +## Documentation + +- [Main README](https://github.com/basemyai/basemyai) +- [CLI reference](https://github.com/basemyai/basemyai/blob/main/docs/cli.md) +- [Architecture decisions (ADR)](https://github.com/basemyai/basemyai/blob/main/docs/ADR.md) + +## License + +Source-available under the [Business Source License 1.1](https://github.com/basemyai/basemyai/blob/main/LICENSE) (converts to Apache-2.0 four years after each version's release). See [ADR-031](https://github.com/basemyai/basemyai/blob/main/docs/adr/ADR-031-unified-busl-license.md). diff --git a/bindings/basemyai-py/pyproject.toml b/bindings/basemyai-py/pyproject.toml index e02e501..326f2df 100644 --- a/bindings/basemyai-py/pyproject.toml +++ b/bindings/basemyai-py/pyproject.toml @@ -6,6 +6,7 @@ build-backend = "maturin" name = "basemyai" version = "0.1.0" description = "Local memory engine for AI agents — Python bindings" +readme = "README.md" requires-python = ">=3.10" license = { text = "BUSL-1.1" } classifiers = [ @@ -17,7 +18,7 @@ classifiers = [ [tool.maturin] module-name = "basemyai._internal" -features = ["crypto", "embed"] +features = ["embed"] python-source = "python" [tool.pytest.ini_options] diff --git a/bindings/basemyai-py/src/memory.rs b/bindings/basemyai-py/src/memory.rs index 78436f4..40de51b 100644 --- a/bindings/basemyai-py/src/memory.rs +++ b/bindings/basemyai-py/src/memory.rs @@ -3,7 +3,7 @@ //! (coroutine asyncio) : le futur tokio Rust est piloté par l'event loop Python //! via `pyo3_async_runtimes`. Le moteur reste 100 % local, en process. -#[cfg(any(all(feature = "crypto", feature = "embed"), feature = "test-util"))] +#[cfg(feature = "embed")] use std::path::PathBuf; use std::sync::Arc; @@ -12,16 +12,16 @@ use pyo3::prelude::*; use pyo3_async_runtimes::tokio::future_into_py; use tokio::sync::Mutex as AsyncMutex; -#[cfg(all(feature = "crypto", feature = "embed"))] +#[cfg(feature = "embed")] use basemyai::AgentId; use basemyai::{MemoryLayer, MemorySubscription}; -#[cfg(all(feature = "crypto", feature = "embed"))] -use basemyai_core::Store; -#[cfg(all(feature = "crypto", feature = "embed"))] -use basemyai_core::{CandleEmbedder, Device, Embedder, EncryptionKey}; +#[cfg(feature = "embed")] +use basemyai_core::EncryptionKey; +#[cfg(feature = "embed")] +use basemyai_core::{CandleEmbedder, Device, Embedder}; -#[cfg(all(feature = "crypto", feature = "embed"))] +#[cfg(feature = "embed")] use crate::errors::ValidationError; use crate::errors::to_pyerr; use crate::types::{AgentStats, Entity, Record, WatchEvent}; @@ -44,11 +44,12 @@ impl Memory { #[pymethods] impl Memory { - /// Ouvre une mémoire persistante chiffrée avec l'embedder Candle local. + /// Ouvre une mémoire persistante chiffrée (moteur natif, ADR-032) avec + /// l'embedder Candle local. /// /// Si `model_dir` est absent, le provisioning ne télécharge le modèle que si /// `consent_to_fetch=True`. Par défaut, aucun accès réseau n'est déclenché. - #[cfg(all(feature = "crypto", feature = "embed"))] + #[cfg(feature = "embed")] #[staticmethod] #[pyo3(signature = (path, agent_id, encryption_key, *, model_dir = None, device = "auto".to_string(), consent_to_fetch = false))] fn open( @@ -78,16 +79,15 @@ impl Memory { .map_err(basemyai::MemoryError::from) .map_err(to_pyerr)?, ); - let store = Store::open(&PathBuf::from(path), Some(EncryptionKey::new(encryption_key))) + let key = EncryptionKey::new(encryption_key); + let mem = basemyai::Memory::open_native(PathBuf::from(path), &key, embedder, agent) .await - .map_err(basemyai::MemoryError::from) .map_err(to_pyerr)?; - let mem = basemyai::Memory::open(store, embedder, agent).await.map_err(to_pyerr)?; Ok(Self { inner: Arc::new(mem) }) }) } - /// Ouvre une mémoire **éphémère, non chiffrée** (`:memory:`) avec un embedder + /// Ouvre une mémoire **éphémère, non chiffrée** avec un embedder /// déterministe sans modèle. Réservé aux tests/spikes (pas de CMake/Candle). #[cfg(feature = "test-util")] #[staticmethod] @@ -98,19 +98,6 @@ impl Memory { }) } - /// Ouvre un fichier libSQL partagé avec l'embedder déterministe de test. - /// Test-only : vérifie l'isolation SQL réelle entre deux agents sans Candle. - #[cfg(feature = "test-util")] - #[staticmethod] - fn open_test_file(py: Python<'_>, path: String, agent_id: String) -> PyResult> { - future_into_py(py, async move { - let mem = basemyai::Memory::open_test_file(&PathBuf::from(path), &agent_id) - .await - .map_err(to_pyerr)?; - Ok(Memory::wrap(mem)) - }) - } - /// `agent_id` propriétaire de cette mémoire. fn agent(&self) -> String { self.inner.agent().as_str().to_string() @@ -300,7 +287,7 @@ impl MemoryWatch { } } -#[cfg(all(feature = "crypto", feature = "embed"))] +#[cfg(feature = "embed")] fn parse_device(value: &str) -> PyResult> { match value { "auto" => Ok(None), diff --git a/bindings/basemyai-py/tests/test_roundtrip.py b/bindings/basemyai-py/tests/test_roundtrip.py index c83d444..d00cc4a 100644 --- a/bindings/basemyai-py/tests/test_roundtrip.py +++ b/bindings/basemyai-py/tests/test_roundtrip.py @@ -107,35 +107,6 @@ async def test_isolation_between_agents(): assert hits_b == [] -@pytest.mark.asyncio -async def test_same_store_isolates_memory_and_graph_by_agent(tmp_path: Path): - db_path = str(tmp_path / "shared.db") - a = await basemyai.Memory.open_test_file(db_path, "agent-a") - b = await basemyai.Memory.open_test_file(db_path, "agent-b") - - await a.remember("secret of agent A", layer="semantic") - await b.remember("public note of agent B", layer="semantic") - - hits_b = await b.recall("secret of agent A", k=5) - assert all(h.text != "secret of agent A" for h in hits_b) - stats_b = await b.stats() - assert stats_b.total == 1 - - await a.add_graph_entity("alice", "person", "Alice A") - await a.add_graph_entity("acme", "organization", "Acme A") - await a.add_graph_edge("alice", "works_at", "acme") - - await b.add_graph_entity("alice", "person", "Alice B") - await b.add_graph_entity("acme", "organization", "Acme B") - await b.add_graph_edge("alice", "works_at", "acme") - - seen_a = await a.recall_graph("alice", max_depth=1) - seen_b = await b.recall_graph("alice", max_depth=1) - - assert [(e.id, e.kind, e.label, e.depth) for e in seen_a] == [("acme", "organization", "Acme A", 1)] - assert [(e.id, e.kind, e.label, e.depth) for e in seen_b] == [("acme", "organization", "Acme B", 1)] - - production_open_enabled = ( os.environ.get("BASEMYAI_RUN_PRODUCTION_OPEN") == "1" and bool(os.environ.get("BASEMYAI_MODEL_PATH")) diff --git a/crates/basemyai-cli/Cargo.toml b/crates/basemyai-cli/Cargo.toml index 39eb1ad..012310f 100644 --- a/crates/basemyai-cli/Cargo.toml +++ b/crates/basemyai-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "basemyai-cli" -description = "Developer CLI for the BaseMyAI agent memory database (init, remember, recall, list, forget, export/import, graph, maintenance, consolidate, setup)." +description = "Developer CLI for the BaseMyAI agent memory database (init, remember, recall, list, forget, export/import, graph, consolidate, setup)." version.workspace = true edition.workspace = true rust-version.workspace = true @@ -15,11 +15,12 @@ name = "basemyai" path = "src/main.rs" [features] -# Mêmes features que basemyai-mcp : le chemin réel exige le chiffrement (crypto) -# et l'embedder Candle (embed). Tous deux sont dans le set par défaut. -default = ["embed", "crypto"] +# `embed` (Candle) est nécessaire pour les commandes qui embeddent +# (remember/recall) — les commandes purement structurelles +# (forget/invalidate/purge/graph/list) ne le chargent pas. Le moteur natif +# (ADR-032) est l'unique backend, plus un choix de feature. +default = ["embed"] embed = ["basemyai/embed"] -crypto = ["basemyai/crypto"] [dependencies] basemyai = { path = "../basemyai" } @@ -27,9 +28,14 @@ basemyai-core = { version = "0.1.0", path = "../basemyai-core" } clap = { version = "4", features = ["derive"] } clap_complete = "4" +comfy-table = "7" +indicatif = "0.18" +owo-colors = { version = "4", features = ["supports-colors"] } +textwrap = "0.16" tokio.workspace = true dirs.workspace = true tracing.workspace = true +tracing-subscriber = { version = "0.3", features = ["env-filter"] } serde.workspace = true serde_json.workspace = true toml.workspace = true diff --git a/crates/basemyai-cli/src/cli.rs b/crates/basemyai-cli/src/cli.rs index 41e057b..183f894 100644 --- a/crates/basemyai-cli/src/cli.rs +++ b/crates/basemyai-cli/src/cli.rs @@ -4,9 +4,10 @@ use std::path::PathBuf; -use clap::{Parser, Subcommand, ValueEnum}; +use clap::{ArgAction, Parser, Subcommand, ValueEnum}; use crate::output::Format; +use crate::ui::ColorMode; /// CLI développeur de BaseMyAI — la base mémoire privée pour agents IA. #[derive(Parser)] @@ -21,6 +22,18 @@ pub(crate) struct Cli { /// Format de sortie. Si omis : `BASEMYAI_FORMAT`, sinon `text`. #[arg(long, global = true, value_enum)] pub(crate) format: Option, + /// Politique couleur (respecte aussi NO_COLOR si `auto`). + #[arg(long, global = true, value_enum, default_value_t = ColorMode::Auto)] + pub(crate) color: ColorMode, + /// Supprime la sortie informative en mode texte. + #[arg(short, long, global = true, default_value_t = false)] + pub(crate) quiet: bool, + /// Niveau de logs de diagnostic sur stderr (`-v`, `-vv`). + #[arg(short = 'v', long, global = true, action = ArgAction::Count)] + pub(crate) verbose: u8, + /// Désactive les spinners/barres de progression. + #[arg(long, global = true, default_value_t = false)] + pub(crate) no_progress: bool, #[command(subcommand)] pub(crate) command: Command, } @@ -123,11 +136,6 @@ pub(crate) enum Command { #[command(subcommand)] action: GraphAction, }, - /// Tâches de maintenance one-shot (GC, oubli adaptatif). - Maintenance { - #[command(subcommand)] - action: MaintenanceAction, - }, /// Consolidation épisodes → faits + graphe, via le meilleur LLM local détecté. Consolidate, /// Vérifie un `.bmai` : conteneur valide, version de format attendue. @@ -168,7 +176,7 @@ pub(crate) enum GraphAction { #[arg(long, default_value_t = 1.0)] weight: f64, }, - /// Traversée multi-sauts depuis une entité de départ (CTE récursive). + /// Traversée multi-sauts depuis une entité de départ (BFS natif, profondeur bornée). Traverse { start: String, #[arg(long, default_value_t = 3)] @@ -176,19 +184,6 @@ pub(crate) enum GraphAction { }, } -#[derive(Subcommand)] -pub(crate) enum MaintenanceAction { - /// Supprime les souvenirs expirés (`valid_until` passé). - Gc, - /// Évince les souvenirs les moins bien notés (importance × récence) au-delà d'un plafond par agent. - ForgetAdaptive { - #[arg(long)] - capacity: usize, - #[arg(long, default_value_t = 30 * 24 * 3600)] - half_life_secs: i64, - }, -} - #[derive(Subcommand)] pub(crate) enum LlmAction { /// Détecte les serveurs LLM locaux et le meilleur modèle pour la machine. diff --git a/crates/basemyai-cli/src/commands/config.rs b/crates/basemyai-cli/src/commands/config.rs index fb11051..b49cb5c 100644 --- a/crates/basemyai-cli/src/commands/config.rs +++ b/crates/basemyai-cli/src/commands/config.rs @@ -23,18 +23,21 @@ pub(crate) fn run( let effective_agent = cfg.resolve_agent(cli_agent).ok(); format.print( || { - println!( - "config file: {}", - CliConfig::file_path() - .map_or_else(|| "(unresolvable)".to_string(), |p| p.display().to_string()) - ); - println!( - "db_path: {}", - effective_path - .as_ref() - .map_or_else(|| "(unset)".to_string(), |p| p.display().to_string()) - ); - println!("agent: {}", effective_agent.as_deref().unwrap_or("(unset)")); + crate::ui::render::section("CLI configuration"); + crate::ui::render::key_values(&[ + ( + "config_file:", + CliConfig::file_path() + .map_or_else(|| "(unresolvable)".to_string(), |p| p.display().to_string()), + ), + ( + "db_path:", + effective_path + .as_ref() + .map_or_else(|| "(unset)".to_string(), |p| p.display().to_string()), + ), + ("agent:", effective_agent.as_deref().unwrap_or("(unset)").to_string()), + ]); }, || { serde_json::json!({ diff --git a/crates/basemyai-cli/src/commands/container.rs b/crates/basemyai-cli/src/commands/container.rs index 499c735..37e172f 100644 --- a/crates/basemyai-cli/src/commands/container.rs +++ b/crates/basemyai-cli/src/commands/container.rs @@ -7,26 +7,29 @@ use std::path::Path; use crate::context::open_store; use crate::error::CliError; use crate::output::Format; +use crate::ui::color::Stream; +use crate::ui::theme; pub(crate) async fn init(path: &Path, format: Format) -> Result<(), CliError> { if path.exists() { return Err(CliError::AlreadyExists(path.to_path_buf())); } let store = open_store(path).await?; - store.migrate(&basemyai::schema()).await?; + let meta = store.container_metadata().await?; + let get = |key: &str| meta.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone()); + let format_version = get("format_version").unwrap_or_default(); format.print( || { - println!("created encrypted .bmai container at {}", path.display()); + println!("created .bmai container at {}", path.display()); println!( - "format_version={}, embedding_dim={}", - basemyai::BMAI_FORMAT_VERSION, + "format_version={format_version}, embedding_dim={}", basemyai::EMBEDDING_DIM ); }, || { serde_json::json!({ "path": path.display().to_string(), - "format_version": basemyai::BMAI_FORMAT_VERSION, + "format_version": format_version, "embedding_dim": basemyai::EMBEDDING_DIM, }) }, @@ -35,10 +38,17 @@ pub(crate) async fn init(path: &Path, format: Format) -> Result<(), CliError> { } pub(crate) async fn migrate(path: &Path, format: Format) -> Result<(), CliError> { - let store = open_store(path).await?; - store.migrate(&basemyai::schema()).await?; + // Le schéma (format.lock, méta de conteneur) est appliqué à l'ouverture — + // `open_store` suffit à "migrer" (idempotent par construction). + let spinner = if format.is_text() { + crate::ui::progress::spinner("Applying container migrations...") + } else { + crate::ui::progress::Spinner::Disabled + }; + open_store(path).await?; + spinner.finish_and_clear(); format.print( - || println!("migrations applied (idempotent) on {}", path.display()), + || crate::ui::render::success(&format!("migrations applied (idempotent) on {}", path.display())), || serde_json::json!({ "path": path.display().to_string(), "status": "migrated" }), ); Ok(()) @@ -46,15 +56,16 @@ pub(crate) async fn migrate(path: &Path, format: Format) -> Result<(), CliError> pub(crate) async fn inspect(path: &Path, format: Format) -> Result<(), CliError> { let store = open_store(path).await?; - let meta = read_meta(&store).await?; - let total = count_memories(&store).await?; + let meta = store.container_metadata().await?; + let total = i64::try_from(store.total_memory_count().await?).unwrap_or(i64::MAX); format.print( || { - println!("Container metadata ({}):", path.display()); - for (k, v) in &meta { - println!(" {k} = {v}"); - } - println!("Total memory rows (all agents): {total}"); + crate::ui::render::section(&format!("Container metadata ({})", path.display())); + crate::ui::table::print_table( + &["Key", "Value"], + meta.iter().map(|(k, v)| vec![k.clone(), v.clone()]).collect::>(), + ); + crate::ui::render::key_values(&[("total_memories:", total.to_string())]); }, || { serde_json::json!({ @@ -69,14 +80,14 @@ pub(crate) async fn inspect(path: &Path, format: Format) -> Result<(), CliError> pub(crate) async fn verify(path: &Path, format: Format) -> Result<(), CliError> { let store = open_store(path).await?; - let meta = read_meta(&store).await?; + let meta = store.container_metadata().await?; let get = |key: &str| meta.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone()); let format_field = get("format"); let version = get("format_version"); let engine = get("storage_engine"); - let expected_version = basemyai::BMAI_FORMAT_VERSION.to_string(); + let expected_version = basemyai::storage::BMAI_FORMAT_VERSION.to_string(); let format_ok = format_field.as_deref() == Some("basemyai-memory"); let version_ok = version.as_deref() == Some(expected_version.as_str()); @@ -85,28 +96,64 @@ pub(crate) async fn verify(path: &Path, format: Format) -> Result<(), CliError> format.print( || { - if format_ok { - println!("✓ format: basemyai-memory"); - } else { - println!( - "✗ format: expected 'basemyai-memory', got {:?}", - format_field.as_deref() - ); - } - if version_ok { - println!("✓ format_version: {}", version.as_deref().unwrap_or_default()); - } else { - println!( - "✗ format_version: expected '{expected_version}', got {:?}", - version.as_deref() - ); - } - match engine.as_deref() { - Some(e) => println!("✓ storage_engine: {e}"), - None => println!("✗ storage_engine: missing"), - } + let checks = vec![ + ( + "format".to_string(), + if format_ok { + format!( + "{} basemyai-memory", + theme::success(&theme::ok_mark(Stream::Stdout), Stream::Stdout) + ) + } else { + format!( + "{} expected 'basemyai-memory', got {:?}", + theme::error(&theme::fail_mark(Stream::Stdout), Stream::Stdout), + format_field.as_deref() + ) + }, + ), + ( + "format_version".to_string(), + if version_ok { + format!( + "{} {}", + theme::success(&theme::ok_mark(Stream::Stdout), Stream::Stdout), + version.as_deref().unwrap_or_default() + ) + } else { + format!( + "{} expected '{expected_version}', got {:?}", + theme::error(&theme::fail_mark(Stream::Stdout), Stream::Stdout), + version.as_deref() + ) + }, + ), + ( + "storage_engine".to_string(), + match engine.as_deref() { + Some(e) => format!( + "{} {e}", + theme::success(&theme::ok_mark(Stream::Stdout), Stream::Stdout) + ), + None => format!( + "{} missing", + theme::error(&theme::fail_mark(Stream::Stdout), Stream::Stdout) + ), + }, + ), + ]; + crate::ui::render::section("Verification checks"); + crate::ui::table::print_table( + &["Check", "Result"], + checks + .into_iter() + .map(|(check, result)| vec![check, result]) + .collect::>(), + ); if ok { - println!("{} is a valid .bmai container", path.display()); + crate::ui::render::success(&format!("{} is a valid .bmai container", path.display())); + } else { + crate::ui::render::hint("run `basemyai inspect` to inspect container metadata"); } }, || { @@ -124,26 +171,3 @@ pub(crate) async fn verify(path: &Path, format: Format) -> Result<(), CliError> if ok { Ok(()) } else { Err(CliError::VerificationFailed) } } - -/// Lit la table de métadonnées `bmai_meta`. -async fn read_meta(store: &basemyai_core::Store) -> Result, CliError> { - let conn = store.connect(); - let mut rows = conn.query("SELECT key, value FROM bmai_meta ORDER BY key", ()).await?; - let mut out = Vec::new(); - while let Some(row) = rows.next().await? { - let k: String = row.get(0)?; - let v: String = row.get(1)?; - out.push((k, v)); - } - Ok(out) -} - -async fn count_memories(store: &basemyai_core::Store) -> Result { - let conn = store.connect(); - let mut rows = conn.query("SELECT COUNT(*) FROM memory", ()).await?; - let total = match rows.next().await? { - Some(row) => row.get::(0)?, - None => 0, - }; - Ok(total) -} diff --git a/crates/basemyai-cli/src/commands/graph.rs b/crates/basemyai-cli/src/commands/graph.rs index cab685d..3c9722e 100644 --- a/crates/basemyai-cli/src/commands/graph.rs +++ b/crates/basemyai-cli/src/commands/graph.rs @@ -21,7 +21,7 @@ pub(crate) async fn add_entity( let (engine, agent_id) = open_engine(path, agent).await?; Graph::new(engine, agent_id).add_entity(id, kind, label).await?; format.print( - || println!("entity '{id}' ({kind}) upserted"), + || crate::ui::render::success(&format!("entity '{id}' ({kind}) upserted")), || serde_json::json!({ "id": id, "kind": kind, "label": label }), ); Ok(()) @@ -41,7 +41,11 @@ pub(crate) async fn add_edge( .add_edge(src, relation, dst, weight) .await?; format.print( - || println!("edge '{src}' -[{relation}]-> '{dst}' (weight {weight}) upserted"), + || { + crate::ui::render::success(&format!( + "edge '{src}' -[{relation}]-> '{dst}' (weight {weight}) upserted" + )) + }, || serde_json::json!({ "src": src, "relation": relation, "dst": dst, "weight": weight }), ); Ok(()) @@ -59,12 +63,17 @@ pub(crate) async fn traverse( format.print( || { if reached.is_empty() { - println!("(nothing reachable from '{start}' within {depth} hop(s))"); + crate::ui::render::info(&format!("no entities reachable from '{start}' within {depth} hop(s)")); + crate::ui::render::hint("add graph edges with `basemyai graph add-edge ...`"); } else { - println!("{} entity(ies) reachable from '{start}':", reached.len()); - for r in &reached { - println!(" [{}] ({}) {} — depth {}", r.id, r.kind, r.label, r.depth); - } + crate::ui::render::section(&format!("{} entity(ies) reachable from '{start}'", reached.len())); + crate::ui::table::print_table( + &["id", "kind", "label", "depth"], + reached + .iter() + .map(|r| vec![r.id.clone(), r.kind.clone(), r.label.clone(), r.depth.to_string()]) + .collect::>(), + ); } }, || { diff --git a/crates/basemyai-cli/src/commands/maintenance.rs b/crates/basemyai-cli/src/commands/maintenance.rs index ae4d5b6..b020307 100644 --- a/crates/basemyai-cli/src/commands/maintenance.rs +++ b/crates/basemyai-cli/src/commands/maintenance.rs @@ -1,61 +1,42 @@ // SPDX-License-Identifier: BUSL-1.1 -//! Tâches de maintenance en mode one-shot (`maintenance gc`, -//! `maintenance forget-adaptive`) et consolidation (`consolidate`). Chacune -//! appelle une tâche déjà existante (`MaintenanceTask::run`) ou la fonction -//! libre `basemyai::consolidate` — pas de nouvelle logique métier ici. +//! Consolidation (`consolidate`) : épisodes → faits + graphe, via +//! `basemyai::consolidate` — pas de nouvelle logique métier ici. +//! +//! GC temporel et oubli adaptatif reposaient sur du SQL de fenêtrage +//! spécifique à libSQL, retiré du workspace (ADR-032) — supprimés avec lui +//! plutôt que portés en passant sur le moteur natif (un portage mérite son +//! propre design/tests, item de suivi séparé). use std::path::Path; -use basemyai_core::MaintenanceTask as _; - -use crate::context::{open_memory, open_store}; +use crate::context::open_memory; use crate::error::CliError; use crate::output::Format; -pub(crate) async fn gc(path: &Path, format: Format) -> Result<(), CliError> { - let store = open_store(path).await?; - basemyai::ExpiredMemoryGc.run(&store).await?; - format.print( - || println!("expired-memory GC complete"), - || serde_json::json!({ "task": "expired-memory-gc" }), - ); - Ok(()) -} - -pub(crate) async fn forget_adaptive( - path: &Path, - capacity_per_agent: usize, - half_life_secs: i64, - format: Format, -) -> Result<(), CliError> { - let store = open_store(path).await?; - let task = basemyai::AdaptiveForgetting { - capacity_per_agent, - recency_half_life_secs: half_life_secs, - }; - task.run(&store).await?; - format.print( - || println!("adaptive forgetting complete (capacity={capacity_per_agent}, half_life_secs={half_life_secs})"), - || serde_json::json!({ "task": "adaptive-forgetting", "capacity_per_agent": capacity_per_agent, "half_life_secs": half_life_secs }), - ); - Ok(()) -} - pub(crate) async fn consolidate(path: &Path, agent: &str, format: Format) -> Result<(), CliError> { let memory = open_memory(path, agent).await?; let provision = basemyai::choose_llm() .await .map_err(|e| CliError::LlmNotAvailable(e.to_string()))?; + let spinner = if format.is_text() { + crate::ui::progress::spinner("Consolidating episodes with local LLM...") + } else { + crate::ui::progress::Spinner::Disabled + }; let report = basemyai::consolidate(&memory, provision.backend.as_ref()).await?; + spinner.finish_and_clear(); format.print( || { - println!( - "consolidation via {} — episodes_seen={}", - provision.model_id, report.episodes_seen - ); - println!( - " facts: {} added, {} skipped — graph: {} entities, {} relations upserted", - report.facts_added, report.facts_skipped, report.entities_upserted, report.relations_upserted + crate::ui::render::section(&format!("Consolidation via {}", provision.model_id)); + crate::ui::table::print_table( + &["Metric", "Value"], + vec![ + vec!["episodes_seen".to_string(), report.episodes_seen.to_string()], + vec!["facts_added".to_string(), report.facts_added.to_string()], + vec!["facts_skipped".to_string(), report.facts_skipped.to_string()], + vec!["entities_upserted".to_string(), report.entities_upserted.to_string()], + vec!["relations_upserted".to_string(), report.relations_upserted.to_string()], + ], ); }, || { diff --git a/crates/basemyai-cli/src/commands/memory.rs b/crates/basemyai-cli/src/commands/memory.rs index 2a90122..d6b19d0 100644 --- a/crates/basemyai-cli/src/commands/memory.rs +++ b/crates/basemyai-cli/src/commands/memory.rs @@ -9,9 +9,11 @@ use std::path::Path; use basemyai::{Memory, Record}; use crate::cli::Layer; -use crate::context::{memory_layer, now_unix, open_engine, open_memory, open_store}; +use crate::context::{memory_layer, now_unix, open_engine, open_memory}; use crate::error::CliError; use crate::output::Format; +use crate::ui::color::Stream; +use crate::ui::theme; pub(crate) async fn remember( memory: &Memory, @@ -26,9 +28,15 @@ pub(crate) async fn remember( unreachable!("clap enforces text/file mutual exclusivity (required_unless_present/conflicts_with)") } (Some(text), None) => { + let spinner = if format.is_text() { + crate::ui::progress::spinner("Embedding and storing memory...") + } else { + crate::ui::progress::Spinner::Disabled + }; let id = memory.remember(&text, layer).await?; + spinner.finish_and_clear(); format.print( - || println!("remembered {id} in layer {}", layer.table()), + || crate::ui::render::success(&format!("remembered {id} in layer {}", layer.table())), || serde_json::json!({ "id": id, "layer": layer.table() }), ); Ok(()) @@ -40,9 +48,15 @@ pub(crate) async fn remember( .map(str::to_string) .filter(|l| !l.trim().is_empty()) .collect(); + let spinner = if format.is_text() { + crate::ui::progress::spinner("Embedding and storing memory batch...") + } else { + crate::ui::progress::Spinner::Disabled + }; let ids = memory.remember_batch(&texts, layer).await?; + spinner.finish_and_clear(); format.print( - || println!("remembered {} item(s) in layer {}", ids.len(), layer.table()), + || crate::ui::render::success(&format!("remembered {} item(s) in layer {}", ids.len(), layer.table())), || serde_json::json!({ "ids": ids, "layer": layer.table(), "count": ids.len() }), ); Ok(()) @@ -60,17 +74,24 @@ pub(crate) async fn recall( graph: bool, format: Format, ) -> Result<(), CliError> { + let spinner = if format.is_text() { + crate::ui::progress::spinner("Searching memories...") + } else { + crate::ui::progress::Spinner::Disabled + }; let records: Vec = match (hybrid, layer, graph) { (true, None, false) => memory.recall_hybrid(query, k).await?, (false, Some(l), false) => memory.recall_by_layer(query, memory_layer(l), k).await?, (false, None, true) => memory.search_graph(query, k).await?, (false, None, false) => memory.recall(query, k).await?, _ => { + spinner.finish_and_clear(); return Err(CliError::MutuallyExclusive( "--hybrid, --layer and --graph are mutually exclusive", )); } }; + spinner.finish_and_clear(); print_records(&records, query, format); Ok(()) } @@ -79,19 +100,30 @@ fn print_records(records: &[Record], query: &str, format: Format) { format.print( || { if records.is_empty() { - println!("(no memories matched)"); + crate::ui::render::warning("no memories matched the current query"); + crate::ui::render::hint("try a broader query or `--hybrid`"); } else { - println!("{} result(s) for \"{query}\":", records.len()); - for (i, r) in records.iter().enumerate() { - println!( - " {}. [{}] [{:.3}] ({}) {}", - i + 1, - r.id, - r.similarity(), - r.layer.table(), - r.text - ); - } + crate::ui::render::section(&format!( + "{} result(s) for {}", + records.len(), + theme::accent(&format!("\"{query}\""), Stream::Stdout) + )); + crate::ui::table::print_table( + &["#", "score", "layer", "id", "excerpt"], + records + .iter() + .enumerate() + .map(|(i, r)| { + vec![ + (i + 1).to_string(), + format!("{:.3}", r.similarity()), + theme::layer(r.layer.table(), Stream::Stdout), + r.id.clone(), + crate::ui::table::wrap_excerpt(&r.text, 72), + ] + }) + .collect::>(), + ); } }, || { @@ -116,62 +148,48 @@ pub(crate) async fn list( include_invalid: bool, format: Format, ) -> Result<(), CliError> { - basemyai::AgentId::new(agent).ok_or(CliError::InvalidAgent)?; - - let store = open_store(path).await?; - let conn = store.connect(); - - let mut sql = String::from("SELECT id, layer, content, valid_from, valid_until FROM memory WHERE agent_id = ?1"); - if !include_invalid { - sql.push_str(" AND (valid_until IS NULL OR valid_until > unixepoch())"); - } - let limit_i64 = i64::try_from(limit).unwrap_or(i64::MAX); - let mut params: Vec = vec![ - basemyai_core::libsql::Value::Text(agent.to_string()), - basemyai_core::libsql::Value::Integer(limit_i64), - ]; - if let Some(l) = layer { - sql.push_str(" AND layer = ?3"); - params.push(basemyai_core::libsql::Value::Text(memory_layer(l).table().to_string())); - } - sql.push_str(" ORDER BY valid_from DESC LIMIT ?2"); - - let mut rows = conn.query(&sql, params).await?; - - let mut items = Vec::new(); - while let Some(row) = rows.next().await? { - let id: String = row.get(0)?; - let layer: String = row.get(1)?; - let content: String = row.get(2)?; - let valid_from: i64 = row.get(3)?; - let valid_until: Option = row.get(4)?; - items.push((id, layer, content, valid_from, valid_until)); - } + let (engine, agent_id) = open_engine(path, agent).await?; + let records = engine + .list_memories(&agent_id, layer.map(memory_layer), limit, include_invalid, now_unix()) + .await?; format.print( || { - if items.is_empty() { - println!("(no memories)"); + if records.is_empty() { + crate::ui::render::info("no memories found for this agent"); } else { - println!("{} memory(ies) for agent '{agent}':", items.len()); - for (id, layer, content, valid_from, valid_until) in &items { - let status = match valid_until { - Some(_) => "invalidated", - None => "valid", - }; - println!(" [{id}] ({layer}, {status}, since {valid_from}) {content}"); - } + crate::ui::render::section(&format!("{} memory(ies) for agent '{agent}'", records.len())); + crate::ui::table::print_table( + &["id", "layer", "status", "valid_from", "content"], + records + .iter() + .map(|r| { + let status = if r.valid_until.is_some() { + "invalidated" + } else { + "valid" + }; + vec![ + r.id.clone(), + theme::layer(r.layer.table(), Stream::Stdout), + status.to_string(), + r.valid_from.to_string(), + crate::ui::table::wrap_excerpt(&r.content, 68), + ] + }) + .collect::>(), + ); } }, || { serde_json::json!({ "agent": agent, - "memories": items.iter().map(|(id, layer, content, valid_from, valid_until)| serde_json::json!({ - "id": id, - "layer": layer, - "text": content, - "valid_from": valid_from, - "valid_until": valid_until, + "memories": records.iter().map(|r| serde_json::json!({ + "id": r.id, + "layer": r.layer.table(), + "text": r.content, + "valid_from": r.valid_from, + "valid_until": r.valid_until, })).collect::>(), }) }, @@ -183,7 +201,7 @@ pub(crate) async fn forget(path: &Path, agent: &str, id: &str, format: Format) - let (engine, agent_id) = open_engine(path, agent).await?; engine.forget(&agent_id, id).await?; format.print( - || println!("forgot {id}"), + || crate::ui::render::success(&format!("forgot {id}")), || serde_json::json!({ "id": id, "action": "forget" }), ); Ok(()) @@ -193,7 +211,7 @@ pub(crate) async fn invalidate(path: &Path, agent: &str, id: &str, format: Forma let (engine, agent_id) = open_engine(path, agent).await?; engine.invalidate(&agent_id, id, now_unix()).await?; format.print( - || println!("invalidated {id}"), + || crate::ui::render::success(&format!("invalidated {id}")), || serde_json::json!({ "id": id, "action": "invalidate" }), ); Ok(()) @@ -208,7 +226,7 @@ pub(crate) async fn purge(path: &Path, agent: &str, yes: bool, format: Format) - let (engine, agent_id) = open_engine(path, agent).await?; engine.purge_agent(&agent_id).await?; format.print( - || println!("purged all data for agent '{agent}'"), + || crate::ui::render::success(&format!("purged all data for agent '{agent}'")), || serde_json::json!({ "agent": agent, "action": "purge" }), ); Ok(()) @@ -226,7 +244,7 @@ pub(crate) async fn export(path: &Path, agent: &str, out: Option, format Some(out_path) => { std::fs::write(&out_path, &jsonl)?; format.print( - || println!("exported agent '{agent}' to {out_path}"), + || crate::ui::render::success(&format!("exported agent '{agent}' to {out_path}")), || serde_json::json!({ "agent": agent, "out": out_path }), ); } @@ -238,17 +256,26 @@ pub(crate) async fn export(path: &Path, agent: &str, out: Option, format pub(crate) async fn import(path: &Path, agent: &str, file: &str, format: Format) -> Result<(), CliError> { let memory = open_memory(path, agent).await?; let jsonl = read_input(file)?; + let spinner = if format.is_text() { + crate::ui::progress::spinner("Importing JSONL snapshot...") + } else { + crate::ui::progress::Spinner::Disabled + }; let report = memory.import_jsonl(&jsonl).await?; + spinner.finish_and_clear(); format.print( || { - println!( - "imported: {} memories ({} skipped), {} entities ({} skipped), {} edges ({} skipped)", - report.memories, - report.memories_skipped, - report.entities, - report.entities_skipped, - report.edges, - report.edges_skipped + crate::ui::render::section("Import report"); + crate::ui::table::print_table( + &["Metric", "Value"], + vec![ + vec!["memories".to_string(), report.memories.to_string()], + vec!["memories_skipped".to_string(), report.memories_skipped.to_string()], + vec!["entities".to_string(), report.entities.to_string()], + vec!["entities_skipped".to_string(), report.entities_skipped.to_string()], + vec!["edges".to_string(), report.edges.to_string()], + vec!["edges_skipped".to_string(), report.edges_skipped.to_string()], + ], ); }, || { diff --git a/crates/basemyai-cli/src/commands/mod.rs b/crates/basemyai-cli/src/commands/mod.rs index 6e3abb1..4c9be6c 100644 --- a/crates/basemyai-cli/src/commands/mod.rs +++ b/crates/basemyai-cli/src/commands/mod.rs @@ -12,13 +12,13 @@ mod provision; use std::path::PathBuf; -use crate::cli::{Cli, Command, GraphAction, LlmAction, MaintenanceAction}; +use crate::cli::{Cli, Command, GraphAction, LlmAction}; use crate::context::open_memory; use crate::error::CliError; use crate::output::Format; use crate::persisted_config::CliConfig; -#[cfg(all(feature = "crypto", feature = "embed"))] +#[cfg(feature = "embed")] pub(crate) async fn dispatch(cli: Cli, format: Format) -> Result<(), CliError> { let cfg = CliConfig::load(); let resolve_path = @@ -41,12 +41,17 @@ pub(crate) async fn dispatch(cli: Cli, format: Format) -> Result<(), CliError> { let s = mem.stats().await?; format.print( || { - println!("Agent '{agent}' — valid memories:"); - println!(" short_term: {}", s.short_term); - println!(" episodic: {}", s.episodic); - println!(" procedural: {}", s.procedural); - println!(" semantic: {}", s.semantic); - println!(" total: {}", s.total()); + crate::ui::render::section(&format!("Agent '{agent}' — valid memories")); + crate::ui::table::print_table( + &["Layer", "Count"], + vec![ + vec!["short_term".to_string(), s.short_term.to_string()], + vec!["episodic".to_string(), s.episodic.to_string()], + vec!["procedural".to_string(), s.procedural.to_string()], + vec!["semantic".to_string(), s.semantic.to_string()], + vec!["total".to_string(), s.total().to_string()], + ], + ); }, || { serde_json::json!({ @@ -129,16 +134,6 @@ pub(crate) async fn dispatch(cli: Cli, format: Format) -> Result<(), CliError> { GraphAction::Traverse { start, depth } => graph::traverse(&path, &agent, &start, depth, format).await, } } - Command::Maintenance { action } => { - let path = resolve_path()?; - match action { - MaintenanceAction::Gc => maintenance::gc(&path, format).await, - MaintenanceAction::ForgetAdaptive { - capacity, - half_life_secs, - } => maintenance::forget_adaptive(&path, capacity, half_life_secs, format).await, - } - } Command::Consolidate => { let path = resolve_path()?; let agent = resolve_agent()?; @@ -160,10 +155,9 @@ fn completions(shell: clap_complete::Shell) { clap_complete::generate(shell, &mut cmd, "basemyai", &mut std::io::stdout()); } -#[cfg(not(all(feature = "crypto", feature = "embed")))] +#[cfg(not(feature = "embed"))] pub(crate) async fn dispatch(_cli: Cli, _format: Format) -> Result<(), CliError> { Err(CliError::Config( - "basemyai CLI must be built with the `crypto` and `embed` features (they are in the default feature set)" - .to_string(), + "basemyai CLI must be built with the `embed` feature (it is in the default feature set)".to_string(), )) } diff --git a/crates/basemyai-cli/src/commands/provision.rs b/crates/basemyai-cli/src/commands/provision.rs index 85a2dfa..7a993fd 100644 --- a/crates/basemyai-cli/src/commands/provision.rs +++ b/crates/basemyai-cli/src/commands/provision.rs @@ -14,21 +14,21 @@ pub(crate) async fn setup(fetch: bool, format: Format) -> Result<(), CliError> { if fetch { if format == Format::Text { - println!("\nProvisioning baseline model (fetching if absent)..."); + crate::ui::render::section("Provisioning baseline model (fetching if absent)"); } - let mp = basemyai::provision_with_progress(true, |recv, total| match total { - Some(t) => eprint!("\r {recv}/{t} bytes"), - None => eprint!("\r {recv} bytes"), - }) - .await?; - eprintln!(); + let bar = crate::ui::progress::DownloadBar::new("Downloading model"); + let mp = basemyai::provision_with_progress(true, |recv, total| bar.update(recv, total)).await?; + bar.finish_and_clear(); format.print( || { - println!( - "model ready: {} (dim {}) at {}", - mp.model_id, - mp.dim, - mp.model_path.display() + crate::ui::table::print_table( + &["Field", "Value"], + vec![ + vec!["model_id".to_string(), mp.model_id.clone()], + vec!["dim".to_string(), mp.dim.to_string()], + vec!["path".to_string(), mp.model_path.display().to_string()], + vec!["provisioned".to_string(), "true".to_string()], + ], ); }, || { @@ -45,10 +45,15 @@ pub(crate) async fn setup(fetch: bool, format: Format) -> Result<(), CliError> { match basemyai::provision(false).await { Ok(mp) => format.print( || { - println!( - "\nmodel already provisioned: {} at {}", - mp.model_id, - mp.model_path.display() + crate::ui::render::section("Model already provisioned"); + crate::ui::table::print_table( + &["Field", "Value"], + vec![ + vec!["model_id".to_string(), mp.model_id.clone()], + vec!["dim".to_string(), mp.dim.to_string()], + vec!["path".to_string(), mp.model_path.display().to_string()], + vec!["provisioned".to_string(), "true".to_string()], + ], ); }, || { @@ -63,8 +68,8 @@ pub(crate) async fn setup(fetch: bool, format: Format) -> Result<(), CliError> { ), Err(_) => format.print( || { - println!( - "\nbaseline model not provisioned. Re-run `basemyai setup --fetch` to download it (explicit consent)." + crate::ui::render::warning( + "baseline model not provisioned. Re-run `basemyai setup --fetch` to download it (explicit consent).", ); }, || { @@ -92,9 +97,16 @@ pub(crate) async fn status(format: Format) -> Result<(), CliError> { let present = mp.model_path.exists(); format.print( || { - println!("\nprovisioned model: {} (dim {})", mp.model_id, mp.dim); - println!(" path: {}", mp.model_path.display()); - println!(" files present: {present}"); + crate::ui::render::section("Provisioned model"); + crate::ui::table::print_table( + &["Field", "Value"], + vec![ + vec!["model_id".to_string(), mp.model_id.clone()], + vec!["dim".to_string(), mp.dim.to_string()], + vec!["path".to_string(), mp.model_path.display().to_string()], + vec!["files_present".to_string(), present.to_string()], + ], + ); }, || { serde_json::json!({ @@ -108,7 +120,7 @@ pub(crate) async fn status(format: Format) -> Result<(), CliError> { ); } Err(e) => format.print( - || println!("\nmodel not provisioned: {e}"), + || crate::ui::render::warning(&format!("model not provisioned: {e}")), || { serde_json::json!({ "hardware": hardware_json(&hw), @@ -124,14 +136,18 @@ pub(crate) async fn status(format: Format) -> Result<(), CliError> { } fn print_hardware(hw: &basemyai::HardwareProfile) { - println!("Detected hardware:"); - println!(" RAM: {} MB", hw.total_ram_mb); - println!(" CPU cores: {}", hw.cpu_cores); - match hw.gpu_vram_mb { - Some(v) => println!(" GPU VRAM: {v} MB"), - None => println!(" GPU VRAM: (none detected)"), - } - println!(" Device: {:?}", hw.device); + crate::ui::render::section("Detected hardware"); + crate::ui::render::key_values(&[ + ("ram_mb:", hw.total_ram_mb.to_string()), + ("cpu_cores:", hw.cpu_cores.to_string()), + ( + "gpu_vram_mb:", + hw.gpu_vram_mb + .map(|v| v.to_string()) + .unwrap_or_else(|| "(none detected)".to_string()), + ), + ("device:", format!("{:?}", hw.device)), + ]); } fn hardware_json(hw: &basemyai::HardwareProfile) -> serde_json::Value { @@ -149,19 +165,27 @@ pub(crate) async fn llm_detect(format: Format) -> Result<(), CliError> { format.print( || { if opts.is_empty() { - println!("no local LLM servers detected (Ollama / llama.cpp / OpenAI-compatible)."); + crate::ui::render::warning("no local LLM servers detected (Ollama / llama.cpp / OpenAI-compatible)."); return; } - println!("detected {} local LLM option(s):", opts.len()); - for o in &opts { - let ram = o - .ram_mb - .map(|r| format!("{r} MB")) - .unwrap_or_else(|| "unknown".to_string()); - println!(" - {} via {:?} @ {} (RAM ~{ram})", o.model_id, o.backend, o.server_url); - } + crate::ui::render::section(&format!("Detected {} local LLM option(s)", opts.len())); + crate::ui::table::print_table( + &["model_id", "backend", "server_url", "ram_mb"], + opts.iter() + .map(|o| { + vec![ + o.model_id.clone(), + format!("{:?}", o.backend), + o.server_url.clone(), + o.ram_mb + .map(|r| format!("{r}")) + .unwrap_or_else(|| "unknown".to_string()), + ] + }) + .collect::>(), + ); if let Some(best) = best { - println!("best for this machine: {}", best.model_id); + crate::ui::render::success(&format!("best for this machine: {}", best.model_id)); } }, || { @@ -185,13 +209,23 @@ pub(crate) async fn llm_suggest(format: Format) -> Result<(), CliError> { format.print( || { if suggestions.is_empty() { - println!("no additional models to suggest for this hardware."); + crate::ui::render::info("no additional models to suggest for this hardware."); return; } - println!("suggested models (e.g. `ollama pull `):"); - for m in &suggestions { - println!(" - {} (~{} MB) — {}", m.ollama_tag, m.ram_mb, m.description); - } + crate::ui::render::section("Suggested models (e.g. `ollama pull `)"); + crate::ui::table::print_table( + &["ollama_tag", "ram_mb", "description"], + suggestions + .iter() + .map(|m| { + vec![ + m.ollama_tag.to_string(), + m.ram_mb.to_string(), + m.description.to_string(), + ] + }) + .collect::>(), + ); }, || { serde_json::json!({ diff --git a/crates/basemyai-cli/src/context.rs b/crates/basemyai-cli/src/context.rs index c388b17..dc8ddbb 100644 --- a/crates/basemyai-cli/src/context.rs +++ b/crates/basemyai-cli/src/context.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: BUSL-1.1 //! Helpers partagés par toutes les commandes : ouverture de la clé/du store/ //! de la mémoire, conversion `cli::Layer` -> `basemyai::MemoryLayer`. Isole -//! les commandes de la mécanique d'ouverture d'un `.bmai` (ADR-007/ADR-019). +//! les commandes de la mécanique d'ouverture d'un `.bmai` (ADR-007/ADR-030/032). use std::path::Path; use std::sync::Arc; @@ -35,35 +35,44 @@ pub(crate) async fn load_embedder() -> Result, Ok(Box::new(embedder)) } -/// Ouvre un store chiffré sans embedder (commandes purement structurelles). -pub(crate) async fn open_store(path: &Path) -> Result { +/// Ouvre un store natif chiffré (au besoin le crée). +/// +/// # Errors +/// Erreur de stockage si la clé est fausse ou si l'ouverture échoue. +pub(crate) async fn open_store(path: &Path) -> Result { if path.extension().and_then(|e| e.to_str()) != Some("bmai") { - eprintln!("warning: '{}' does not use the .bmai extension", path.display()); + crate::ui::render::warning(&format!("'{}' does not use the .bmai extension", path.display())); } let key = require_key()?; - Ok(basemyai_core::Store::open(path, Some(key)).await?) + let path = path.to_path_buf(); + tokio::task::spawn_blocking(move || basemyai::storage::NativeMemoryStore::open_encrypted(&path, key.expose())) + .await + .map_err(|e| { + CliError::Core(basemyai_core::CoreError::Storage(format!( + "ouverture du store natif interrompue : {e}" + ))) + })? + .map_err(CliError::from) } -/// Ouvre une mémoire complète (store chiffré + embedder + isolation agent). +/// Ouvre une mémoire complète (store + embedder + isolation agent). pub(crate) async fn open_memory(path: &Path, agent: &str) -> Result { let agent_id = basemyai::AgentId::new(agent).ok_or(CliError::InvalidAgent)?; - let store = open_store(path).await?; + let key = require_key()?; let embedder = load_embedder().await?; - Ok(basemyai::Memory::open(store, embedder, agent_id).await?) + Ok(basemyai::Memory::open_native(path, &key, embedder, agent_id).await?) } -/// Ouvre l'accès mémoire bas niveau (store chiffré + migrations), sans -/// embedder — pour les opérations qui ne font aucun embedding (forget, -/// invalidate, purge, graphe). Évite de payer le chargement du modèle -/// Candle pour des mutations purement SQL. +/// Ouvre l'accès mémoire bas niveau (store, sans embedder) — pour les +/// opérations qui ne font aucun embedding (forget, invalidate, purge, graphe, +/// list). Évite de payer le chargement du modèle Candle. pub(crate) async fn open_engine( path: &Path, agent: &str, ) -> Result<(Arc, basemyai::AgentId), CliError> { let agent_id = basemyai::AgentId::new(agent).ok_or(CliError::InvalidAgent)?; let store = open_store(path).await?; - store.migrate(&basemyai::schema()).await?; - let engine: Arc = Arc::new(basemyai::storage::LibsqlMemoryStore::new(store)); + let engine: Arc = Arc::new(store); Ok((engine, agent_id)) } diff --git a/crates/basemyai-cli/src/error.rs b/crates/basemyai-cli/src/error.rs index 3ccbe57..8f14847 100644 --- a/crates/basemyai-cli/src/error.rs +++ b/crates/basemyai-cli/src/error.rs @@ -72,11 +72,6 @@ pub(crate) enum CliError { /// Échec IO (lecture d'un fichier `--file`, écriture d'un export `--out`). #[error(transparent)] Io(#[from] std::io::Error), - - /// Erreur SQL bas niveau, pour les rares lectures directes hors - /// `basemyai::storage::MemoryStore` (métadonnées `bmai_meta`, `list`). - #[error("storage error: {0}")] - Sql(#[from] basemyai_core::libsql::Error), } impl CliError { @@ -97,7 +92,6 @@ impl CliError { Self::Core(e) => core_error_code(e), Self::Config(_) => "CONFIG_ERROR", Self::Io(_) => "IO_ERROR", - Self::Sql(_) => "STORAGE_ERROR", } } @@ -117,7 +111,6 @@ impl CliError { Self::Core(e) => core_error_exit(e), Self::Config(_) => exit::USAGE, Self::Io(_) => exit::GENERIC, - Self::Sql(_) => exit::GENERIC, } } } diff --git a/crates/basemyai-cli/src/main.rs b/crates/basemyai-cli/src/main.rs index d34e657..5dd8edb 100644 --- a/crates/basemyai-cli/src/main.rs +++ b/crates/basemyai-cli/src/main.rs @@ -6,8 +6,7 @@ //! création/inspection/vérification d'un conteneur `.bmai` chiffré (ADR-019), //! le cycle de vie complet d'un souvenir (`remember`/`recall`/`list`/`forget`/ //! `invalidate`/`purge`/`export`/`import`), le graphe entités/relations -//! (`graph`), les tâches de maintenance one-shot (`maintenance`) et la -//! consolidation (`consolidate`). +//! (`graph`) et la consolidation (`consolidate`). //! //! ## Chiffrement obligatoire //! @@ -30,15 +29,14 @@ //! //! ## Features //! -//! Le chemin réel exige `crypto` (chiffrement libSQL) et `embed` (embedder -//! Candle) — tous deux dans le set par défaut. Sans eux, le binaire se contente -//! d'afficher l'aide et une erreur explicite. +//! Le chemin réel exige `embed` (embedder Candle). Sans cette feature, le +//! binaire se contente d'afficher l'aide et une erreur explicite. //! //! ## Layout //! //! `cli` (schéma d'arguments) → `commands::dispatch` (routage) → un module par //! domaine sous `commands/` (`config`, `container`, `memory`, `graph`, -//! `maintenance`, `provision`), chacun s'appuyant sur les helpers partagés de +//! `provision`), chacun s'appuyant sur les helpers partagés de //! `context` (ouverture clé/store/mémoire) et sur `error`/`exit` pour le //! contrat de sortie scriptable. `persisted_config` gère //! `~/.basemyai/config.toml`. @@ -50,13 +48,33 @@ mod error; mod exit; mod output; mod persisted_config; +mod ui; use clap::Parser; use cli::Cli; use output::Format; +use tracing_subscriber::EnvFilter; fn main() -> std::process::ExitCode { let cli = Cli::parse(); + ui::init(ui::UiSettings { + color_mode: cli.color, + quiet: cli.quiet, + no_progress: cli.no_progress, + }); + + if cli.verbose > 0 { + let level = match cli.verbose { + 0 => "warn", + 1 => "info", + _ => "debug", + }; + let _ = tracing_subscriber::fmt() + .with_env_filter(EnvFilter::new(level)) + .with_target(false) + .try_init(); + } + let runtime = match tokio::runtime::Runtime::new() { Ok(rt) => rt, Err(e) => { diff --git a/crates/basemyai-cli/src/output.rs b/crates/basemyai-cli/src/output.rs index 78086fe..b7dd5af 100644 --- a/crates/basemyai-cli/src/output.rs +++ b/crates/basemyai-cli/src/output.rs @@ -14,6 +14,11 @@ pub(crate) enum Format { } impl Format { + #[must_use] + pub(crate) fn is_text(self) -> bool { + self == Self::Text + } + /// Résout le format effectif : flag explicite, sinon `BASEMYAI_FORMAT`, sinon texte. #[must_use] pub(crate) fn resolve(explicit: Option) -> Self { @@ -40,7 +45,7 @@ impl Format { /// `message`, qui peut changer de formulation). pub(crate) fn print_error(self, err: &crate::error::CliError) { match self { - Format::Text => eprintln!("error: {err}"), + Format::Text => crate::ui::error::print_cli_error(err), Format::Json => { eprintln!( "{}", diff --git a/crates/basemyai-cli/src/ui/color.rs b/crates/basemyai-cli/src/ui/color.rs new file mode 100644 index 0000000..e1a4d3e --- /dev/null +++ b/crates/basemyai-cli/src/ui/color.rs @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: BUSL-1.1 + +use std::io::{IsTerminal as _, stderr, stdout}; + +use clap::ValueEnum; + +#[derive(Debug, Copy, Clone, Default, PartialEq, Eq, ValueEnum)] +pub(crate) enum ColorMode { + #[default] + Auto, + Always, + Never, +} + +#[derive(Debug, Copy, Clone)] +pub(crate) enum Stream { + Stdout, + Stderr, +} + +fn no_color() -> bool { + std::env::var_os("NO_COLOR").is_some() +} + +fn force_color() -> bool { + std::env::var_os("FORCE_COLOR").is_some() +} + +fn is_tty(stream: Stream) -> bool { + match stream { + Stream::Stdout => stdout().is_terminal(), + Stream::Stderr => stderr().is_terminal(), + } +} + +pub(crate) fn colors_enabled(mode: ColorMode, stream: Stream) -> bool { + match mode { + ColorMode::Never => false, + ColorMode::Always => true, + ColorMode::Auto => { + if no_color() { + return false; + } + if force_color() { + return true; + } + is_tty(stream) + } + } +} diff --git a/crates/basemyai-cli/src/ui/error.rs b/crates/basemyai-cli/src/ui/error.rs new file mode 100644 index 0000000..2c1fac7 --- /dev/null +++ b/crates/basemyai-cli/src/ui/error.rs @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: BUSL-1.1 + +use crate::error::CliError; +use crate::ui::color::Stream; +use crate::ui::theme; + +pub(crate) fn print_cli_error(err: &CliError) { + let rendered = err.to_string(); + let mut lines = rendered.lines(); + + if let Some(first) = lines.next() { + eprintln!("{} {first}", theme::error("error:", Stream::Stderr)); + } else { + eprintln!("{}", theme::error("error", Stream::Stderr)); + } + + for line in lines { + if line.starts_with("hint:") { + eprintln!( + "{} {}", + theme::accent("hint:", Stream::Stderr), + line.trim_start_matches("hint:").trim() + ); + } else if line.starts_with("see:") { + eprintln!( + "{} {}", + theme::accent("see:", Stream::Stderr), + line.trim_start_matches("see:").trim() + ); + } else if !line.trim().is_empty() { + eprintln!(" {line}"); + } + } +} diff --git a/crates/basemyai-cli/src/ui/mod.rs b/crates/basemyai-cli/src/ui/mod.rs new file mode 100644 index 0000000..07d2576 --- /dev/null +++ b/crates/basemyai-cli/src/ui/mod.rs @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: BUSL-1.1 + +pub(crate) mod color; +pub(crate) mod error; +pub(crate) mod progress; +pub(crate) mod render; +pub(crate) mod table; +pub(crate) mod theme; + +use std::sync::OnceLock; + +pub(crate) use color::ColorMode; + +#[derive(Debug, Copy, Clone)] +pub(crate) struct UiSettings { + pub(crate) color_mode: ColorMode, + pub(crate) quiet: bool, + pub(crate) no_progress: bool, +} + +impl Default for UiSettings { + fn default() -> Self { + Self { + color_mode: ColorMode::Auto, + quiet: false, + no_progress: false, + } + } +} + +static SETTINGS: OnceLock = OnceLock::new(); + +pub(crate) fn init(settings: UiSettings) { + let _ = SETTINGS.set(settings); +} + +pub(crate) fn settings() -> &'static UiSettings { + SETTINGS.get_or_init(UiSettings::default) +} diff --git a/crates/basemyai-cli/src/ui/progress.rs b/crates/basemyai-cli/src/ui/progress.rs new file mode 100644 index 0000000..4ca2e35 --- /dev/null +++ b/crates/basemyai-cli/src/ui/progress.rs @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: BUSL-1.1 + +use std::io::IsTerminal as _; +use std::time::Duration; + +use indicatif::{ProgressBar, ProgressStyle}; + +use crate::ui::settings; + +fn enabled() -> bool { + !settings().quiet && !settings().no_progress && std::io::stderr().is_terminal() +} + +pub(crate) enum Spinner { + Disabled, + Active(ProgressBar), +} + +impl Spinner { + pub(crate) fn finish_and_clear(self) { + if let Self::Active(pb) = self { + pb.finish_and_clear(); + } + } +} + +pub(crate) fn spinner(message: &str) -> Spinner { + if !enabled() { + return Spinner::Disabled; + } + let pb = ProgressBar::new_spinner(); + pb.set_style(ProgressStyle::with_template("{spinner:.green} {msg}").expect("valid spinner template")); + pb.enable_steady_tick(Duration::from_millis(90)); + pb.set_message(message.to_string()); + Spinner::Active(pb) +} + +pub(crate) struct DownloadBar { + bar: Option, +} + +impl DownloadBar { + pub(crate) fn new(label: &str) -> Self { + if !enabled() { + return Self { bar: None }; + } + + let pb = ProgressBar::new(0); + pb.set_style( + ProgressStyle::with_template( + "{spinner:.green} {msg} [{bar:40.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec})", + ) + .expect("valid download template") + .progress_chars("=>-"), + ); + pb.set_message(label.to_string()); + Self { bar: Some(pb) } + } + + pub(crate) fn update(&self, received: u64, total: Option) { + if let Some(pb) = &self.bar { + if let Some(t) = total { + pb.set_length(t); + } + pb.set_position(received); + } else { + match total { + Some(t) => eprint!("\r {received}/{t} bytes"), + None => eprint!("\r {received} bytes"), + } + } + } + + pub(crate) fn finish_and_clear(&self) { + if let Some(pb) = &self.bar { + pb.finish_and_clear(); + } else { + eprintln!(); + } + } +} diff --git a/crates/basemyai-cli/src/ui/render.rs b/crates/basemyai-cli/src/ui/render.rs new file mode 100644 index 0000000..9029c60 --- /dev/null +++ b/crates/basemyai-cli/src/ui/render.rs @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: BUSL-1.1 + +use crate::ui::color::Stream; +use crate::ui::settings; +use crate::ui::theme; + +pub(crate) fn section(title: &str) { + if settings().quiet { + return; + } + println!("{}", theme::accent(title, Stream::Stdout)); +} + +pub(crate) fn info(message: &str) { + if settings().quiet { + return; + } + println!("{message}"); +} + +pub(crate) fn success(message: &str) { + if settings().quiet { + return; + } + println!("{}", theme::success(message, Stream::Stdout)); +} + +pub(crate) fn warning(message: &str) { + eprintln!( + "{} {}", + theme::warning("warning:", Stream::Stderr), + theme::muted(message, Stream::Stderr) + ); +} + +pub(crate) fn hint(message: &str) { + eprintln!("{} {message}", theme::accent("hint:", Stream::Stderr)); +} + +pub(crate) fn key_values(rows: &[(&str, String)]) { + if settings().quiet { + return; + } + let max_key = rows.iter().map(|(k, _)| k.len()).max().unwrap_or_default(); + for (key, value) in rows { + println!("{key:>) { + if settings().quiet { + return; + } + let mut table = Table::new(); + table + .load_preset(UTF8_FULL_CONDENSED) + .apply_modifier(UTF8_ROUND_CORNERS) + .set_content_arrangement(ContentArrangement::Dynamic) + .set_header(headers.to_vec()); + + for row in rows { + table.add_row(row.into_iter().map(Cell::new).collect::>()); + } + println!("{table}"); +} + +pub(crate) fn wrap_excerpt(text: &str, width: usize) -> String { + if text.is_empty() { + return String::new(); + } + let effective = width.clamp(20, 120); + wrap(text, effective) + .into_iter() + .map(|line| line.into_owned()) + .collect::>() + .join("\n") +} diff --git a/crates/basemyai-cli/src/ui/theme.rs b/crates/basemyai-cli/src/ui/theme.rs new file mode 100644 index 0000000..b72f45e --- /dev/null +++ b/crates/basemyai-cli/src/ui/theme.rs @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: BUSL-1.1 + +use owo_colors::OwoColorize as _; + +use crate::ui::color::{Stream, colors_enabled}; +use crate::ui::settings; + +fn enabled(stream: Stream) -> bool { + colors_enabled(settings().color_mode, stream) +} + +fn paint(stream: Stream, plain: &str, f: impl FnOnce(&str) -> String) -> String { + if enabled(stream) { f(plain) } else { plain.to_string() } +} + +pub(crate) fn accent(text: &str, stream: Stream) -> String { + paint(stream, text, |t| t.truecolor(0xd7, 0xff, 0x3f).bold().to_string()) +} + +pub(crate) fn muted(text: &str, stream: Stream) -> String { + paint(stream, text, |t| t.truecolor(0x74, 0x6f, 0x66).to_string()) +} + +pub(crate) fn success(text: &str, stream: Stream) -> String { + paint(stream, text, |t| t.truecolor(0x00, 0xa8, 0x8a).to_string()) +} + +pub(crate) fn warning(text: &str, stream: Stream) -> String { + paint(stream, text, |t| t.truecolor(0xff, 0x5a, 0x1f).to_string()) +} + +pub(crate) fn error(text: &str, stream: Stream) -> String { + paint(stream, text, |t| t.bright_red().bold().to_string()) +} + +pub(crate) fn layer(layer: &str, stream: Stream) -> String { + match layer { + "short_term" => paint(stream, layer, |t| t.yellow().to_string()), + "episodic" => paint(stream, layer, |t| t.cyan().to_string()), + "procedural" => paint(stream, layer, |t| t.magenta().to_string()), + "semantic" => paint(stream, layer, |t| t.green().to_string()), + _ => layer.to_string(), + } +} + +pub(crate) fn ok_mark(stream: Stream) -> String { + if enabled(stream) { + "✓".to_string() + } else { + "(ok)".to_string() + } +} + +pub(crate) fn fail_mark(stream: Stream) -> String { + if enabled(stream) { + "✗".to_string() + } else { + "(fail)".to_string() + } +} diff --git a/crates/basemyai-cli/tests/cli.rs b/crates/basemyai-cli/tests/cli.rs index 47c66d5..2e961eb 100644 --- a/crates/basemyai-cli/tests/cli.rs +++ b/crates/basemyai-cli/tests/cli.rs @@ -28,6 +28,8 @@ fn isolated(home: &Path) -> Command { } cmd.env("HOME", home); cmd.env("USERPROFILE", home); + cmd.env("NO_COLOR", "1"); + cmd.current_dir(home); cmd } @@ -311,29 +313,49 @@ fn graph_round_trip() { assert_eq!(reached[0]["id"], "wonderland"); } +/// Depuis la migration natif-only, la sous-commande `maintenance` a été retirée +/// de la CLI. L'invocation doit échouer explicitement comme sous-commande +/// inconnue. #[test] -fn maintenance_gc_runs_on_empty_db() { +fn maintenance_command_is_not_available() { let home = tempfile::tempdir().expect("tempdir"); let db = db_path(home.path()); init_container(home.path(), &db); isolated(home.path()) .env("BASEMYAI_DB_KEY", KEY) - .arg("--db") + .args(["--db"]) .arg(&db) .args(["maintenance", "gc"]) .assert() - .success(); + .code(2) + .stderr(predicate::str::contains("unrecognized subcommand 'maintenance'")); } #[test] -fn no_db_path_is_not_configured_exit_4() { +fn no_db_path_uses_default_container_path() { let home = tempfile::tempdir().expect("tempdir"); isolated(home.path()) .env("BASEMYAI_DB_KEY", KEY) .args(["--format", "json", "--agent", "alice", "inspect"]) .assert() - .code(4) - .stderr(predicate::str::contains("\"code\":\"NOT_CONFIGURED\"")); + .success() + .stdout(predicate::str::contains("\"path\":\".\\\\test-agent.bmai\"")); +} + +#[test] +fn color_never_disables_ansi_sequences() { + let home = tempfile::tempdir().expect("tempdir"); + let db = db_path(home.path()); + init_container(home.path(), &db); + + isolated(home.path()) + .env("BASEMYAI_DB_KEY", KEY) + .args(["--color", "never", "--db"]) + .arg(&db) + .arg("verify") + .assert() + .success() + .stdout(predicate::str::contains('\u{1b}').not()); } diff --git a/crates/basemyai-core/Cargo.toml b/crates/basemyai-core/Cargo.toml index ab7e9cc..49615c0 100644 --- a/crates/basemyai-core/Cargo.toml +++ b/crates/basemyai-core/Cargo.toml @@ -1,6 +1,7 @@ [package] name = "basemyai-core" -description = "Business-agnostic embedded foundation: libSQL (native vector + encryption), Candle embeddings, async maintenance." +description = "Business-agnostic embedded foundation: native storage engine (vector + graph + FTS + encryption), Candle embeddings, async maintenance." +readme = "README.md" version.workspace = true edition.workspace = true rust-version.workspace = true @@ -8,7 +9,7 @@ license.workspace = true authors.workspace = true repository.workspace = true documentation = "https://docs.rs/basemyai-core" -keywords = ["embedded", "vector-search", "libsql", "embeddings", "sqlite"] +keywords = ["embedded", "vector-search", "embeddings", "storage-engine"] categories = ["database-implementations", "algorithms"] [features] @@ -16,12 +17,6 @@ default = [] # Inférence ML (Candle) — opt-in : un consommateur peut fournir son propre # Embedder via le trait sans compiler Candle. embed = ["dep:candle-core", "dep:candle-nn", "dep:candle-transformers", "dep:tokenizers", "dep:serde_json"] -# Chiffrement au repos (libSQL/SQLite3MultipleCiphers) — exige CMake. -crypto = ["libsql/encryption"] -# Backend natif basemyai-engine (ADR-024/ADR-025) — capability discovery only -# (StorageEngine), pas de MemoryStore : bloqué sur N3 (index vecteur) et N4 -# (graphe). Jamais par défaut. -engine-native = ["dep:basemyai-engine"] [dependencies] thiserror.workspace = true @@ -35,21 +30,13 @@ candle-nn = { workspace = true, optional = true } candle-transformers = { workspace = true, optional = true } tokenizers = { workspace = true, optional = true } serde_json = { workspace = true, optional = true } -# libSQL : vecteur natif (pas d'extension à linker), SQLite-compat, async. -# Le cœur compile sans CMake ; `crypto` ajoute le chiffrement (qui l'exige). -libsql = { version = "0.9.30", default-features = false, features = ["core"] } -# Backend natif (ADR-024/ADR-025) — capability discovery seulement, feature -# `engine-native`. Interne, non publié (voir crates/basemyai-engine/Cargo.toml). -basemyai-engine = { path = "../basemyai-engine", optional = true } +# Backend natif (ADR-024/ADR-025/ADR-032) — dépendance directe et unique. +# Interne, non publié (voir crates/basemyai-engine/Cargo.toml). +basemyai-engine = { path = "../basemyai-engine" } [dev-dependencies] tokio.workspace = true -criterion.workspace = true tempfile.workspace = true -[[bench]] -name = "knn_scalability" -harness = false - [lints] workspace = true diff --git a/crates/basemyai-core/README.md b/crates/basemyai-core/README.md new file mode 100644 index 0000000..72dbfd3 --- /dev/null +++ b/crates/basemyai-core/README.md @@ -0,0 +1,146 @@ +# basemyai-core + +[![crates.io](https://img.shields.io/crates/v/basemyai-core?color=dca282)](https://crates.io/crates/basemyai-core) +[![docs.rs](https://img.shields.io/docsrs/basemyai-core)](https://docs.rs/basemyai-core) +[![License](https://img.shields.io/crates/l/basemyai-core)](https://github.com/basemyai/basemyai/blob/main/LICENSE) + +**Business-agnostic embedded foundation** for the BaseMyAI ecosystem — native storage engine, vector search, full-text search, graph primitives, optional Candle embeddings, encryption at rest, and an async maintenance worker. + +This crate provides **mechanism**; consumers provide **meaning**. It knows nothing about `agent_id`, memory layers, temporal validity, or code symbols. + +> For the full agent memory product, use [`basemyai`](https://crates.io/crates/basemyai). For ecosystem context, see the [main repository](https://github.com/basemyai/basemyai#architecture-core--semantics). + +## Design principle + +``` +┌──────────────────────────────────────────────┐ +│ basemyai memory semantics │ +│ layers · temporal RAG · agent isolation │ +└────────────────────┬─────────────────────────┘ + │ built on +┌────────────────────▼─────────────────────────┐ +│ basemyai-core business-agnostic core │ +│ storage · vectors · FTS · graph · embed │ +└──────────────────────────────────────────────┘ +``` + +**Agnosticity invariant (ADR-001):** `basemyai-core` must not contain `agent_id`, `valid_from`/`valid_until`, memory layers, or code-domain types such as `Symbol`/`Edge`. A `grep` over `src/` for these concepts must return zero matches. + +Primary consumer: + +- [`basemyai`](https://crates.io/crates/basemyai) — agent memory semantics (this repo) + +Third-party Rust crates may also depend on `basemyai-core` directly and supply their own semantics (never `basemyai`). + +## What this crate provides + +| Primitive | Type / trait | +|---|---| +| Storage engine | `StorageEngine`, `NativeEngine` | +| Engine capabilities | `EngineCapabilities`, `EngineKind` | +| Encryption key | `EncryptionKey` | +| Distance metric | `Metric` | +| Embeddings | `Embedder` trait, `CandleEmbedder` (feature `embed`) | +| Device selection | `Device` | +| Background tasks | `MaintenanceWorker`, `MaintenanceTask` | +| Errors | `CoreError` | + +Semantic memory operations (`remember`, `recall`, layers, isolation) live in `basemyai::storage::NativeMemoryStore` — not here. + +## Features + +```toml +[dependencies] +basemyai-core = "0.1" + +# Enable Candle in-process embeddings (all-MiniLM-L6-v2) +basemyai-core = { version = "0.1", features = ["embed"] } +``` + +| Feature | Description | +|---|---| +| *(default)* | Storage engine + maintenance loop (no ML) | +| `embed` | Candle BERT embedder (`all-MiniLM-L6-v2`, 384d) + tokenizers | + +You can implement your own `Embedder` without enabling `embed` — useful for custom models or test fakes. + +## Quick start + +### Open the native engine + +```rust +use basemyai_core::{EncryptionKey, NativeEngine}; +use std::path::Path; + +let engine = NativeEngine::open(Path::new("./data.bmai"))?; +// Or encrypted: +let key = EncryptionKey::new("your-secret-key"); +let engine = NativeEngine::open_encrypted(Path::new("./data.bmai"), key.expose().as_bytes())?; +``` + +### Custom embedder (no Candle) + +```rust +use basemyai_core::Embedder; + +struct MyEmbedder; + +impl Embedder for MyEmbedder { + fn embed(&self, text: &str) -> basemyai_core::Result> { + // Your embedding logic + Ok(vec![0.0; 384]) + } + fn embed_batch(&self, texts: &[String]) -> basemyai_core::Result>> { + texts.iter().map(|t| self.embed(t)).collect() + } + fn model_id(&self) -> &str { "my-model-384" } + fn dim(&self) -> usize { 384 } +} +``` + +### Maintenance worker + +```rust +use basemyai_core::{MaintenanceWorker, MaintenanceTask}; +use std::sync::Arc; + +struct MyTask; +impl MaintenanceTask for MyTask { + fn name(&self) -> &str { "my-task" } + async fn run(&self) -> basemyai_core::Result<()> { + // Periodic work injected by the consumer + Ok(()) + } +} + +let worker = MaintenanceWorker::new(); +worker.register(Arc::new(MyTask)); +// worker.spawn() — runs registered tasks on a schedule +``` + +## Native engine + +The storage backend is the home-grown BaseMyAI engine ([ADR-024](https://github.com/basemyai/basemyai/blob/main/docs/adr/ADR-024-native-engine.md)/[025](https://github.com/basemyai/basemyai/blob/main/docs/adr/ADR-025-native-engine-storage-foundation.md)): + +- LSM-tree with WAL + SSTables +- Native vector index (LM-DiskANN / Vamana) +- Native full-text search (BM25) +- Native graph storage (prefix-scoped KV layout) +- Encryption at rest ([ADR-030](https://github.com/basemyai/basemyai/blob/main/docs/adr/ADR-030-native-encryption-at-rest.md)) + +`basemyai-engine` is an internal, unpublished crate. You interact with it through `NativeEngine` and `StorageEngine` in this crate. + +## Embedder policy + +The `Embedder` **never downloads** and **never detects hardware**. It receives a resolved model path and `Device` from the consumer's setup layer (`basemyai::provision` or your own). Zero network after setup. + +## Documentation + +- [docs.rs](https://docs.rs/basemyai-core) +- [Main README](https://github.com/basemyai/basemyai) +- [Architecture decisions (ADR)](https://github.com/basemyai/basemyai/blob/main/docs/ADR.md) +- [ADR-001 — Two-crate split](https://github.com/basemyai/basemyai/blob/main/docs/adr/ADR-001-two-crates-split.md) + +## License + +Source-available under the [Business Source License 1.1](https://github.com/basemyai/basemyai/blob/main/LICENSE) (converts to Apache-2.0 four years after each version's release). See [ADR-031](https://github.com/basemyai/basemyai/blob/main/docs/adr/ADR-031-unified-busl-license.md). diff --git a/crates/basemyai-core/benches/knn_scalability.rs b/crates/basemyai-core/benches/knn_scalability.rs deleted file mode 100644 index 124b0f0..0000000 --- a/crates/basemyai-core/benches/knn_scalability.rs +++ /dev/null @@ -1,149 +0,0 @@ -use std::fmt::Write as _; -use std::hint::black_box; -use std::path::{Path, PathBuf}; -use std::time::{SystemTime, UNIX_EPOCH}; - -use basemyai_core::{Store, libsql}; -use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; -use tokio::runtime::Runtime; - -const DIM: usize = 384; -const TABLE: &str = "bench_emb"; -const DEFAULT_SIZES: &str = "10000"; -const INSERT_CHUNK: usize = 1_000; - -fn knn_scalability(c: &mut Criterion) { - let runtime = Runtime::new().expect("tokio runtime for async libSQL bench"); - let mut group = c.benchmark_group("knn_scalability"); - group.sample_size(10); - - for rows in bench_sizes() { - group.throughput(Throughput::Elements(u64::try_from(rows).expect("rows fit in u64"))); - let (store, path) = runtime.block_on(seed_store(rows)).expect("seed synthetic vector store"); - let query = synthetic_vector(rows / 2); - - group.bench_with_input(BenchmarkId::new("vector_knn_cosine_k10", rows), &rows, |b, _| { - b.iter(|| { - let neighbors = runtime - .block_on(store.vector_knn(TABLE, black_box(&query), black_box(10), None)) - .expect("KNN query succeeds"); - black_box(neighbors); - }); - }); - - drop(store); - cleanup(&path); - } - - group.finish(); -} - -fn bench_sizes() -> Vec { - let raw = std::env::var("BASEMYAI_KNN_BENCH_SIZES").unwrap_or_else(|_| DEFAULT_SIZES.to_string()); - let sizes: Vec = raw - .split(',') - .filter_map(|part| part.trim().parse::().ok()) - .filter(|size| *size > 0) - .collect(); - if sizes.is_empty() { vec![10_000] } else { sizes } -} - -/// Sème `rows` vecteurs synthétiques puis construit l'index vectoriel natif -/// **après** le chargement en masse (bulk-load-then-index), au lieu de laisser -/// libSQL maintenir le graphe `libsql_vector_idx` de façon incrémentale ligne -/// par ligne. Mesuré empiriquement comme nécessaire pour rester dans un temps -/// de seeding raisonnable au-delà de la dizaine de milliers de lignes — voir -/// `docs/benchmarks/m6-knn-results-2026-07-01.md` (« amplification à la -/// construction incrémentale de l'index »). Ce chemin bulk-load n'est PAS le -/// comportement par défaut de `Store::ensure_vector_table` (qui crée l'index -/// avant insertion, adapté à l'usage incrémental normal de la lib) : il est -/// scopé à ce benchmark via `Store::ensure_vector_table_no_index` + -/// `Store::create_vector_index`. -async fn seed_store(rows: usize) -> basemyai_core::Result<(Store, PathBuf)> { - let path = temp_db_path(rows); - cleanup(&path); - - let store = Store::open_with(&path, None, 4).await?; - store.ensure_vector_table_no_index(TABLE, DIM).await?; - - eprintln!("[knn_scalability] seeding {rows} rows (bulk insert, no index yet)..."); - let mut inserted = 0; - while inserted < rows { - let upper = rows.min(inserted + INSERT_CHUNK); - let txn = store.begin_write().await?; - for i in inserted..upper { - txn.execute( - &format!( - "INSERT INTO {TABLE} (id, emb) VALUES (?1, vector(?2)) \ - ON CONFLICT(id) DO UPDATE SET emb = vector(?2)" - ), - libsql::params![format!("v-{i:08}"), vector_literal(&synthetic_vector(i))], - ) - .await - .map_err(|e| basemyai_core::CoreError::Storage(e.to_string()))?; - } - txn.commit().await?; - inserted = upper; - if inserted % (INSERT_CHUNK * 20) == 0 || inserted == rows { - eprintln!("[knn_scalability] ...{inserted}/{rows} rows inserted"); - } - } - - eprintln!("[knn_scalability] building libsql_vector_idx over {rows} rows..."); - store.create_vector_index(TABLE).await?; - eprintln!("[knn_scalability] index built for {rows} rows"); - - Ok((store, path)) -} - -fn temp_db_path(rows: usize) -> PathBuf { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock after unix epoch") - .as_nanos(); - std::env::temp_dir().join(format!("basemyai-knn-bench-{}-{rows}-{now}.db", std::process::id())) -} - -fn cleanup(path: &Path) { - let _ = std::fs::remove_file(path); - let _ = std::fs::remove_file(path.with_extension("db-wal")); - let _ = std::fs::remove_file(path.with_extension("db-shm")); -} - -#[allow(clippy::cast_precision_loss)] -fn synthetic_vector(seed: usize) -> Vec { - let mut state = (seed as u64).wrapping_add(0x9E37_79B9_7F4A_7C15); - let mut vector = Vec::with_capacity(DIM); - for _ in 0..DIM { - state = state.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1); - let unit = ((state >> 40) as u32) as f32 / ((1_u32 << 24) as f32); - vector.push((unit * 2.0) - 1.0); - } - normalize(&mut vector); - vector -} - -fn normalize(vector: &mut [f32]) { - let norm = vector.iter().map(|x| x * x).sum::().sqrt(); - if norm > 0.0 { - for x in vector { - *x /= norm; - } - } -} - -fn vector_literal(vector: &[f32]) -> String { - let mut out = String::with_capacity((vector.len() * 10) + 2); - out.push('['); - for (i, value) in vector.iter().enumerate() { - if i > 0 { - out.push(','); - } - write!(&mut out, "{value:.8}").expect("writing to String cannot fail"); - } - out.push(']'); - out -} - -criterion_group!(benches, knn_scalability); -criterion_main!(benches); diff --git a/crates/basemyai-core/src/embed/candle.rs b/crates/basemyai-core/src/embed/candle.rs index a090183..b3b8e13 100644 --- a/crates/basemyai-core/src/embed/candle.rs +++ b/crates/basemyai-core/src/embed/candle.rs @@ -15,7 +15,7 @@ use tokenizers::Tokenizer; use super::{Device, Embedder}; use crate::{CoreError, Result}; -/// Modèle baseline V1 (compat `.idx` ForgeMyAI). +/// Modèle baseline unique V1 (ADR-003). const MODEL_ID: &str = "all-MiniLM-L6-v2"; /// Dimension des vecteurs produits par le baseline. const DIM: usize = 384; diff --git a/crates/basemyai-core/src/lib.rs b/crates/basemyai-core/src/lib.rs index b3556b5..363ca00 100644 --- a/crates/basemyai-core/src/lib.rs +++ b/crates/basemyai-core/src/lib.rs @@ -3,19 +3,20 @@ //! //! Socle embarqué **agnostique métier** de l'écosystème BaseMyAI. //! -//! Il fournit des *primitives* — store libSQL async, recherche vectorielle -//! **native** (libSQL), embeddings in-process (Candle), chiffrement (libSQL), -//! boucle de maintenance — et **jamais** de concept métier. +//! Il fournit des *primitives* — moteur de stockage natif (ADR-024/025, +//! `basemyai-engine`), embeddings in-process (Candle), chiffrement au repos +//! (ADR-030), boucle de maintenance — et **jamais** de concept métier. libSQL +//! (ADR-011) a été le backend V1 ; **retiré du workspace par ADR-033** : +//! le moteur natif est l'unique backend. //! //! Règle d'agnosticité (cf. `ADR.md` ADR-001) : ce crate ne connaît ni //! `agent_id`, ni `valid_from`/`valid_until`, ni les couches mémoire, ni les -//! `Symbol`/`Edge` de ForgeMyAI. Il expose un *mécanisme* ; le consommateur -//! fournit le *sens* (filtre SQL paramétré, tâches de maintenance injectées). +//! `Symbol`/`Edge` d'un consommateur code. Il expose un *mécanisme* ; le +//! consommateur fournit le *sens* (tâches de maintenance injectées, sémantique +//! côté `basemyai::storage::NativeMemoryStore`). //! -//! Consommateurs : `basemyai` (sémantique mémoire) et ForgeMyAI (crate Rust natif). - -// Scaffold : les corps réels arrivent à la phase d'implémentation. -#![allow(dead_code)] +//! Consommateur principal : `basemyai` (sémantique mémoire). Le core est aussi +//! importable par des crates Rust tiers qui apportent leur propre sémantique. mod embed; mod error; @@ -29,13 +30,8 @@ pub use embed::{Device, Embedder}; pub use error::{CoreError, Result}; pub use maintenance::{MaintenanceTask, MaintenanceWorker}; /// Wrapper capability-only pour le backend natif `basemyai-engine` -/// (ADR-024/ADR-025) — gated par la feature `engine-native`. Ne fournit pas -/// `MemoryStore` : voir `docs/TODO-NATIVE-ENGINE.md` N2. -#[cfg(feature = "engine-native")] +/// (ADR-024/ADR-025/ADR-033), unique backend du workspace. Ne fournit pas +/// `MemoryStore` : voir +/// `basemyai::storage::NativeMemoryStore` pour l'implémentation sémantique. pub use storage::NativeEngine; -pub use storage::{EncryptionKey, EngineCapabilities, EngineKind, Migration, StorageEngine, Store, WriteTxn}; -pub use storage::{Filter, Metric, Neighbor, Value}; - -/// Ré-export : les consommateurs déclarent leur schéma et leurs requêtes via -/// l'API libSQL exposée par [`Store::connect`]. -pub use libsql; +pub use storage::{EncryptionKey, EngineCapabilities, EngineKind, Metric, StorageEngine}; diff --git a/crates/basemyai-core/src/maintenance.rs b/crates/basemyai-core/src/maintenance.rs index d4e4248..a734e76 100644 --- a/crates/basemyai-core/src/maintenance.rs +++ b/crates/basemyai-core/src/maintenance.rs @@ -1,12 +1,16 @@ // SPDX-License-Identifier: BUSL-1.1 //! Boucle de maintenance async. Le core **fait tourner la boucle** ; les tâches //! sont **injectées par le consommateur** (mécanisme au core, sens au -//! consommateur). Le GC par `valid_until` est une tâche `basemyai`, pas du core. +//! consommateur) et sont **auto-suffisantes** — chacune possède déjà ce dont +//! elle a besoin (`Arc`, backend natif, etc.), le worker ne leur passe +//! aucun store partagé (ADR-032 : avant la suppression de libSQL, `run` +//! recevait un `&Store` — `ConsolidationTask`, la seule tâche restante, l'a +//! toujours ignoré). use std::sync::Arc; use std::time::Duration; -use crate::{Result, Store}; +use crate::Result; /// Une tâche de maintenance fournie par le consommateur. #[async_trait::async_trait] @@ -14,11 +18,11 @@ pub trait MaintenanceTask: Send + Sync { /// Nom lisible (tracing/debug). fn name(&self) -> &str; - /// Exécute la tâche contre le store. + /// Exécute la tâche. /// /// # Errors - /// Propage toute erreur du store ; la boucle logue et continue. - async fn run(&self, store: &Store) -> Result<()>; + /// Propage toute erreur ; la boucle logue et continue. + async fn run(&self) -> Result<()>; } /// Planifie et exécute des [`MaintenanceTask`] en tâche de fond, sans bloquer @@ -46,13 +50,12 @@ impl MaintenanceWorker { /// /// Une boucle `tokio::spawn` par tâche : `sleep(every)` puis `run`. Une /// erreur est loguée (`warn`) et n'interrompt pas la boucle. - pub fn start(self, store: Arc) { + pub fn start(self) { for (every, task) in self.tasks { - let store = Arc::clone(&store); tokio::spawn(async move { loop { tokio::time::sleep(every).await; - if let Err(e) = task.run(&store).await { + if let Err(e) = task.run().await { tracing::warn!(task = task.name(), error = %e, "maintenance task failed"); } } diff --git a/crates/basemyai-core/src/storage/engine.rs b/crates/basemyai-core/src/storage/engine.rs index f49818f..f4f5729 100644 --- a/crates/basemyai-core/src/storage/engine.rs +++ b/crates/basemyai-core/src/storage/engine.rs @@ -6,15 +6,15 @@ //! semantics stay in the consumer; backend-specific mechanics stay behind //! [`Store`](crate::Store). -/// Built-in storage backend kind. +/// Built-in storage backend kind. `Libsql` existed through ADR-011; removed +/// entirely by ADR-032 (native-only) — kept as a single variant so the type +/// isn't a redundant unit struct, but every implementor is `Native` today. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[non_exhaustive] pub enum EngineKind { - /// libSQL-compatible embedded backend. - Libsql, - /// Home-grown `basemyai-engine` backend (ADR-024/ADR-025). Feature-gated - /// behind `engine-native`; see [`EngineCapabilities::native`] for what it - /// honestly supports today. + /// Home-grown `basemyai-engine` backend (ADR-024/ADR-025/ADR-032), the + /// only workspace backend; see + /// [`EngineCapabilities::native`] for what it honestly supports today. Native, } @@ -37,22 +37,8 @@ pub struct EngineCapabilities { } impl EngineCapabilities { - /// Capabilities of the current libSQL backend instance. - #[must_use] - pub const fn libsql(encrypted: bool) -> Self { - Self { - kind: EngineKind::Libsql, - vectors: true, - full_text: true, - recursive_queries: true, - transactions: true, - encrypted, - } - } - /// Capabilities of the current `basemyai-engine` (native) backend - /// instance. `encrypted` reflects the opened instance, same contract as - /// [`EngineCapabilities::libsql`]. + /// instance. `encrypted` reflects whether the opened instance is encrypted. /// /// Honest as of N5.4 (`docs/TODO-NATIVE-ENGINE.md`, ADR-027/028/030): /// `basemyai-engine` is a WAL+memtable+SST KV engine with atomic diff --git a/crates/basemyai-core/src/storage/key.rs b/crates/basemyai-core/src/storage/key.rs new file mode 100644 index 0000000..f970b45 --- /dev/null +++ b/crates/basemyai-core/src/storage/key.rs @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: BUSL-1.1 +//! Clé de chiffrement générique — indépendante du backend depuis la +//! suppression de libSQL (ADR-032). Utilisée par le moteur natif +//! (`basemyai-engine`, ADR-030) via [`EncryptionKey::expose`]. + +use std::fmt; + +/// Clé de chiffrement, **fournie à l'ouverture, jamais persistée**. `Debug` masqué. +#[derive(Clone)] +pub struct EncryptionKey(String); + +impl EncryptionKey { + /// Wrap une clé de chiffrement. La valeur n'est jamais loguée ni affichée. + #[must_use] + pub fn new(key: impl Into) -> Self { + Self(key.into()) + } + + /// Expose la clé brute — nécessaire pour ouvrir le moteur natif, qui + /// reçoit la clé en `&str`/`&[u8]` plutôt qu'en ce type. À ne jamais + /// loguer ni afficher : l'appelant hérite de la responsabilité du + /// non-affichage. + #[must_use] + pub fn expose(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for EncryptionKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("EncryptionKey(***)") + } +} diff --git a/crates/basemyai-core/src/storage/mod.rs b/crates/basemyai-core/src/storage/mod.rs index b5e71e1..588c842 100644 --- a/crates/basemyai-core/src/storage/mod.rs +++ b/crates/basemyai-core/src/storage/mod.rs @@ -1,12 +1,10 @@ // SPDX-License-Identifier: BUSL-1.1 mod engine; -#[cfg(feature = "engine-native")] +mod key; mod native; -mod store; mod vector; pub use engine::{EngineCapabilities, EngineKind, StorageEngine}; -#[cfg(feature = "engine-native")] +pub use key::EncryptionKey; pub use native::NativeEngine; -pub use store::{EncryptionKey, Migration, Store, WriteTxn}; -pub use vector::{Filter, Metric, Neighbor, Value}; +pub use vector::Metric; diff --git a/crates/basemyai-core/src/storage/store.rs b/crates/basemyai-core/src/storage/store.rs deleted file mode 100644 index 078e801..0000000 --- a/crates/basemyai-core/src/storage/store.rs +++ /dev/null @@ -1,714 +0,0 @@ -// SPDX-License-Identifier: BUSL-1.1 -//! Store libSQL async + migrations + recherche vectorielle **native**. -//! -//! libSQL est SQLite-compatible et embarque le vecteur (`F32_BLOB`, -//! `libsql_vector_idx`, `vector_top_k`) — **aucune extension à linker**. Le -//! chiffrement au repos est intégré (feature `crypto`, qui exige CMake). - -use std::fmt; -use std::ops::Deref; -use std::path::{Path, PathBuf}; -use std::sync::OnceLock; -use std::sync::atomic::{AtomicUsize, Ordering}; - -use libsql::{Builder, Connection, Database, TransactionBehavior}; -use tokio::sync::{Mutex, MutexGuard}; - -use super::{Filter, Metric, Neighbor, Value}; -use crate::{CoreError, EngineCapabilities, Result, StorageEngine}; - -/// Taille par défaut du pool de connexions lecteur ouvert par [`Store::open`]. -/// Les lectures (KNN) se répartissent en round-robin sur ces connexions ; -/// l'écriture reste sérialisée sur l'unique writer. -const DEFAULT_READ_POOL: usize = 4; - -/// Facteur de sur-échantillonnage du top-k natif quand un `Filter` est présent : -/// on demande `k * KNN_OVERSAMPLE` voisins avant d'appliquer le `WHERE`, pour -/// qu'il reste ~`k` résultats une fois filtrés. -const KNN_OVERSAMPLE: usize = 8; - -/// Sur-échantillonnage pour le re-classement par métrique non native -/// (euclidienne/hamming) : on récupère `k * RERANK_OVERSAMPLE` candidats cosinus -/// avant de trier en Rust sur la métrique demandée. -const RERANK_OVERSAMPLE: usize = 16; - -/// Clé de chiffrement, **fournie à l'ouverture, jamais persistée**. `Debug` masqué. -#[derive(Clone)] -pub struct EncryptionKey(String); - -impl EncryptionKey { - /// Wrap une clé de chiffrement. La valeur n'est jamais loguée ni affichée. - #[must_use] - pub fn new(key: impl Into) -> Self { - Self(key.into()) - } - - pub(crate) fn expose(&self) -> &str { - &self.0 - } -} - -impl fmt::Debug for EncryptionKey { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str("EncryptionKey(***)") - } -} - -/// Migration de schéma versionnée. Le **consommateur** déclare son schéma. -#[derive(Debug, Clone, Copy)] -pub struct Migration { - pub version: u32, - pub up_sql: &'static str, -} - -/// Store libSQL avec **pool de lecteurs**. Un unique `writer` sérialisé (WAL + -/// `busy_timeout`) porte toutes les écritures ; un pool de `readers` (round-robin) -/// porte les lectures, qui se parallélisent sous WAL sans bloquer le writer. La -/// `Database` est conservée vivante pour pouvoir rouvrir des connexions. -/// -/// **Dégénérescence `:memory:`** : chaque `db.connect()` sur une base en mémoire -/// ouvre une *base distincte*, donc impossible de pooler ; le store garde alors -/// l'unique writer partagé (libSQL synchronise l'accès en interne), `readers` -/// est vide et [`reader`](Self::reader) retombe sur le writer. Pas de PRAGMA WAL -/// en mémoire. -pub struct Store { - /// Conservée vivante pour que les connexions (writer + pool) restent - /// valides — `Connection` ne garde pas la `Database` en vie. Lue uniquement - /// comme propriétaire ; jamais déréférencée après l'ouverture. - #[allow(dead_code)] - db: Database, - /// L'unique connexion d'écriture, sérialisée via `write_lock`. - writer: Connection, - /// Pool de connexions lecteur (round-robin). **Vide** pour `:memory:`. - readers: Vec, - /// Curseur round-robin sur `readers`. - next: AtomicUsize, - path: Option, - encrypted: bool, - /// Sérialise les [`WriteTxn`] : deux `BEGIN` concurrents sur le writer - /// s'imbriqueraient (erreur SQLite) sans ce verrou. - write_lock: Mutex<()>, -} - -impl fmt::Debug for Store { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("Store") - .field("path", &self.path) - .field("encrypted", &self.encrypted) - .finish() - } -} - -impl Store { - /// Ouvre (ou crée) un store sur fichier. `key = Some(_)` exige la feature - /// `crypto` (chiffrement libSQL, nécessite CMake). - /// - /// # Errors - /// [`CoreError::Encryption`] si une clé est fournie sans la feature `crypto`, - /// [`CoreError::Storage`] si l'ouverture échoue. - pub async fn open(path: &Path, key: Option) -> Result { - Self::open_with(path, key, DEFAULT_READ_POOL).await - } - - /// Comme [`open`](Self::open) mais avec une taille de pool lecteur explicite. - /// `pool_size = 0` ⇒ aucune connexion lecteur (toutes les lectures retombent - /// sur le writer, comme en mémoire). - /// - /// Toute l'ouverture (writer + lecteurs) se fait **séquentiellement sous le - /// même `native_open_lock`** : la race native `sqlite3_open_v2` (voir le doc - /// de [`native_open_lock`]) interdit deux ouvertures concurrentes. - /// - /// # Errors - /// [`CoreError::Encryption`] si une clé est fournie sans la feature `crypto`, - /// [`CoreError::Storage`] si l'ouverture échoue. - pub async fn open_with(path: &Path, key: Option, pool_size: usize) -> Result { - let encrypted = key.is_some(); - let _guard = native_open_lock().lock().await; - let db = build_local(path, key).await?; - - // Writer + PRAGMAs WAL (file-backed uniquement). - let writer = db.connect().map_err(map)?; - writer - .execute_batch( - "PRAGMA journal_mode=WAL;\n\ - PRAGMA synchronous=NORMAL;\n\ - PRAGMA busy_timeout=5000;", - ) - .await - .map_err(map)?; - - // Pool lecteur : ouvertures SÉQUENTIELLES, toujours sous le même guard. - let mut readers = Vec::with_capacity(pool_size); - for _ in 0..pool_size { - let r = db.connect().map_err(map)?; - r.execute_batch("PRAGMA busy_timeout=5000;").await.map_err(map)?; - readers.push(r); - } - - Ok(Self { - db, - writer, - readers, - next: AtomicUsize::new(0), - path: Some(path.to_path_buf()), - encrypted, - write_lock: Mutex::new(()), - }) - } - - /// Ouvre un store en mémoire (tests). **Dégénéré** : pas de pool (chaque - /// `db.connect()` en mémoire est une base distincte), `readers` vide, pas de - /// PRAGMA WAL. La `Database` est conservée vivante. - /// - /// # Errors - /// [`CoreError::Storage`] si l'initialisation échoue. - pub async fn open_in_memory() -> Result { - let _guard = native_open_lock().lock().await; - let db = Builder::new_local(":memory:").build().await.map_err(map)?; - let writer = db.connect().map_err(map)?; - Ok(Self { - db, - writer, - readers: Vec::new(), - next: AtomicUsize::new(0), - path: None, - encrypted: false, - write_lock: Mutex::new(()), - }) - } - - /// Connexion **writer** (clone) — pour les requêtes, préférer - /// [`reader`](Self::reader). libSQL synchronise l'accès en interne ; les - /// écritures ad hoc via cette connexion restent correctes et sérialisées - /// (WAL + `busy_timeout`). - #[must_use] - pub fn connect(&self) -> Connection { - self.writer.clone() - } - - /// Connexion **lecteur** (clone) issue du pool, en round-robin. Si le pool - /// est vide (`:memory:` ou `pool_size = 0`), retombe sur le writer. Les - /// connexions SERIALIZED sont sûres à partager sans checkout. - #[must_use] - pub fn reader(&self) -> Connection { - if self.readers.is_empty() { - self.writer.clone() - } else { - let i = self.next.fetch_add(1, Ordering::Relaxed) % self.readers.len(); - self.readers[i].clone() - } - } - - /// Applique les migrations de version supérieure à la courante. Idempotent. - /// - /// # Errors - /// [`CoreError::Storage`] en cas d'échec SQL. - pub async fn migrate(&self, migrations: &[Migration]) -> Result<()> { - let _migration = migration_lock().lock().await; - let txn = self.begin_write().await?; - txn.execute_batch("CREATE TABLE IF NOT EXISTS _schema_version (version INTEGER NOT NULL);") - .await - .map_err(map)?; - let current = scalar_i64(&txn, "SELECT COALESCE(MAX(version), 0) FROM _schema_version").await?; - - for m in migrations.iter().filter(|m| i64::from(m.version) > current) { - txn.execute_batch(m.up_sql).await.map_err(map)?; - txn.execute( - "INSERT INTO _schema_version (version) VALUES (?1)", - libsql::params![i64::from(m.version)], - ) - .await - .map_err(map)?; - } - txn.commit().await?; - Ok(()) - } - - /// Crée (si absent) la table + l'index vectoriel natif `metric=cosine`. - /// - /// # Errors - /// [`CoreError::Vector`] si `table` n'est pas un identifiant sûr, ou échec SQL. - pub async fn ensure_vector_table(&self, table: &str, dim: usize) -> Result<()> { - let table = ident(table)?; - let conn = self.connect(); - conn.execute_batch(&format!( - "CREATE TABLE IF NOT EXISTS {table} (id TEXT PRIMARY KEY, emb F32_BLOB({dim}));\n\ - CREATE INDEX IF NOT EXISTS {table}_idx ON {table}(libsql_vector_idx(emb, 'metric=cosine'));", - )) - .await - .map_err(map)?; - Ok(()) - } - - /// Variante de [`ensure_vector_table`](Self::ensure_vector_table) qui crée - /// **uniquement la table**, sans l'index `libsql_vector_idx`. - /// - /// **Scope : bulk-load / benchmark uniquement.** L'index natif de libSQL - /// (graphe de type DiskANN) est maintenu de façon incrémentale : chaque - /// insertion déclenche une recherche de voisins dans le graphe déjà - /// construit, dont le coût croît avec la taille de l'index. Sur un chargement - /// massif séquentiel, ça a été mesuré comme un mur de scalabilité réel (voir - /// `docs/benchmarks/m6-knn-results-2026-07-01.md`) — construire l'index - /// après le chargement en masse plutôt qu'avant l'évite. **N'utilisez pas - /// ceci pour le chemin d'usage normal de la lib** (`Memory`, etc.), qui doit - /// pouvoir interroger le KNN à tout moment y compris pendant l'insertion - /// incrémentale : préférez toujours [`ensure_vector_table`](Self::ensure_vector_table) - /// hors bulk-load explicite. Combiner avec [`create_vector_index`](Self::create_vector_index) - /// une fois le chargement terminé. - /// - /// # Errors - /// [`CoreError::Vector`] si `table` n'est pas un identifiant sûr, ou échec SQL. - pub async fn ensure_vector_table_no_index(&self, table: &str, dim: usize) -> Result<()> { - let table = ident(table)?; - let conn = self.connect(); - conn.execute_batch(&format!( - "CREATE TABLE IF NOT EXISTS {table} (id TEXT PRIMARY KEY, emb F32_BLOB({dim}));", - )) - .await - .map_err(map)?; - Ok(()) - } - - /// Crée (si absent) l'index vectoriel natif `metric=cosine` sur une table - /// déjà remplie. Contrepartie bulk-load de - /// [`ensure_vector_table_no_index`](Self::ensure_vector_table_no_index) : - /// appeler **après** le chargement en masse, pour construire l'index en une - /// fois plutôt que de payer le coût incrémental à chaque ligne. - /// - /// # Errors - /// [`CoreError::Vector`] si `table` n'est pas un identifiant sûr, ou échec SQL. - pub async fn create_vector_index(&self, table: &str) -> Result<()> { - let table = ident(table)?; - let conn = self.connect(); - conn.execute_batch(&format!( - "CREATE INDEX IF NOT EXISTS {table}_idx ON {table}(libsql_vector_idx(emb, 'metric=cosine'));", - )) - .await - .map_err(map)?; - Ok(()) - } - - /// Insère ou met à jour le vecteur d'un identifiant. - /// - /// # Errors - /// [`CoreError::Vector`] si `table` est invalide, ou échec SQL. - pub async fn vector_upsert(&self, table: &str, id: &str, vector: &[f32]) -> Result<()> { - let table = ident(table)?; - let conn = self.connect(); - conn.execute( - &format!( - "INSERT INTO {table} (id, emb) VALUES (?1, vector(?2)) \ - ON CONFLICT(id) DO UPDATE SET emb = vector(?2)", - ), - libsql::params![id, vec_to_json(vector)], - ) - .await - .map_err(map)?; - Ok(()) - } - - /// `k` plus proches voisins (cosine, ANN natif), sous le `filter` paramétré - /// optionnel fourni par l'appelant. - /// - /// La distance retournée est la **distance cosinus réelle** (`[0, 2]`, - /// `0` = identique), recalculée via `vector_distance_cos` — pas un placeholder. - /// - /// Le filtre s'applique *après* le top-k natif. Pour qu'il reste `k` - /// résultats une fois filtrés, on **sur-échantillonne** le top-k natif d'un - /// facteur `KNN_OVERSAMPLE` (×8) dès qu'un filtre est présent, puis on tronque à - /// `k` après le `WHERE`. C'est best-effort (un filtre très sélectif sur un - /// gros index peut encore rendre < `k`), mais ça couvre le cas courant sans - /// boucle d'élargissement. - /// - /// # Errors - /// [`CoreError::Vector`] si `table` est invalide, ou échec SQL. - pub async fn vector_knn( - &self, - table: &str, - query: &[f32], - k: usize, - filter: Option<&Filter>, - ) -> Result> { - let table = ident(table)?; - let conn = self.reader(); - - let filtered = matches!(filter, Some(f) if !f.where_sql.is_empty()); - let inner_k = if filtered { k.saturating_mul(KNN_OVERSAMPLE) } else { k }; - - // Placeholders anonymes, liés dans l'ordre textuel du SQL ci-dessous : - // 1) query (distance dans le SELECT) 2) query (vector_top_k) - // 3) inner_k (vector_top_k) 4..) params du filtre N) k (LIMIT) - let mut params: Vec = vec![ - libsql::Value::Text(vec_to_json(query)), - libsql::Value::Text(vec_to_json(query)), - libsql::Value::Integer(i64::try_from(inner_k).unwrap_or(i64::MAX)), - ]; - let where_clause = match filter { - Some(f) if !f.where_sql.is_empty() => { - params.extend(f.params.iter().map(to_libsql_value)); - format!(" WHERE {}", f.where_sql) - } - _ => String::new(), - }; - params.push(libsql::Value::Integer(i64::try_from(k).unwrap_or(i64::MAX))); - - let sql = format!( - "SELECT t.id, vector_distance_cos(t.emb, vector(?)) AS dist \ - FROM vector_top_k('{table}_idx', vector(?), ?) AS v \ - JOIN {table} AS t ON t.rowid = v.id{where_clause} \ - ORDER BY dist LIMIT ?", - ); - - let mut rows = conn.query(&sql, params).await.map_err(map)?; - let mut out = Vec::new(); - while let Some(row) = rows.next().await.map_err(map)? { - #[allow(clippy::cast_possible_truncation)] - let distance = row.get::(1).map_err(map)? as f32; - out.push(Neighbor { - id: row.get::(0).map_err(map)?, - distance, - }); - } - Ok(out) - } - - /// KNN avec **métrique explicite**. - /// - /// [`Metric::Cosine`] emprunte le chemin natif ([`vector_knn`](Self::vector_knn)). - /// [`Metric::Euclidean`] / [`Metric::Hamming`] sur-échantillonnent les candidats - /// cosinus (`k * RERANK_OVERSAMPLE`) puis les **re-classent en Rust** sur les - /// vecteurs réels (rappel piloté par l'ANN cosinus, tri par la métrique cible). - /// - /// # Errors - /// [`CoreError::Vector`] si `table` est invalide, ou échec SQL. - pub async fn vector_knn_metric( - &self, - table: &str, - query: &[f32], - k: usize, - filter: Option<&Filter>, - metric: Metric, - ) -> Result> { - match metric { - Metric::Cosine => self.vector_knn(table, query, k, filter).await, - Metric::Euclidean | Metric::Hamming => self.vector_knn_reranked(table, query, k, filter, metric).await, - } - } - - /// Re-classement en Rust : récupère les vecteurs des candidats cosinus - /// sur-échantillonnés, recalcule la distance pour `metric`, trie, tronque à `k`. - async fn vector_knn_reranked( - &self, - table: &str, - query: &[f32], - k: usize, - filter: Option<&Filter>, - metric: Metric, - ) -> Result> { - let table = ident(table)?; - let conn = self.reader(); - let inner_k = k.saturating_mul(RERANK_OVERSAMPLE); - - // Placeholders : 1) query (vector_top_k) 2) inner_k 3..) params du filtre. - let mut params: Vec = vec![ - libsql::Value::Text(vec_to_json(query)), - libsql::Value::Integer(i64::try_from(inner_k).unwrap_or(i64::MAX)), - ]; - let where_clause = match filter { - Some(f) if !f.where_sql.is_empty() => { - params.extend(f.params.iter().map(to_libsql_value)); - format!(" WHERE {}", f.where_sql) - } - _ => String::new(), - }; - - let sql = format!( - "SELECT t.id, vector_extract(t.emb) AS v \ - FROM vector_top_k('{table}_idx', vector(?), ?) AS top \ - JOIN {table} AS t ON t.rowid = top.id{where_clause}", - ); - - let mut rows = conn.query(&sql, params).await.map_err(map)?; - let mut scored: Vec = Vec::new(); - while let Some(row) = rows.next().await.map_err(map)? { - let id = row.get::(0).map_err(map)?; - let vtext = row.get::(1).map_err(map)?; - let candidate = parse_vector(&vtext); - scored.push(Neighbor { - id, - distance: metric_distance(query, &candidate, metric), - }); - } - - scored.sort_by(|a, b| a.distance.partial_cmp(&b.distance).unwrap_or(std::cmp::Ordering::Equal)); - scored.truncate(k); - Ok(scored) - } - - /// Ouvre une [`WriteTxn`] : transaction d'écriture **sérialisée** - /// (`BEGIN IMMEDIATE` + verrou writer interne). Toute écriture multi-tables - /// du consommateur doit passer par ici pour être atomique — un échec en - /// cours de route annule tout (rollback automatique au drop). - /// - /// # Errors - /// [`CoreError::Storage`] si le `BEGIN` échoue. - pub async fn begin_write(&self) -> Result> { - let writer = self.write_lock.lock().await; - let txn = self - .writer - .transaction_with_behavior(TransactionBehavior::Immediate) - .await - .map_err(map)?; - Ok(WriteTxn { txn, _writer: writer }) - } - - /// Chemin du fichier (`None` si en mémoire). - #[must_use] - pub fn path(&self) -> Option<&Path> { - self.path.as_deref() - } - - /// `true` si le store est chiffré. - #[must_use] - pub fn is_encrypted(&self) -> bool { - self.encrypted - } - - /// Change la clé de chiffrement **en place** via `PRAGMA rekey` - /// (SQLite3MultipleCiphers, le backend de la feature `crypto`) : les pages - /// sont ré-écrites avec `new_key` sans recréer ni dumper le fichier. - /// - /// # Pourquoi ce `Store` devient caduc après l'appel - /// - /// `PRAGMA rekey` ne ré-encrypte que sur la connexion qui l'exécute (ici - /// le writer, sous `write_lock`) : c'est le seul point d'entrée SQL qui - /// déclenche `sqlite3_rekey_v2` côté SQLite3MultipleCiphers. Les autres - /// connexions déjà ouvertes — tout le pool de **lecteurs** — gardent en - /// mémoire l'**ancienne** clé dans leur propre état de chiffrement natif - /// (fixé une fois via `sqlite3_key` à leur ouverture) ; rien ne les - /// notifie du changement, et leurs prochaines lectures de pages - /// nouvellement ré-écrites échoueront (déchiffrement avec la mauvaise - /// clé). Pire : `libsql::Database` capture `encryption_config` **une - /// fois, immuablement**, à sa construction (`Builder::build`) — toute - /// connexion ouverte *après coup* via `db.connect()` (donc un futur appel - /// à [`reader`](Self::reader), ou un warm-up de pool) réutiliserait - /// encore l'**ancienne** clé et échouerait contre le fichier désormais - /// chiffré avec `new_key`. libSQL 0.9.30 n'offre ni API pour rafraîchir - /// cette config après coup, ni mécanisme pour diffuser un rekey aux - /// connexions sœurs. - /// - /// **Conséquence : après un appel réussi, cette instance de `Store` - /// (writer + pool de lecteurs) doit être considérée caduque.** - /// L'appelant doit la laisser tomber (`drop`) et rouvrir un `Store` frais - /// via [`Store::open`] avec `new_key` — exactement comme après une - /// ouverture initiale. Ne rien lire/écrire d'autre sur cette instance - /// après `rotate_key` : au mieux les lecteurs échouent proprement, au - /// pire ils servent des pages mises en cache avant la rotation (lecture - /// obsolète mais silencieusement "valide"). - /// - /// # Errors - /// [`CoreError::Encryption`] si le store n'est pas chiffré (rien à - /// rotater — `rotate_key` ne sert pas à chiffrer a posteriori un store en - /// clair, seulement à changer la clé d'un store déjà chiffré). - /// [`CoreError::Storage`] si le `PRAGMA rekey` échoue côté SQL. - #[cfg(feature = "crypto")] - pub async fn rotate_key(&self, new_key: EncryptionKey) -> Result<()> { - if !self.encrypted { - return Err(CoreError::Encryption); - } - // Sérialise avec `begin_write`/toute transaction en cours : le rekey - // doit être la seule opération en vol sur le writer pendant la - // ré-écriture des pages. - let _writer = self.write_lock.lock().await; - // `PRAGMA rekey` ne supporte pas les placeholders liés (`?`) : la - // grammaire PRAGMA de SQLite n'accepte qu'un littéral. On échappe donc - // la clé comme un littéral SQL (guillemet simple doublé) plutôt que de - // l'interpoler telle quelle. - let escaped = new_key.expose().replace('\'', "''"); - // SQLite3MultipleCiphers refuse `PRAGMA rekey` tant que la connexion - // est en `journal_mode=WAL` (« Rekeying is not supported in WAL - // journal mode », vérifié empiriquement contre libSQL 0.9.30) : le - // rekey ré-écrit la totalité des pages via le rollback journal - // classique, incompatible avec le modèle WAL. On repasse donc en - // `DELETE` pour la durée du rekey, puis on restaure `WAL` (le mode - // nominal de ce `Store`, cf. [`Store::open_with`]) juste après. - self.writer - .execute_batch("PRAGMA journal_mode=DELETE;") - .await - .map_err(map)?; - let rekey_result = self.writer.execute_batch(&format!("PRAGMA rekey = '{escaped}';")).await; - // Toujours retenter de revenir en WAL, même si le rekey a échoué : on - // ne veut pas laisser le fichier en mode DELETE suite à une erreur. - self.writer - .execute_batch("PRAGMA journal_mode=WAL;") - .await - .map_err(map)?; - rekey_result.map_err(map)?; - Ok(()) - } -} - -impl StorageEngine for Store { - fn capabilities(&self) -> EngineCapabilities { - EngineCapabilities::libsql(self.encrypted) - } -} - -/// Transaction d'écriture sérialisée, ouverte par [`Store::begin_write`]. -/// -/// Déréférence vers la [`Connection`] sous-jacente : les `execute`/`query` -/// passés dessus font partie de la transaction. Sans [`commit`](Self::commit), -/// le drop **annule** tout (rollback). Le verrou writer est tenu pendant toute -/// la vie de la transaction — les autres `WriteTxn` attendent. -pub struct WriteTxn<'a> { - txn: libsql::Transaction, - _writer: MutexGuard<'a, ()>, -} - -impl WriteTxn<'_> { - /// Valide la transaction et relâche le verrou writer. - /// - /// # Errors - /// [`CoreError::Storage`] si le `COMMIT` échoue. - pub async fn commit(self) -> Result<()> { - self.txn.commit().await.map_err(map) - } -} - -impl Deref for WriteTxn<'_> { - type Target = Connection; - - fn deref(&self) -> &Connection { - &self.txn - } -} - -/// Sérialise toutes les ouvertures natives (`sqlite3_open_v2` via libSQL), process-wide. -/// -/// libSQL configure son threading SQLite (`SQLITE_CONFIG_SERIALIZED` + -/// `sqlite3_initialize`) derrière un `Once` interne au premier `Database::new` ; ce -/// verrou évite que deux ouvertures concurrentes touchent cette init globale en -/// même temps. **Insuffisant seul** contre la rare race native Windows observée à -/// haute concurrence de threads (`STATUS_ACCESS_VIOLATION`, voir -/// `RUST_TEST_THREADS=1` dans `.cargo/config.toml`) — celle-ci se reproduit même -/// avec ce verrou en place, donc ailleurs dans le runtime libSQL. Conservé pour -/// la sémantique correcte qu'il garantit en prod ; les ouvertures sont rares (une -/// par session), donc le coût est négligeable. -fn native_open_lock() -> &'static Mutex<()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) -} - -/// Sérialise les migrations process-wide. -/// -/// Chaque [`Store`] a son propre verrou writer, donc deux stores ouverts sur le -/// même fichier froid pourraient sinon créer `_schema_version` et appliquer le -/// même lot de DDL en parallèle. Le verrou global garde le chemin cold-open -/// simple et déterministe ; la transaction writer rend chaque lot tout-ou-rien. -fn migration_lock() -> &'static Mutex<()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) -} - -#[cfg(feature = "crypto")] -async fn build_local(path: &Path, key: Option) -> Result { - let mut builder = Builder::new_local(path); - if let Some(key) = key { - let cfg = libsql::EncryptionConfig::new(libsql::Cipher::Aes256Cbc, key.expose().as_bytes().to_vec().into()); - builder = builder.encryption_config(cfg); - } - builder.build().await.map_err(map) -} - -#[cfg(not(feature = "crypto"))] -async fn build_local(path: &Path, key: Option) -> Result { - if key.is_some() { - return Err(CoreError::Encryption); // chiffrement = feature `crypto` (CMake) - } - Builder::new_local(path).build().await.map_err(map) -} - -async fn scalar_i64(conn: &Connection, sql: &str) -> Result { - let mut rows = conn.query(sql, ()).await.map_err(map)?; - let row = rows - .next() - .await - .map_err(map)? - .ok_or_else(|| CoreError::Storage("empty scalar query".into()))?; - row.get::(0).map_err(map) -} - -/// Valide un identifiant de table (anti-injection ; les noms ne sont pas paramétrables). -fn ident(s: &str) -> Result<&str> { - if !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { - Ok(s) - } else { - Err(CoreError::Vector(format!("invalid table identifier: {s:?}"))) - } -} - -fn vec_to_json(v: &[f32]) -> String { - let mut s = String::with_capacity(v.len() * 8 + 2); - s.push('['); - for (i, x) in v.iter().enumerate() { - if i > 0 { - s.push(','); - } - s.push_str(&x.to_string()); - } - s.push(']'); - s -} - -fn to_libsql_value(v: &Value) -> libsql::Value { - match v { - Value::Integer(i) => libsql::Value::Integer(*i), - Value::Real(r) => libsql::Value::Real(*r), - Value::Text(t) => libsql::Value::Text(t.clone()), - Value::Blob(b) => libsql::Value::Blob(b.clone()), - Value::Null => libsql::Value::Null, - } -} - -fn map(e: libsql::Error) -> CoreError { - CoreError::Storage(e.to_string()) -} - -/// Parse la sortie texte de `vector_extract` (`"[a,b,c]"`) en `Vec`. -fn parse_vector(s: &str) -> Vec { - s.trim() - .trim_start_matches('[') - .trim_end_matches(']') - .split(',') - .filter_map(|x| { - let t = x.trim(); - if t.is_empty() { None } else { t.parse::().ok() } - }) - .collect() -} - -/// Distance entre `query` et `candidate` pour la métrique de re-classement. -/// `Cosine` n'emprunte pas ce chemin (fallback défensif neutre). -fn metric_distance(query: &[f32], candidate: &[f32], metric: Metric) -> f32 { - match metric { - Metric::Euclidean => query - .iter() - .zip(candidate) - .map(|(x, y)| { - let d = x - y; - d * d - }) - .sum::() - .sqrt(), - Metric::Hamming => { - let mut differing = 0.0_f32; - for (x, y) in query.iter().zip(candidate) { - if x.is_sign_negative() != y.is_sign_negative() { - differing += 1.0; - } - } - differing - } - Metric::Cosine => 1.0, - } -} diff --git a/crates/basemyai-core/src/storage/vector.rs b/crates/basemyai-core/src/storage/vector.rs index 2b1156f..cb1334f 100644 --- a/crates/basemyai-core/src/storage/vector.rs +++ b/crates/basemyai-core/src/storage/vector.rs @@ -1,72 +1,21 @@ // SPDX-License-Identifier: BUSL-1.1 -//! Types de la recherche vectorielle. Les opérations (`vector_upsert`, -//! `vector_knn`) sont **natives** sur [`Store`](crate::Store) via libSQL — pas -//! de trait/backend à abstraire (ADR : pivot libSQL). -//! -//! **Le pattern clé** subsiste : `vector_knn` accepte un [`Filter`] *fourni par -//! l'appelant*. Le core applique le filtre sans en connaître le sens. Le filtre -//! est **paramétré** — fragment SQL `?` + valeurs liées — donc agnostique *et* -//! anti-injection. - -/// Valeur SQL liée à un placeholder `?` d'un [`Filter`]. -/// -/// `#[non_exhaustive]` : de nouveaux types SQL libSQL peuvent être ajoutés. -#[derive(Debug, Clone)] -#[non_exhaustive] -pub enum Value { - /// Entier signé 64 bits. - Integer(i64), - /// Flottant 64 bits. - Real(f64), - /// Texte UTF-8. - Text(String), - /// Données binaires brutes. - Blob(Vec), - /// Valeur SQL NULL. - Null, -} - -/// Filtre SQL paramétré fourni par le consommateur. `where_sql` contient des -/// `?` ; les valeurs (potentiellement non fiables) vivent dans `params`. -#[derive(Debug, Default, Clone)] -pub struct Filter { - /// Fragment `WHERE` avec des `?` anonymes (anti-injection). - pub where_sql: String, - /// Valeurs liées aux `?`, dans l'ordre textuel. - pub params: Vec, -} - -impl Filter { - /// Construit un filtre à partir d'un fragment `WHERE` et de ses paramètres. - #[must_use] - pub fn new(where_sql: impl Into, params: Vec) -> Self { - Self { - where_sql: where_sql.into(), - params, - } - } -} - -/// Un voisin retourné par `vector_knn`. -#[derive(Debug, Clone)] -pub struct Neighbor { - /// Identifiant de la ligne (`id TEXT PRIMARY KEY`). - pub id: String, - /// Distance pour la métrique demandée (`0` = identique, croissante = plus - /// éloigné). Cosinus dans `[0, 2]` par défaut ; voir [`Metric`]. - pub distance: f32, -} +//! Metric de distance pour le KNN — le seul type de `storage::vector` qui +//! survit à la bascule 100% natif (ADR-032) : `Filter`/`Value`/`Neighbor` +//! étaient des types de construction de requête libSQL, supprimés avec +//! `Store`. `Metric` reste un concept de domaine, indépendant du backend. /// Métrique de distance pour le KNN. /// -/// L'index natif libSQL est **cosinus**. Pour [`Metric::Euclidean`] et -/// [`Metric::Hamming`], le KNN sur-échantillonne les candidats cosinus puis les -/// **re-classe en Rust** sur les vecteurs réels (le rappel reste piloté par -/// l'index ANN cosinus, le tri final par la métrique demandée). +/// L'index natif (`basemyai-engine`, LM-DiskANN) est **cosinus**. +/// [`Metric::Euclidean`]/[`Metric::Hamming`] n'ont pas d'implémentation de +/// re-classement côté natif aujourd'hui (l'ancien chemin de re-classement +/// vivait dans le `Store` libSQL supprimé) — un appelant qui les demande +/// reçoit une erreur franche du backend, jamais un résultat silencieusement +/// faux. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[non_exhaustive] pub enum Metric { - /// Distance cosinus (native, `1 - cos`). Défaut. + /// Distance cosinus (native). Défaut. #[default] Cosine, /// Distance euclidienne (L2) sur les vecteurs. diff --git a/crates/basemyai-core/tests/agnosticity.rs b/crates/basemyai-core/tests/agnosticity.rs index 403c45e..9d74680 100644 --- a/crates/basemyai-core/tests/agnosticity.rs +++ b/crates/basemyai-core/tests/agnosticity.rs @@ -5,7 +5,7 @@ use std::fs; use std::path::Path; -/// Concepts métier interdits dans le socle (mémoire d'agent + code ForgeMyAI). +/// Concepts métier interdits dans le socle (mémoire d'agent + sémantique code). const FORBIDDEN: &[&str] = &["agent_id", "valid_from", "valid_until", "episodic", "Symbol", "Edge"]; fn scan(dir: &Path, hits: &mut Vec) { diff --git a/crates/basemyai-core/tests/contracts.rs b/crates/basemyai-core/tests/contracts.rs deleted file mode 100644 index 3c0eadd..0000000 --- a/crates/basemyai-core/tests/contracts.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! Contrats du socle : types stables, indépendants des backends natifs. - -use basemyai_core::{Device, EncryptionKey, EngineKind, Filter, StorageEngine, Store, Value}; - -#[test] -fn device_defaults_to_cpu() { - assert_eq!(Device::default(), Device::Cpu); -} - -#[test] -fn encryption_key_debug_never_leaks_the_secret() { - let key = EncryptionKey::new("super-secret-value"); - let shown = format!("{key:?}"); - assert!(!shown.contains("super-secret-value"), "le secret a fuité dans Debug"); - assert_eq!(shown, "EncryptionKey(***)"); -} - -#[test] -fn filter_carries_parameterized_clause() { - let filter = Filter::new("col = ?", vec![Value::Integer(7)]); - assert_eq!(filter.where_sql, "col = ?"); - assert_eq!(filter.params.len(), 1); -} - -#[tokio::test] -async fn store_reports_libsql_capabilities() { - let store = Store::open_in_memory().await.expect("store opens"); - let caps = store.capabilities(); - - assert_eq!(caps.kind, EngineKind::Libsql); - assert!(caps.vectors); - assert!(caps.full_text); - assert!(caps.recursive_queries); - assert!(caps.transactions); - assert!(!caps.encrypted); -} diff --git a/crates/basemyai-core/tests/libsql_smoke.rs b/crates/basemyai-core/tests/libsql_smoke.rs deleted file mode 100644 index 509f940..0000000 --- a/crates/basemyai-core/tests/libsql_smoke.rs +++ /dev/null @@ -1,34 +0,0 @@ -//! Fail-fast : libSQL compile sur Windows ? roundtrip + vecteur natif en mémoire ? - -use libsql::Builder; - -#[tokio::test] -async fn libsql_builds_and_native_vector_works() { - let db = Builder::new_local(":memory:") - .build() - .await - .expect("build in-memory db"); - let conn = db.connect().expect("connect"); - - conn.execute_batch( - "CREATE TABLE item (id INTEGER PRIMARY KEY, emb F32_BLOB(3));\n\ - CREATE INDEX item_idx ON item(libsql_vector_idx(emb, 'metric=cosine'));", - ) - .await - .expect("schema + native vector index"); - - conn.execute("INSERT INTO item (id, emb) VALUES (1, vector('[1,2,3]'))", ()) - .await - .expect("insert vector"); - conn.execute("INSERT INTO item (id, emb) VALUES (2, vector('[9,9,9]'))", ()) - .await - .expect("insert vector 2"); - - let mut rows = conn - .query("SELECT id FROM vector_top_k('item_idx', vector('[1,2,3]'), 1)", ()) - .await - .expect("vector_top_k query"); - let row = rows.next().await.expect("row result").expect("at least one row"); - let id: i64 = row.get(0).expect("get id"); - assert_eq!(id, 1, "le plus proche de [1,2,3] doit être l'item 1"); -} diff --git a/crates/basemyai-core/tests/store.rs b/crates/basemyai-core/tests/store.rs deleted file mode 100644 index 4c24515..0000000 --- a/crates/basemyai-core/tests/store.rs +++ /dev/null @@ -1,371 +0,0 @@ -//! Store libSQL réel : migrations idempotentes, roundtrip, vecteur natif. - -use basemyai_core::{EncryptionKey, Filter, Metric, Migration, Store, Value, libsql}; - -const SCHEMA: [Migration; 1] = [Migration { - version: 1, - up_sql: "CREATE TABLE note (id INTEGER PRIMARY KEY, body TEXT NOT NULL);", -}]; - -const KEEP_SCHEMA: [Migration; 1] = [Migration { - version: 1, - up_sql: "CREATE TABLE keep_flag (id TEXT PRIMARY KEY, keep INTEGER NOT NULL);", -}]; - -const BROKEN_SCHEMA: [Migration; 1] = [Migration { - version: 1, - up_sql: "CREATE TABLE partial_migration (id INTEGER PRIMARY KEY); SELECT * FROM missing_table;", -}]; - -fn now() -> i64 { - use std::time::{SystemTime, UNIX_EPOCH}; - i64::try_from(SystemTime::now().duration_since(UNIX_EPOCH).expect("clock").as_secs()).expect("fits i64") -} - -fn temp_db_path(name: &str) -> std::path::PathBuf { - std::env::temp_dir().join(format!("basemyai-core-{name}-{}-{}.db", std::process::id(), now())) -} - -fn cleanup(path: &std::path::Path) { - let _ = std::fs::remove_file(path); - let _ = std::fs::remove_file(path.with_extension("db-wal")); - let _ = std::fs::remove_file(path.with_extension("db-shm")); -} - -#[tokio::test] -async fn migrate_applies_schema_and_is_idempotent() { - let store = Store::open_in_memory().await.expect("open in-memory"); - store.migrate(&SCHEMA).await.expect("first migrate"); - store.migrate(&SCHEMA).await.expect("re-migrate is a no-op"); - - let conn = store.connect(); - conn.execute("INSERT INTO note (body) VALUES ('hello')", ()) - .await - .expect("insert"); - let mut rows = conn - .query("SELECT body FROM note WHERE id = 1", ()) - .await - .expect("select"); - let row = rows.next().await.expect("row").expect("one row"); - let body: String = row.get(0).expect("get body"); - assert_eq!(body, "hello"); -} - -#[tokio::test] -async fn concurrent_cold_migrations_apply_once() { - let path = temp_db_path("concurrent-migrate"); - let store_a = Store::open(&path, None).await.expect("open A"); - let store_b = Store::open(&path, None).await.expect("open B"); - - let (a, b) = tokio::join!(store_a.migrate(&SCHEMA), store_b.migrate(&SCHEMA)); - a.expect("migrate A"); - b.expect("migrate B"); - - let conn = store_a.connect(); - let mut rows = conn - .query("SELECT COUNT(*) FROM _schema_version WHERE version = 1", ()) - .await - .expect("count schema version"); - let row = rows.next().await.expect("row").expect("one row"); - let count: i64 = row.get(0).expect("count"); - assert_eq!(count, 1, "concurrent cold migration must record the version once"); - - cleanup(&path); -} - -#[tokio::test] -async fn failed_migration_rolls_back_ddl_and_version() { - let store = Store::open_in_memory().await.expect("open"); - let err = store.migrate(&BROKEN_SCHEMA).await; - assert!(err.is_err(), "broken migration must fail"); - - let conn = store.connect(); - let mut rows = conn - .query( - "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'partial_migration'", - (), - ) - .await - .expect("query sqlite_master"); - let row = rows.next().await.expect("row").expect("one row"); - let table_count: i64 = row.get(0).expect("count"); - assert_eq!(table_count, 0, "DDL from a failed migration must roll back"); - - let mut rows = conn - .query( - "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = '_schema_version'", - (), - ) - .await - .expect("query schema version table"); - let row = rows.next().await.expect("row").expect("one row"); - let schema_version_tables: i64 = row.get(0).expect("count"); - assert_eq!( - schema_version_tables, 0, - "failed migration must not persist schema bookkeeping" - ); -} - -#[tokio::test] -async fn native_vector_knn_returns_nearest() { - let store = Store::open_in_memory().await.expect("open"); - store.ensure_vector_table("emb", 3).await.expect("vector table"); - store - .vector_upsert("emb", "a", &[1.0, 2.0, 3.0]) - .await - .expect("upsert a"); - store - .vector_upsert("emb", "b", &[9.0, 9.0, 9.0]) - .await - .expect("upsert b"); - - let hits = store.vector_knn("emb", &[1.0, 2.0, 3.0], 1, None).await.expect("knn"); - assert_eq!(hits.len(), 1); - assert_eq!(hits[0].id, "a", "le plus proche de [1,2,3] est 'a'"); -} - -#[tokio::test] -async fn euclidean_metric_reranks_by_l2_distance() { - let store = Store::open_in_memory().await.expect("open"); - store.ensure_vector_table("emb", 3).await.expect("vector table"); - // 'near' colinéaire à la query (cosinus identique) mais plus loin en L2 ; - // 'exact' est la query elle-même. L'euclidienne doit préférer 'exact'. - store - .vector_upsert("emb", "exact", &[1.0, 0.0, 0.0]) - .await - .expect("up exact"); - store - .vector_upsert("emb", "near", &[5.0, 0.0, 0.0]) - .await - .expect("up near"); - store - .vector_upsert("emb", "far", &[0.0, 1.0, 0.0]) - .await - .expect("up far"); - - let hits = store - .vector_knn_metric("emb", &[1.0, 0.0, 0.0], 2, None, Metric::Euclidean) - .await - .expect("euclidean knn"); - assert_eq!(hits[0].id, "exact", "L2 : le vecteur identique est le plus proche"); - assert!(hits[0].distance < hits[1].distance, "distances triées croissantes"); -} - -#[tokio::test] -async fn hamming_metric_counts_sign_differences() { - let store = Store::open_in_memory().await.expect("open"); - store.ensure_vector_table("emb", 3).await.expect("vector table"); - store - .vector_upsert("emb", "same_signs", &[2.0, 3.0, 4.0]) - .await - .expect("up same"); - store - .vector_upsert("emb", "one_flip", &[-1.0, 3.0, 4.0]) - .await - .expect("up flip"); - - let hits = store - .vector_knn_metric("emb", &[1.0, 1.0, 1.0], 2, None, Metric::Hamming) - .await - .expect("hamming knn"); - // Query toute positive : 'same_signs' (0 diff) avant 'one_flip' (1 diff). - assert_eq!(hits[0].id, "same_signs"); - assert_eq!(hits[0].distance, 0.0); - assert_eq!(hits[1].distance, 1.0); -} - -#[tokio::test] -async fn knn_reports_real_cosine_distance() { - let store = Store::open_in_memory().await.expect("open"); - store.ensure_vector_table("emb", 3).await.expect("vector table"); - store - .vector_upsert("emb", "same", &[1.0, 0.0, 0.0]) - .await - .expect("upsert same"); - store - .vector_upsert("emb", "orth", &[0.0, 1.0, 0.0]) - .await - .expect("upsert orth"); - - let hits = store.vector_knn("emb", &[1.0, 0.0, 0.0], 2, None).await.expect("knn"); - assert_eq!(hits.len(), 2); - // Trié par distance croissante : le vecteur identique d'abord (~0), l'orthogonal ensuite (~1). - assert_eq!(hits[0].id, "same"); - assert!( - hits[0].distance < 0.001, - "distance au vecteur identique ~0, vu {}", - hits[0].distance - ); - assert!( - hits[1].distance > 0.9, - "distance à l'orthogonal ~1, vu {}", - hits[1].distance - ); -} - -#[tokio::test] -async fn knn_filter_oversamples_to_return_k() { - let store = Store::open_in_memory().await.expect("open"); - store.migrate(&KEEP_SCHEMA).await.expect("schema"); - store.ensure_vector_table("emb", 3).await.expect("vector table"); - - // 20 vecteurs ; les plus proches de la requête sont marqués keep=0 (à exclure), - // les bons candidats keep=1 sont volontairement plus loin. Sans sur-échantillonnage, - // le top-k natif ne ramènerait que des keep=0 et le filtre viderait le résultat. - for i in 0..20i64 { - let id = format!("v{i}"); - let drift = (i as f32) * 0.01; - store - .vector_upsert("emb", &id, &[1.0 - drift, drift, 0.0]) - .await - .expect("upsert"); - let keep = i64::from(i >= 10); - store - .connect() - .execute( - "INSERT INTO keep_flag (id, keep) VALUES (?1, ?2)", - libsql::params![id, keep], - ) - .await - .expect("flag"); - } - - let filter = Filter::new( - "t.id IN (SELECT id FROM keep_flag WHERE keep = ?)", - vec![Value::Integer(1)], - ); - let hits = store - .vector_knn("emb", &[1.0, 0.0, 0.0], 5, Some(&filter)) - .await - .expect("knn"); - assert_eq!( - hits.len(), - 5, - "le sur-échantillonnage doit garantir k=5 malgré le filtre" - ); - for h in &hits { - let n: i64 = h.id.trim_start_matches('v').parse().expect("id"); - assert!(n >= 10, "seuls les keep=1 (v10..v19) doivent passer, vu {}", h.id); - } -} - -#[tokio::test] -async fn invalid_table_identifier_is_rejected() { - let store = Store::open_in_memory().await.expect("open"); - assert!(store.ensure_vector_table("emb; DROP TABLE x", 3).await.is_err()); -} - -/// Sans la feature `crypto`, fournir une clé doit échouer proprement. -#[cfg(not(feature = "crypto"))] -#[tokio::test] -async fn encryption_requires_crypto_feature() { - let path = std::env::temp_dir().join("basemyai-enc-pending.db"); - let result = Store::open(&path, Some(EncryptionKey::new("k"))).await; - assert!(result.is_err(), "le chiffrement exige la feature `crypto` (CMake)"); -} - -/// Avec la feature `crypto` : roundtrip chiffré au repos. La bonne clé relit la -/// base ; une mauvaise clé ne peut pas la déchiffrer. -#[cfg(feature = "crypto")] -#[tokio::test] -async fn encrypted_roundtrip_and_wrong_key_fails() { - let path = std::env::temp_dir().join("basemyai-core-crypto-roundtrip.db"); - let _ = std::fs::remove_file(&path); - - // Écrit un vecteur sous la clé correcte, puis ferme. - { - let store = Store::open(&path, Some(EncryptionKey::new("correct-horse"))) - .await - .expect("open encrypted"); - store.ensure_vector_table("emb", 3).await.expect("table"); - store.vector_upsert("emb", "a", &[1.0, 2.0, 3.0]).await.expect("upsert"); - } - - // Rouvre avec la bonne clé : la donnée est relisible. - { - let store = Store::open(&path, Some(EncryptionKey::new("correct-horse"))) - .await - .expect("reopen ok"); - let hits = store.vector_knn("emb", &[1.0, 2.0, 3.0], 1, None).await.expect("knn"); - assert_eq!(hits.len(), 1); - assert_eq!(hits[0].id, "a"); - } - - // Rouvre avec une mauvaise clé : impossible de lire la base chiffrée - // (échec à l'ouverture ou à la première requête). - { - let usable = match Store::open(&path, Some(EncryptionKey::new("wrong-key"))).await { - Ok(store) => store.vector_knn("emb", &[1.0, 2.0, 3.0], 1, None).await.is_ok(), - Err(_) => false, - }; - assert!( - !usable, - "une mauvaise clé ne doit jamais permettre de lire la base chiffrée" - ); - } - - let _ = std::fs::remove_file(&path); -} - -/// `rotate_key` change la clé en place : l'ancienne clé ne relit plus rien, -/// la nouvelle relit la donnée écrite avant la rotation — sur un `Store` -/// **fraîchement rouvert** (cf. la doc de [`Store::rotate_key`] : le pool de -/// lecteurs de l'instance ayant exécuté le rekey devient caduc, l'appelant -/// doit rouvrir). -#[cfg(feature = "crypto")] -#[tokio::test] -async fn rotate_key_changes_encryption_key_in_place() { - let path = std::env::temp_dir().join("basemyai-core-crypto-rotate.db"); - cleanup(&path); - - // Écrit sous la clé d'origine. - { - let store = Store::open(&path, Some(EncryptionKey::new("old-key"))) - .await - .expect("open encrypted"); - store.ensure_vector_table("emb", 3).await.expect("table"); - store.vector_upsert("emb", "a", &[1.0, 2.0, 3.0]).await.expect("upsert"); - - // Rotation en place, sans recréer le fichier. - store - .rotate_key(EncryptionKey::new("new-key")) - .await - .expect("rotate_key succeeds on an encrypted store"); - // Cette instance est désormais caduque (cf. doc rotate_key) : on la - // laisse tomber ici sans plus rien lire/écrire dessus. - } - - // L'ancienne clé ne doit plus permettre de lire la base. - { - let usable = match Store::open(&path, Some(EncryptionKey::new("old-key"))).await { - Ok(store) => store.vector_knn("emb", &[1.0, 2.0, 3.0], 1, None).await.is_ok(), - Err(_) => false, - }; - assert!(!usable, "l'ancienne clé ne doit plus déchiffrer la base après rotation"); - } - - // La nouvelle clé relit intégralement la donnée écrite avant rotation. - { - let store = Store::open(&path, Some(EncryptionKey::new("new-key"))) - .await - .expect("reopen with new key"); - let hits = store.vector_knn("emb", &[1.0, 2.0, 3.0], 1, None).await.expect("knn"); - assert_eq!(hits.len(), 1); - assert_eq!(hits[0].id, "a"); - } - - cleanup(&path); -} - -/// `rotate_key` sur un store non chiffré n'a rien à rotater : erreur propre, -/// pas de tentative de `PRAGMA rekey` sur une base en clair. -#[cfg(feature = "crypto")] -#[tokio::test] -async fn rotate_key_rejects_unencrypted_store() { - let store = Store::open_in_memory().await.expect("open in-memory"); - let err = store - .rotate_key(EncryptionKey::new("some-key")) - .await - .expect_err("rotate_key on an unencrypted store must fail"); - assert!(matches!(err, basemyai_core::CoreError::Encryption)); -} diff --git a/crates/basemyai-engine/Cargo.toml b/crates/basemyai-engine/Cargo.toml index 814a762..15a5c44 100644 --- a/crates/basemyai-engine/Cargo.toml +++ b/crates/basemyai-engine/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "basemyai-engine" -description = "Home-grown LSM-tree storage foundation for BaseMyAI's native engine (ADR-024/ADR-025) — internal, not published, not yet wired into basemyai-core." +description = "Home-grown LSM-tree storage engine for BaseMyAI native backend (ADR-024/ADR-025/ADR-032) — internal, not published." version.workspace = true edition.workspace = true rust-version.workspace = true @@ -9,15 +9,6 @@ authors.workspace = true repository.workspace = true publish = false -[features] -default = [] -# Gates anything this crate exposes to the rest of the workspace once wiring -# begins (EngineKind::Native, MemoryStore impl — a separate, later N2 item). -# Not used yet: today this crate only builds and tests itself. See -# docs/adr/ADR-025-native-engine-storage-foundation.md and -# docs/PLAN-NATIVE-ENGINE.md §3.1. -engine-native = [] - [dependencies] thiserror.workspace = true tracing.workspace = true diff --git a/crates/basemyai-engine/src/idx/graph/mod.rs b/crates/basemyai-engine/src/idx/graph/mod.rs index d300717..89a7cd5 100644 --- a/crates/basemyai-engine/src/idx/graph/mod.rs +++ b/crates/basemyai-engine/src/idx/graph/mod.rs @@ -6,9 +6,8 @@ //! (zero-drift) between an in-RAM flavor ([`RamGraph`]) and a KV-persisted //! flavor ([`PersistentGraph`]). //! -//! This is a **literal behavioral port** of `basemyai`'s `graph_traverse` -//! (a recursive CTE over `entity`/`edge` SQL tables, -//! `crates/basemyai/src/storage/libsql_store.rs`) — see [`traverse`]'s +//! This is a **literal behavioral port** of the original `graph_traverse` +//! (a recursive CTE over `entity`/`edge` tables) — see [`traverse`]'s //! module doc for the exact semantics preserved, and //! `tests/graph_parity.rs` for the ported `crates/basemyai/tests/graph.rs` //! scenarios, run against both flavors. diff --git a/crates/basemyai-engine/src/idx/graph/persistent.rs b/crates/basemyai-engine/src/idx/graph/persistent.rs index d6a0e06..f49413d 100644 --- a/crates/basemyai-engine/src/idx/graph/persistent.rs +++ b/crates/basemyai-engine/src/idx/graph/persistent.rs @@ -134,6 +134,38 @@ impl PersistentGraph { Ok(out) } + /// The entity block for `(agent, id)`, if any — the point lookup behind + /// an idempotent import's "already present?" check (ADR-032), public + /// mirror of the private [`EngineGraphProvider::entity`] read the BFS + /// traversal uses. + pub fn entity(&self, engine: &Engine, agent: &str, id: &str) -> Result> { + let key = graph_index::entity_key(agent, id)?; + let Some(bytes) = engine.get(key.as_bytes())? else { + return Ok(None); + }; + Ok(Some(entity::decode(&bytes)?)) + } + + /// Every edge of `agent`, as `(src, relation, dst, meta)` tuples in + /// ascending key-byte order (the structural scan order) — the + /// whole-agent enumeration behind exporting an agent's graph (ADR-032), + /// mirror of [`Self::entities`]. A malformed key inside the reserved + /// keyspace is a hard error, never silently skipped. + pub fn edges(&self, engine: &Engine, agent: &str) -> Result> { + let prefix = graph_index::edge_agent_prefix(agent)?; + let entries = engine.scan_prefix(&prefix)?; + let mut out = Vec::with_capacity(entries.len()); + for (key, value) in entries { + let Some((src, relation, dst)) = graph_index::edge_src_relation_dst(prefix.len(), key.as_bytes()) else { + return Err(EngineError::CorruptGraphEdge { + reason: format!("malformed edge key under the (agent={agent:?}) scan prefix"), + }); + }; + out.push((src, relation, dst, edge::decode(&value)?)); + } + Ok(out) + } + /// The attributes of the edge `(agent, src) --relation--> dst`, if any — /// what a caller needs to reproduce the libSQL upsert's /// `ON CONFLICT ... DO UPDATE SET weight` semantics (update the weight, diff --git a/crates/basemyai-engine/src/idx/graph/traverse.rs b/crates/basemyai-engine/src/idx/graph/traverse.rs index 0fe6618..76abe88 100644 --- a/crates/basemyai-engine/src/idx/graph/traverse.rs +++ b/crates/basemyai-engine/src/idx/graph/traverse.rs @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: BUSL-1.1 +// SPDX-License-Identifier: BUSL-1.1 //! Shared BFS traversal algorithm (N4). Written once against a //! [`GraphProvider`] abstraction so the in-RAM [`super::ram::RamGraph`] and //! the KV-persisted [`super::persistent::PersistentGraph`] read exactly the @@ -6,9 +6,8 @@ //! `idx::vector::graph`'s shared Vamana algorithm already established //! between its RAM and persistent flavors. //! -//! This is a **literal behavioral port** of `basemyai`'s -//! `LibsqlMemoryStore::graph_traverse` (a recursive CTE over `entity`/`edge` -//! SQL tables, `crates/basemyai/src/storage/libsql_store.rs`), not a +//! This is a **literal behavioral port** of the original graph traversal +//! (a recursive CTE over `entity`/`edge` tables), not a //! reinvention — `crates/basemyai/tests/graph.rs` is the spec of behavior to //! equal exactly (ported scenario-for-scenario into //! `crates/basemyai-engine/tests/graph_parity.rs`), and one deliberate diff --git a/crates/basemyai-engine/src/idx/memory/persistent.rs b/crates/basemyai-engine/src/idx/memory/persistent.rs index 5a42464..2553061 100644 --- a/crates/basemyai-engine/src/idx/memory/persistent.rs +++ b/crates/basemyai-engine/src/idx/memory/persistent.rs @@ -304,6 +304,15 @@ impl PersistentMemoryIndex { Ok(out) } + /// Total number of memory records across **every** agent — a plain count + /// over the whole reserved `idx/memory/rec/` keyspace, distinct from + /// [`Self::scan_agent`]'s per-agent prefix. The container-level read + /// behind a CLI/diagnostic "how many memories total" (ADR-032) — no + /// per-record decode needed, just the entry count. + pub fn count_all(&self, engine: &Engine) -> Result { + Ok(engine.scan_prefix(memory_index::RECORD_PREFIX)?.len() as u64) + } + /// Resolves a vector-index id back to the `(agent, id)` pair owning it, /// or `None` for ids with no mapping (e.g. a hit whose memory was /// forgotten by an interrupted earlier attempt). diff --git a/crates/basemyai-engine/src/key/mod.rs b/crates/basemyai-engine/src/key/mod.rs index 35dc958..9663708 100644 --- a/crates/basemyai-engine/src/key/mod.rs +++ b/crates/basemyai-engine/src/key/mod.rs @@ -243,6 +243,25 @@ pub mod graph_index { let dst = String::from_utf8(dst_bytes.to_vec()).ok()?; Some((relation, dst)) } + + /// Extracts `(src, relation, dst)` from a full edge key, given the byte + /// length of the exact **agent** prefix it was scanned under (i.e. + /// `edge_agent_prefix(agent).len()`) — the whole-agent enumeration read + /// behind exporting an agent's graph (ADR-032). Same wire-distrust + /// discipline as [`edge_relation_dst`]: both length counts are bounded + /// against the actual remaining bytes before any slicing, malformed or + /// truncated keys return `None` rather than panicking. + #[must_use] + pub fn edge_src_relation_dst(prefix_len: usize, key_bytes: &[u8]) -> Option<(String, String, String)> { + let suffix = key_bytes.get(prefix_len..)?; + let src_len_bytes: [u8; 4] = suffix.get(0..4)?.try_into().ok()?; + let src_len = u32::from_be_bytes(src_len_bytes) as usize; + let rest = suffix.get(4..)?; + let src_bytes = rest.get(..src_len)?; + let src = String::from_utf8(src_bytes.to_vec()).ok()?; + let (relation, dst) = edge_relation_dst(0, rest.get(src_len..)?)?; + Some((src, relation, dst)) + } } /// Keyspace reserved for the native memory index (N5.1, ADR-027 §2). Like diff --git a/crates/basemyai-mcp/Cargo.toml b/crates/basemyai-mcp/Cargo.toml index c006ee9..98cc05d 100644 --- a/crates/basemyai-mcp/Cargo.toml +++ b/crates/basemyai-mcp/Cargo.toml @@ -9,9 +9,8 @@ authors.workspace = true repository.workspace = true [features] -default = ["embed", "crypto", "stdio", "http"] +default = ["embed", "stdio", "http"] embed = ["basemyai/embed"] -crypto = ["basemyai/crypto"] stdio = ["rmcp/transport-io"] http = ["rmcp/transport-streamable-http-server", "dep:axum", "dep:tower", "dep:tower-http"] # `test-util` : InMemoryProvider + embedder déterministe (ni CMake ni Candle). diff --git a/crates/basemyai-mcp/src/lib.rs b/crates/basemyai-mcp/src/lib.rs index cdba9fd..2768748 100644 --- a/crates/basemyai-mcp/src/lib.rs +++ b/crates/basemyai-mcp/src/lib.rs @@ -41,8 +41,7 @@ pub use tools::{ RecallResult, RememberParams, RememberResult, StatsParams, StatsResult, WatchParams, WatchResult, }; -#[cfg(feature = "crypto")] -pub use provider::EncryptedFileProvider; +pub use provider::FileProvider; #[cfg(feature = "test-util")] pub use provider::InMemoryProvider; diff --git a/crates/basemyai-mcp/src/main.rs b/crates/basemyai-mcp/src/main.rs index 5513c32..94278c0 100644 --- a/crates/basemyai-mcp/src/main.rs +++ b/crates/basemyai-mcp/src/main.rs @@ -2,8 +2,8 @@ //! Binaire `basemyai-mcp` : serveur MCP de production. //! //! Wiring de prod : provisioning hardware-aware de l'embedder (Candle) → provider -//! libSQL **chiffré** → serveur MCP sur **stdio** (défaut, intégration agent local) -//! ou **HTTP** local (`BASEMYAI_MCP_TRANSPORT=http`). +//! natif **chiffré** (ADR-032) → serveur MCP sur **stdio** (défaut, intégration +//! agent local) ou **HTTP** local (`BASEMYAI_MCP_TRANSPORT=http`). //! //! ## Plug-and-play (le cas d'usage principal) //! @@ -29,12 +29,12 @@ async fn main() -> Result<(), Box> { run().await } -#[cfg(all(feature = "crypto", feature = "embed"))] +#[cfg(feature = "embed")] async fn run() -> Result<(), Box> { use std::sync::Arc; - use basemyai_core::{CandleEmbedder, Embedder, EncryptionKey}; - use basemyai_mcp::{Config, EncryptedFileProvider, McpServer, run_http, run_stdio}; + use basemyai_core::{CandleEmbedder, Embedder}; + use basemyai_mcp::{Config, FileProvider, McpServer, run_http, run_stdio}; // Logs sur STDERR uniquement : en stdio, STDOUT est le canal MCP — y écrire // corromprait le protocole. `with_ansi(false)` : sortie propre dans les logs d'hôte. @@ -46,27 +46,22 @@ async fn run() -> Result<(), Box> { let config = Config::from_env()?; let transport = std::env::var("BASEMYAI_MCP_TRANSPORT").unwrap_or_else(|_| "stdio".to_string()); - // Clé de chiffrement de la base (obligatoire — chiffrement au repos, ADR-007). + // Clé de chiffrement de la base (obligatoire — chiffrement au repos, ADR-007 ; + // le backend natif chiffre sans CMake, ADR-030). let db_key = std::env::var("BASEMYAI_DB_KEY").map_err(|_| "BASEMYAI_DB_KEY is required (encryption is mandatory)")?; - // Chemin de la base partagée : ~/.basemyai/memory.bmai (isolation par agent au niveau SQL). + // Chemin de la base partagée : ~/.basemyai/memory.bmai (isolation par agent + // structurelle sous préfixe de clé, ADR-006/ADR-027). let home = dirs::home_dir().ok_or("cannot resolve home directory")?; let db_path = home.join(".basemyai").join("memory.bmai"); - if let Some(parent) = db_path.parent() { - std::fs::create_dir_all(parent)?; - } // Embedder : provisioning hardware-aware. Fetch du modèle SEULEMENT si consenti. let consent = std::env::var("BASEMYAI_FETCH").map(|v| v == "1").unwrap_or(false); let mp = basemyai::provision(consent).await?; let embedder: Arc = Arc::new(CandleEmbedder::load(&mp.model_path, mp.device)?); - let provider = Arc::new(EncryptedFileProvider::new( - db_path, - EncryptionKey::new(db_key), - embedder, - )); + let provider = Arc::new(FileProvider::open(db_path, db_key, embedder).await?); let server = McpServer::new(provider, config.clone()); match transport.as_str() { @@ -86,11 +81,11 @@ async fn run() -> Result<(), Box> { Ok(()) } -#[cfg(not(all(feature = "crypto", feature = "embed")))] +#[cfg(not(feature = "embed"))] async fn run() -> Result<(), Box> { Err( - "basemyai-mcp must be built with the `crypto` and `embed` features for the production server \ - (they are in the default feature set)" + "basemyai-mcp must be built with the `embed` feature for the production server \ + (it is in the default feature set)" .into(), ) } diff --git a/crates/basemyai-mcp/src/provider.rs b/crates/basemyai-mcp/src/provider.rs index a4e52b0..ad6fc40 100644 --- a/crates/basemyai-mcp/src/provider.rs +++ b/crates/basemyai-mcp/src/provider.rs @@ -4,10 +4,11 @@ //! pool en ouvre une par agent via un [`MemoryProvider`] injecté. //! //! Deux implémentations : -//! - [`EncryptedFileProvider`] (feature `crypto`) : production. Tous les agents -//! partagent **un** fichier libSQL chiffré ; l'isolation reste garantie au -//! niveau SQL par `agent_id`. L'embedder (Candle, lourd) est **partagé** via -//! `Arc` — un seul modèle en mémoire pour tous les agents. +//! - [`FileProvider`] : production. Un store natif chiffré partagé +//! (ADR-032) ; tous les agents partagent **un** store, l'isolation reste +//! garantie structurellement par préfixe de clé. L'embedder (Candle, +//! lourd) est **partagé** via `Arc` — un seul modèle en mémoire pour tous +//! les agents. //! - [`InMemoryProvider`] (feature `test-util`) : tests/spikes, sans CMake ni //! modèle. @@ -25,51 +26,65 @@ pub trait MemoryProvider: Send + Sync { async fn open(&self, agent: AgentId) -> Result; } -/// Provider de production : un fichier libSQL chiffré partagé, embedder partagé. -#[cfg(feature = "crypto")] -pub struct EncryptedFileProvider { - store_path: std::path::PathBuf, - key: basemyai_core::EncryptionKey, +/// Provider de production (ADR-032) : un store natif chiffré partagé, +/// embedder partagé. Contrairement à un pool de connexions par agent, le +/// moteur natif est mono-écrivain exclusif (ADR-025) : le store est ouvert +/// **une seule fois** ici et partagé (`Arc`) entre tous les agents — jamais +/// rouvert par agent. +pub struct FileProvider { + store: std::sync::Arc, embedder: std::sync::Arc, } -#[cfg(feature = "crypto")] -impl EncryptedFileProvider { - /// Construit le provider à partir du chemin de base chiffrée, de la clé de - /// chiffrement et d'un embedder partagé (résolu par le setup hardware-aware). - #[must_use] - pub fn new( +impl FileProvider { + /// Ouvre (au besoin crée) un store natif chiffré à `store_path`. + /// + /// # Errors + /// Erreur de stockage si l'ouverture (recovery WAL, chargement des méta + /// d'index) échoue, ou si la clé est fausse. + pub async fn open( store_path: std::path::PathBuf, - key: basemyai_core::EncryptionKey, + key: String, embedder: std::sync::Arc, - ) -> Self { - Self { - store_path, - key, - embedder, + ) -> Result { + use basemyai::MemoryError; + + if let Some(parent) = store_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + crate::McpError::Memory(MemoryError::Core(basemyai_core::CoreError::Storage(format!( + "création du répertoire parent de '{}' : {e}", + store_path.display() + )))) + })?; } + let path = store_path.clone(); + let store = + tokio::task::spawn_blocking(move || basemyai::storage::NativeMemoryStore::open_encrypted(&path, &key)) + .await + .map_err(|e| { + crate::McpError::Memory(MemoryError::Core(basemyai_core::CoreError::Storage(format!( + "ouverture du store natif interrompue : {e}" + )))) + })??; + Ok(Self { + store: std::sync::Arc::new(store), + embedder, + }) } } -#[cfg(feature = "crypto")] #[async_trait::async_trait] -impl MemoryProvider for EncryptedFileProvider { +impl MemoryProvider for FileProvider { async fn open(&self, agent: AgentId) -> Result { - use basemyai::MemoryError; - let store = basemyai_core::Store::open(&self.store_path, Some(self.key.clone())) - .await - .map_err(|e| crate::McpError::Memory(MemoryError::from(e)))?; let embedder = Box::new(SharedEmbedder(std::sync::Arc::clone(&self.embedder))); - Ok(Memory::open(store, embedder, agent).await?) + Ok(Memory::from_native_store(std::sync::Arc::clone(&self.store), embedder, agent).await?) } } /// Adaptateur : expose un `Arc` partagé comme un `Box` /// propre à chaque [`Memory`], sans cloner le modèle sous-jacent (Candle, lourd). -#[cfg(feature = "crypto")] struct SharedEmbedder(std::sync::Arc); -#[cfg(feature = "crypto")] impl basemyai_core::Embedder for SharedEmbedder { fn embed(&self, text: &str) -> basemyai_core::Result> { self.0.embed(text) @@ -85,7 +100,7 @@ impl basemyai_core::Embedder for SharedEmbedder { } } -/// Provider de test : base `:memory:` non chiffrée + embedder déterministe. +/// Provider de test : base éphémère non chiffrée + embedder déterministe. /// **Jamais en production** (vecteurs non sémantiques, mémoire éphémère). #[cfg(feature = "test-util")] #[derive(Default)] diff --git a/crates/basemyai-rest/Cargo.toml b/crates/basemyai-rest/Cargo.toml index 597dd29..f18950e 100644 --- a/crates/basemyai-rest/Cargo.toml +++ b/crates/basemyai-rest/Cargo.toml @@ -17,9 +17,8 @@ name = "basemyai-rest" path = "src/main.rs" [features] -default = ["crypto", "embed"] -embed = ["basemyai/embed"] -crypto = ["basemyai/crypto"] +default = ["embed"] +embed = ["basemyai/embed"] # `test-util` : InMemoryProvider + embedder déterministe (ni CMake ni Candle). test-util = ["basemyai/test-util"] diff --git a/crates/basemyai-rest/src/lib.rs b/crates/basemyai-rest/src/lib.rs index 2537653..2b76ca9 100644 --- a/crates/basemyai-rest/src/lib.rs +++ b/crates/basemyai-rest/src/lib.rs @@ -19,7 +19,6 @@ pub use provider::MemoryProvider; pub use routes::build_app; pub use state::AppState; -#[cfg(feature = "crypto")] -pub use provider::EncryptedFileProvider; +pub use provider::FileProvider; #[cfg(feature = "test-util")] pub use provider::InMemoryProvider; diff --git a/crates/basemyai-rest/src/main.rs b/crates/basemyai-rest/src/main.rs index 9a33364..b2a3847 100644 --- a/crates/basemyai-rest/src/main.rs +++ b/crates/basemyai-rest/src/main.rs @@ -11,12 +11,12 @@ async fn main() -> Result<(), Box> { run().await } -#[cfg(all(feature = "crypto", feature = "embed"))] +#[cfg(feature = "embed")] async fn run() -> Result<(), Box> { use std::sync::Arc; - use basemyai_core::{CandleEmbedder, Device, Embedder, EncryptionKey}; - use basemyai_rest::{AppState, Config, EncryptedFileProvider, build_app}; + use basemyai_core::{CandleEmbedder, Device, Embedder}; + use basemyai_rest::{AppState, Config, FileProvider, build_app}; use tokio::net::TcpListener; let config = Config::from_env()?; @@ -27,16 +27,14 @@ async fn run() -> Result<(), Box> { .into()); } - // Clé de chiffrement de la base (obligatoire — chiffrement au repos, ADR-007). + // Clé de chiffrement de la base (obligatoire — chiffrement au repos, ADR-007 ; + // le backend natif chiffre sans CMake, ADR-030). let db_key = config .db_key .clone() .ok_or("BASEMYAI_REST_DB_KEY or BASEMYAI_DB_KEY is required (encryption is mandatory)")?; let db_path = config.db_path.clone(); - if let Some(parent) = db_path.parent() { - std::fs::create_dir_all(parent)?; - } // Embedder : modèle local si fourni, sinon provisioning hardware-aware // (fetch seulement si consenti). @@ -47,11 +45,8 @@ async fn run() -> Result<(), Box> { Arc::new(CandleEmbedder::load(&mp.model_path, mp.device)?) }; - let provider = Arc::new(EncryptedFileProvider::new( - db_path, - EncryptionKey::new(db_key), - embedder, - )); + let provider: Arc = + Arc::new(FileProvider::open(db_path, db_key, embedder).await?); let addr = config.socket_addr(); let app = build_app(AppState::new(provider, config)); @@ -61,7 +56,11 @@ async fn run() -> Result<(), Box> { Ok(()) } -#[cfg(not(all(feature = "crypto", feature = "embed")))] +#[cfg(not(feature = "embed"))] async fn run() -> Result<(), Box> { - Err("basemyai-rest must be built with the `crypto` and `embed` features for the production server".into()) + Err( + "basemyai-rest must be built with the `embed` feature for the production server \ + (it is in the default feature set)" + .into(), + ) } diff --git a/crates/basemyai-rest/src/provider.rs b/crates/basemyai-rest/src/provider.rs index e760be8..421d685 100644 --- a/crates/basemyai-rest/src/provider.rs +++ b/crates/basemyai-rest/src/provider.rs @@ -14,53 +14,63 @@ pub trait MemoryProvider: Send + Sync { async fn open(&self, agent: AgentId) -> Result; } -/// Provider de production : un fichier libSQL chiffré partagé, embedder partagé. -#[cfg(feature = "crypto")] -pub struct EncryptedFileProvider { - store_path: std::path::PathBuf, - key: basemyai_core::EncryptionKey, +/// Provider de production : un store natif chiffré partagé, embedder partagé +/// (ADR-032). Contrairement à un pool de connexions par agent, le moteur +/// natif est mono-écrivain exclusif (ADR-025) : le store est ouvert **une +/// seule fois** ici et partagé (`Arc`) entre tous les agents — jamais rouvert +/// par agent. +pub struct FileProvider { + store: std::sync::Arc, embedder: std::sync::Arc, } -#[cfg(feature = "crypto")] -impl EncryptedFileProvider { - /// Construit le provider (chemin base chiffrée, clé, embedder partagé). - #[must_use] - pub fn new( +impl FileProvider { + /// Ouvre (au besoin crée) un store natif chiffré à `store_path`. + /// + /// # Errors + /// Erreur de stockage si l'ouverture (recovery WAL, chargement des méta + /// d'index) échoue, ou si la clé est fausse. + pub async fn open( store_path: std::path::PathBuf, - key: basemyai_core::EncryptionKey, + key: String, embedder: std::sync::Arc, - ) -> Self { - Self { - store_path, - key, - embedder, + ) -> Result { + if let Some(parent) = store_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + basemyai::MemoryError::Core(basemyai_core::CoreError::Storage(format!( + "création du répertoire parent de '{}' : {e}", + store_path.display() + ))) + })?; } + let path = store_path.clone(); + let store = + tokio::task::spawn_blocking(move || basemyai::storage::NativeMemoryStore::open_encrypted(&path, &key)) + .await + .map_err(|e| { + basemyai::MemoryError::Core(basemyai_core::CoreError::Storage(format!( + "ouverture du store natif interrompue : {e}" + ))) + })??; + Ok(Self { + store: std::sync::Arc::new(store), + embedder, + }) } } -#[cfg(feature = "crypto")] #[async_trait::async_trait] -impl MemoryProvider for EncryptedFileProvider { +impl MemoryProvider for FileProvider { async fn open(&self, agent: AgentId) -> Result { - let store = basemyai_core::Store::open(&self.store_path, Some(self.key.clone())) - .await - .map_err(basemyai::MemoryError::from)?; - Memory::open( - store, - Box::new(SharedEmbedder(std::sync::Arc::clone(&self.embedder))), - agent, - ) - .await + let embedder = Box::new(SharedEmbedder(std::sync::Arc::clone(&self.embedder))); + Memory::from_native_store(std::sync::Arc::clone(&self.store), embedder, agent).await } } /// Adaptateur : `Arc` partagé vu comme `Box` par /// chaque [`Memory`], sans cloner le modèle sous-jacent. -#[cfg(feature = "crypto")] struct SharedEmbedder(std::sync::Arc); -#[cfg(feature = "crypto")] impl basemyai_core::Embedder for SharedEmbedder { fn embed(&self, text: &str) -> basemyai_core::Result> { self.0.embed(text) @@ -76,7 +86,7 @@ impl basemyai_core::Embedder for SharedEmbedder { } } -/// Provider de test : base `:memory:` non chiffrée + embedder déterministe. +/// Provider de test : base éphémère non chiffrée + embedder déterministe. #[cfg(feature = "test-util")] #[derive(Default)] pub struct InMemoryProvider; diff --git a/crates/basemyai/Cargo.toml b/crates/basemyai/Cargo.toml index fa1d06f..0a85223 100644 --- a/crates/basemyai/Cargo.toml +++ b/crates/basemyai/Cargo.toml @@ -1,6 +1,7 @@ [package] name = "basemyai" description = "Local memory engine for AI agents: 4 memory layers, temporal RAG, per-agent isolation, mandatory encryption. Built on basemyai-core." +readme = "README.md" version.workspace = true edition.workspace = true rust-version.workspace = true @@ -12,29 +13,24 @@ keywords = ["memory", "agent", "ai", "rag", "vector-search"] categories = ["database-implementations", "algorithms"] [features] -# SQLite est non-optionnel dans le core. -# `embed` : active l'inférence Candle (lourd, exige les fichiers modèle). -# `crypto` : chiffrement libSQL au repos — obligatoire pour Memory::open sur fichier -# (ADR-007). Exige CMake au build time. +# `embed` : active l'inférence Candle (lourd, exige les fichiers modèle). +# Le moteur natif (ADR-024/025/030) est l'**unique** backend depuis ADR-032 +# (libSQL retiré du workspace) — plus un choix de feature, un dépendance +# directe. default = [] embed = ["basemyai-core/embed"] -crypto = ["basemyai-core/crypto"] # `test-util` : expose `HashEmbedder` + `Memory::open_in_memory` — embedder # déterministe sans modèle pour tests/spikes des bindings (ni Candle ni CMake). # `tempfile` sert `NativeMemoryStore::open_ephemeral` (l'équivalent natif # d'`open_in_memory` — le moteur LSM n'a pas de mode in-memory). # Jamais en production. test-util = ["dep:tempfile"] -# Backend natif basemyai-engine (ADR-024, câblage `MemoryStore` ADR-027/N5.1). -# Jamais par défaut : libSQL reste le backend V1 tant que N5.6 (ADR de bascule) -# n'est pas instruit. -engine-native = ["dep:basemyai-engine"] [dependencies] basemyai-core = { version = "0.1.0", path = "../basemyai-core" } -# Backend natif (ADR-024/ADR-027) — feature `engine-native`. Interne, non -# publié (même précédent que la dépendance optionnelle de basemyai-core). -basemyai-engine = { path = "../basemyai-engine", optional = true } +# Backend natif (ADR-024/ADR-027/ADR-032) — dépendance directe, non optionnelle +# depuis la suppression de libSQL. Interne, non publié. +basemyai-engine = { path = "../basemyai-engine" } tempfile = { workspace = true, optional = true } thiserror.workspace = true serde.workspace = true @@ -55,6 +51,7 @@ sha2 = { workspace = true } [dev-dependencies] # Accès direct aux types du core dans les tests d'intégration (fakes pour la DI). basemyai-core = { path = "../basemyai-core" } +tempfile.workspace = true [lints] workspace = true diff --git a/crates/basemyai/README.md b/crates/basemyai/README.md new file mode 100644 index 0000000..649b304 --- /dev/null +++ b/crates/basemyai/README.md @@ -0,0 +1,146 @@ +# basemyai + +[![crates.io](https://img.shields.io/crates/v/basemyai?color=dca282)](https://crates.io/crates/basemyai) +[![docs.rs](https://img.shields.io/docsrs/basemyai)](https://docs.rs/basemyai) +[![License](https://img.shields.io/crates/l/basemyai)](https://github.com/basemyai/basemyai/blob/main/LICENSE) + +**Local memory engine for AI agents** — four memory layers, temporal RAG, per-agent isolation, knowledge graph, adaptive forgetting, and mandatory encryption at rest. + +Built in Rust on top of [`basemyai-core`](https://crates.io/crates/basemyai-core). Everything stays on-device in a single encrypted `.bmai` file powered by the native BaseMyAI storage engine. + +> For the full product overview, bindings (Python, Node, REST), and CLI, see the [main repository](https://github.com/basemyai/basemyai). + +## What this crate provides + +`basemyai` is the **memory semantics** layer: + +| Concept | Module | +|---|---| +| Four memory layers | `memory::MemoryLayer` | +| Per-agent isolation | `memory::AgentId` | +| Temporal validity | `temporal::Validity` | +| Vector + hybrid recall | `memory::Memory` | +| Knowledge graph | `cognition::Graph` | +| Episode → fact consolidation | `cognition::consolidate` | +| Multi-signal fusion (RRF) | `retrieval::rrf_fuse` | +| Hardware-aware provisioning | `provision` | +| Background maintenance | `maintenance` | + +`basemyai-core` provides the business-agnostic foundation (storage engine, embeddings, encryption primitives). This crate adds agent memory meaning on top. + +## Features + +```toml +[dependencies] +basemyai = "0.1" + +# Enable Candle in-process embeddings (all-MiniLM-L6-v2) +basemyai = { version = "0.1", features = ["embed"] } +``` + +| Feature | Description | +|---|---| +| `embed` (recommended) | Candle BERT embedder (`all-MiniLM-L6-v2`, 384d) | +| `test-util` | `HashEmbedder` + `Memory::open_in_memory` for tests only | + +The native storage engine (`basemyai-engine`) is always included — it is the sole backend since [ADR-032](https://github.com/basemyai/basemyai/blob/main/docs/adr/ADR-032-native-only.md). + +## Quick start + +```rust +use basemyai::{AgentId, Memory, MemoryLayer}; +use basemyai_core::{CandleEmbedder, Device, EncryptionKey, Embedder}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let key = EncryptionKey::new("your-secret-key"); + let agent = AgentId::new("agent-42").expect("non-empty id"); + let model_path = dirs::home_dir() + .unwrap() + .join(".basemyai/models/all-MiniLM-L6-v2"); + let embedder: Box = + Box::new(CandleEmbedder::load(&model_path, Device::Cpu)?); + + let mem = Memory::open_native("./agent.bmai", &key, embedder, agent).await?; + + mem.remember("User is on the Pro plan.", MemoryLayer::Semantic).await?; + + let hits = mem.recall("billing plan", 5).await?; + for hit in &hits { + println!("[{}] {:.3} {}", hit.layer.table(), hit.score, hit.text); + } + + Ok(()) +} +``` + +### Memory layers + +| Layer | Holds | Lifetime | +|---|---|---| +| `ShortTerm` | Working context for the active session | Expires fast | +| `Episodic` | Events and interactions | Time-bounded | +| `Procedural` | Learned workflows and skills | Long-lived | +| `Semantic` | Facts and knowledge | Until invalidated | + +Every record carries `valid_from` / `valid_until` — memory is temporal by construction. + +### Knowledge graph + +```rust +let graph = mem.graph(); +graph.add_entity("alice", "person", "Alice").await?; +graph.add_entity("acme", "org", "Acme Corp").await?; +graph.add_edge("alice", "works_at", "acme", 1.0).await?; + +let reached = graph.traverse("alice", 2).await?; +``` + +### Multi-signal retrieval (RRF) + +```rust +use basemyai::{Ranking, rrf_fuse}; + +let fused = rrf_fuse(&[ + Ranking { signal: "vector".into(), ids: vec![/* ... */] }, + Ranking { signal: "graph".into(), ids: vec![/* ... */] }, +], 10); +``` + +## Encryption + +Encryption at rest is **mandatory**. Open with `EncryptionKey::new(...)` — the key is supplied at open time and never stored or transmitted. Data on disk uses the native envelope scheme ([ADR-030](https://github.com/basemyai/basemyai/blob/main/docs/adr/ADR-030-native-encryption-at-rest.md), XChaCha20-Poly1305). + +## Hardware-aware setup + +The embedder never auto-downloads. Provision the baseline model explicitly: + +```rust +let provision = basemyai::provision(/* consent_to_fetch */ true).await?; +// provision.model_path, provision.device +``` + +Or use the CLI: `basemyai setup --fetch`. + +## Consumption surfaces + +The same Rust core is also available via: + +| Surface | Package | +|---|---| +| Python SDK | [`basemyai` on PyPI](https://pypi.org/project/basemyai/) | +| Node SDK | [`basemyai` on npm](https://www.npmjs.com/package/basemyai) | +| REST sidecar | `basemyai-rest` (binary, not on crates.io) | +| CLI | `basemyai-cli` (binary, not on crates.io) | + +## Documentation + +- [docs.rs](https://docs.rs/basemyai) +- [Main README](https://github.com/basemyai/basemyai) +- [CLI reference](https://github.com/basemyai/basemyai/blob/main/docs/cli.md) +- [Architecture decisions (ADR)](https://github.com/basemyai/basemyai/blob/main/docs/ADR.md) +- [BaseMyAI is not a vector DB](https://github.com/basemyai/basemyai/blob/main/docs/not-a-vector-db.md) + +## License + +Source-available under the [Business Source License 1.1](https://github.com/basemyai/basemyai/blob/main/LICENSE) (converts to Apache-2.0 four years after each version's release). See [ADR-031](https://github.com/basemyai/basemyai/blob/main/docs/adr/ADR-031-unified-busl-license.md). diff --git a/crates/basemyai/examples/llm_consolidation.rs b/crates/basemyai/examples/llm_consolidation.rs index d66a632..a4d1661 100644 --- a/crates/basemyai/examples/llm_consolidation.rs +++ b/crates/basemyai/examples/llm_consolidation.rs @@ -6,7 +6,7 @@ //! Run: `cargo run --example llm_consolidation -p basemyai` use basemyai::{AgentId, LlmInference, Memory, MemoryLayer, consolidate}; -use basemyai_core::{Embedder, Store}; +use basemyai_core::{Embedder, EncryptionKey}; struct FakeEmbedder; @@ -50,9 +50,10 @@ impl LlmInference for FakeLlm { #[tokio::main] async fn main() -> Result<(), Box> { - let store = Store::open_in_memory().await?; + let dir = tempfile::tempdir()?; + let key = EncryptionKey::new("demo-key-do-not-use-in-prod"); let agent = AgentId::new("consolidation-demo").expect("non-empty id"); - let memory = Memory::open(store, Box::new(FakeEmbedder), agent).await?; + let memory = Memory::open_native(dir.path(), &key, Box::new(FakeEmbedder), agent).await?; // Store a raw episode (what happened). memory diff --git a/crates/basemyai/examples/memory_basic.rs b/crates/basemyai/examples/memory_basic.rs index 7607622..c5d524e 100644 --- a/crates/basemyai/examples/memory_basic.rs +++ b/crates/basemyai/examples/memory_basic.rs @@ -6,7 +6,7 @@ //! Run: `cargo run --example memory_basic -p basemyai` use basemyai::{AgentId, Memory, MemoryLayer}; -use basemyai_core::{Embedder, Store}; +use basemyai_core::{Embedder, EncryptionKey}; struct FakeEmbedder; @@ -27,9 +27,10 @@ impl Embedder for FakeEmbedder { #[tokio::main] async fn main() -> Result<(), Box> { - let store = Store::open_in_memory().await?; + let dir = tempfile::tempdir()?; + let key = EncryptionKey::new("demo-key-do-not-use-in-prod"); let agent = AgentId::new("demo-agent").expect("non-empty id"); - let memory = Memory::open(store, Box::new(FakeEmbedder), agent).await?; + let memory = Memory::open_native(dir.path(), &key, Box::new(FakeEmbedder), agent).await?; memory .remember("The Eiffel Tower is in Paris.", MemoryLayer::Semantic) diff --git a/crates/basemyai/examples/temporal_replacement.rs b/crates/basemyai/examples/temporal_replacement.rs index c52d740..d5a6a6d 100644 --- a/crates/basemyai/examples/temporal_replacement.rs +++ b/crates/basemyai/examples/temporal_replacement.rs @@ -4,7 +4,7 @@ //! Run: `cargo run --example temporal_replacement -p basemyai` use basemyai::{AgentId, Memory, MemoryLayer}; -use basemyai_core::{Embedder, Store}; +use basemyai_core::{Embedder, EncryptionKey}; const DIM: usize = 384; @@ -41,9 +41,10 @@ impl Embedder for DemoEmbedder { #[tokio::main] async fn main() -> Result<(), Box> { - let store = Store::open_in_memory().await?; + let dir = tempfile::tempdir()?; + let key = EncryptionKey::new("demo-key-do-not-use-in-prod"); let agent = AgentId::new("temporal-demo").expect("non-empty id"); - let memory = Memory::open(store, Box::new(DemoEmbedder), agent).await?; + let memory = Memory::open_native(dir.path(), &key, Box::new(DemoEmbedder), agent).await?; let old_id = memory .remember("The user is on the Free billing plan.", MemoryLayer::Semantic) diff --git a/crates/basemyai/src/lib.rs b/crates/basemyai/src/lib.rs index 002d914..3fce228 100644 --- a/crates/basemyai/src/lib.rs +++ b/crates/basemyai/src/lib.rs @@ -28,14 +28,18 @@ pub use cognition::{ Reached, apply_extraction, consolidate, consolidation_prompt, parse_extraction, }; pub use error::{MemoryError, Result}; -pub use maintenance::{AdaptiveForgetting, ConsolidationTask, ExpiredMemoryGc}; +pub use maintenance::ConsolidationTask; #[cfg(feature = "test-util")] pub use memory::HashEmbedder; -pub use memory::schema::{BMAI_FORMAT_VERSION, EMBEDDING_DIM, schema}; pub use memory::{ AgentId, AgentStats, ImportReport, MAX_TEXT_LEN, Memory, MemoryEvent, MemoryEventKind, MemoryLayer, MemorySubscription, Record, }; +pub use storage::BMAI_FORMAT_VERSION; + +/// Dimension des embeddings du baseline (`all-MiniLM-L6-v2`) — modèle unique +/// en V1 (CLAUDE.md). +pub const EMBEDDING_DIM: usize = 384; pub use provision::{ AnythingLlmBackend, BASELINE_DIM, BASELINE_MODEL_ID, BackendKind, HardwareProfile, KNOWN_MODELS, KnownModel, LlmOption, LlmProvision, ModelProvision, OllamaBackend, OpenAiCompatBackend, anythingllm_from_env, best_llm_option, diff --git a/crates/basemyai/src/maintenance/forgetting.rs b/crates/basemyai/src/maintenance/forgetting.rs deleted file mode 100644 index 7ab6242..0000000 --- a/crates/basemyai/src/maintenance/forgetting.rs +++ /dev/null @@ -1,83 +0,0 @@ -// SPDX-License-Identifier: BUSL-1.1 -//! Oubli adaptatif (VISION §5.2), au-delà du GC temporel V1. Éviction par score -//! combiné **importance × récence** (et, à terme, « surprise »), **décroissance** -//! progressive de l'importance, et plafond de capacité par agent. -//! -//! Implémenté comme [`MaintenanceTask`] injectée dans le worker agnostique du -//! core (VISION §4.3) : le core fait tourner la boucle, `basemyai` porte la -//! politique. Ne bloque jamais le chemin critique. - -use basemyai_core::{MaintenanceTask, Result, Store}; - -use crate::now_unix; - -/// Politique d'oubli adaptatif, enregistrée dans le `MaintenanceWorker`. -#[derive(Debug, Clone, Copy)] -pub struct AdaptiveForgetting { - /// Nombre maximum de souvenirs conservés par agent (les moins bien notés - /// au-delà sont évincés). - pub capacity_per_agent: usize, - /// Demi-vie de la récence en secondes (sert à pondérer le score de rétention). - pub recency_half_life_secs: i64, -} - -#[async_trait::async_trait] -impl MaintenanceTask for AdaptiveForgetting { - fn name(&self) -> &str { - "adaptive-forgetting" - } - - async fn run(&self, store: &Store) -> Result<()> { - let now = now_unix(); - let half_life = self.recency_half_life_secs; - // Plafond de capacité par agent (lié en paramètre, jamais interpolé). - let capacity = i64::try_from(self.capacity_per_agent).unwrap_or(i64::MAX); - - // Score de rétention par souvenir : - // age = max(0, now - COALESCE(last_access, valid_from)) - // recency = H / (H + age) (décroissance hyperbolique) - // retention = importance + recency - // - // On évite volontairement `0.5^(age/H)` : d'une part cette build de - // libSQL n'embarque pas les fonctions mathématiques (`pow`/`exp`), d'autre - // part l'exponentielle sous-déborde en f64 dès que `age` dépasse quelques - // centaines de demi-vies (deux souvenirs anciens deviennent alors - // indistinguables à `0.0`). La forme hyperbolique `H / (H + age)` ne - // dépend d'aucune fonction native, reste dans `(0, 1]`, vaut `1` à - // `age = 0` et `0.5` à `age = H` (« demi-vie » préservée), et **dégrade - // gracieusement** : elle distingue encore deux grands âges. Strictement - // décroissante en `age`, donc l'ordre par récence (et l'additivité avec - // `importance`) est respecté. - // - // On classe par agent (`PARTITION BY agent_id`) du meilleur au moins bon - // score, `id` départageant les ex æquo, puis on évince tout ce qui dépasse - // `capacity` (rang > capacity). Une seule requête, fonction de fenêtrage - // native libSQL/SQLite. - let txn = store.begin_write().await?; - let evicted_ids = "\ - SELECT id FROM (\ - SELECT id, ROW_NUMBER() OVER (\ - PARTITION BY agent_id \ - ORDER BY importance \ - + CAST(?2 AS REAL) / (\ - CAST(?2 AS REAL) \ - + max(0, CAST(?1 AS REAL) - COALESCE(last_access, valid_from))\ - ) DESC, id\ - ) AS rn FROM memory\ - ) WHERE rn > ?3"; - txn.execute( - &format!("DELETE FROM memory_fts WHERE id IN ({evicted_ids})"), - basemyai_core::libsql::params![now, half_life, capacity], - ) - .await - .map_err(|e| basemyai_core::CoreError::Storage(e.to_string()))?; - txn.execute( - &format!("DELETE FROM memory WHERE id IN ({evicted_ids})"), - basemyai_core::libsql::params![now, half_life, capacity], - ) - .await - .map_err(|e| basemyai_core::CoreError::Storage(e.to_string()))?; - txn.commit().await?; - Ok(()) - } -} diff --git a/crates/basemyai/src/maintenance/gc.rs b/crates/basemyai/src/maintenance/gc.rs deleted file mode 100644 index ad2dac7..0000000 --- a/crates/basemyai/src/maintenance/gc.rs +++ /dev/null @@ -1,42 +0,0 @@ -// SPDX-License-Identifier: BUSL-1.1 -//! GC des mémoires expirées (ADR-005/ADR-008). La sémantique `valid_until` -//! est portée par `basemyai`, jamais par le core. - -use basemyai_core::{MaintenanceTask, Result, Store}; - -use crate::now_unix; - -/// GC des mémoires expirées : supprime toute ligne dont `valid_until` est -/// passé. La sémantique `valid_until` est portée par `basemyai`, jamais par -/// le core. -#[derive(Debug, Default, Clone, Copy)] -pub struct ExpiredMemoryGc; - -#[async_trait::async_trait] -impl MaintenanceTask for ExpiredMemoryGc { - fn name(&self) -> &str { - "expired-memory-gc" - } - - async fn run(&self, store: &Store) -> Result<()> { - let now = now_unix(); - let txn = store.begin_write().await?; - txn.execute( - "DELETE FROM memory_fts \ - WHERE id IN (\ - SELECT id FROM memory WHERE valid_until IS NOT NULL AND valid_until <= ?1\ - )", - basemyai_core::libsql::params![now], - ) - .await - .map_err(|e| basemyai_core::CoreError::Storage(e.to_string()))?; - txn.execute( - "DELETE FROM memory WHERE valid_until IS NOT NULL AND valid_until <= ?1", - basemyai_core::libsql::params![now], - ) - .await - .map_err(|e| basemyai_core::CoreError::Storage(e.to_string()))?; - txn.commit().await?; - Ok(()) - } -} diff --git a/crates/basemyai/src/maintenance/mod.rs b/crates/basemyai/src/maintenance/mod.rs index ef970fe..327d3ab 100644 --- a/crates/basemyai/src/maintenance/mod.rs +++ b/crates/basemyai/src/maintenance/mod.rs @@ -1,23 +1,23 @@ // SPDX-License-Identifier: BUSL-1.1 //! Tâches de maintenance **sémantiques**, injectées dans le worker agnostique -//! du core. Le GC par `valid_until` vit ici (ADR-005/ADR-008) : le core fait -//! tourner la boucle, mais ignore le sens de l'expiration. - -mod forgetting; -mod gc; - -pub use forgetting::AdaptiveForgetting; -pub use gc::ExpiredMemoryGc; +//! du core (`basemyai_core::MaintenanceWorker`). +//! +//! GC temporel (`valid_until`) et oubli adaptatif (ADR-005/ADR-008, VISION +//! §5.2) reposaient sur du SQL de fenêtrage (`ROW_NUMBER() OVER (PARTITION +//! BY ...)`) spécifique au backend libSQL, retiré du workspace par ADR-032 — +//! supprimés avec lui plutôt que portés en passant sur le moteur natif (un +//! portage mérite son propre design/tests). `ConsolidationTask` survit : +//! elle ne dépendait déjà d'aucun store partagé (auto-suffisante via +//! `Arc`). use std::sync::Arc; -use basemyai_core::{MaintenanceTask, Result, Store}; +use basemyai_core::{MaintenanceTask, Result}; use crate::{LlmInference, Memory, consolidate}; -/// Tâche de fond de consolidation (épisodes → faits + graphe). Stocke ses propres -/// [`Memory`] et [`LlmInference`] ; le `_store` fourni par le worker est ignoré -/// (la mémoire possède son propre store). +/// Tâche de fond de consolidation (épisodes → faits + graphe). Auto-suffisante : +/// possède sa propre [`Memory`] et son [`LlmInference`]. pub struct ConsolidationTask { memory: Arc, llm: Arc, @@ -37,11 +37,10 @@ impl MaintenanceTask for ConsolidationTask { "consolidation" } - /// Lance une passe de consolidation. Ignore `_store` (la mémoire est - /// auto-suffisante). Mappe [`MemoryError`](crate::MemoryError) vers - /// [`CoreError::Storage`](basemyai_core::CoreError::Storage) pour + /// Lance une passe de consolidation. Mappe [`MemoryError`](crate::MemoryError) + /// vers [`CoreError::Storage`](basemyai_core::CoreError::Storage) pour /// satisfaire l'interface du core. - async fn run(&self, _store: &Store) -> Result<()> { + async fn run(&self) -> Result<()> { consolidate(&self.memory, self.llm.as_ref()) .await .map(|_| ()) diff --git a/crates/basemyai/src/memory/mod.rs b/crates/basemyai/src/memory/mod.rs index 7b0acd1..717f546 100644 --- a/crates/basemyai/src/memory/mod.rs +++ b/crates/basemyai/src/memory/mod.rs @@ -1,13 +1,12 @@ // SPDX-License-Identifier: BUSL-1.1 -//! Façade mémoire. Injecte les primitives du core (`Store`, `VectorIndex`, -//! `Embedder`) — testable en isolation via des doubles. Applique l'isolation -//! par agent et le RAG temporel par-dessus. +//! Façade mémoire. Injecte les primitives du core (moteur natif, `Embedder`) +//! — testable en isolation via des doubles. Applique l'isolation par agent et +//! le RAG temporel par-dessus. mod event; mod isolation; mod layer; mod porting; -pub(crate) mod schema; #[cfg(feature = "test-util")] mod testutil; @@ -18,7 +17,7 @@ pub use porting::ImportReport; #[cfg(feature = "test-util")] pub use testutil::HashEmbedder; -use basemyai_core::{Embedder, Metric, Store, libsql}; +use basemyai_core::{Embedder, Metric}; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::broadcast; @@ -26,7 +25,7 @@ use uuid::Uuid; use event::DEFAULT_EVENT_CAPACITY; -use crate::storage::{LibsqlMemoryStore, MemoryStore, NewMemory}; +use crate::storage::{MemoryStore, NativeMemoryStore, NewMemory}; use crate::temporal::Validity; use crate::{MemoryError, RRF_K, Ranking, Result, now_unix, rrf_fuse}; @@ -34,7 +33,8 @@ use crate::{MemoryError, RRF_K, Ranking, Result, now_unix, rrf_fuse}; /// saturerait le prompt de consolidation (`MAX_EPISODES` ne borne que le /// *nombre* d'épisodes, pas leur taille individuelle) — DoS de contexte. /// Cohérent avec la limite documentée côté REST (`crates/basemyai-rest/openapi.yaml`). -pub const MAX_TEXT_LEN: usize = 65_536; +/// Bornée à `u16::MAX` pour rester compatible avec le format des termes FTS. +pub const MAX_TEXT_LEN: usize = 65_535; /// Provenance par défaut d'un souvenir mémorisé directement par l'agent (par /// opposition à `"consolidation"`, faits promus par le pipeline LLM). @@ -47,10 +47,10 @@ const META_EMBEDDING_DIM: &str = "embedding_dim"; /// événement [`MemoryEventKind::Consolidated`] d'un [`MemoryEventKind::Remembered`]. pub(crate) const SOURCE_CONSOLIDATION: &str = "consolidation"; -/// Mémoire d'un agent : moteur de stockage (vecteur natif) + embedder, -/// scellés par un [`AgentId`]. Le chiffrement est obligatoire (ADR-007). +/// Mémoire d'un agent : moteur de stockage natif + embedder, scellés par un +/// [`AgentId`]. Le chiffrement est obligatoire (ADR-007/ADR-030). pub struct Memory { - engine: Arc, + engine: Arc, embedder: Box, agent: AgentId, /// Diffuseur d'événements mémoire (abonnements temps réel). Émis **après** @@ -60,35 +60,75 @@ pub struct Memory { } impl Memory { - /// Assemble une mémoire à partir des primitives du core déjà construites, - /// **sans** migrer le schéma (à utiliser quand le schéma est déjà en place). - #[must_use] - fn from_migrated_store(store: Store, embedder: Box, agent: AgentId) -> Self { + /// Assemble une mémoire sur un store natif déjà ouvert, **partagé** + /// (`Arc`) : vérifie le contrat embedding puis scelle la façade par + /// `agent`. + /// + /// Le moteur natif est **mono-écrivain exclusif** (ADR-025) : ouvrir deux + /// fois le même répertoire-store corromprait le WAL. Un même + /// `Arc` doit donc être **partagé** entre toutes les + /// `Memory` d'agents différents qui pointent vers le même store (le + /// pattern des providers REST/MCP — isolation structurelle par préfixe de + /// clé, ADR-027 §2, jamais une instance de store par agent). Réservé aux + /// consommateurs qui construisent/possèdent déjà leur `NativeMemoryStore` + /// (providers de surface, éphémère de test) — [`Memory::open_native`] + /// gère le cas commun d'une seule `Memory`. + /// + /// # Errors + /// [`crate::MemoryError::EmbeddingModelMismatch`] si le store a été créé + /// avec un autre modèle/dimension d'embedding ; propage les erreurs de + /// stockage. + pub async fn from_native_store( + store: Arc, + embedder: Box, + agent: AgentId, + ) -> Result { + ensure_embedding_contract(&store, embedder.as_ref()).await?; let (events, _) = broadcast::channel(DEFAULT_EVENT_CAPACITY); - Self { - engine: Arc::new(LibsqlMemoryStore::new(store)), + Ok(Self { + engine: store, embedder, agent, events, - } + }) } - /// Ouvre une mémoire : vérifie le chiffrement, applique le schéma - /// (`memory` + index vecteur natif), puis renvoie la façade scellée par `agent`. + /// Ouvre une mémoire sur un répertoire-store `.bmai` (au besoin le crée), + /// **chiffré au repos** (ADR-030 — la clé est obligatoire, ADR-007 ; sans + /// CMake), vérifie le contrat embedding, puis renvoie la façade scellée + /// par `agent`. /// - /// Le chiffrement est **obligatoire** pour les stores sur fichier (ADR-007) : - /// un store `:memory:` est éphémère, la règle ne s'y applique pas. + /// Ouvre un **nouveau** `NativeMemoryStore` à chaque appel (mono-écrivain + /// exclusif, ADR-025) : n'appeler qu'**une fois** par répertoire-store + /// par process — c'est le cas commun d'une seule `Memory` (CLI, spike). + /// Un consommateur qui sert **plusieurs agents** sur le même store (un + /// provider REST/MCP) doit ouvrir une fois via + /// [`NativeMemoryStore::open_encrypted`], l'envelopper en `Arc`, puis + /// appeler [`Memory::from_native_store`] pour chaque agent — jamais + /// rouvrir le répertoire. /// /// # Errors - /// [`crate::MemoryError::EncryptionRequired`] si le store est sur fichier et non chiffré. - /// [`crate::MemoryError::Core`] si la migration échoue. - pub async fn open(store: Store, embedder: Box, agent: AgentId) -> Result { - if store.path().is_some() && !store.is_encrypted() { - return Err(crate::MemoryError::EncryptionRequired); - } - store.migrate(&schema::schema()).await?; - ensure_embedding_contract(&store, embedder.as_ref()).await?; - Ok(Self::from_migrated_store(store, embedder, agent)) + /// Erreur de stockage typée si la clé est fausse ou si `path` contient un + /// store en clair ; [`crate::MemoryError::EmbeddingModelMismatch`] si le + /// store a été créé avec un autre modèle/dimension d'embedding. + pub async fn open_native( + path: impl AsRef, + key: &basemyai_core::EncryptionKey, + embedder: Box, + agent: AgentId, + ) -> Result { + let path = path.as_ref().to_path_buf(); + let key = key.expose().to_string(); + // L'ouverture (recovery WAL, chargement des méta d'index) est + // bloquante — jamais sur un thread du runtime. + let store = tokio::task::spawn_blocking(move || NativeMemoryStore::open_encrypted(&path, &key)) + .await + .map_err(|e| { + crate::MemoryError::Core(basemyai_core::CoreError::Storage(format!( + "ouverture du store natif interrompue : {e}" + ))) + })??; + Self::from_native_store(Arc::new(store), embedder, agent).await } /// L'agent propriétaire de cette mémoire. @@ -106,7 +146,7 @@ impl Memory { /// d'un autre agent ne reçoit que les événements de cet autre agent — il ne /// peut pas remonter au-delà de ce que [`Memory`] émet, et chaque `Memory` /// n'émet que pour son propre agent. La sécurité multi-tenant repose sur - /// l'isolation SQL en amont (ADR-006) ; ce filtre la prolonge au flux. + /// l'isolation structurelle en amont (ADR-006) ; ce filtre la prolonge au flux. #[must_use] pub fn watch(&self, agent_id: &str, layer: Option) -> MemorySubscription { MemorySubscription::new(self.events.subscribe(), agent_id.to_string(), layer) @@ -131,10 +171,10 @@ impl Memory { Arc::clone(&self.engine) as Arc } - /// Moteur de stockage **concret**. `pub(crate)` : réservé à - /// `memory::porting` (export/import JSONL), qui a besoin de colonnes hors - /// du contrat sémantique [`MemoryStore`] (cf. [`LibsqlMemoryStore::store`]). - pub(crate) fn libsql_engine(&self) -> &LibsqlMemoryStore { + /// Moteur natif **concret**. `pub(crate)` : réservé à `memory::porting` + /// (export/import JSONL) et à la rotation de clé — jamais aux chemins + /// sémantiques, qui passent par le contrat [`MemoryStore`]. + pub(crate) fn native_engine(&self) -> &Arc { &self.engine } @@ -142,48 +182,30 @@ impl Memory { /// /// Permet aux consommateurs externes (MCP, REST, bindings) de traverser le /// graphe entités/relations (`recall_graph`) sans accéder au moteur, tout - /// en conservant l'isolation par agent au niveau SQL (ADR-006). + /// en conservant l'isolation par agent (ADR-006). #[must_use] pub fn graph(&self) -> crate::Graph { crate::Graph::new(self.engine(), self.agent.clone()) } - /// Ouvre une mémoire **éphémère, non chiffrée** (`:memory:`) dotée d'un - /// embedder déterministe sans modèle ([`HashEmbedder`]). + /// Ouvre une mémoire **éphémère, non chiffrée** dotée d'un embedder + /// déterministe sans modèle ([`HashEmbedder`]) — le backend que les + /// utilisateurs reçoivent réellement, la suite de tests façade l'exerce + /// tel quel. /// /// Réservé aux **tests et aux spikes des bindings** : ni Candle, ni fichiers /// modèle, ni CMake — un roundtrip remember/recall fonctionne hors-ligne. - /// **Jamais en production** (vecteurs non sémantiques). Le store `:memory:` - /// est éphémère : la règle de chiffrement obligatoire ne s'y applique pas. + /// **Jamais en production** (vecteurs non sémantiques). Le store est + /// éphémère : la règle de chiffrement obligatoire ne s'y applique pas. /// /// # Errors /// [`crate::MemoryError::MissingAgent`] si `agent_id` est vide ; - /// [`crate::MemoryError::Core`] si l'ouverture/migration échoue. + /// [`crate::MemoryError::Core`] si l'ouverture échoue. #[cfg(feature = "test-util")] pub async fn open_in_memory(agent_id: &str) -> Result { let agent = AgentId::new(agent_id).ok_or(crate::MemoryError::MissingAgent)?; - let store = Store::open_in_memory().await?; - Self::open(store, Box::new(HashEmbedder::new()), agent).await - } - - /// Ouvre une mémoire sur un fichier libSQL **non chiffré** avec l'embedder - /// déterministe de test, en contournant volontairement la règle de - /// chiffrement obligatoire de [`Memory::open`]. - /// - /// Réservé aux **tests et spikes des bindings** (`test-util`) : vérifie - /// l'isolation SQL réelle entre agents sur un vrai fichier partagé, sans - /// Candle ni CMake. **Jamais en production** — le seul bypass de chiffrement - /// du crate vit ici, strictement confiné à cette feature. - /// - /// # Errors - /// [`crate::MemoryError::MissingAgent`] si `agent_id` est vide ; - /// [`crate::MemoryError::Core`] si l'ouverture/migration échoue. - #[cfg(feature = "test-util")] - pub async fn open_test_file(path: &std::path::Path, agent_id: &str) -> Result { - let agent = AgentId::new(agent_id).ok_or(crate::MemoryError::MissingAgent)?; - let store = Store::open(path, None).await?; - store.migrate(&schema::schema()).await?; - Ok(Self::from_migrated_store(store, Box::new(HashEmbedder::new()), agent)) + let store = NativeMemoryStore::open_ephemeral()?; + Self::from_native_store(Arc::new(store), Box::new(HashEmbedder::new()), agent).await } /// Mémorise un texte dans une couche, valide dès maintenant et sans @@ -200,8 +222,9 @@ impl Memory { /// l'identifiant (UUID v4) du souvenir créé — les consommateurs (MCP, REST, /// bindings) le retournent à l'appelant pour invalidation/effacement ultérieurs. /// - /// L'insertion `memory` + miroir FTS est **atomique** ([`Store::begin_write`]) : - /// jamais de souvenir visible par vecteur mais invisible en BM25, ou l'inverse. + /// L'insertion souvenir + miroir FTS est **atomique** (un seul batch WAL, + /// N5.5) : jamais de souvenir visible par vecteur mais invisible en BM25, + /// ou l'inverse. /// /// # Errors /// [`MemoryError::TextTooLong`] si `text` dépasse [`MAX_TEXT_LEN`]. @@ -255,14 +278,14 @@ impl Memory { } /// Mémorise un lot avec une fenêtre de validité explicite : **une** passe - /// d'embedding ([`Embedder::embed_batch`]) et **une** transaction — le lot - /// devient visible d'un coup, ou pas du tout. C'est le chemin de - /// l'ingestion initiale (import d'historique, seed de connaissances). + /// d'embedding ([`Embedder::embed_batch`]) et **un seul batch atomique** + /// (N5.5) — le lot devient visible d'un coup, ou pas du tout. C'est le + /// chemin de l'ingestion initiale (import d'historique, seed de connaissances). /// /// # Errors /// [`MemoryError::TextTooLong`] si un texte du lot dépasse [`MAX_TEXT_LEN`] /// (fail-fast : le premier texte trop long stoppe le lot, rien n'est inséré - /// puisque la validation précède l'embedding/la transaction). + /// puisque la validation précède l'embedding/l'insertion). /// Propage aussi les erreurs d'embedding/stockage. pub async fn remember_batch_with( &self, @@ -325,7 +348,7 @@ impl Memory { /// /// L'isolation (`agent_id`) et le filtre temporel sont appliqués par le /// moteur de stockage ([`MemoryStore::recall_vector`]) — `Memory` ne - /// connaît plus le SQL, seulement le vecteur de requête et l'agent. + /// connaît que le vecteur de requête et l'agent. /// /// # Errors /// Propage les erreurs d'embedding/recherche. @@ -340,8 +363,10 @@ impl Memory { /// Recall sémantique temporel avec **métrique explicite** ([`Metric`]). /// /// [`Metric::Cosine`] emprunte le chemin natif ; [`Metric::Euclidean`] et - /// [`Metric::Hamming`] sur-échantillonnent les candidats cosinus puis sont - /// re-classées en Rust (ADR-012). Met à jour `last_access` sur les résultats. + /// [`Metric::Hamming`] n'ont pas d'implémentation de re-classement sur le + /// backend natif aujourd'hui (erreur franche, jamais un résultat + /// silencieusement faux — ADR-032). Met à jour `last_access` sur les + /// résultats. /// /// # Errors /// Propage les erreurs d'embedding/recherche. @@ -354,7 +379,7 @@ impl Memory { } /// Recall **hybride** (ADR-014) : fusionne le classement **vectoriel** et le - /// classement **BM25** (full-text natif libSQL) par Reciprocal Rank Fusion + /// classement **BM25** (full-text natif, ADR-028) par Reciprocal Rank Fusion /// ([`rrf_fuse`]). Un terme exact que l'embedding rate (sigle, identifiant /// rare, nom propre) remonte par BM25 ; une reformulation que les mots-clés /// ratent remonte par le vecteur. Borné à l'agent et à la validité. @@ -445,7 +470,7 @@ impl Memory { } /// Suppression physique d'un souvenir (RGPD, droit à l'effacement). - /// Atomique : la ligne `memory` et son miroir FTS disparaissent ensemble. + /// Atomique : le souvenir et son miroir FTS disparaissent ensemble. /// /// # Errors /// Propage les erreurs de stockage. @@ -460,10 +485,9 @@ impl Memory { Ok(()) } - /// Purge **toutes** les données de cet agent : souvenirs (`memory`), entités - /// (`entity`) et relations (`edge`). Irréversible (RGPD, droit à l'oubli). - /// Idempotent : ne renvoie pas d'erreur si l'agent n'a aucune donnée. - /// Atomique : pas de purge partielle possible (tout ou rien). + /// Purge **toutes** les données de cet agent : souvenirs, entités et + /// relations. Irréversible (RGPD, droit à l'oubli). Idempotent : ne + /// renvoie pas d'erreur si l'agent n'a aucune donnée. /// /// # Errors /// Propage les erreurs de stockage. @@ -490,26 +514,17 @@ impl Memory { self.engine.recall_graph_filtered(&self.agent, &qvec, k, now).await } - /// Change la clé de chiffrement du store sous-jacent **en place** - /// (`PRAGMA rekey`, cf. [`basemyai_core::Store::rotate_key`]) — sans - /// recréer le fichier `.bmai`. - /// - /// **Après un appel réussi, cette `Memory` (et le `Store` qu'elle - /// enveloppe) devient caduque** : le pool de lecteurs du core retient - /// l'ancienne clé et libSQL ne permet pas de la rafraîchir après coup - /// (détail complet dans la doc de - /// [`basemyai_core::Store::rotate_key`]). L'appelant doit laisser tomber - /// cette `Memory` et rouvrir via [`Memory::open`] avec `new_key` — le - /// même `Store` (même fichier) mais une clé neuve. + /// Change la clé de chiffrement du store sous-jacent **en place** — + /// re-scellement O(1) de la DEK sous la nouvelle clé (ADR-030) : cette + /// `Memory` **reste pleinement utilisable** après l'appel, aucune + /// réouverture requise. /// /// # Errors /// [`basemyai_core::CoreError::Encryption`] si le store n'est pas - /// chiffré ; [`basemyai_core::CoreError::Storage`] si le `PRAGMA rekey` - /// échoue. Les deux remontent enveloppées dans [`MemoryError::Core`]. - #[cfg(feature = "crypto")] + /// chiffré ; [`basemyai_core::CoreError::Storage`] si la rotation échoue. + /// Les deux remontent enveloppées dans [`MemoryError::Core`]. pub async fn rotate_key(&self, new_key: basemyai_core::EncryptionKey) -> Result<()> { - self.engine.store().rotate_key(new_key).await?; - Ok(()) + self.engine.rotate_key(new_key.expose()).await } } @@ -522,65 +537,33 @@ fn check_text_len(text: &str) -> Result<()> { Ok(()) } -async fn ensure_embedding_contract(store: &Store, embedder: &dyn Embedder) -> Result<()> { - let conn = store.connect(); - conn.execute( - "INSERT OR IGNORE INTO bmai_meta (key, value) VALUES (?1, ?2)", - libsql::params![META_EMBEDDING_MODEL_ID, embedder.model_id()], - ) - .await - .map_err(storage)?; - conn.execute( - "INSERT OR IGNORE INTO bmai_meta (key, value) VALUES (?1, ?2)", - libsql::params![META_EMBEDDING_DIM, embedder.dim().to_string()], - ) - .await - .map_err(storage)?; - - let mut rows = conn - .query( - "SELECT key, value FROM bmai_meta WHERE key IN (?1, ?2)", - libsql::params![META_EMBEDDING_MODEL_ID, META_EMBEDDING_DIM], - ) - .await - .map_err(storage)?; - - let mut meta = HashMap::new(); - while let Some(row) = rows.next().await.map_err(storage)? { - meta.insert( - row.get::(0).map_err(storage)?, - row.get::(1).map_err(storage)?, - ); - } - - let stored_model = meta - .get(META_EMBEDDING_MODEL_ID) - .ok_or_else(|| MemoryError::EmbeddingMetadata("missing embedding_model_id".into()))?; - let stored_dim = meta - .get(META_EMBEDDING_DIM) - .ok_or_else(|| MemoryError::EmbeddingMetadata("missing embedding_dim".into()))? +/// Vérifie/scelle le contrat embedding (`embedding_model_id`/`embedding_dim`) +/// sur la méta consommateur du moteur ([`NativeMemoryStore::meta_ensure`]) : +/// sémantique `INSERT OR IGNORE` puis vérification stricte — un embedder +/// différent de celui qui a créé le store est rejeté plutôt que de produire +/// des vecteurs silencieusement incompatibles. +async fn ensure_embedding_contract(store: &NativeMemoryStore, embedder: &dyn Embedder) -> Result<()> { + let stored_model = store.meta_ensure(META_EMBEDDING_MODEL_ID, embedder.model_id()).await?; + let stored_dim = store + .meta_ensure(META_EMBEDDING_DIM, &embedder.dim().to_string()) + .await? .parse::() .map_err(|e| MemoryError::EmbeddingMetadata(format!("invalid embedding_dim: {e}")))?; if stored_model != embedder.model_id() || stored_dim != embedder.dim() { return Err(MemoryError::EmbeddingModelMismatch { - stored_model: stored_model.clone(), + stored_model, stored_dim, embedder_model: embedder.model_id().to_string(), embedder_dim: embedder.dim(), }); } - Ok(()) } -fn storage(e: libsql::Error) -> MemoryError { - basemyai_core::CoreError::Storage(e.to_string()).into() -} - -/// Construit une expression FTS5 MATCH sûre depuis une requête libre : tokens -/// alphanumériques, chacun cité (literal, donc insensible aux mots-clés FTS5 -/// comme AND/OR/NEAR) et joints par OR (orienté rappel). `None` si aucun token. +/// Construit une expression FTS `MATCH` sûre depuis une requête libre : tokens +/// alphanumériques, chacun cité (literal) et joints par OR (orienté rappel). +/// `None` si aucun token. fn fts_match_expr(query: &str) -> Option { let tokens: Vec = query .split(|c: char| !c.is_alphanumeric()) @@ -599,6 +582,16 @@ fn fts_match_expr(query: &str) -> Option { mod tests { use super::*; use basemyai_core::Result as CoreResult; + use std::sync::{LazyLock, Mutex}; + + static TEMP_DIR_GUARDS: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); + + fn open_native_store_for_tests() -> Arc { + let dir = tempfile::tempdir().expect("create tempdir"); + let store = NativeMemoryStore::open(dir.path()).expect("native store opens"); + TEMP_DIR_GUARDS.lock().expect("tempdir guard mutex poisoned").push(dir); + Arc::new(store) + } struct TestEmbedder { model: &'static str, @@ -625,41 +618,36 @@ mod tests { #[tokio::test] async fn open_records_embedding_model_metadata() { - let store = Store::open_in_memory().await.expect("store opens"); + let store = open_native_store_for_tests(); let agent = AgentId::new("metadata-agent").expect("valid agent"); - let memory = Memory::open( - store, + let memory = Memory::from_native_store( + Arc::clone(&store), Box::new(TestEmbedder { model: "test-model-a", - dim: schema::EMBEDDING_DIM, + dim: crate::EMBEDDING_DIM, }), agent, ) .await .expect("memory opens"); + drop(memory); - let conn = memory.libsql_engine().store().connect(); - let mut rows = conn - .query( - "SELECT value FROM bmai_meta WHERE key = ?1", - libsql::params![META_EMBEDDING_MODEL_ID], - ) + let stored = store + .meta_ensure(META_EMBEDDING_MODEL_ID, "should-not-overwrite") .await - .expect("metadata query"); - let row = rows.next().await.expect("row read").expect("metadata row"); - let model = row.get::(0).expect("model value"); - assert_eq!(model, "test-model-a"); + .expect("meta read"); + assert_eq!(stored, "test-model-a"); } #[tokio::test] async fn open_rejects_incompatible_embedding_model() { - let store = Store::open_in_memory().await.expect("store opens"); + let store = open_native_store_for_tests(); let agent = AgentId::new("metadata-agent").expect("valid agent"); - let memory = Memory::open( - store, + let _memory = Memory::from_native_store( + Arc::clone(&store), Box::new(TestEmbedder { model: "test-model-a", - dim: schema::EMBEDDING_DIM, + dim: crate::EMBEDDING_DIM, }), agent, ) @@ -667,10 +655,10 @@ mod tests { .expect("memory opens"); let err = ensure_embedding_contract( - memory.libsql_engine().store(), + &store, &TestEmbedder { model: "test-model-b", - dim: schema::EMBEDDING_DIM, + dim: crate::EMBEDDING_DIM, }, ) .await diff --git a/crates/basemyai/src/memory/porting.rs b/crates/basemyai/src/memory/porting.rs index 06a108f..fb3acfc 100644 --- a/crates/basemyai/src/memory/porting.rs +++ b/crates/basemyai/src/memory/porting.rs @@ -1,5 +1,6 @@ // SPDX-License-Identifier: BUSL-1.1 -//! Export/import de la mémoire d'un agent (portabilité, backup, migration). +//! Export/import de la mémoire d'un agent (portabilité, backup, migration de +//! modèle d'embedding). //! //! Format **JSONL versionné** : une ligne d'en-tête puis une ligne JSON par //! souvenir, entité et relation. Les **embeddings ne sont pas exportés** : ils @@ -7,36 +8,17 @@ //! qui fait de l'export le chemin de migration de modèle d'embedding (on //! change de modèle, on réimporte, tout est ré-encodé). //! -//! L'import est **atomique** (une seule [`basemyai_core::WriteTxn`]) et -//! **idempotent** (`INSERT OR IGNORE` : réimporter le même fichier ne -//! duplique rien). Les lignes importées sont rattachées à l'agent de la -//! mémoire **cible** — l'`agent_id` de l'en-tête est informatif. +//! Idempotent (les ids déjà présents sont comptés `*_skipped` et laissés +//! intacts). **Écart d'atomicité assumé** : les souvenirs neufs s'insèrent en +//! un seul batch WAL tout-ou-rien (`put_many`, N5.5), mais entités/arêtes +//! suivent en upserts individuels durables — l'import est idempotent et +//! reprennable, pas globalement atomique (ADR-027 §6, ADR-032 §3). use serde::{Deserialize, Serialize}; -use basemyai_core::libsql; - -use super::{Memory, MemoryLayer}; -use crate::{MemoryError, Result, now_unix}; - -/// Mappe une erreur libSQL en [`MemoryError`] (via `CoreError::Storage`). -fn storage(e: libsql::Error) -> MemoryError { - basemyai_core::CoreError::Storage(e.to_string()).into() -} - -/// Formate un vecteur en littéral SQL `[a,b,c]` consommé par `vector(?)`. -fn to_vec_literal(v: &[f32]) -> String { - let mut s = String::with_capacity(v.len() * 8 + 2); - s.push('['); - for (i, x) in v.iter().enumerate() { - if i > 0 { - s.push(','); - } - s.push_str(&x.to_string()); - } - s.push(']'); - s -} +use super::Memory; +use crate::storage::{NativeImportEdge, NativeImportEntity, NativeImportMemory}; +use crate::{MemoryError, MemoryLayer, Result, now_unix}; /// Identifiant de format de l'en-tête JSONL. const FORMAT: &str = "basemyai-export"; @@ -124,9 +106,7 @@ impl Memory { /// # Errors /// Propage les erreurs de stockage/sérialisation. pub async fn export_jsonl(&self) -> Result { - let conn = self.libsql_engine().store().connect(); let mut out = String::new(); - push_line( &mut out, &ExportLine::Header { @@ -139,69 +119,47 @@ impl Memory { }, )?; - let mut rows = conn - .query( - "SELECT id, layer, content, valid_from, valid_until, importance, last_access \ - FROM memory WHERE agent_id = ?1 ORDER BY valid_from, id", - libsql::params![self.agent.as_str()], - ) - .await - .map_err(storage)?; - while let Some(row) = rows.next().await.map_err(storage)? { + let rows = self.native_engine().export_rows(&self.agent).await?; + + for (id, record) in rows.memories { push_line( &mut out, &ExportLine::Memory { - id: text(&row, 0)?, - layer: text(&row, 1)?, - content: text(&row, 2)?, - valid_from: integer(&row, 3)?, - valid_until: integer_opt(&row, 4)?, - importance: real(&row, 5)?, - last_access: integer_opt(&row, 6)?, + id, + layer: record.layer, + content: record.content, + valid_from: record.valid_from, + valid_until: record.valid_until, + importance: record.importance, + last_access: Some(record.last_access), }, )?; } - let mut rows = conn - .query( - "SELECT id, kind, label, valid_from, valid_until, importance \ - FROM entity WHERE agent_id = ?1 ORDER BY id", - libsql::params![self.agent.as_str()], - ) - .await - .map_err(storage)?; - while let Some(row) = rows.next().await.map_err(storage)? { + for (id, entity) in rows.entities { push_line( &mut out, &ExportLine::Entity { - id: text(&row, 0)?, - kind: text(&row, 1)?, - label: text(&row, 2)?, - valid_from: integer(&row, 3)?, - valid_until: integer_opt(&row, 4)?, - importance: real(&row, 5)?, + id, + kind: entity.kind, + label: entity.label, + valid_from: entity.valid_from, + valid_until: entity.valid_until, + importance: default_weight(), }, )?; } - let mut rows = conn - .query( - "SELECT src, dst, relation, weight, valid_from, valid_until \ - FROM edge WHERE agent_id = ?1 ORDER BY src, dst, relation", - libsql::params![self.agent.as_str()], - ) - .await - .map_err(storage)?; - while let Some(row) = rows.next().await.map_err(storage)? { + for (src, relation, dst, meta) in rows.edges { push_line( &mut out, &ExportLine::Edge { - src: text(&row, 0)?, - dst: text(&row, 1)?, - relation: text(&row, 2)?, - weight: real(&row, 3)?, - valid_from: integer(&row, 4)?, - valid_until: integer_opt(&row, 5)?, + src, + dst, + relation, + weight: meta.weight, + valid_from: meta.valid_from, + valid_until: meta.valid_until, }, )?; } @@ -213,10 +171,10 @@ impl Memory { /// de la façade, quel que soit l'`agent_id` d'origine de l'export). /// /// Les souvenirs sont **ré-embeddés** par l'embedder courant (une passe - /// `embed_batch` par lots de 128), puis tout est inséré dans **une seule - /// transaction** : l'import aboutit entièrement ou pas du tout. - /// Idempotent : les lignes dont l'identifiant existe déjà sont comptées - /// en `*_skipped` et laissées intactes. + /// `embed_batch` par lots de 128), puis les souvenirs neufs partent en un + /// seul batch WAL tout-ou-rien (N5.5). Idempotent : les lignes dont + /// l'identifiant existe déjà sont comptées en `*_skipped` et laissées + /// intactes. /// /// # Errors /// [`MemoryError::Porting`] si l'en-tête manque/diverge ou si une ligne est @@ -276,8 +234,8 @@ impl Memory { label, valid_from, valid_until, - importance, - } => entities.push((id, kind, label, valid_from, valid_until, importance)), + .. + } => entities.push((id, kind, label, valid_from, valid_until)), ExportLine::Edge { src, dst, @@ -294,102 +252,57 @@ impl Memory { )); } - // ── Ré-embedding par lots (hors transaction : CPU-bound) ───────────── + // ── Ré-embedding par lots (CPU-bound) ───────────────────────────────── let contents: Vec = memories.iter().map(|m| m.2.clone()).collect(); let mut vectors = Vec::with_capacity(contents.len()); for chunk in contents.chunks(EMBED_CHUNK) { vectors.extend(self.embedder.embed_batch(chunk)?); } - // ── Écriture atomique ───────────────────────────────────────────────── - let mut report = ImportReport::default(); - let txn = self.libsql_engine().store().begin_write().await?; - - for ((id, layer, content, valid_from, valid_until, importance, last_access), vector) in - memories.iter().zip(&vectors) - { - let inserted = txn - .execute( - "INSERT OR IGNORE INTO memory \ - (id, agent_id, layer, content, valid_from, valid_until, importance, last_access, emb) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, vector(?9))", - libsql::params![ - id.as_str(), - self.agent.as_str(), - layer.table(), - content.as_str(), - *valid_from, - *valid_until, - *importance, - *last_access, - to_vec_literal(vector), - ], - ) - .await - .map_err(storage)?; - if inserted > 0 { - txn.execute( - "INSERT INTO memory_fts (id, agent_id, content) VALUES (?1, ?2, ?3)", - libsql::params![id.as_str(), self.agent.as_str(), content.as_str()], - ) - .await - .map_err(storage)?; - report.memories += 1; - } else { - report.memories_skipped += 1; - } - } - - for (id, kind, label, valid_from, valid_until, importance) in &entities { - let inserted = txn - .execute( - "INSERT OR IGNORE INTO entity (id, agent_id, kind, label, valid_from, valid_until, importance) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", - libsql::params![ - id.as_str(), - self.agent.as_str(), - kind.as_str(), - label.as_str(), - *valid_from, - *valid_until, - *importance, - ], - ) - .await - .map_err(storage)?; - if inserted > 0 { - report.entities += 1; - } else { - report.entities_skipped += 1; - } - } - - for (src, dst, relation, weight, valid_from, valid_until) in &edges { - let inserted = txn - .execute( - "INSERT OR IGNORE INTO edge (src, dst, agent_id, relation, weight, valid_from, valid_until) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", - libsql::params![ - src.as_str(), - dst.as_str(), - self.agent.as_str(), - relation.as_str(), - *weight, - *valid_from, - *valid_until, - ], - ) - .await - .map_err(storage)?; - if inserted > 0 { - report.edges += 1; - } else { - report.edges_skipped += 1; - } - } + let import_memories: Vec = memories + .into_iter() + .zip(vectors) + .map( + |((id, layer, content, valid_from, valid_until, importance, last_access), vector)| NativeImportMemory { + id, + layer, + content, + source: super::SOURCE_USER.to_string(), + valid_from, + valid_until, + importance, + last_access, + vector, + }, + ) + .collect(); + let import_entities: Vec = entities + .into_iter() + .map(|(id, kind, label, valid_from, valid_until)| NativeImportEntity { + id, + kind, + label, + valid_from, + valid_until, + }) + .collect(); + let import_edges: Vec = edges + .into_iter() + .map( + |(src, dst, relation, weight, valid_from, valid_until)| NativeImportEdge { + src, + dst, + relation, + weight, + valid_from, + valid_until, + }, + ) + .collect(); - txn.commit().await?; - Ok(report) + self.native_engine() + .import_rows(&self.agent, import_memories, import_entities, import_edges) + .await } } @@ -400,33 +313,3 @@ fn push_line(out: &mut String, line: &ExportLine) -> Result<()> { out.push('\n'); Ok(()) } - -// ── Lecture typée des colonnes libSQL (erreurs → Storage) ──────────────────── - -fn text(row: &libsql::Row, idx: i32) -> Result { - row.get::(idx).map_err(storage) -} - -fn integer(row: &libsql::Row, idx: i32) -> Result { - row.get::(idx).map_err(storage) -} - -fn integer_opt(row: &libsql::Row, idx: i32) -> Result> { - match row.get_value(idx).map_err(storage)? { - libsql::Value::Null => Ok(None), - libsql::Value::Integer(i) => Ok(Some(i)), - other => { - Err(basemyai_core::CoreError::Storage(format!("colonne {idx} : entier attendu, reçu {other:?}")).into()) - } - } -} - -fn real(row: &libsql::Row, idx: i32) -> Result { - match row.get_value(idx).map_err(storage)? { - libsql::Value::Real(r) => Ok(r), - // SQLite peut rendre l'affinité entière pour un littéral REAL rond. - #[allow(clippy::cast_precision_loss)] - libsql::Value::Integer(i) => Ok(i as f64), - other => Err(basemyai_core::CoreError::Storage(format!("colonne {idx} : réel attendu, reçu {other:?}")).into()), - } -} diff --git a/crates/basemyai/src/memory/schema.rs b/crates/basemyai/src/memory/schema.rs deleted file mode 100644 index 2512dc5..0000000 --- a/crates/basemyai/src/memory/schema.rs +++ /dev/null @@ -1,187 +0,0 @@ -// SPDX-License-Identifier: BUSL-1.1 -//! Schéma SQL de la couche mémoire. C'est **ici** (et pas dans le core) que -//! vivent `agent_id`, `valid_from`/`valid_until` et la notion de couche : le -//! core ne connaît que des tables + un index vecteur natif. -//! -//! La table doit s'appeler `memory` pour que l'index natif `memory_idx` soit -//! retrouvé par `Store::vector_knn("memory", ...)` (lookup `'{table}_idx'`). - -use basemyai_core::Migration; - -/// Dimension des embeddings du baseline (`all-MiniLM-L6-v2`). -pub const EMBEDDING_DIM: usize = 384; -/// Version publique du conteneur `.bmai`. -pub const BMAI_FORMAT_VERSION: u32 = 1; - -const BMAI_META_SCHEMA_V5: &str = "\ -CREATE TABLE IF NOT EXISTS bmai_meta ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL -); -INSERT OR IGNORE INTO bmai_meta (key, value) VALUES - ('format', 'basemyai-memory'), - ('format_version', '1'), - ('storage_engine', 'libsql'), - ('schema_family', 'agent-memory'), - ('embedding_dim', '384'); -"; - -const MEMORY_SCHEMA_V1: &str = "\ -CREATE TABLE IF NOT EXISTS memory ( - id TEXT PRIMARY KEY, - agent_id TEXT NOT NULL, - layer TEXT NOT NULL, - content TEXT NOT NULL, - valid_from INTEGER NOT NULL, - valid_until INTEGER, - emb F32_BLOB(384) -); -CREATE INDEX IF NOT EXISTS memory_idx ON memory(libsql_vector_idx(emb, 'metric=cosine')); -"; - -/// Graphe entités/relations (VISION §4.1, Phase 2). **Tables + CTE récursives** -/// dans le même fichier libSQL — pas de Kuzu/Neo4j. Comme la mémoire, chaque -/// ligne porte `agent_id` (isolation ADR-006) et une fenêtre de validité -/// (`valid_from`/`valid_until`, ADR-005). C'est du *sens*, donc ici, pas dans le -/// core (test d'agnosticité préservé). -const GRAPH_SCHEMA_V2: &str = "\ -CREATE TABLE IF NOT EXISTS entity ( - agent_id TEXT NOT NULL, - id TEXT NOT NULL, - kind TEXT NOT NULL, - label TEXT NOT NULL, - valid_from INTEGER NOT NULL, - valid_until INTEGER, - importance REAL NOT NULL DEFAULT 0, - PRIMARY KEY (agent_id, id) -); -CREATE INDEX IF NOT EXISTS entity_agent_idx ON entity(agent_id); - -CREATE TABLE IF NOT EXISTS edge ( - agent_id TEXT NOT NULL, - src TEXT NOT NULL, - dst TEXT NOT NULL, - relation TEXT NOT NULL, - weight REAL NOT NULL DEFAULT 1, - valid_from INTEGER NOT NULL, - valid_until INTEGER, - PRIMARY KEY (agent_id, src, dst, relation) -); -CREATE INDEX IF NOT EXISTS edge_src_idx ON edge(agent_id, src); -"; - -/// Répare les premières bases qui avaient des clés graphe globales au lieu de -/// clés composites par agent. Sans ça, deux agents ne pouvaient pas partager le -/// même identifiant logique (`alice`) ou la même relation (`alice -> acme`). -const GRAPH_AGENT_SCOPED_KEYS_V6: &str = "\ -CREATE TABLE IF NOT EXISTS entity_v6 ( - agent_id TEXT NOT NULL, - id TEXT NOT NULL, - kind TEXT NOT NULL, - label TEXT NOT NULL, - valid_from INTEGER NOT NULL, - valid_until INTEGER, - importance REAL NOT NULL DEFAULT 0, - PRIMARY KEY (agent_id, id) -); -INSERT OR IGNORE INTO entity_v6 (agent_id, id, kind, label, valid_from, valid_until, importance) - SELECT agent_id, id, kind, label, valid_from, valid_until, importance FROM entity; -DROP TABLE entity; -ALTER TABLE entity_v6 RENAME TO entity; -CREATE INDEX IF NOT EXISTS entity_agent_idx ON entity(agent_id); - -CREATE TABLE IF NOT EXISTS edge_v6 ( - agent_id TEXT NOT NULL, - src TEXT NOT NULL, - dst TEXT NOT NULL, - relation TEXT NOT NULL, - weight REAL NOT NULL DEFAULT 1, - valid_from INTEGER NOT NULL, - valid_until INTEGER, - PRIMARY KEY (agent_id, src, dst, relation) -); -INSERT OR IGNORE INTO edge_v6 (agent_id, src, dst, relation, weight, valid_from, valid_until) - SELECT agent_id, src, dst, relation, weight, valid_from, valid_until FROM edge; -DROP TABLE edge; -ALTER TABLE edge_v6 RENAME TO edge; -CREATE INDEX IF NOT EXISTS edge_src_idx ON edge(agent_id, src); -"; - -/// Recherche **hybride** (ADR-014) : index full-text BM25 natif libSQL (FTS5) en -/// complément du vecteur. Les deux signaux sont fusionnés par RRF (`rrf_fuse`) -/// dans `recall_hybrid` — un terme exact que l'embedding rate (sigle, identifiant, -/// nom propre rare) remonte quand même par BM25. -/// -/// Table **autonome** (pas external-content) : `id` et `agent_id` non indexés -/// (filtrage/jointure), seul `content` est tokenisé (`porter` = racinisation, -/// `remove_diacritics` = pliage des accents). Tenue à jour par la façade `Memory` -/// (insert au `remember`, delete au `forget`/`purge_agent`). Le `INSERT … SELECT` -/// **backfill** les souvenirs déjà présents lors de la migration. -const MEMORY_FTS_SCHEMA_V4: &str = "\ -CREATE VIRTUAL TABLE IF NOT EXISTS memory_fts USING fts5( - id UNINDEXED, - agent_id UNINDEXED, - content, - tokenize = 'porter unicode61 remove_diacritics 2' -); -INSERT INTO memory_fts (id, agent_id, content) - SELECT id, agent_id, content FROM memory; -"; - -/// Oubli adaptatif (VISION §5.2, Phase 2). Ajoute à `memory` les deux signaux -/// nécessaires au score de rétention : `importance` (pondération métier, défaut -/// neutre `0`) et `last_access` (dernier accès Unix, *nullable* — le fallback -/// est `valid_from`). On **ajoute** une migration, on ne réécrit jamais un -/// schéma déjà appliqué. Les `INSERT` existants listent leurs colonnes -/// explicitement : les défauts garantissent leur compatibilité. -const MEMORY_SCHEMA_V3: &str = "\ -ALTER TABLE memory ADD COLUMN importance REAL NOT NULL DEFAULT 0; -ALTER TABLE memory ADD COLUMN last_access INTEGER; -"; - -/// Provenance des faits (ADR-018 / audit sécurité, memory poisoning). -/// Distingue un souvenir mémorisé directement par l'agent (`'user'`, défaut) -/// d'un fait **promu par consolidation LLM** (`'consolidation'`) : ce dernier -/// a traversé une étape d'inférence sur du contenu potentiellement non fiable -/// (les épisodes), donc une confiance différente — l'escalade `episodic → -/// semantic` ne doit pas se faire silencieusement au même niveau de confiance -/// qu'un fait direct. On **ajoute** une colonne, on ne réécrit jamais un -/// schéma déjà appliqué (même pattern que `MEMORY_SCHEMA_V3`). -const MEMORY_SCHEMA_V7: &str = "\ -ALTER TABLE memory ADD COLUMN source TEXT NOT NULL DEFAULT 'user'; -"; - -/// Migrations de la couche mémoire + graphe, à passer à `Store::migrate`. -#[must_use] -pub fn schema() -> Vec { - vec![ - Migration { - version: 1, - up_sql: MEMORY_SCHEMA_V1, - }, - Migration { - version: 2, - up_sql: GRAPH_SCHEMA_V2, - }, - Migration { - version: 3, - up_sql: MEMORY_SCHEMA_V3, - }, - Migration { - version: 4, - up_sql: MEMORY_FTS_SCHEMA_V4, - }, - Migration { - version: 5, - up_sql: BMAI_META_SCHEMA_V5, - }, - Migration { - version: 6, - up_sql: GRAPH_AGENT_SCOPED_KEYS_V6, - }, - Migration { - version: 7, - up_sql: MEMORY_SCHEMA_V7, - }, - ] -} diff --git a/crates/basemyai/src/provision/embedder.rs b/crates/basemyai/src/provision/embedder.rs index 731b49a..5c98625 100644 --- a/crates/basemyai/src/provision/embedder.rs +++ b/crates/basemyai/src/provision/embedder.rs @@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use sysinfo::System; -/// Modèle baseline garanti partout en V1 (compat `.idx` ForgeMyAI). +/// Modèle baseline garanti partout en V1 (ADR-003). pub const BASELINE_MODEL_ID: &str = "all-MiniLM-L6-v2"; /// Dimension du baseline. pub const BASELINE_DIM: usize = 384; diff --git a/crates/basemyai/src/storage/libsql_store.rs b/crates/basemyai/src/storage/libsql_store.rs deleted file mode 100644 index 95bdd00..0000000 --- a/crates/basemyai/src/storage/libsql_store.rs +++ /dev/null @@ -1,522 +0,0 @@ -// SPDX-License-Identifier: BUSL-1.1 -//! Seule implémentation V1 de [`MemoryStore`] : enveloppe -//! [`basemyai_core::Store`] et concentre tout le SQL/`Filter` brut du crate. -//! Le SQL ici est un **déplacement**, pas une réécriture, des requêtes qui -//! vivaient auparavant dans `memory/mod.rs`, `cognition/graph.rs` et -//! `cognition/consolidation.rs`. - -use basemyai_core::libsql::{self, Connection}; -use basemyai_core::{Filter, Metric, Store, Value}; - -use super::{HydratedRecord, MemoryStore, NewMemory}; -use crate::cognition::Reached; -use crate::temporal::Validity; -use crate::{AgentId, AgentStats, MemoryLayer, Record, Result}; - -/// Filtre `WHERE` agent + validité temporelle, commun à tous les recalls. -/// `layer` ajoute une quatrième condition/valeur quand présent. -fn agent_temporal_filter(agent: &AgentId, now: i64, layer: Option) -> Filter { - match layer { - Some(l) => Filter::new( - "agent_id = ? AND valid_from <= ? AND (valid_until IS NULL OR valid_until > ?) AND layer = ?", - vec![ - Value::Text(agent.as_str().to_string()), - Value::Integer(now), - Value::Integer(now), - Value::Text(l.table().to_string()), - ], - ), - None => Filter::new( - "agent_id = ? AND valid_from <= ? AND (valid_until IS NULL OR valid_until > ?)", - vec![ - Value::Text(agent.as_str().to_string()), - Value::Integer(now), - Value::Integer(now), - ], - ), - } -} - -/// Moteur de stockage libSQL — V1 unique, ADR-011. -pub struct LibsqlMemoryStore { - store: Store, -} - -impl LibsqlMemoryStore { - /// Enveloppe un [`Store`] déjà ouvert (et migré) dans un moteur mémoire. - #[must_use] - pub fn new(store: Store) -> Self { - Self { store } - } - - /// Store libSQL sous-jacent. `pub(crate)` : réservé à `memory::porting` - /// (export/import JSONL), qui a besoin de colonnes (`importance`, - /// `last_access`) hors du contrat sémantique [`MemoryStore`] et reste, à - /// ce titre, couplé au backend concret V1 — au même titre que - /// `maintenance::{gc, forgetting}` (suivi possible si un second backend - /// apparaît un jour, pas une régression introduite par ce refactor). - pub(crate) fn store(&self) -> &Store { - &self.store - } - - /// Couche d'un souvenir par id + agent, ou `None` si absent (ou appartenant - /// à un autre agent — la requête est bornée par `agent_id`). `pub(crate)` : - /// la façade [`Memory`](crate::Memory) s'en sert pour étiqueter les - /// événements `Invalidated`/`Forgotten` de la bonne couche, et n'émet que si - /// un souvenir existe réellement pour cet agent (pas sur un no-op cross-agent). - pub(crate) async fn layer_of(&self, agent: &AgentId, id: &str) -> Result> { - let conn = self.store.reader(); - let mut rows = conn - .query( - "SELECT layer FROM memory WHERE id = ?1 AND agent_id = ?2 LIMIT 1", - libsql::params![id, agent.as_str()], - ) - .await - .map_err(storage)?; - match rows.next().await.map_err(storage)? { - Some(row) => { - let layer_str: String = row.get(0).map_err(storage)?; - Ok(Some(MemoryLayer::from_table(&layer_str)?)) - } - None => Ok(None), - } - } - - /// `(content, layer)` d'un souvenir par id + agent, ou `None` si absent. - async fn query_row_content_layer( - &self, - conn: &Connection, - agent: &AgentId, - id: &str, - ) -> Result> { - let mut rows = conn - .query( - "SELECT content, layer FROM memory WHERE id = ?1 AND agent_id = ?2", - libsql::params![id, agent.as_str()], - ) - .await - .map_err(storage)?; - match rows.next().await.map_err(storage)? { - Some(row) => { - let content: String = row.get(0).map_err(storage)?; - let layer_str: String = row.get(1).map_err(storage)?; - Ok(Some((content, MemoryLayer::from_table(&layer_str)?))) - } - None => Ok(None), - } - } - - /// Hydrate des [`basemyai_core::Neighbor`] en [`Record`] (score = distance), - /// puis marque `last_access` — brique commune de `recall_vector` et - /// `recall_graph_filtered`. - async fn hydrate_neighbors( - &self, - agent: &AgentId, - neighbors: Vec, - now: i64, - ) -> Result> { - let conn = self.store.connect(); - let mut out = Vec::with_capacity(neighbors.len()); - for n in &neighbors { - if let Some((content, layer)) = self.query_row_content_layer(&conn, agent, &n.id).await? { - out.push(Record { - id: n.id.clone(), - text: content, - layer, - score: n.distance, - }); - } - } - touch_last_access(&conn, out.iter().map(|r| r.id.as_str()), now).await?; - Ok(out) - } -} - -/// Marque `last_access = now` pour chaque id, sur la connexion fournie. -async fn touch_last_access<'a>(conn: &Connection, ids: impl Iterator, now: i64) -> Result<()> { - for id in ids { - conn.execute( - "UPDATE memory SET last_access = ?1 WHERE id = ?2", - libsql::params![now, id.to_string()], - ) - .await - .map_err(storage)?; - } - Ok(()) -} - -#[async_trait::async_trait] -impl MemoryStore for LibsqlMemoryStore { - async fn put_memory( - &self, - id: &str, - agent: &AgentId, - layer: MemoryLayer, - text: &str, - validity: Validity, - vector: &[f32], - source: &str, - ) -> Result<()> { - let txn = self.store.begin_write().await?; - insert_memory_row(&txn, id, agent.as_str(), layer, text, validity, vector, source).await?; - txn.commit().await?; - Ok(()) - } - - async fn put_memory_batch(&self, agent: &AgentId, items: &[NewMemory<'_>]) -> Result<()> { - if items.is_empty() { - return Ok(()); - } - let txn = self.store.begin_write().await?; - for item in items { - insert_memory_row( - &txn, - &item.id, - agent.as_str(), - item.layer, - item.text, - item.validity, - item.vector, - item.source, - ) - .await?; - } - txn.commit().await?; - Ok(()) - } - - async fn recall_vector( - &self, - agent: &AgentId, - query: &[f32], - k: usize, - layer: Option, - metric: Metric, - now: i64, - ) -> Result> { - let filter = agent_temporal_filter(agent, now, layer); - let neighbors = self - .store - .vector_knn_metric("memory", query, k, Some(&filter), metric) - .await?; - self.hydrate_neighbors(agent, neighbors, now).await - } - - async fn recall_graph_filtered(&self, agent: &AgentId, query: &[f32], k: usize, now: i64) -> Result> { - let filter = Filter::new( - "agent_id = ? AND valid_from <= ? AND (valid_until IS NULL OR valid_until > ?) \ - AND EXISTS (\ - SELECT 1 FROM entity \ - WHERE entity.agent_id = ? \ - AND (entity.valid_until IS NULL OR entity.valid_until > ?) \ - AND instr(content, entity.label) > 0\ - )", - vec![ - Value::Text(agent.as_str().to_string()), - Value::Integer(now), - Value::Integer(now), - Value::Text(agent.as_str().to_string()), - Value::Integer(now), - ], - ); - let neighbors = self.store.vector_knn("memory", query, k, Some(&filter)).await?; - self.hydrate_neighbors(agent, neighbors, now).await - } - - async fn vector_ranking_ids(&self, agent: &AgentId, query: &[f32], k: usize, now: i64) -> Result> { - let filter = agent_temporal_filter(agent, now, None); - let neighbors = self.store.vector_knn("memory", query, k, Some(&filter)).await?; - Ok(neighbors.into_iter().map(|n| n.id).collect()) - } - - async fn keyword_ranking_ids(&self, agent: &AgentId, match_expr: &str, k: usize, now: i64) -> Result> { - let conn = self.store.reader(); - let mut rows = conn - .query( - // FTS5 exige le nom réel de la table dans MATCH/bm25 (pas un alias). - "SELECT memory_fts.id FROM memory_fts JOIN memory m ON m.id = memory_fts.id \ - WHERE memory_fts MATCH ?1 AND memory_fts.agent_id = ?2 \ - AND m.valid_from <= ?3 AND (m.valid_until IS NULL OR m.valid_until > ?3) \ - ORDER BY bm25(memory_fts) LIMIT ?4", - libsql::params![match_expr, agent.as_str(), now, i64::try_from(k).unwrap_or(i64::MAX)], - ) - .await - .map_err(storage)?; - let mut ids = Vec::new(); - while let Some(row) = rows.next().await.map_err(storage)? { - ids.push(row.get::(0).map_err(storage)?); - } - Ok(ids) - } - - async fn hydrate(&self, agent: &AgentId, ids: &[String], now: i64) -> Result> { - let conn = self.store.connect(); - let mut out = Vec::with_capacity(ids.len()); - for id in ids { - if let Some((content, layer)) = self.query_row_content_layer(&conn, agent, id).await? { - out.push(HydratedRecord { - id: id.clone(), - text: content, - layer, - }); - } - } - touch_last_access(&conn, out.iter().map(|r| r.id.as_str()), now).await?; - Ok(out) - } - - async fn invalidate(&self, agent: &AgentId, id: &str, now: i64) -> Result<()> { - let conn = self.store.connect(); - conn.execute( - "UPDATE memory SET valid_until = ?1 WHERE id = ?2 AND agent_id = ?3", - libsql::params![now, id, agent.as_str()], - ) - .await - .map_err(storage)?; - Ok(()) - } - - async fn forget(&self, agent: &AgentId, id: &str) -> Result<()> { - let txn = self.store.begin_write().await?; - txn.execute( - "DELETE FROM memory WHERE id = ?1 AND agent_id = ?2", - libsql::params![id, agent.as_str()], - ) - .await - .map_err(storage)?; - txn.execute( - "DELETE FROM memory_fts WHERE id = ?1 AND agent_id = ?2", - libsql::params![id, agent.as_str()], - ) - .await - .map_err(storage)?; - txn.commit().await?; - Ok(()) - } - - async fn purge_agent(&self, agent: &AgentId) -> Result<()> { - let txn = self.store.begin_write().await?; - // Noms de tables en dur (jamais d'input) ; l'agent passe en paramètre lié. - // `memory_fts` (miroir BM25) est purgé avec le reste (ADR-014). - for table in ["memory", "entity", "edge", "memory_fts"] { - txn.execute( - &format!("DELETE FROM {table} WHERE agent_id = ?1"), - libsql::params![agent.as_str()], - ) - .await - .map_err(storage)?; - } - txn.commit().await?; - Ok(()) - } - - async fn agent_stats(&self, agent: &AgentId, now: i64) -> Result { - let conn = self.store.reader(); - let mut rows = conn - .query( - "SELECT layer, COUNT(*) FROM memory \ - WHERE agent_id = ?1 AND valid_from <= ?2 \ - AND (valid_until IS NULL OR valid_until > ?2) \ - GROUP BY layer", - libsql::params![agent.as_str(), now], - ) - .await - .map_err(storage)?; - - let mut stats = AgentStats::default(); - while let Some(row) = rows.next().await.map_err(storage)? { - let layer_str: String = row.get(0).map_err(storage)?; - let count: i64 = row.get(1).map_err(storage)?; - let n = usize::try_from(count).unwrap_or(0); - match layer_str.as_str() { - "short_term" => stats.short_term = n, - "episodic" => stats.episodic = n, - "procedural" => stats.procedural = n, - "semantic" => stats.semantic = n, - _ => {} - } - } - Ok(stats) - } - - async fn graph_upsert_entity( - &self, - agent: &AgentId, - id: &str, - kind: &str, - label: &str, - validity: Validity, - ) -> Result<()> { - let conn = self.store.connect(); - conn.execute( - "INSERT INTO entity (id, agent_id, kind, label, valid_from, valid_until) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6) \ - ON CONFLICT(agent_id, id) DO UPDATE SET \ - kind = excluded.kind, label = excluded.label, \ - valid_from = excluded.valid_from, valid_until = excluded.valid_until", - libsql::params![ - id, - agent.as_str(), - kind, - label, - validity.valid_from, - validity.valid_until - ], - ) - .await - .map_err(storage)?; - Ok(()) - } - - async fn graph_upsert_edge( - &self, - agent: &AgentId, - src: &str, - relation: &str, - dst: &str, - weight: f64, - now: i64, - ) -> Result<()> { - let conn = self.store.connect(); - conn.execute( - "INSERT INTO edge (src, dst, agent_id, relation, weight, valid_from, valid_until) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL) \ - ON CONFLICT(agent_id, src, dst, relation) DO UPDATE SET weight = excluded.weight", - libsql::params![src, dst, agent.as_str(), relation, weight, now], - ) - .await - .map_err(storage)?; - Ok(()) - } - - async fn graph_traverse(&self, agent: &AgentId, start: &str, max_depth: u32, now: i64) -> Result> { - let conn = self.store.reader(); - let sql = "\ - WITH RECURSIVE reach(node, depth) AS ( \ - SELECT ?1, 0 \ - UNION \ - SELECT e.dst, r.depth + 1 \ - FROM edge e JOIN reach r ON e.src = r.node \ - WHERE e.agent_id = ?2 \ - AND (e.valid_until IS NULL OR e.valid_until > ?3) \ - AND r.depth < ?4 \ - ) \ - SELECT e.id, e.kind, e.label, MIN(r.depth) AS d \ - FROM reach r \ - JOIN entity e ON e.id = r.node \ - WHERE r.node <> ?1 \ - AND e.agent_id = ?2 \ - AND (e.valid_until IS NULL OR e.valid_until > ?3) \ - GROUP BY e.id, e.kind, e.label \ - ORDER BY d, e.id"; - - let mut rows = conn - .query(sql, libsql::params![start, agent.as_str(), now, i64::from(max_depth)]) - .await - .map_err(storage)?; - - let mut out = Vec::new(); - while let Some(row) = rows.next().await.map_err(storage)? { - let depth: i64 = row.get(3).map_err(storage)?; - out.push(Reached { - id: row.get::(0).map_err(storage)?, - kind: row.get::(1).map_err(storage)?, - label: row.get::(2).map_err(storage)?, - depth: u32::try_from(depth).unwrap_or(u32::MAX), - }); - } - Ok(out) - } - - async fn recent_episodes(&self, agent: &AgentId, limit: usize, now: i64) -> Result> { - let conn = self.store.reader(); - let mut rows = conn - .query( - "SELECT content FROM memory \ - WHERE agent_id = ?1 AND layer = 'episodic' \ - AND valid_from <= ?2 AND (valid_until IS NULL OR valid_until > ?2) \ - ORDER BY valid_from DESC LIMIT ?3", - libsql::params![agent.as_str(), now, i64::try_from(limit).unwrap_or(i64::MAX)], - ) - .await - .map_err(storage)?; - - let mut out = Vec::new(); - while let Some(row) = rows.next().await.map_err(storage)? { - out.push(row.get::(0).map_err(storage)?); - } - Ok(out) - } - - async fn exact_fact_exists(&self, agent: &AgentId, content: &str) -> Result { - let conn = self.store.reader(); - let mut rows = conn - .query( - "SELECT 1 FROM memory \ - WHERE agent_id = ?1 AND layer = 'semantic' AND content = ?2 LIMIT 1", - libsql::params![agent.as_str(), content], - ) - .await - .map_err(storage)?; - Ok(rows.next().await.map_err(storage)?.is_some()) - } -} - -/// Mappe une erreur libSQL en [`crate::MemoryError`] (via `CoreError::Storage`). -fn storage(e: libsql::Error) -> crate::MemoryError { - basemyai_core::CoreError::Storage(e.to_string()).into() -} - -/// Insère un souvenir (`memory` + miroir FTS, ADR-014) sur la connexion -/// fournie — une [`basemyai_core::WriteTxn`] en pratique, pour que les deux -/// écritures soient atomiques. `source` trace la provenance (`'user'` direct, -/// `'consolidation'` promu par le pipeline LLM, ADR-018 / audit sécurité). -#[allow(clippy::too_many_arguments)] -async fn insert_memory_row( - conn: &Connection, - id: &str, - agent: &str, - layer: MemoryLayer, - text: &str, - validity: Validity, - vector: &[f32], - source: &str, -) -> Result<()> { - conn.execute( - "INSERT INTO memory (id, agent_id, layer, content, valid_from, valid_until, emb, source) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, vector(?7), ?8)", - libsql::params![ - id, - agent, - layer.table(), - text, - validity.valid_from, - validity.valid_until, - to_vec_literal(vector), - source, - ], - ) - .await - .map_err(storage)?; - conn.execute( - "INSERT INTO memory_fts (id, agent_id, content) VALUES (?1, ?2, ?3)", - libsql::params![id, agent, text], - ) - .await - .map_err(storage)?; - Ok(()) -} - -/// Formate un vecteur en littéral SQL `[a,b,c]` consommé par `vector(?)`. -fn to_vec_literal(v: &[f32]) -> String { - let mut s = String::with_capacity(v.len() * 8 + 2); - s.push('['); - for (i, x) in v.iter().enumerate() { - if i > 0 { - s.push(','); - } - s.push_str(&x.to_string()); - } - s.push(']'); - s -} diff --git a/crates/basemyai/src/storage/mod.rs b/crates/basemyai/src/storage/mod.rs index d23f97d..06ffe2e 100644 --- a/crates/basemyai/src/storage/mod.rs +++ b/crates/basemyai/src/storage/mod.rs @@ -1,27 +1,20 @@ // SPDX-License-Identifier: BUSL-1.1 -//! Frontière moteur de stockage (suivi ADR-019 : *« Gradually move SQL/libSQL- -//! specific code behind an engine module… Add backend contract tests before -//! any second backend exists »*). +//! Frontière moteur de stockage. [`basemyai_core::StorageEngine`] reste le +//! contrat d'**identité/capacités** bas niveau (inchangé par ce module). +//! [`MemoryStore`] est un *second* contrat, à un niveau sémantique différent : +//! il connaît `agent_id`, les couches mémoire et le graphe — exactement ce que +//! `basemyai-core` n'a pas le droit de connaître (ADR-001). Il vit donc ici, +//! dans `basemyai`, jamais dans le core agnostique. //! -//! [`basemyai_core::StorageEngine`] reste le contrat d'**identité/capacités** -//! bas niveau (inchangé par ce module). [`MemoryStore`] est un *second* -//! contrat, à un niveau sémantique différent : il connaît `agent_id`, les -//! couches mémoire et le graphe — exactement ce que `basemyai-core` n'a pas le -//! droit de connaître (ADR-001). Il vit donc ici, dans `basemyai`, jamais dans -//! le core agnostique. -//! -//! [`Filter`](basemyai_core::Filter)/[`Value`](basemyai_core::Value) et le SQL -//! brut n'apparaissent dans **aucune** signature de [`MemoryStore`] : ils -//! restent un détail d'implémentation de [`LibsqlMemoryStore`], la seule -//! implémentation prévue en V1. +//! [`NativeMemoryStore`] est l'**unique** implémentation depuis ADR-032 +//! (libSQL retiré du workspace, ADR-011 clos) — le trait reste un seam +//! délibéré (testabilité, ADR-020), pas une abstraction sans second cas +//! d'usage. -mod libsql_store; -#[cfg(feature = "engine-native")] mod native_store; -pub use libsql_store::LibsqlMemoryStore; -#[cfg(feature = "engine-native")] -pub use native_store::NativeMemoryStore; +pub use native_store::{BMAI_FORMAT_VERSION, NativeExportRows, NativeMemoryStore}; +pub(crate) use native_store::{NativeImportEdge, NativeImportEntity, NativeImportMemory}; use basemyai_core::Metric; @@ -40,6 +33,17 @@ pub struct NewMemory<'a> { pub source: &'a str, } +/// Un souvenir listé (`MemoryStore::list_memories`) — toutes les colonnes que +/// le CLI `list` affiche, sans score de classement (ce n'est pas un recall). +#[derive(Debug, Clone)] +pub struct ListedRecord { + pub id: String, + pub layer: MemoryLayer, + pub content: String, + pub valid_from: i64, + pub valid_until: Option, +} + /// Un souvenir hydraté (contenu + couche), sans score de classement — brique /// de [`MemoryStore::hydrate`], partagée par les chemins de recall qui /// attachent leur propre score (distance cosinus, RRF…) après hydratation. @@ -56,8 +60,8 @@ pub struct HydratedRecord { /// [`LlmInference`](crate::LlmInference). /// /// Un seul trait, pas de split `MemoryStore`/`GraphStore` : il n'y a qu'une -/// implémentation prévue en V1 ([`LibsqlMemoryStore`]), scinder maintenant -/// serait une abstraction sans second cas d'usage réel. +/// implémentation ([`NativeMemoryStore`]), scinder maintenant serait une +/// abstraction sans second cas d'usage réel. #[async_trait::async_trait] pub trait MemoryStore: Send + Sync { /// Insère un souvenir (et son miroir FTS) de façon atomique. @@ -148,4 +152,25 @@ pub trait MemoryStore: Send + Sync { /// déjà pour `agent` — brique de la déduplication de consolidation (le /// volet similarité sémantique reste côté `Memory::recall_by_layer`). async fn exact_fact_exists(&self, agent: &AgentId, content: &str) -> Result; + + /// Couche d'un souvenir par id, borné à `agent` — `None` si absent (ou + /// appartenant à un autre agent). Brique de l'étiquetage des événements + /// `Invalidated`/`Forgotten` de la façade [`Memory`](crate::Memory) : + /// n'émettre que si un souvenir existe réellement pour cet agent, jamais + /// sur un no-op cross-agent. + async fn layer_of(&self, agent: &AgentId, id: &str) -> Result>; + + /// Liste les souvenirs de `agent`, du plus récent au plus ancien + /// (`valid_from` décroissant), filtrés sur une couche optionnelle et + /// bornés à `limit` — brique du CLI `list` (diagnostic, pas un recall : + /// aucun embedding). `include_invalid` inclut les souvenirs dont + /// `valid_until` est déjà passé (défaut : exclus). + async fn list_memories( + &self, + agent: &AgentId, + layer: Option, + limit: usize, + include_invalid: bool, + now: i64, + ) -> Result>; } diff --git a/crates/basemyai/src/storage/native_store.rs b/crates/basemyai/src/storage/native_store.rs index 1560c87..6f7bdef 100644 --- a/crates/basemyai/src/storage/native_store.rs +++ b/crates/basemyai/src/storage/native_store.rs @@ -10,13 +10,13 @@ //! //! ## Parité comportementale (ADR-027 §6, ADR-028) //! -//! Chaque méthode reproduit la requête SQL de [`super::LibsqlMemoryStore`], +//! Chaque méthode implémente la sémantique de requête historique du domaine, //! y compris ses non-filtres : `hydrate` et `exact_fact_exists` ne vérifient -//! **pas** la validité temporelle (l'original non plus), `graph_upsert_edge` +//! **pas** la validité temporelle, `graph_upsert_edge` //! préserve le `valid_from` d'une arête existante et ne met à jour que -//! `weight` (le `ON CONFLICT ... DO UPDATE SET weight` original). Le KNN -//! oversample ×[`OVERSAMPLE`] puis post-filtre (ADR-012 — libSQL fait -//! exactement cela quand un filtre est présent, et ici un filtre +//! `weight` (parité sémantique historique V1 : upsert ne modifie que le poids +//! d'une arête existante). Le KNN +//! oversample ×[`OVERSAMPLE`] puis post-filtre (ADR-012 : un filtre //! agent+validité est *toujours* présent). `keyword_ranking_ids` est BM25 //! natif (ADR-028) sur le sous-ensemble de `match_expr` que //! `fts_match_expr()` produit réellement — pas de racinisation Porter (gap @@ -64,16 +64,51 @@ use crate::cognition::Reached; use crate::temporal::Validity; use crate::{AgentId, AgentStats, MemoryLayer, Record, Result}; +/// Préfixe KV des métadonnées conteneur (équivalent sémantique `bmai_meta`, ADR-019). +/// Paires clé/valeur UTF-8 brutes que `basemyai` possède (contrat embedding, +/// `format`/`storage_engine`/…) — hors du keyspace réservé `idx/` du moteur. +const BMAI_META_PREFIX: &[u8] = b"meta/bmai/"; + +/// Clé complète d'une entrée de méta consommateur. +fn bmai_meta_key(name: &str) -> Vec { + let mut key = Vec::with_capacity(BMAI_META_PREFIX.len() + name.len()); + key.extend_from_slice(BMAI_META_PREFIX); + key.extend_from_slice(name.as_bytes()); + key +} + +/// Version publique du conteneur `.bmai` (ADR-033 : moteur natif, seul backend). +pub const BMAI_FORMAT_VERSION: u32 = 2; + +/// Seme les méta de conteneur (`format`/`format_version`/`storage_engine`/ +/// `schema_family`/`embedding_dim`) au premier `open` d'un store natif. +/// Idempotent (`INSERT OR IGNORE`, une entrée déjà présente n'est jamais +/// réécrite) : appelé à **chaque** ouverture, jamais seulement à la création. +fn ensure_container_meta(engine: &mut Engine) -> Result<()> { + for (name, value) in [ + ("format", "basemyai-memory".to_string()), + ("format_version", BMAI_FORMAT_VERSION.to_string()), + ("storage_engine", "native".to_string()), + ("schema_family", "agent-memory".to_string()), + ("embedding_dim", crate::EMBEDDING_DIM.to_string()), + ] { + let key = bmai_meta_key(name); + if engine.get(&key).map_err(storage)?.is_none() { + engine.put(&key, value.as_bytes()).map_err(storage)?; + } + } + Ok(()) +} + /// Facteur d'oversampling du KNN filtré (ADR-012) : on demande `k × 8` /// candidats à l'index, puis le post-filtre agent/validité/couche réduit à -/// `k` — même politique que le `vector_knn` libSQL en présence d'un filtre. +/// `k` — politique ADR-012 (filtre agent+validité toujours présent). const OVERSAMPLE: usize = 8; -/// Importance par défaut d'un souvenir inséré — parité avec le `DEFAULT 1.0` -/// de la colonne `importance` du schéma libSQL. +/// Importance par défaut d'un souvenir inséré — parité avec le défaut historique V1 (`1.0`). const DEFAULT_IMPORTANCE: f64 = 1.0; -/// Moteur de stockage natif — ADR-024/ADR-027, feature `engine-native`. +/// Moteur de stockage natif — ADR-024/ADR-027/ADR-033 (unique implémentation `MemoryStore`). /// /// Concurrence (N5.5, barre hardening M6) : `inner` est un `RwLock`, pas un /// `Mutex` — les chemins de lecture pure (`vector_ranking_ids`, @@ -94,7 +129,7 @@ const DEFAULT_IMPORTANCE: f64 = 1.0; pub struct NativeMemoryStore { inner: Arc>, /// Garde de vie du répertoire temporaire d'[`Self::open_ephemeral`] — - /// supprimé au drop du store, comme un `open_in_memory` libSQL. + /// supprimé au drop du store (store éphémère test-only). #[cfg(feature = "test-util")] _tempdir: Option, } @@ -108,8 +143,7 @@ struct NativeInner { } /// Mappe une erreur du backend natif (ou du pont async) en -/// [`crate::MemoryError`] — même convention que le `storage()` de -/// `libsql_store.rs`. +/// [`crate::MemoryError`]. fn storage(e: impl std::fmt::Display) -> crate::MemoryError { basemyai_core::CoreError::Storage(e.to_string()).into() } @@ -144,6 +178,7 @@ impl NativeMemoryStore { let params = basemyai_engine::VectorIndexParams::with_dim(crate::EMBEDDING_DIM); let vectors = PersistentVectorIndex::open(&mut engine, params).map_err(storage)?; let memory = PersistentMemoryIndex::open(&engine).map_err(storage)?; + ensure_container_meta(&mut engine)?; Ok(Self { inner: Arc::new(RwLock::new(NativeInner { engine, @@ -159,10 +194,9 @@ impl NativeMemoryStore { /// Fait tourner la clé de chiffrement **en place** (ADR-030 §4) : la DEK /// du store est ré-enveloppée sous une KEK dérivée de `new_key`, - /// `crypto.meta` remplacé atomiquement. O(1), et contrairement à - /// [`Memory::rotate_key`](crate::Memory::rotate_key) côté libSQL, - /// **cette instance reste pleinement utilisable après l'appel** — pas de - /// réouverture requise. + /// `crypto.meta` remplacé atomiquement. O(1), et **cette instance reste + /// pleinement utilisable après l'appel** — pas de réouverture requise + /// (contrairement à l'ancien chemin `PRAGMA rekey` libSQL). /// /// # Errors /// Erreur de stockage si le store n'a pas été ouvert chiffré (rien à @@ -174,9 +208,8 @@ impl NativeMemoryStore { .await } - /// Store natif jetable dans un répertoire temporaire, supprimé au drop — - /// l'équivalent natif de `Store::open_in_memory` (le moteur LSM n'a pas - /// de mode in-memory). Réservé aux tests, comme son homologue libSQL. + /// Store natif jetable dans un répertoire temporaire, supprimé au drop + /// (le moteur LSM n'a pas de mode in-memory). Réservé aux tests. /// /// # Errors /// Erreur de stockage si le répertoire temporaire ou le store ne se @@ -205,6 +238,41 @@ impl NativeMemoryStore { Ok(store) } + /// Méta de conteneur (`format`/`format_version`/`storage_engine`/…, plus + /// `embedding_model_id`/`embedding_dim` si une [`crate::Memory`] a déjà + /// été ouverte dessus) — lecture des paires clé/valeur sous le préfixe + /// `bmai_meta` (CLI `inspect`/`verify`, ADR-033). Triée par + /// nom pour un affichage stable. + /// + /// # Errors + /// Erreur de stockage si le scan échoue. + pub async fn container_metadata(&self) -> Result> { + self.with_inner_read(|inner| { + let entries = inner.engine.scan_prefix(BMAI_META_PREFIX).map_err(storage)?; + let mut out = Vec::with_capacity(entries.len()); + for (key, value) in entries { + let name = String::from_utf8(key.as_bytes()[BMAI_META_PREFIX.len()..].to_vec()) + .map_err(|e| storage(format!("nom de méta consommateur non UTF-8 : {e}")))?; + let value = String::from_utf8(value).map_err(|e| storage(format!("valeur de méta non UTF-8 : {e}")))?; + out.push((name, value)); + } + out.sort(); + Ok(out) + }) + .await + } + + /// Nombre total de souvenirs, **toutes couches et tous agents confondus** + /// — l'homologue natif de `SELECT COUNT(*) FROM memory` du CLI `inspect` + /// (ADR-032). + /// + /// # Errors + /// Erreur de stockage si le scan échoue. + pub async fn total_memory_count(&self) -> Result { + self.with_inner_read(|inner| inner.memory.count_all(&inner.engine).map_err(storage)) + .await + } + /// Exécute `f` sur l'état natif dans le pool bloquant de tokio sous /// verrou d'**écriture** (exclusif), pris à l'intérieur de la closure /// (jamais à travers un `.await`) — les mutations (`put_memory*`, @@ -245,6 +313,218 @@ impl NativeMemoryStore { .await .map_err(|e| storage(format!("tâche bloquante du store natif interrompue : {e}")))? } + + /// Sémantique `INSERT OR IGNORE` puis lecture sur la méta consommateur + /// (ADR-033) : si `name` existe, renvoie la valeur **stockée** (jamais + /// écrasée) ; sinon écrit `value` et la renvoie. Brique du contrat + /// embedding (`ensure_embedding_contract`). + pub(crate) async fn meta_ensure(&self, name: &str, value: &str) -> Result { + let (name, value) = (name.to_string(), value.to_string()); + self.with_inner(move |inner| { + let key = bmai_meta_key(&name); + if let Some(existing) = inner.engine.get(&key).map_err(storage)? { + return String::from_utf8(existing) + .map_err(|e| storage(format!("méta consommateur {name:?} non UTF-8 : {e}"))); + } + inner.engine.put(&key, value.as_bytes()).map_err(storage)?; + Ok(value) + }) + .await + } + + /// Tout ce qui appartient à `agent`, en lignes brutes du moteur — la + /// brique de l'export JSONL (ADR-032). Les tris reproduisent les + /// Tri déterministe pour l'export JSONL (souvenirs par `(valid_from, id)`, + /// entités par `id`, arêtes par `(src, dst, relation)`) pour qu'un même + /// contenu produise un export identique octet pour octet quel que soit + /// le backend. + pub async fn export_rows(&self, agent: &AgentId) -> Result { + let agent = agent.clone(); + self.with_inner_read(move |inner| { + let NativeInner { + engine, memory, graph, .. + } = inner; + let mut memories = memory.scan_agent(engine, agent.as_str()).map_err(storage)?; + memories.sort_by(|(a_id, a), (b_id, b)| (a.valid_from, a_id).cmp(&(b.valid_from, b_id))); + // Le scan structurel rend déjà les entités par id ascendant + // (ordre des octets de clé) — équivalent du `ORDER BY id`. + let entities = graph.entities(engine, agent.as_str()).map_err(storage)?; + let mut edges = graph.edges(engine, agent.as_str()).map_err(storage)?; + edges.sort_by(|(a_src, a_rel, a_dst, _), (b_src, b_rel, b_dst, _)| { + (a_src, a_dst, a_rel).cmp(&(b_src, b_dst, b_rel)) + }); + Ok(NativeExportRows { + memories, + entities, + edges, + }) + }) + .await + } + + /// Import idempotent de lignes complètes (ADR-032) : les souvenirs + /// nouveaux partent en **un seul** batch WAL tout-ou-rien + /// (`put_many`, N5.5) avec leur fidélité complète + /// (`importance`/`last_access` préservés — contrairement à + /// `put_memory_batch` qui applique les défauts d'un souvenir neuf) ; + /// les ids déjà présents (dans le store **ou** plus haut dans le même + /// fichier) sont comptés `*_skipped` et laissés intacts — la sémantique + /// Sémantique insert-or-ignore sur la méta consommateur. Entités et arêtes suivent en + /// upserts individuels durables : l'import natif est **idempotent et + /// reprennable**, pas globalement atomique (écart assumé, ADR-032 §3 — + /// même classe que `purge_agent`, ADR-027 §6). + pub(crate) async fn import_rows( + &self, + agent: &AgentId, + memories: Vec, + entities: Vec, + edges: Vec, + ) -> Result { + let agent = agent.clone(); + self.with_inner(move |inner| { + let mut report = crate::ImportReport::default(); + + let NativeInner { + engine, + vectors, + memory, + graph, + fts, + } = &mut *inner; + + // ── Souvenirs : filtre des présents, un batch pour les neufs ── + let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new(); + let mut fresh: Vec<&NativeImportMemory> = Vec::new(); + for m in &memories { + let exists = memory.get(engine, agent.as_str(), &m.id).map_err(storage)?.is_some(); + if exists || !seen.insert(m.id.as_str()) { + report.memories_skipped += 1; + } else { + fresh.push(m); + } + } + let entries: Vec<(&str, NewMemoryRecord<'_>, Vec)> = fresh + .iter() + .map(|m| { + ( + m.id.as_str(), + NewMemoryRecord { + layer: m.layer.table(), + content: &m.content, + source: &m.source, + valid_from: m.valid_from, + valid_until: m.valid_until, + importance: m.importance, + last_access: m.last_access.unwrap_or(m.valid_from), + }, + m.vector.clone(), + ) + }) + .collect(); + if !entries.is_empty() { + memory + .put_many(engine, vectors, fts, agent.as_str(), &entries) + .map_err(storage)?; + report.memories += entries.len(); + } + + // ── Entités : `INSERT OR IGNORE` — jamais d'écrasement ──────── + for e in entities { + if graph.entity(engine, agent.as_str(), &e.id).map_err(storage)?.is_some() { + report.entities_skipped += 1; + continue; + } + graph + .upsert_entity( + engine, + agent.as_str(), + &e.id, + basemyai_engine::GraphEntity { + kind: e.kind, + label: e.label, + valid_from: e.valid_from, + valid_until: e.valid_until, + }, + ) + .map_err(storage)?; + report.entities += 1; + } + + // ── Arêtes : idem, la méta complète de l'export est préservée ─ + for e in edges { + if graph + .edge_meta(engine, agent.as_str(), &e.src, &e.relation, &e.dst) + .map_err(storage)? + .is_some() + { + report.edges_skipped += 1; + continue; + } + graph + .upsert_edge( + engine, + agent.as_str(), + &e.src, + &e.relation, + &e.dst, + basemyai_engine::GraphEdgeMeta { + weight: e.weight, + valid_from: e.valid_from, + valid_until: e.valid_until, + }, + ) + .map_err(storage)?; + report.edges += 1; + } + + Ok(report) + }) + .await + } +} + +/// Lignes brutes d'un export natif ([`NativeMemoryStore::export_rows`]) — +/// tout ce qui appartient à un agent, dans l'ordre de sérialisation JSONL. +pub struct NativeExportRows { + pub memories: Vec<(String, basemyai_engine::MemoryRecord)>, + pub entities: Vec<(String, basemyai_engine::GraphEntity)>, + pub edges: Vec<(String, String, String, basemyai_engine::GraphEdgeMeta)>, +} + +/// Un souvenir complet à importer ([`NativeMemoryStore::import_rows`]) — +/// fidélité totale (`importance`/`last_access`), vecteur déjà recalculé par +/// l'embedder de la mémoire cible. +pub(crate) struct NativeImportMemory { + pub id: String, + pub layer: MemoryLayer, + pub content: String, + pub source: String, + pub valid_from: i64, + pub valid_until: Option, + pub importance: f64, + pub last_access: Option, + pub vector: Vec, +} + +/// Une entité de graphe à importer. Pas d'équivalent `importance` dans +/// `GraphEntity:1` — champ jamais écrit par le contrat `MemoryStore`, perdu +/// à l'import natif (écart documenté, ADR-033 §3). +pub(crate) struct NativeImportEntity { + pub id: String, + pub kind: String, + pub label: String, + pub valid_from: i64, + pub valid_until: Option, +} + +/// Une arête de graphe à importer, méta complète de l'export préservée. +pub(crate) struct NativeImportEdge { + pub src: String, + pub dst: String, + pub relation: String, + pub weight: f64, + pub valid_from: i64, + pub valid_until: Option, } /// `true` si la fenêtre `[valid_from, valid_until)` couvre `now` — le filtre @@ -320,8 +600,7 @@ impl NativeInner { /// Insère plusieurs souvenirs de `agent` en **un seul** batch atomique /// (N5.5, `PersistentMemoryIndex::put_many`) : plus l'écart « atomique /// par item » d'ADR-027 §6 — un `put_memory_batch` natif est désormais - /// UN enregistrement WAL, tout-ou-rien, comme la transaction libSQL qu'il - /// remplace. + /// UN enregistrement WAL, tout-ou-rien (équivalent sémantique d'une txn unique). fn put_many(&mut self, agent: &AgentId, items: &[NewMemory<'_>]) -> Result<()> { let Self { engine, @@ -357,6 +636,57 @@ impl NativeInner { #[async_trait::async_trait] impl MemoryStore for NativeMemoryStore { + async fn layer_of(&self, agent: &AgentId, id: &str) -> Result> { + let (agent, id) = (agent.clone(), id.to_string()); + // Lecture pure — verrou de lecture partagé (N5.5). + self.with_inner_read(move |inner| { + match inner.memory.get(&inner.engine, agent.as_str(), &id).map_err(storage)? { + Some(record) => Ok(Some(MemoryLayer::from_table(&record.layer)?)), + None => Ok(None), + } + }) + .await + } + + async fn list_memories( + &self, + agent: &AgentId, + layer: Option, + limit: usize, + include_invalid: bool, + now: i64, + ) -> Result> { + let agent = agent.clone(); + // Lecture pure — verrou de lecture partagé (N5.5). + self.with_inner_read(move |inner| { + let NativeInner { engine, memory, .. } = inner; + let mut records: Vec<(String, basemyai_engine::MemoryRecord)> = + memory.scan_agent(engine, agent.as_str()).map_err(storage)?; + records.retain(|(_, record)| include_invalid || record_valid_at(record, now)); + if let Some(l) = layer { + records.retain(|(_, record)| record.layer == l.table()); + } + // Parité ORDER BY valid_from DESC (tri stable, comme + // `recent_episodes`) : le scan structurel ne garantit qu'un ordre + // par id, il faut trier explicitement. + records.sort_by_key(|(_, record)| std::cmp::Reverse(record.valid_from)); + records.truncate(limit); + records + .into_iter() + .map(|(id, record)| { + Ok(super::ListedRecord { + id, + layer: MemoryLayer::from_table(&record.layer)?, + content: record.content, + valid_from: record.valid_from, + valid_until: record.valid_until, + }) + }) + .collect() + }) + .await + } + async fn put_memory( &self, id: &str, @@ -501,7 +831,7 @@ impl MemoryStore for NativeMemoryStore { // Le moteur (`search_bm25`) est agnostique de la validité // temporelle (mécanisme au moteur, sens au consommateur) ; on // sur-échantillonne donc comme le chemin vectoriel (ADR-012) pour - // ne pas sous-compter après le filtre — même raison que libSQL + // ne pas sous-compter après le filtre — oversampling ADR-012 // applique son filtre `valid_from`/`valid_until` *avant* son // `LIMIT` dans la requête SQL. let oversampled = k.saturating_mul(OVERSAMPLE); @@ -654,7 +984,7 @@ impl MemoryStore for NativeMemoryStore { }; self.with_inner(move |inner| { let NativeInner { engine, graph, .. } = &mut *inner; - // Parité ON CONFLICT DO UPDATE SET kind/label/valid_* : + // Parité upsert entité : kind/label/valid_* préservés si l'id existe. // écrasement complet. graph .upsert_entity(engine, agent.as_str(), &id, entity) @@ -675,8 +1005,7 @@ impl MemoryStore for NativeMemoryStore { let (agent, src, relation, dst) = (agent.clone(), src.to_string(), relation.to_string(), dst.to_string()); self.with_inner(move |inner| { let NativeInner { engine, graph, .. } = &mut *inner; - // Parité ON CONFLICT DO UPDATE SET weight : une arête existante - // garde sa fenêtre de validité, seule `weight` bouge. + // Parité upsert arête : seul le poids est mis à jour si l'arête existe. let meta = match graph .edge_meta(engine, agent.as_str(), &src, &relation, &dst) .map_err(storage)? diff --git a/crates/basemyai/src/temporal.rs b/crates/basemyai/src/temporal.rs index 2f21629..f78aabe 100644 --- a/crates/basemyai/src/temporal.rs +++ b/crates/basemyai/src/temporal.rs @@ -1,11 +1,9 @@ // SPDX-License-Identifier: BUSL-1.1 //! RAG temporel (ADR-005). Chaque mémoire porte une fenêtre de validité ; le -//! recall ne retourne que ce qui est **pertinent ET encore valide**. -//! -//! Le filtre temporel s'exprime via le [`basemyai_core::Filter`] -//! paramétré du core — le core ne sait pas que le filtre concerne le temps. - -use basemyai_core::{Filter, Value}; +//! recall ne retourne que ce qui est **pertinent ET encore valide**. Le +//! filtrage lui-même vit dans le moteur de stockage +//! ([`crate::storage::NativeMemoryStore`]) — ce module ne porte que le +//! concept `Validity`, indépendant du backend. /// Fenêtre de validité d'une mémoire. `valid_until = None` => valide jusqu'à /// invalidation explicite. @@ -33,13 +31,3 @@ impl Validity { self.valid_from <= now && self.valid_until.is_none_or(|until| now < until) } } - -/// Construit le fragment de filtre temporel paramétré passé au KNN du core : -/// `valid_from <= ? AND (valid_until IS NULL OR valid_until > ?)`. -#[must_use] -pub fn temporal_filter(now: i64) -> Filter { - Filter::new( - "valid_from <= ? AND (valid_until IS NULL OR valid_until > ?)", - vec![Value::Integer(now), Value::Integer(now)], - ) -} diff --git a/crates/basemyai/tests/consolidation.rs b/crates/basemyai/tests/consolidation.rs index 09ea1af..e9217b2 100644 --- a/crates/basemyai/tests/consolidation.rs +++ b/crates/basemyai/tests/consolidation.rs @@ -3,9 +3,11 @@ //! la promotion des faits en `semantic`, le peuplement du graphe, et la //! déduplication (relancer ne duplique pas). -use basemyai::{AgentId, LlmInference, Memory, MemoryLayer, consolidate}; -use basemyai_core::libsql::Connection; -use basemyai_core::{Embedder, Result as CoreResult, Store}; +use std::sync::Arc; + +use basemyai::{AgentId, LlmInference, Memory, MemoryEventKind, MemoryLayer, consolidate}; +use basemyai_core::{Embedder, Result as CoreResult}; +mod support; const DIM: usize = 384; @@ -65,20 +67,16 @@ fn agent(id: &str) -> AgentId { AgentId::new(id).expect("non-empty agent id") } -async fn scalar_i64(conn: &Connection, sql: &str) -> i64 { - let mut rows = conn.query(sql, ()).await.expect("query"); - let row = rows.next().await.expect("row").expect("une ligne"); - row.get::(0).expect("i64") +async fn open_memory(agent_id: &str) -> Memory { + let store = Arc::new(support::open_native_store()); + Memory::from_native_store(store, Box::new(FakeEmbedder), agent(agent_id)) + .await + .expect("open memory") } #[tokio::test] async fn consolidates_episodes_into_facts_and_graph() { - let store = Store::open_in_memory().await.expect("open"); - // Connexion gardée pour inspecter le graphe (base :memory: partagée). - let conn = store.connect(); - let mem = Memory::open(store, Box::new(FakeEmbedder), agent("a")) - .await - .expect("open memory"); + let mem = open_memory("a").await; mem.remember("Alice a rejoint Acme en mars", MemoryLayer::Episodic) .await @@ -103,23 +101,13 @@ async fn consolidates_episodes_into_facts_and_graph() { ); // Le graphe est peuplé (2 entités, 1 arête) pour l'agent. - assert_eq!( - scalar_i64(&conn, "SELECT COUNT(*) FROM entity WHERE agent_id = 'a'").await, - 2 - ); - assert_eq!( - scalar_i64(&conn, "SELECT COUNT(*) FROM edge WHERE agent_id = 'a'").await, - 1 - ); + let reached = mem.graph().traverse("alice", 1).await.expect("traverse"); + assert!(reached.iter().any(|r| r.id == "acme"), "l'arête employeur doit exister"); } #[tokio::test] async fn consolidation_is_idempotent() { - let store = Store::open_in_memory().await.expect("open"); - let conn = store.connect(); - let mem = Memory::open(store, Box::new(FakeEmbedder), agent("a")) - .await - .expect("open memory"); + let mem = open_memory("a").await; mem.remember("Alice a rejoint Acme", MemoryLayer::Episodic) .await @@ -133,70 +121,38 @@ async fn consolidation_is_idempotent() { assert_eq!(second.facts_added, 0, "aucun fait nouveau"); assert_eq!(second.facts_skipped, 1, "le fait identique est ignoré"); - // Un seul fait sémantique, deux entités, une arête malgré les deux passes. - assert_eq!( - scalar_i64( - &conn, - "SELECT COUNT(*) FROM memory WHERE agent_id='a' AND layer='semantic'" - ) - .await, - 1 - ); - assert_eq!( - scalar_i64(&conn, "SELECT COUNT(*) FROM entity WHERE agent_id='a'").await, - 2 - ); - assert_eq!( - scalar_i64(&conn, "SELECT COUNT(*) FROM edge WHERE agent_id='a'").await, - 1 - ); + // Un seul fait sémantique malgré les deux passes. + let stats = mem.stats().await.expect("stats"); + assert_eq!(stats.semantic, 1); } -/// `source` distingue un fait promu par consolidation (`"consolidation"`) -/// d'un souvenir mémorisé directement par l'agent (`"user"`) — audit -/// sécurité (ADR-018), traçabilité de l'escalade de confiance `episodic → -/// semantic`. +/// `MemoryEventKind` distingue un fait promu par consolidation +/// (`Consolidated`) d'un souvenir mémorisé directement par l'agent +/// (`Remembered`) — audit sécurité (ADR-018), traçabilité de l'escalade de +/// confiance `episodic → semantic`. #[tokio::test] async fn consolidated_facts_are_tagged_with_consolidation_source() { - let store = Store::open_in_memory().await.expect("open"); - let conn = store.connect(); - let mem = Memory::open(store, Box::new(FakeEmbedder), agent("a")) - .await - .expect("open memory"); + let mem = open_memory("a").await; + let mut watcher = mem.watch("a", None); - // Souvenir direct de l'agent : source = 'user'. + // Souvenir direct de l'agent : événement `Remembered`. mem.remember("direct user memory", MemoryLayer::Semantic) .await .expect("direct remember"); + let direct_event = watcher.recv().await.expect("event for direct remember"); + assert_eq!(direct_event.kind, MemoryEventKind::Remembered); mem.remember("Alice a rejoint Acme en mars", MemoryLayer::Episodic) .await .expect("episode"); - consolidate(&mem, &FakeLlm).await.expect("consolidate"); + let _episode_event = watcher.recv().await.expect("event for episode"); - let mut rows = conn - .query( - "SELECT source FROM memory WHERE agent_id = 'a' AND content = ?1", - basemyai_core::libsql::params!["direct user memory"], - ) - .await - .expect("query user source"); - let row = rows.next().await.expect("row").expect("une ligne"); - let source: String = row.get(0).expect("source col"); - assert_eq!(source, "user", "un remember direct doit porter source='user'"); - - let mut rows = conn - .query( - "SELECT source FROM memory WHERE agent_id = 'a' AND content = ?1", - basemyai_core::libsql::params!["Alice travaille chez Acme"], - ) - .await - .expect("query consolidation source"); - let row = rows.next().await.expect("row").expect("une ligne"); - let source: String = row.get(0).expect("source col"); + consolidate(&mem, &FakeLlm).await.expect("consolidate"); + let consolidated_event = watcher.recv().await.expect("event for consolidated fact"); assert_eq!( - source, "consolidation", - "un fait promu par consolidation doit porter source='consolidation'" + consolidated_event.kind, + MemoryEventKind::Consolidated, + "un fait promu par consolidation doit émettre l'événement Consolidated" ); } @@ -208,11 +164,7 @@ async fn consolidated_facts_are_tagged_with_consolidation_source() { /// proche de 1. #[tokio::test] async fn near_duplicate_fact_is_detected_via_semantic_similarity() { - let store = Store::open_in_memory().await.expect("open"); - let conn = store.connect(); - let mem = Memory::open(store, Box::new(FakeEmbedder), agent("a")) - .await - .expect("open memory"); + let mem = open_memory("a").await; // Pré-existant : quasi identique au fait canned renvoyé par `FakeLlm` // ("Alice travaille chez Acme"), au caractère final près — pas le même @@ -233,23 +185,16 @@ async fn near_duplicate_fact_is_detected_via_semantic_similarity() { ); assert_eq!(report.facts_skipped, 1, "il doit être compté comme doublon"); + let stats = mem.stats().await.expect("stats"); assert_eq!( - scalar_i64( - &conn, - "SELECT COUNT(*) FROM memory WHERE agent_id='a' AND layer='semantic'" - ) - .await, - 1, + stats.semantic, 1, "un seul fait sémantique doit subsister malgré la reformulation" ); } #[tokio::test] async fn no_episodes_is_a_noop() { - let store = Store::open_in_memory().await.expect("open"); - let mem = Memory::open(store, Box::new(FakeEmbedder), agent("a")) - .await - .expect("open memory"); + let mem = open_memory("a").await; // Aucun épisode : la consolidation ne touche pas au LLM et renvoie un rapport vide. let report = consolidate(&mem, &FakeLlm).await.expect("consolidate"); diff --git a/crates/basemyai/tests/consolidation_e2e.rs b/crates/basemyai/tests/consolidation_e2e.rs index 6723687..94252d8 100644 --- a/crates/basemyai/tests/consolidation_e2e.rs +++ b/crates/basemyai/tests/consolidation_e2e.rs @@ -17,7 +17,8 @@ use std::time::Duration; use basemyai::{AgentId, AnythingLlmBackend, LlmInference, Memory, MemoryLayer, anythingllm_from_env, consolidate}; -use basemyai_core::{Embedder, Result, Store}; +use basemyai_core::{Embedder, Result}; +mod support; const DIM: usize = 384; @@ -59,8 +60,8 @@ fn agent(id: &str) -> AgentId { /// Ouvre une mémoire en RAM pour les tests. async fn open_memory(agent_id: &str) -> Memory { - let store = Store::open_in_memory().await.expect("store"); - Memory::open(store, Box::new(FakeEmbedder), agent(agent_id)) + let store = std::sync::Arc::new(support::open_native_store()); + Memory::from_native_store(store, Box::new(FakeEmbedder), agent(agent_id)) .await .expect("memory") } diff --git a/crates/basemyai/tests/contracts.rs b/crates/basemyai/tests/contracts.rs index 8395a35..6e1f2ec 100644 --- a/crates/basemyai/tests/contracts.rs +++ b/crates/basemyai/tests/contracts.rs @@ -1,9 +1,10 @@ //! Contrats de la sémantique mémoire : logique temporelle, isolation, couches, //! conversion d'erreur, et assemblage par injection de dépendance. -use basemyai::temporal::{Validity, temporal_filter}; +use basemyai::temporal::Validity; use basemyai::{AgentId, Memory, MemoryError, MemoryLayer}; -use basemyai_core::{Embedder, Result as CoreResult, Store}; +use basemyai_core::{Embedder, Result as CoreResult}; +mod support; // ── Double de test : Embedder synthétique (sync) pour la DI. ── @@ -44,14 +45,6 @@ fn validity_with_expiry_is_exclusive_on_until() { assert!(!v.is_valid_at(200)); // borne haute exclusive } -#[test] -fn temporal_filter_is_parameterized_with_two_binds() { - let f = temporal_filter(1_234); - assert!(f.where_sql.contains("valid_until")); - assert!(f.where_sql.contains('?')); - assert_eq!(f.params.len(), 2); -} - // ── Isolation (ADR-006) & couches (ADR-004) ── #[test] @@ -80,41 +73,10 @@ fn core_error_converts_into_memory_error() { #[tokio::test] async fn memory_assembles_via_dependency_injection() { - let store = Store::open_in_memory().await.expect("store opens"); + let store = std::sync::Arc::new(support::open_native_store()); let agent = AgentId::new("assistant-42").expect("valid agent"); - let mem = Memory::open(store, Box::new(FakeEmbedder), agent.clone()) + let mem = Memory::from_native_store(store, Box::new(FakeEmbedder), agent.clone()) .await .expect("memory opens"); assert_eq!(mem.agent(), &agent); } - -// ── Chiffrement obligatoire (ADR-007) ───────────────────────────────────────── - -#[tokio::test] -async fn open_without_key_fails_for_file_store() { - // Un store sur fichier sans clé de chiffrement doit être rejeté par Memory::open. - // Les stores `:memory:` sont exemptés (éphémères, chiffrement sans objet). - let path = std::env::temp_dir().join(format!("basemyai_enc_test_{}.db", std::process::id())); - let _ = std::fs::remove_file(&path); // état propre - - let store = Store::open(&path, None).await.expect("store opens sans clé"); - let agent = AgentId::new("test-enc").expect("valid"); - let result = Memory::open(store, Box::new(FakeEmbedder), agent).await; - - let _ = std::fs::remove_file(&path); // nettoyage - - assert!( - matches!(result, Err(basemyai::MemoryError::EncryptionRequired)), - "Memory::open doit refuser un store fichier non chiffré" - ); -} - -#[tokio::test] -async fn open_in_memory_store_bypasses_encryption_requirement() { - // Les stores `:memory:` sont éphémères — Memory::open les accepte sans clé. - let store = Store::open_in_memory().await.expect("store opens"); - let agent = AgentId::new("test-mem").expect("valid"); - Memory::open(store, Box::new(FakeEmbedder), agent) - .await - .expect("Memory::open accepte un store in-memory sans clé de chiffrement"); -} diff --git a/crates/basemyai/tests/events.rs b/crates/basemyai/tests/events.rs index 8e1aaee..375dde5 100644 --- a/crates/basemyai/tests/events.rs +++ b/crates/basemyai/tests/events.rs @@ -8,7 +8,8 @@ use std::time::Duration; use basemyai::{AgentId, Memory, MemoryEventKind, MemoryLayer}; -use basemyai_core::{Embedder, Result, Store}; +use basemyai_core::{Embedder, Result}; +mod support; const DIM: usize = 384; @@ -49,8 +50,8 @@ fn agent(id: &str) -> AgentId { } async fn open_memory(agent_id: &str) -> Memory { - let store = Store::open_in_memory().await.expect("open in-memory store"); - Memory::open(store, Box::new(FakeEmbedder), agent(agent_id)) + let store = std::sync::Arc::new(support::open_native_store()); + Memory::from_native_store(store, Box::new(FakeEmbedder), agent(agent_id)) .await .expect("open memory") } diff --git a/crates/basemyai/tests/forgetting.rs b/crates/basemyai/tests/forgetting.rs deleted file mode 100644 index 28cdb3f..0000000 --- a/crates/basemyai/tests/forgetting.rs +++ /dev/null @@ -1,182 +0,0 @@ -//! Tests d'intégration de l'oubli adaptatif (VISION §5.2). On insère des -//! souvenirs en SQL direct (pas besoin d'embedder : `emb` est *nullable*), on -//! lance la tâche [`AdaptiveForgetting`], puis on vérifie l'éviction par score -//! (importance + récence), l'isolation par agent et l'effet de la récence. - -use basemyai::AdaptiveForgetting; -use basemyai_core::{MaintenanceTask, Store}; - -/// Insère un souvenir minimal pour `agent`, avec `importance` et `last_access` -/// donnés (`last_access` *nullable*). `emb` reste NULL (colonne nullable). -async fn insert(store: &Store, id: &str, agent: &str, importance: f64, last_access: Option, valid_from: i64) { - let conn = store.connect(); - conn.execute( - "INSERT INTO memory \ - (id, agent_id, layer, content, valid_from, valid_until, emb, importance, last_access) \ - VALUES (?1, ?2, 'semantic', ?1, ?3, NULL, NULL, ?4, ?5)", - basemyai_core::libsql::params![id, agent, valid_from, importance, last_access], - ) - .await - .expect("insert souvenir"); - conn.execute( - "INSERT INTO memory_fts (id, agent_id, content) VALUES (?1, ?2, ?1)", - basemyai_core::libsql::params![id, agent], - ) - .await - .expect("insert souvenir fts"); -} - -/// Liste les `id` restants pour un agent donné, triés. -async fn remaining_ids(store: &Store, agent: &str) -> Vec { - let conn = store.connect(); - let mut rows = conn - .query( - "SELECT id FROM memory WHERE agent_id = ?1 ORDER BY id", - basemyai_core::libsql::params![agent], - ) - .await - .expect("query ids"); - let mut ids = Vec::new(); - while let Some(row) = rows.next().await.expect("row") { - ids.push(row.get::(0).expect("id text")); - } - ids -} - -async fn count_for(store: &Store, agent: &str) -> i64 { - let conn = store.connect(); - let mut rows = conn - .query( - "SELECT COUNT(*) FROM memory WHERE agent_id = ?1", - basemyai_core::libsql::params![agent], - ) - .await - .expect("query count"); - let row = rows.next().await.expect("row").expect("une ligne count"); - row.get::(0).expect("count int") -} - -async fn fts_count_for(store: &Store, id: &str, agent: &str) -> i64 { - let conn = store.connect(); - let mut rows = conn - .query( - "SELECT COUNT(*) FROM memory_fts WHERE id = ?1 AND agent_id = ?2", - basemyai_core::libsql::params![id, agent], - ) - .await - .expect("query fts count"); - let row = rows.next().await.expect("row").expect("une ligne count"); - row.get::(0).expect("count int") -} - -#[tokio::test] -async fn evicts_least_important_beyond_capacity() { - let store = Store::open_in_memory().await.expect("open"); - store.migrate(&basemyai::schema()).await.expect("migrate"); - - // 5 souvenirs, même récence (last_access identique) : seul l'importance - // départage. Capacité = 3 => les 3 plus importants restent. - let t = 1_000_i64; - insert(&store, "m1", "a", 0.1, Some(t), t).await; - insert(&store, "m2", "a", 0.9, Some(t), t).await; - insert(&store, "m3", "a", 0.5, Some(t), t).await; - insert(&store, "m4", "a", 0.7, Some(t), t).await; - insert(&store, "m5", "a", 0.3, Some(t), t).await; - - let task = AdaptiveForgetting { - capacity_per_agent: 3, - recency_half_life_secs: 86_400, - }; - task.run(&store).await.expect("run"); - - assert_eq!( - count_for(&store, "a").await, - 3, - "la capacité par agent doit être respectée" - ); - let kept = remaining_ids(&store, "a").await; - // Les 3 plus importants : m2 (0.9), m4 (0.7), m3 (0.5). - assert_eq!( - kept, - vec!["m2".to_string(), "m3".to_string(), "m4".to_string()], - "doivent rester les plus importants" - ); -} - -#[tokio::test] -async fn capacity_is_per_agent_isolated() { - let store = Store::open_in_memory().await.expect("open"); - store.migrate(&basemyai::schema()).await.expect("migrate"); - - let t = 2_000_i64; - // Agent A : 4 souvenirs, capacité 2 => 2 évincés. - insert(&store, "a1", "A", 0.1, Some(t), t).await; - insert(&store, "a2", "A", 0.2, Some(t), t).await; - insert(&store, "a3", "A", 0.3, Some(t), t).await; - insert(&store, "a4", "A", 0.4, Some(t), t).await; - // Agent B : 1 seul souvenir => intouché malgré l'éviction de A. - insert(&store, "b1", "B", 0.01, Some(t), t).await; - - let task = AdaptiveForgetting { - capacity_per_agent: 2, - recency_half_life_secs: 86_400, - }; - task.run(&store).await.expect("run"); - - assert_eq!(count_for(&store, "A").await, 2, "A plafonné à 2"); - assert_eq!( - count_for(&store, "B").await, - 1, - "B ne doit pas être touché par l'éviction de A" - ); - assert_eq!(remaining_ids(&store, "B").await, vec!["b1".to_string()]); -} - -#[tokio::test] -async fn recency_breaks_ties_at_equal_importance() { - let store = Store::open_in_memory().await.expect("open"); - store.migrate(&basemyai::schema()).await.expect("migrate"); - - // Importance identique : la récence (last_access) départage. Capacité 1. - // half_life court pour rendre l'écart de récence décisif. - let recent = 10_000_i64; - let old = 0_i64; - insert(&store, "old", "a", 0.5, Some(old), old).await; - insert(&store, "recent", "a", 0.5, Some(recent), old).await; - - // `now` proche de `recent` : "recent" a une récence ~1, "old" ~0. - let task = AdaptiveForgetting { - capacity_per_agent: 1, - recency_half_life_secs: 3_600, - }; - // On appelle run après avoir figé un now suffisamment grand via insertion : - // la tâche utilise now_unix() (temps réel courant >> recent), donc l'écart - // (now - recent) << (now - old) garde "recent". - task.run(&store).await.expect("run"); - - assert_eq!(count_for(&store, "a").await, 1, "capacité 1"); - assert_eq!( - remaining_ids(&store, "a").await, - vec!["recent".to_string()], - "à importance égale, le souvenir au last_access le plus récent est conservé" - ); -} - -#[tokio::test] -async fn evicts_matching_fts_rows_with_memory_rows() { - let store = Store::open_in_memory().await.expect("open"); - store.migrate(&basemyai::schema()).await.expect("migrate"); - - let t = 3_000_i64; - insert(&store, "drop-me", "a", 0.1, Some(t), t).await; - insert(&store, "keep-me", "a", 0.9, Some(t), t).await; - - let task = AdaptiveForgetting { - capacity_per_agent: 1, - recency_half_life_secs: 86_400, - }; - task.run(&store).await.expect("run"); - - assert_eq!(fts_count_for(&store, "drop-me", "a").await, 0); - assert_eq!(fts_count_for(&store, "keep-me", "a").await, 1); -} diff --git a/crates/basemyai/tests/format.rs b/crates/basemyai/tests/format.rs index 4da10b9..84b0913 100644 --- a/crates/basemyai/tests/format.rs +++ b/crates/basemyai/tests/format.rs @@ -1,31 +1,18 @@ -//! `.bmai` container metadata contract. +//! `.bmai` container metadata contract (moteur natif, ADR-033). -use basemyai::schema; -use basemyai_core::Store; +use basemyai::storage::BMAI_FORMAT_VERSION; +mod support; #[tokio::test] -async fn schema_writes_bmai_container_metadata() { - let store = Store::open_in_memory().await.expect("store opens"); - store.migrate(&schema()).await.expect("schema migrates"); +async fn native_store_writes_bmai_container_metadata() { + let store = support::open_native_store(); + let meta = store.container_metadata().await.expect("metadata read"); + let get = |key: &str| meta.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone()); - let conn = store.connect(); - let mut rows = conn - .query( - "SELECT key, value FROM bmai_meta WHERE key IN ('format', 'format_version', 'storage_engine')", - (), - ) - .await - .expect("metadata query succeeds"); - - let mut values = std::collections::BTreeMap::new(); - while let Some(row) = rows.next().await.expect("row reads") { - values.insert( - row.get::(0).expect("key column"), - row.get::(1).expect("value column"), - ); - } - - assert_eq!(values.get("format").map(String::as_str), Some("basemyai-memory")); - assert_eq!(values.get("format_version").map(String::as_str), Some("1")); - assert_eq!(values.get("storage_engine").map(String::as_str), Some("libsql")); + assert_eq!(get("format").as_deref(), Some("basemyai-memory")); + assert_eq!( + get("format_version").as_deref(), + Some(BMAI_FORMAT_VERSION.to_string().as_str()) + ); + assert_eq!(get("storage_engine").as_deref(), Some("native")); } diff --git a/crates/basemyai/tests/gc.rs b/crates/basemyai/tests/gc.rs deleted file mode 100644 index 9124885..0000000 --- a/crates/basemyai/tests/gc.rs +++ /dev/null @@ -1,58 +0,0 @@ -//! Tests d'intégration du GC des souvenirs expirés et de son miroir FTS. - -use basemyai::ExpiredMemoryGc; -use basemyai_core::{MaintenanceTask, Store}; - -async fn insert(store: &Store, id: &str, agent: &str, valid_until: Option) { - let conn = store.connect(); - conn.execute( - "INSERT INTO memory \ - (id, agent_id, layer, content, valid_from, valid_until, emb, importance, last_access) \ - VALUES (?1, ?2, 'semantic', ?1, 0, ?3, NULL, 0, NULL)", - basemyai_core::libsql::params![id, agent, valid_until], - ) - .await - .expect("insert memory"); - conn.execute( - "INSERT INTO memory_fts (id, agent_id, content) VALUES (?1, ?2, ?1)", - basemyai_core::libsql::params![id, agent], - ) - .await - .expect("insert memory_fts"); -} - -async fn table_count(store: &Store, table: &str, id: &str) -> i64 { - let conn = store.connect(); - let sql = format!("SELECT COUNT(*) FROM {table} WHERE id = ?1"); - let mut rows = conn - .query(&sql, basemyai_core::libsql::params![id]) - .await - .expect("query count"); - let row = rows.next().await.expect("row").expect("one count row"); - row.get::(0).expect("count") -} - -fn now_unix() -> i64 { - use std::time::{SystemTime, UNIX_EPOCH}; - i64::try_from(SystemTime::now().duration_since(UNIX_EPOCH).expect("clock").as_secs()).expect("fits i64") -} - -#[tokio::test] -async fn expired_gc_deletes_matching_fts_rows_atomically() { - let store = Store::open_in_memory().await.expect("open"); - store.migrate(&basemyai::schema()).await.expect("migrate"); - - let now = now_unix(); - insert(&store, "expired", "a", Some(now - 10)).await; - insert(&store, "live", "a", Some(now + 10_000)).await; - insert(&store, "forever", "a", None).await; - - ExpiredMemoryGc.run(&store).await.expect("gc"); - - assert_eq!(table_count(&store, "memory", "expired").await, 0); - assert_eq!(table_count(&store, "memory_fts", "expired").await, 0); - assert_eq!(table_count(&store, "memory", "live").await, 1); - assert_eq!(table_count(&store, "memory_fts", "live").await, 1); - assert_eq!(table_count(&store, "memory", "forever").await, 1); - assert_eq!(table_count(&store, "memory_fts", "forever").await, 1); -} diff --git a/crates/basemyai/tests/graph.rs b/crates/basemyai/tests/graph.rs deleted file mode 100644 index fca7c5d..0000000 --- a/crates/basemyai/tests/graph.rs +++ /dev/null @@ -1,169 +0,0 @@ -//! Tests d'intégration du graphe (Phase 2, VISION §4.1) : traversée multi-sauts -//! par CTE récursive, isolation par agent, exclusion temporelle, terminaison sur -//! cycle. - -use basemyai::storage::{LibsqlMemoryStore, MemoryStore}; -use basemyai::temporal::Validity; -use basemyai::{AgentId, Graph}; -use basemyai_core::Store; -use std::path::PathBuf; -use std::sync::Arc; - -fn agent(id: &str) -> AgentId { - AgentId::new(id).expect("non-empty agent id") -} - -/// Enveloppe un `Store` dans le moteur de stockage partagé par les `Graph` de -/// test. Cloner l'`Arc` (pas le `Store`) permet à plusieurs `Graph` de -/// pointer sur la même connexion sous-jacente, comme `Memory::graph()` le -/// fait en production. -fn engine_on(store: Store) -> Arc { - Arc::new(LibsqlMemoryStore::new(store)) -} - -fn now() -> i64 { - use std::time::{SystemTime, UNIX_EPOCH}; - i64::try_from(SystemTime::now().duration_since(UNIX_EPOCH).expect("clock").as_secs()).expect("fits i64") -} - -fn temp_db_path(name: &str) -> PathBuf { - std::env::temp_dir().join(format!("basemyai-{name}-{}-{}.db", std::process::id(), now())) -} - -async fn migrated_store() -> Store { - let store = Store::open_in_memory().await.expect("open"); - store.migrate(&basemyai::schema()).await.expect("migrate"); - store -} - -async fn migrated_file_store(path: &std::path::Path) -> Store { - let store = Store::open(path, None).await.expect("open file store"); - store.migrate(&basemyai::schema()).await.expect("migrate"); - store -} - -#[tokio::test] -async fn traverses_multiple_hops() { - let engine = engine_on(migrated_store().await); - let g = Graph::new(engine, agent("a")); - - // Alice → (employeur) Acme → (a_racheté) Beta - g.add_entity("alice", "person", "Alice").await.expect("alice"); - g.add_entity("acme", "company", "Acme").await.expect("acme"); - g.add_entity("beta", "company", "Beta").await.expect("beta"); - g.add_edge("alice", "employeur", "acme", 1.0).await.expect("edge1"); - g.add_edge("acme", "a_racheté", "beta", 1.0).await.expect("edge2"); - - // Profondeur 1 : seulement Acme. - let d1 = g.traverse("alice", 1).await.expect("traverse d1"); - assert_eq!(d1.iter().map(|r| r.id.as_str()).collect::>(), ["acme"]); - assert_eq!(d1[0].depth, 1); - - // Profondeur 2 : Acme (1) puis Beta (2). - let d2 = g.traverse("alice", 2).await.expect("traverse d2"); - let ids: Vec<_> = d2.iter().map(|r| (r.id.as_str(), r.depth)).collect(); - assert_eq!(ids, [("acme", 1), ("beta", 2)]); -} - -#[tokio::test] -async fn isolation_hides_other_agents_edges() { - let engine = engine_on(migrated_store().await); - let ga = Graph::new(Arc::clone(&engine), agent("A")); - let gb = Graph::new(Arc::clone(&engine), agent("B")); - - // Même base : A construit un chemin, B ne doit rien voir. - ga.add_entity("x", "thing", "X").await.expect("x"); - ga.add_entity("y", "thing", "Y").await.expect("y"); - ga.add_edge("x", "rel", "y", 1.0).await.expect("edge"); - - let seen_by_b = gb.traverse("x", 3).await.expect("b traverse"); - assert!(seen_by_b.is_empty(), "B ne doit voir aucune entité/arête de A"); -} - -#[tokio::test] -async fn agents_can_reuse_same_graph_ids_without_conflict() { - let engine = engine_on(migrated_store().await); - let ga = Graph::new(Arc::clone(&engine), agent("A")); - let gb = Graph::new(Arc::clone(&engine), agent("B")); - - ga.add_entity("alice", "person", "Alice A").await.expect("alice A"); - ga.add_entity("acme", "company", "Acme A").await.expect("acme A"); - ga.add_edge("alice", "works_at", "acme", 1.0).await.expect("edge A"); - - gb.add_entity("alice", "person", "Alice B").await.expect("alice B"); - gb.add_entity("acme", "company", "Acme B").await.expect("acme B"); - gb.add_edge("alice", "works_at", "acme", 1.0).await.expect("edge B"); - - let seen_by_a = ga.traverse("alice", 1).await.expect("A traverse"); - let seen_by_b = gb.traverse("alice", 1).await.expect("B traverse"); - - assert_eq!(seen_by_a[0].label, "Acme A"); - assert_eq!(seen_by_b[0].label, "Acme B"); -} - -#[tokio::test] -async fn file_backed_same_store_isolates_graph_agents() { - let path = temp_db_path("graph-isolation"); - let ga = Graph::new(engine_on(migrated_file_store(&path).await), agent("A")); - let gb = Graph::new(engine_on(migrated_file_store(&path).await), agent("B")); - - ga.add_entity("alice", "person", "Alice A").await.expect("alice A"); - ga.add_entity("acme", "company", "Acme A").await.expect("acme A"); - ga.add_edge("alice", "works_at", "acme", 1.0).await.expect("edge A"); - - gb.add_entity("alice", "person", "Alice B").await.expect("alice B"); - gb.add_entity("acme", "company", "Acme B").await.expect("acme B"); - gb.add_edge("alice", "works_at", "acme", 1.0).await.expect("edge B"); - - let seen_by_a = ga.traverse("alice", 1).await.expect("A traverse"); - let seen_by_b = gb.traverse("alice", 1).await.expect("B traverse"); - - assert_eq!(seen_by_a[0].label, "Acme A"); - assert_eq!(seen_by_b[0].label, "Acme B"); -} - -#[tokio::test] -async fn excludes_expired_entities_and_edges() { - let engine = engine_on(migrated_store().await); - let g = Graph::new(engine, agent("a")); - let n = now(); - - g.add_entity("root", "thing", "Root").await.expect("root"); - // Cible encore valide, atteinte par une arête encore valide. - g.add_entity("live", "thing", "Live").await.expect("live"); - g.add_edge("root", "rel", "live", 1.0).await.expect("edge live"); - // Cible expirée : ne doit pas remonter même si une arête y mène. - g.add_entity_with( - "stale", - "thing", - "Stale", - Validity { - valid_from: n - 100, - valid_until: Some(n - 10), - }, - ) - .await - .expect("stale"); - g.add_edge("root", "rel", "stale", 1.0).await.expect("edge stale"); - - let reached = g.traverse("root", 2).await.expect("traverse"); - let ids: Vec<_> = reached.iter().map(|r| r.id.as_str()).collect(); - assert_eq!(ids, ["live"], "l'entité expirée ne doit pas apparaître"); -} - -#[tokio::test] -async fn terminates_on_cycle() { - let engine = engine_on(migrated_store().await); - let g = Graph::new(engine, agent("a")); - - // Cycle a → b → a : la traversée bornée doit terminer, pas boucler. - g.add_entity("a1", "thing", "A1").await.expect("a1"); - g.add_entity("b1", "thing", "B1").await.expect("b1"); - g.add_edge("a1", "rel", "b1", 1.0).await.expect("e1"); - g.add_edge("b1", "rel", "a1", 1.0).await.expect("e2"); - - let reached = g.traverse("a1", 5).await.expect("traverse cycle"); - // a1 exclu (départ), b1 atteint à profondeur 1. - assert_eq!(reached.iter().map(|r| r.id.as_str()).collect::>(), ["b1"]); - assert_eq!(reached[0].depth, 1); -} diff --git a/crates/basemyai/tests/key_rotation.rs b/crates/basemyai/tests/key_rotation.rs deleted file mode 100644 index 8bb233f..0000000 --- a/crates/basemyai/tests/key_rotation.rs +++ /dev/null @@ -1,152 +0,0 @@ -//! Rotation de clé de chiffrement (`Memory::rotate_key`, feature `crypto`) : -//! roundtrip complet à travers la façade `Memory`, pas seulement le `Store` -//! du core (couvert séparément dans `basemyai-core/tests/store.rs`). - -#![cfg(feature = "crypto")] - -use basemyai::{AgentId, Memory, MemoryLayer}; -use basemyai_core::{Embedder, EncryptionKey, Result, Store}; - -const DIM: usize = 384; - -/// Embedder déterministe (cf. `porting.rs`/`memory.rs`) : pas de Candle ici, -/// seul le roundtrip chiffrement/rotation est sous test. -struct FakeEmbedder; - -impl FakeEmbedder { - fn vec_for(text: &str) -> Vec { - let mut v = vec![0.0_f32; DIM]; - for (i, b) in text.bytes().enumerate() { - v[i % DIM] += f32::from(b) + 1.0; - } - v[0] += 1.0; - v - } -} - -impl Embedder for FakeEmbedder { - fn embed(&self, text: &str) -> Result> { - Ok(Self::vec_for(text)) - } - - fn embed_batch(&self, texts: &[String]) -> Result>> { - Ok(texts.iter().map(|t| Self::vec_for(t)).collect()) - } - - fn model_id(&self) -> &str { - "fake-deterministic" - } - - fn dim(&self) -> usize { - DIM - } -} - -fn agent(id: &str) -> AgentId { - AgentId::new(id).expect("non-empty agent id") -} - -fn temp_db_path(name: &str) -> std::path::PathBuf { - std::env::temp_dir().join(format!("basemyai-key-rotation-{name}-{}.db", std::process::id())) -} - -fn cleanup(path: &std::path::Path) { - let _ = std::fs::remove_file(path); - let _ = std::fs::remove_file(path.with_extension("db-wal")); - let _ = std::fs::remove_file(path.with_extension("db-shm")); -} - -/// Rotation en place via `Memory::rotate_key` : la donnée mémorisée avant -/// rotation reste intacte sous la nouvelle clé, l'ancienne clé n'ouvre plus -/// rien. La `Memory` ayant exécuté la rotation est laissée tomber ensuite -/// (cf. doc `rotate_key` : le pool de lecteurs devient caduc), une nouvelle -/// `Memory::open` est requise pour continuer à utiliser le fichier. -#[tokio::test] -async fn rotate_key_preserves_data_and_invalidates_old_key() { - let path = temp_db_path("roundtrip"); - cleanup(&path); - - let remembered_id = { - let store = Store::open(&path, Some(EncryptionKey::new("old-passphrase"))) - .await - .expect("open encrypted store"); - let memory = Memory::open(store, Box::new(FakeEmbedder), agent("rotate-agent")) - .await - .expect("open memory"); - - let id = memory - .remember("the moon is made of rock", MemoryLayer::Semantic) - .await - .expect("remember"); - - memory - .rotate_key(EncryptionKey::new("new-passphrase")) - .await - .expect("rotate_key succeeds"); - - id - // `memory`/`store` dropped here — must not be reused post-rotation. - }; - - // L'ancienne clé ne doit plus permettre d'ouvrir la mémoire : soit - // `Store::open` échoue directement (header illisible), soit il réussit - // mais `Memory::open` échoue à la migration (page de donnée illisible) — - // les deux comportements sont observés selon l'état du fichier, ce test - // n'exige que « aucune des deux étapes ne produit une mémoire utilisable ». - { - let usable = match Store::open(&path, Some(EncryptionKey::new("old-passphrase"))).await { - Ok(store) => Memory::open(store, Box::new(FakeEmbedder), agent("rotate-agent")) - .await - .is_ok(), - Err(_) => false, - }; - assert!( - !usable, - "l'ancienne clé ne doit plus déchiffrer la mémoire après rotation" - ); - } - - // La nouvelle clé rouvre la mémoire et retrouve le souvenir intact. - { - let store = Store::open(&path, Some(EncryptionKey::new("new-passphrase"))) - .await - .expect("reopen with new key"); - let memory = Memory::open(store, Box::new(FakeEmbedder), agent("rotate-agent")) - .await - .expect("open memory with new key"); - - let hits = memory.recall("the moon is made of rock", 5).await.expect("recall"); - assert!( - hits.iter().any(|r| r.id == remembered_id), - "le souvenir mémorisé avant rotation doit rester lisible sous la nouvelle clé" - ); - } - - cleanup(&path); -} - -/// `rotate_key` propage l'erreur du core (`CoreError::Encryption`) quand le -/// store sous-jacent n'est pas chiffré — inatteignable via `Memory::open` -/// normalement (chiffrement obligatoire sur fichier, ADR-007), donc exercé -/// ici via le store de test non chiffré (`test-util`). -#[cfg(feature = "test-util")] -#[tokio::test] -async fn rotate_key_on_unencrypted_memory_fails() { - let path = temp_db_path("unencrypted"); - cleanup(&path); - - let memory = Memory::open_test_file(&path, "rotate-agent-plain") - .await - .expect("open unencrypted test file"); - - let err = memory - .rotate_key(EncryptionKey::new("whatever")) - .await - .expect_err("rotate_key on an unencrypted store must fail"); - assert!(matches!( - err, - basemyai::MemoryError::Core(basemyai_core::CoreError::Encryption) - )); - - cleanup(&path); -} diff --git a/crates/basemyai/tests/maintenance_worker.rs b/crates/basemyai/tests/maintenance_worker.rs index 0b2f36e..50c7feb 100644 --- a/crates/basemyai/tests/maintenance_worker.rs +++ b/crates/basemyai/tests/maintenance_worker.rs @@ -1,13 +1,16 @@ -//! Tests d'intégration du wiring MaintenanceWorker (M0.2). +//! Tests d'intégration du wiring `MaintenanceWorker` (M0.2). //! Vérifie que `ConsolidationTask` tourne correctement via l'interface -//! `MaintenanceTask`, et que `AdaptiveForgetting` + `ExpiredMemoryGc` -//! s'enregistrent dans `MaintenanceWorker` sans modification. +//! `MaintenanceTask` — GC/oubli adaptatif étaient les deux autres tâches +//! enregistrées ici avant ADR-032 (retrait de libSQL) ; elles n'ont pas +//! d'équivalent natif aujourd'hui (voir `crates/basemyai-cli/src/commands/ +//! maintenance.rs`). use std::sync::Arc; use std::time::Duration; -use basemyai::{AdaptiveForgetting, AgentId, ConsolidationTask, ExpiredMemoryGc, LlmInference, Memory, MemoryLayer}; -use basemyai_core::{Embedder, MaintenanceTask, MaintenanceWorker, Result as CoreResult, Store}; +use basemyai::{AgentId, ConsolidationTask, LlmInference, Memory, MemoryLayer}; +use basemyai_core::{Embedder, MaintenanceTask, MaintenanceWorker, Result as CoreResult}; +mod support; const DIM: usize = 384; @@ -59,26 +62,25 @@ fn agent(id: &str) -> AgentId { AgentId::new(id).expect("agent id non vide") } +async fn open_memory(agent_id: &str) -> Memory { + let store = Arc::new(support::open_native_store()); + Memory::from_native_store(store, Box::new(FakeEmbedder), agent(agent_id)) + .await + .expect("open memory") +} + /// `ConsolidationTask::run` appelle effectivement `consolidate` sur la mémoire -/// interne — le `_store` fourni est ignoré mais l'interface est respectée. +/// interne. #[tokio::test] async fn consolidation_task_runs_via_maintenance_interface() { - let store = Store::open_in_memory().await.expect("open"); - let mem = Arc::new( - Memory::open(store, Box::new(FakeEmbedder), agent("a")) - .await - .expect("open memory"), - ); + let mem = Arc::new(open_memory("a").await); mem.remember("Alice a rejoint Acme", MemoryLayer::Episodic) .await .expect("épisode"); let task = ConsolidationTask::new(Arc::clone(&mem), Arc::new(FakeLlm)); - - // Simule l'appel depuis le MaintenanceWorker : store passé en paramètre mais ignoré. - let dummy = Store::open_in_memory().await.expect("dummy store"); - task.run(&dummy).await.expect("run"); + task.run().await.expect("run"); // Le fait est consolidé en couche sémantique. let hits = mem.recall("Alice travaille chez Acme", 5).await.expect("recall"); @@ -93,12 +95,7 @@ async fn consolidation_task_runs_via_maintenance_interface() { /// `run`. Prouve que le wiring tient à travers `tokio::spawn` + `sleep`. #[tokio::test] async fn consolidation_runs_through_worker_background_loop() { - let store = Store::open_in_memory().await.expect("open"); - let mem = Arc::new( - Memory::open(store, Box::new(FakeEmbedder), agent("a")) - .await - .expect("open memory"), - ); + let mem = Arc::new(open_memory("a").await); mem.remember("Alice a rejoint Acme", MemoryLayer::Episodic) .await .expect("épisode"); @@ -109,7 +106,7 @@ async fn consolidation_runs_through_worker_background_loop() { Duration::from_millis(40), Arc::new(ConsolidationTask::new(Arc::clone(&mem), Arc::new(FakeLlm))), ) - .start(Arc::new(Store::open_in_memory().await.expect("dummy store"))); + .start(); // Laisse la boucle de fond s'exécuter au moins une fois. tokio::time::sleep(Duration::from_millis(200)).await; @@ -120,20 +117,3 @@ async fn consolidation_runs_through_worker_background_loop() { "la boucle de maintenance doit avoir consolidé le fait en semantic" ); } - -/// Vérification à la compilation + test que `MaintenanceWorker` accepte les trois -/// types de tâche sans modification d'interface. -#[test] -fn maintenance_worker_registers_all_task_types() { - let _worker = MaintenanceWorker::new() - .register(Duration::from_secs(3600), Arc::new(ExpiredMemoryGc)) - .register( - Duration::from_secs(7200), - Arc::new(AdaptiveForgetting { - capacity_per_agent: 10_000, - recency_half_life_secs: 86_400, - }), - ); - // ConsolidationTask nécessite Memory + LLM — non instancié ici mais compilable. - // Le test ci-dessus le vérifie end-to-end. -} diff --git a/crates/basemyai/tests/memory.rs b/crates/basemyai/tests/memory.rs index c75ab60..6fc9b0c 100644 --- a/crates/basemyai/tests/memory.rs +++ b/crates/basemyai/tests/memory.rs @@ -1,10 +1,12 @@ //! Tests d'intégration de la couche mémoire : roundtrip remember/recall, //! isolation par agent, expiration temporelle. Embedder fake déterministe. +use std::sync::Arc; + use basemyai::temporal::Validity; use basemyai::{AgentId, AgentStats, Memory, MemoryLayer}; -use basemyai_core::libsql; -use basemyai_core::{Embedder, Result, Store}; +use basemyai_core::{Embedder, Result}; +mod support; const DIM: usize = 384; @@ -51,12 +53,16 @@ fn now() -> i64 { i64::try_from(SystemTime::now().duration_since(UNIX_EPOCH).expect("clock").as_secs()).expect("fits i64") } +async fn open_memory(agent_id: &str) -> Memory { + let store = Arc::new(support::open_native_store()); + Memory::from_native_store(store, Box::new(FakeEmbedder), agent(agent_id)) + .await + .expect("open memory") +} + #[tokio::test] async fn remember_then_recall_returns_item() { - let store = Store::open_in_memory().await.expect("open"); - let mem = Memory::open(store, Box::new(FakeEmbedder), agent("a")) - .await - .expect("open memory"); + let mem = open_memory("a").await; mem.remember("the sky is blue", MemoryLayer::Semantic) .await @@ -74,10 +80,7 @@ async fn remember_then_recall_returns_item() { async fn recall_with_metric_supports_euclidean_and_hamming() { use basemyai::Metric; - let store = Store::open_in_memory().await.expect("open"); - let mem = Memory::open(store, Box::new(FakeEmbedder), agent("a")) - .await - .expect("open memory"); + let mem = open_memory("a").await; mem.remember("the sky is blue", MemoryLayer::Semantic) .await @@ -86,24 +89,25 @@ async fn recall_with_metric_supports_euclidean_and_hamming() { .await .expect("remember 2"); - for metric in [Metric::Euclidean, Metric::Hamming, Metric::Cosine] { - let hits = mem + // Le backend natif ne re-classe que Cosine aujourd'hui (ADR-032) — + // Euclidean/Hamming renvoient une erreur franche, jamais un faux résultat. + for metric in [Metric::Euclidean, Metric::Hamming] { + let err = mem .recall_with_metric("the sky is blue", 5, metric) .await - .expect("recall"); - assert!( - hits.iter().any(|r| r.text == "the sky is blue"), - "metric {metric:?} doit retrouver l'item exact" - ); + .expect_err("metric non implémentée doit échouer franchement"); + assert!(matches!(err, basemyai::MemoryError::Core(_))); } + let hits = mem + .recall_with_metric("the sky is blue", 5, Metric::Cosine) + .await + .expect("recall cosine"); + assert!(hits.iter().any(|r| r.text == "the sky is blue")); } #[tokio::test] async fn recall_hybrid_surfaces_exact_keyword_match() { - let store = Store::open_in_memory().await.expect("open"); - let mem = Memory::open(store, Box::new(FakeEmbedder), agent("a")) - .await - .expect("open memory"); + let mem = open_memory("a").await; mem.remember("the quick brown fox jumps", MemoryLayer::Semantic) .await @@ -126,10 +130,7 @@ async fn recall_hybrid_surfaces_exact_keyword_match() { #[tokio::test] async fn recall_hybrid_respects_isolation_and_validity() { - let store = Store::open_in_memory().await.expect("open"); - let mem = Memory::open(store, Box::new(FakeEmbedder), agent("a")) - .await - .expect("open memory"); + let mem = open_memory("a").await; let n = now(); let expired = Validity { @@ -149,10 +150,7 @@ async fn recall_hybrid_respects_isolation_and_validity() { #[tokio::test] async fn forget_removes_from_fts_mirror() { - let store = Store::open_in_memory().await.expect("open"); - let mem = Memory::open(store, Box::new(FakeEmbedder), agent("a")) - .await - .expect("open memory"); + let mem = open_memory("a").await; let id = mem .remember("WIDGET unique identifier", MemoryLayer::Semantic) @@ -169,10 +167,7 @@ async fn forget_removes_from_fts_mirror() { #[tokio::test] async fn remember_batch_inserts_all_and_mirrors_fts() { - let store = Store::open_in_memory().await.expect("open"); - let mem = Memory::open(store, Box::new(FakeEmbedder), agent("a")) - .await - .expect("open memory"); + let mem = open_memory("a").await; let texts = vec![ "first batched fact".to_string(), @@ -201,10 +196,7 @@ async fn remember_batch_inserts_all_and_mirrors_fts() { #[tokio::test] async fn remember_batch_empty_is_noop() { - let store = Store::open_in_memory().await.expect("open"); - let mem = Memory::open(store, Box::new(FakeEmbedder), agent("a")) - .await - .expect("open memory"); + let mem = open_memory("a").await; let ids = mem .remember_batch(&[], MemoryLayer::Semantic) @@ -216,13 +208,9 @@ async fn remember_batch_empty_is_noop() { #[tokio::test] async fn concurrent_remembers_serialize_without_error() { - // Les écritures sont transactionnelles sur une connexion partagée : le - // verrou writer du Store doit sérialiser les transactions concurrentes - // (sans lui, le second BEGIN imbriqué échouerait). - let store = Store::open_in_memory().await.expect("open"); - let mem = Memory::open(store, Box::new(FakeEmbedder), agent("a")) - .await - .expect("open memory"); + // Les écritures sont sérialisées côté moteur (mono-écrivain, ADR-025) : + // des `remember`/`remember_batch` concurrents doivent tous aboutir. + let mem = open_memory("a").await; let batch = ["concurrent three".to_string(), "concurrent four".to_string()]; let (r1, r2, r3) = tokio::join!( @@ -243,26 +231,18 @@ async fn concurrent_remembers_serialize_without_error() { #[tokio::test] async fn isolation_hides_other_agents_items() { - // Agent A mémorise dans une base, on copie son contenu dans la base de B - // n'est pas possible (in-memory) : on vérifie plutôt que, partageant la - // MÊME base, B (recall borné à `agent_id = 'B'`) ne voit pas les lignes de A. - let store = Store::open_in_memory().await.expect("open"); - store.migrate(&basemyai::schema()).await.expect("migrate"); - - // Insère un item de l'agent A directement (même base, connexion partagée). - let vec_a = FakeEmbedder::vec_for("secret of agent A"); - let lit = format!("[{}]", vec_a.iter().map(f32::to_string).collect::>().join(",")); - let conn = store.connect(); - conn.execute( - "INSERT INTO memory (id, agent_id, layer, content, valid_from, valid_until, emb) \ - VALUES (?1, 'A', 'semantic', 'secret of agent A', 0, NULL, vector(?2))", - basemyai_core::libsql::params!["row-a", lit], - ) - .await - .expect("insert A"); - - // Mémoire bornée à l'agent B sur la MÊME base. - let mem_b = Memory::open(store, Box::new(FakeEmbedder), agent("B")) + // Deux agents sur la MÊME instance de store natif (partagée, comme un + // provider REST/MCP) : B ne doit jamais voir les souvenirs de A. + let store = Arc::new(support::open_native_store()); + let mem_a = Memory::from_native_store(Arc::clone(&store), Box::new(FakeEmbedder), agent("A")) + .await + .expect("open memory A"); + mem_a + .remember("secret of agent A", MemoryLayer::Semantic) + .await + .expect("A remembers"); + + let mem_b = Memory::from_native_store(Arc::clone(&store), Box::new(FakeEmbedder), agent("B")) .await .expect("open memory B"); mem_b @@ -279,10 +259,7 @@ async fn isolation_hides_other_agents_items() { #[tokio::test] async fn temporal_excludes_expired_items() { - let store = Store::open_in_memory().await.expect("open"); - let mem = Memory::open(store, Box::new(FakeEmbedder), agent("a")) - .await - .expect("open memory"); + let mem = open_memory("a").await; let n = now(); // valid_until dans le passé => expiré. @@ -318,9 +295,8 @@ async fn temporal_excludes_expired_items() { #[tokio::test] async fn recall_updates_last_access() { - let store = Store::open_in_memory().await.expect("open"); - let conn = store.connect(); - let mem = Memory::open(store, Box::new(FakeEmbedder), agent("a")) + let store = Arc::new(support::open_native_store()); + let mem = Memory::from_native_store(Arc::clone(&store), Box::new(FakeEmbedder), agent("a")) .await .expect("open memory"); @@ -328,46 +304,44 @@ async fn recall_updates_last_access() { .await .expect("remember"); - // Avant recall : last_access doit être NULL. - let mut rows = conn - .query( - "SELECT last_access FROM memory WHERE content = ?1", - libsql::params!["traceable fact"], - ) - .await - .expect("query before recall"); - let row = rows.next().await.expect("next").expect("row"); - let val: libsql::Value = row.get(0).expect("get"); - assert!( - matches!(val, libsql::Value::Null), - "last_access doit être NULL avant recall" + let last_access_of = |rows: &[(String, basemyai_engine::MemoryRecord)]| { + rows.iter() + .find(|(_, r)| r.content == "traceable fact") + .expect("record present") + .1 + .last_access + }; + + // Avant recall : `last_access` doit valoir `valid_from` (jamais accédé). + let before = store.export_rows(&agent("a")).await.expect("export before recall"); + let before_access = last_access_of(&before.memories); + let valid_from = before + .memories + .iter() + .find(|(_, r)| r.content == "traceable fact") + .unwrap() + .1 + .valid_from; + assert_eq!( + before_access, valid_from, + "last_access doit valoir valid_from avant recall" ); let hits = mem.recall("traceable fact", 5).await.expect("recall"); assert!(!hits.is_empty(), "recall doit trouver l'item"); - // Après recall : last_access doit être renseigné. - let mut rows = conn - .query( - "SELECT last_access FROM memory WHERE content = ?1", - libsql::params!["traceable fact"], - ) - .await - .expect("query after recall"); - let row = rows.next().await.expect("next").expect("row"); - let val: libsql::Value = row.get(0).expect("get"); + // Après recall : `last_access` doit avoir avancé. + let after = store.export_rows(&agent("a")).await.expect("export after recall"); + let after_access = last_access_of(&after.memories); assert!( - matches!(val, libsql::Value::Integer(_)), - "last_access doit être défini après recall" + after_access >= before_access, + "last_access doit être mis à jour après recall" ); } #[tokio::test] async fn recall_by_layer_filters_correctly() { - let store = Store::open_in_memory().await.expect("open"); - let mem = Memory::open(store, Box::new(FakeEmbedder), agent("a")) - .await - .expect("open memory"); + let mem = open_memory("a").await; // Même texte dans deux couches différentes. mem.remember("layered content", MemoryLayer::Semantic) @@ -406,10 +380,7 @@ async fn recall_by_layer_filters_correctly() { #[tokio::test] async fn invalidate_hides_item_from_recall() { - let store = Store::open_in_memory().await.expect("open"); - let mem = Memory::open(store, Box::new(FakeEmbedder), agent("a")) - .await - .expect("open memory"); + let mem = open_memory("a").await; mem.remember("to be invalidated", MemoryLayer::Semantic) .await @@ -442,9 +413,8 @@ async fn invalidate_hides_item_from_recall() { #[tokio::test] async fn forget_removes_item_physically() { - let store = Store::open_in_memory().await.expect("open"); - let conn = store.connect(); - let mem = Memory::open(store, Box::new(FakeEmbedder), agent("a")) + let store = Arc::new(support::open_native_store()); + let mem = Memory::from_native_store(Arc::clone(&store), Box::new(FakeEmbedder), agent("a")) .await .expect("open memory"); @@ -470,21 +440,16 @@ async fn forget_removes_item_physically() { ); // La ligne doit être physiquement supprimée. - let mut rows = conn - .query("SELECT COUNT(*) FROM memory WHERE id = ?1", libsql::params![id]) - .await - .expect("count query"); - let row = rows.next().await.expect("next").expect("row"); - let count: i64 = row.get(0).expect("count"); - assert_eq!(count, 0, "forget doit supprimer physiquement la ligne"); + let rows = store.export_rows(&agent("a")).await.expect("export after forget"); + assert!( + rows.memories.iter().all(|(rid, _)| *rid != id), + "forget doit supprimer physiquement l'enregistrement" + ); } #[tokio::test] async fn stats_counts_per_layer() { - let store = Store::open_in_memory().await.expect("open"); - let mem = Memory::open(store, Box::new(FakeEmbedder), agent("a")) - .await - .expect("open memory"); + let mem = open_memory("a").await; mem.remember("s1", MemoryLayer::Semantic).await.expect("s1"); mem.remember("s2", MemoryLayer::Semantic).await.expect("s2"); @@ -501,10 +466,7 @@ async fn stats_counts_per_layer() { #[tokio::test] async fn remember_rejects_text_over_max_len() { - let store = Store::open_in_memory().await.expect("open"); - let mem = Memory::open(store, Box::new(FakeEmbedder), agent("a")) - .await - .expect("open memory"); + let mem = open_memory("a").await; let too_long = "x".repeat(basemyai::MAX_TEXT_LEN + 1); let err = mem @@ -522,10 +484,7 @@ async fn remember_rejects_text_over_max_len() { #[tokio::test] async fn remember_accepts_text_at_exact_max_len() { - let store = Store::open_in_memory().await.expect("open"); - let mem = Memory::open(store, Box::new(FakeEmbedder), agent("a")) - .await - .expect("open memory"); + let mem = open_memory("a").await; let at_limit = "x".repeat(basemyai::MAX_TEXT_LEN); mem.remember(&at_limit, MemoryLayer::Semantic) @@ -535,11 +494,7 @@ async fn remember_accepts_text_at_exact_max_len() { #[tokio::test] async fn search_graph_scoped_to_entity_mentions() { - let store = Store::open_in_memory().await.expect("open"); - let conn = store.connect(); - let mem = Memory::open(store, Box::new(FakeEmbedder), agent("a")) - .await - .expect("open memory"); + let mem = open_memory("a").await; // Souvenir mentionnant une entité du graphe ("Alice"). mem.remember("Alice works at Acme", MemoryLayer::Semantic) @@ -550,14 +505,10 @@ async fn search_graph_scoped_to_entity_mentions() { .await .expect("remember other"); - // Ajoute l'entité "Alice" au graphe via SQL direct (conn partagée). - conn.execute( - "INSERT INTO entity (id, agent_id, kind, label, valid_from, valid_until, importance) \ - VALUES (?1, ?2, ?3, ?4, ?5, NULL, 0)", - libsql::params!["e-alice", "a", "person", "Alice", 0_i64], - ) - .await - .expect("insert entity"); + mem.graph() + .add_entity("e-alice", "person", "Alice") + .await + .expect("insert entity"); let hits = mem.search_graph("who works where?", 5).await.expect("search_graph"); diff --git a/crates/basemyai/tests/memory_tests.rs b/crates/basemyai/tests/memory_tests.rs index ae201f1..26da5c9 100644 --- a/crates/basemyai/tests/memory_tests.rs +++ b/crates/basemyai/tests/memory_tests.rs @@ -1,30 +1,16 @@ -//! Point d'entrée du runner de tests **déclaratifs multi-backend** (N2, +//! Point d'entrée du runner de tests **déclaratifs** (N2, //! `docs/TODO-NATIVE-ENGINE.md`). Rejoue `memory_tests::scenarios::all()` -//! contre chaque backend enregistré ci-dessous via `backend_suite!`. -//! -//! Aujourd'hui : un seul backend réel, `Libsql`. Brancher `Native` (dépendance -//! N3/N4, non commencés — voir `docs/TODO-NATIVE-ENGINE.md`) est mécanique : -//! implémenter `MemoryStore` pour lui, écrire une factory async équivalente à -//! `make_libsql_store`, puis décommenter/ajouter une ligne `backend_suite!`. -//! Aucune autre modification de ce fichier ni de `memory_tests/mod.rs` n'est -//! nécessaire — c'est précisément ce que la borne générique -//! `run_scenario` rend possible. +//! contre le backend natif via `backend_suite!` — le runner reste générique +//! sur `MemoryStore` (posé au N2 pour un diff multi-backend qui a bien eu +//! lieu tant que libSQL vivait, ADR-032 §conséquences), donc brancher un futur +//! second backend resterait mécanique. #[path = "memory_tests/mod.rs"] mod memory_tests; +mod support; -use basemyai::storage::LibsqlMemoryStore; -use basemyai_core::Store; use memory_tests::run_scenario; -/// Backend `Libsql` frais (in-memory, migré) — une instance par scénario, -/// isolation totale même si deux scénarios partageaient un `agent` id. -async fn make_libsql_store() -> LibsqlMemoryStore { - let store = Store::open_in_memory().await.expect("store in-memory ouvre"); - store.migrate(&basemyai::schema()).await.expect("migration"); - LibsqlMemoryStore::new(store) -} - /// Enregistre un backend : génère un `#[tokio::test]` qui rejoue **tous** les /// scénarios de `memory_tests::scenarios::all()` contre une instance fraîche /// du backend nommé `$backend`, construite par `$make`. @@ -40,15 +26,10 @@ macro_rules! backend_suite { }; } -backend_suite!(libsql, make_libsql_store); - /// `put_memory_batch` est tout-ou-rien : un id dupliqué **au milieu** du lot -/// ne doit laisser aucune trace des items valides qui l'entourent — ni côté -/// libSQL (violation de contrainte UNIQUE, transaction jamais commitée), ni -/// côté Native depuis N5.5 (`PersistentMemoryIndex::put_many`, résorbant -/// l'écart « atomique par item » d'ADR-027 §6). Générique sur -/// [`MemoryStore`] pour être rejouée verbatim contre les deux backends — -/// même discipline que `run_scenario`. +/// ne doit laisser aucune trace des items valides qui l'entourent +/// (`PersistentMemoryIndex::put_many`, résorbant l'écart « atomique par +/// item » d'ADR-027 §6). async fn assert_put_memory_batch_is_all_or_nothing(store: &S) { use basemyai::MemoryLayer; use basemyai::storage::NewMemory; @@ -117,48 +98,32 @@ async fn assert_put_memory_batch_is_all_or_nothing basemyai::storage::NativeMemoryStore { - basemyai::storage::NativeMemoryStore::open_ephemeral().expect("store natif éphémère ouvre") + support::open_native_store() } -#[cfg(feature = "engine-native")] backend_suite!(native, make_native_store); -#[cfg(feature = "engine-native")] #[tokio::test] async fn native_put_memory_batch_is_all_or_nothing() { assert_put_memory_batch_is_all_or_nothing(&make_native_store().await).await; } -/// Backend `Native` **chiffré au repos** (N5.4, ADR-030) : la suite complète -/// des scénarios rejouée contre un store natif dont WAL et SST sont scellés — -/// le chiffrement doit être transparent pour tout le contrat `MemoryStore`, -/// zéro divergence tolérée avec les deux backends en clair ci-dessus. -#[cfg(feature = "engine-native")] +/// Backend natif **chiffré au repos** (N5.4, ADR-030) : la suite complète des +/// scénarios rejouée contre un store natif dont WAL et SST sont scellés — le +/// chiffrement doit être transparent pour tout le contrat `MemoryStore`, zéro +/// divergence tolérée avec le backend en clair ci-dessus. async fn make_native_encrypted_store() -> basemyai::storage::NativeMemoryStore { - basemyai::storage::NativeMemoryStore::open_ephemeral_encrypted("clé-de-test-scénarios") - .expect("store natif chiffré éphémère ouvre") + support::open_encrypted_native_store("clé-de-test-scénarios") } -#[cfg(feature = "engine-native")] backend_suite!(native_encrypted, make_native_encrypted_store); -/// Rotation de clé sur le backend natif (N5.4, ADR-030 §4) — le pendant -/// natif de `tests/key_rotation.rs` (libSQL, feature `crypto`) : la donnée -/// mémorisée avant rotation reste lisible sous la nouvelle clé, l'ancienne -/// clé n'ouvre plus rien, et — contrairement à libSQL — l'instance ayant -/// exécuté la rotation reste utilisable sans réouverture. -#[cfg(feature = "engine-native")] +/// Rotation de clé (N5.4, ADR-030 §4) : la donnée mémorisée avant rotation +/// reste lisible sous la nouvelle clé, l'ancienne clé n'ouvre plus rien, et +/// l'instance ayant exécuté la rotation reste utilisable sans réouverture. #[tokio::test] async fn native_rotate_key_preserves_data_and_invalidates_old_key() { use basemyai::MemoryLayer; @@ -212,27 +177,24 @@ async fn native_rotate_key_preserves_data_and_invalidates_old_key() { assert_eq!(got[0].text, "la lune est en roche"); } -/// `rotate_key` sur un store natif ouvert en clair : erreur franche, parité -/// de posture avec `rotate_key_on_unencrypted_memory_fails` -/// (`tests/key_rotation.rs`, `CoreError::Encryption` côté libSQL). -/// Concurrence des lecteurs (N5.5, barre hardening M6) : depuis le passage -/// de `NativeMemoryStore` de `Mutex` à `RwLock`, plusieurs lectures doivent +/// `rotate_key` sur un store natif ouvert en clair : erreur franche. +/// Concurrence des lecteurs (N5.5, barre hardening M6) : depuis le passage de +/// `NativeMemoryStore` de `Mutex` à `RwLock`, plusieurs lectures doivent /// pouvoir s'exécuter **en parallèle** sans se corrompre ni se bloquer les /// unes les autres. Ce test vérifie la correction sous charge concurrente /// (beaucoup de lectures mixtes en vol simultanément, résultats tous /// corrects) et **mesure** — sans assertion stricte sur la latence, trop /// bruitée en CI — le ratio séquentiel/concurrent, journalisé pour /// inspection humaine plutôt que comme un seuil de flakiness. -#[cfg(feature = "engine-native")] #[tokio::test] async fn native_concurrent_reads_are_correct_and_faster_than_sequential() { use basemyai::MemoryLayer; - use basemyai::storage::{MemoryStore, NativeMemoryStore}; + use basemyai::storage::MemoryStore; use basemyai::temporal::Validity; use std::sync::Arc; use std::time::Instant; - let store = Arc::new(NativeMemoryStore::open_ephemeral().expect("store natif éphémère")); + let store = Arc::new(support::open_native_store()); let agent = basemyai::AgentId::new("concurrent-reads-agent").expect("agent id"); const N: usize = 200; @@ -328,10 +290,9 @@ async fn native_concurrent_reads_are_correct_and_faster_than_sequential() { ); } -#[cfg(feature = "engine-native")] #[tokio::test] async fn native_rotate_key_on_plaintext_store_fails() { - let store = basemyai::storage::NativeMemoryStore::open_ephemeral().expect("store en clair"); + let store = support::open_native_store(); assert!( store.rotate_key("peu-importe").await.is_err(), "rotate_key sur un store non chiffré doit échouer" diff --git a/crates/basemyai/tests/memory_tests/mod.rs b/crates/basemyai/tests/memory_tests/mod.rs index ece541f..2b62106 100644 --- a/crates/basemyai/tests/memory_tests/mod.rs +++ b/crates/basemyai/tests/memory_tests/mod.rs @@ -6,12 +6,11 @@ //! `agent: Option<&'static str>`) pour les scénarios d'isolation //! multi-agent (N5.3). [`run_scenario`] les rejoue contre **n'importe //! quelle** implémentation de [`MemoryStore`] — la borne est générique -//! (`S: MemoryStore`), aucune implémentation concrète (`LibsqlMemoryStore` ou -//! `NativeMemoryStore`) n'apparaît dans ce fichier. +//! (`S: MemoryStore`), aucune implémentation concrète (`NativeMemoryStore`) +//! n'apparaît dans ce fichier. //! -//! Deux backends existent (`Libsql`, `Native` sous la feature -//! `engine-native`) — voir `../memory_tests.rs` pour l'enregistrement des -//! backends via `backend_suite!`. +//! Le backend natif est rejoué en clair et chiffré — voir `../memory_tests.rs` +//! pour l'enregistrement des backends via `backend_suite!`. pub(crate) mod scenarios; diff --git a/crates/basemyai/tests/native_memory_store_bench.rs b/crates/basemyai/tests/native_memory_store_bench.rs index 61f52fd..a55c155 100644 --- a/crates/basemyai/tests/native_memory_store_bench.rs +++ b/crates/basemyai/tests/native_memory_store_bench.rs @@ -16,7 +16,7 @@ //! --test native_memory_store_bench -- --ignored --nocapture //! ``` -#![cfg(all(feature = "test-util", feature = "engine-native"))] +#![cfg(feature = "test-util")] use basemyai::storage::{MemoryStore, NativeMemoryStore}; use basemyai::temporal::Validity; diff --git a/crates/basemyai/tests/p1_isolation_adversarial.rs b/crates/basemyai/tests/p1_isolation_adversarial.rs index 30b235a..c34969c 100644 --- a/crates/basemyai/tests/p1_isolation_adversarial.rs +++ b/crates/basemyai/tests/p1_isolation_adversarial.rs @@ -1,13 +1,15 @@ //! Public adversarial isolation proof for P1 market differentiation. //! -//! This test intentionally uses hostile-looking `agent_id`, text, FTS queries, -//! known foreign ids, and graph ids. The invariant under test is simple: -//! knowing another agent's identifiers or injecting SQL-looking text must not -//! bypass the SQL-level `agent_id` boundary. +//! This test intentionally uses hostile-looking `agent_id`, text, FTS +//! queries, known foreign ids, and graph ids. The invariant under test is +//! simple: knowing another agent's identifiers or injecting SQL-looking text +//! must not bypass the structural per-agent isolation boundary (ADR-006, +//! ADR-027 §2 — key-prefix isolation on the native backend). use basemyai::temporal::Validity; use basemyai::{AgentId, Memory, MemoryLayer}; -use basemyai_core::{Embedder, Result, Store}; +use basemyai_core::{Embedder, Result}; +mod support; const DIM: usize = 384; @@ -48,47 +50,35 @@ fn agent(id: &str) -> AgentId { #[tokio::test] async fn hostile_agent_id_query_and_known_ids_do_not_cross_tenant_boundary() { - let store = Store::open_in_memory().await.expect("open store"); - store.migrate(&basemyai::schema()).await.expect("migrate"); - let conn = store.connect(); - - let secret_id = "agent-a-secret"; - let secret_vector = to_vec_literal(&FakeEmbedder::vec_for("secret token SABLE-777 belongs only to agent A")); - conn.execute( - "INSERT INTO memory (id, agent_id, layer, content, valid_from, valid_until, emb) \ - VALUES (?1, 'agent-a', 'semantic', ?2, 0, NULL, vector(?3))", - basemyai_core::libsql::params![ - secret_id, - "secret token SABLE-777 belongs only to agent A", - secret_vector - ], - ) - .await - .expect("insert agent A memory"); - conn.execute( - "INSERT INTO memory_fts (id, agent_id, content) VALUES (?1, 'agent-a', ?2)", - basemyai_core::libsql::params![secret_id, "secret token SABLE-777 belongs only to agent A"], - ) - .await - .expect("insert agent A fts"); - conn.execute( - "INSERT INTO entity (agent_id, id, kind, label, valid_from, valid_until, importance) \ - VALUES ('agent-a', 'shared-root', 'secret', 'Agent A private graph node', 0, NULL, 0), \ - ('agent-a', 'shared-leaf', 'secret', 'Agent A private graph leaf', 0, NULL, 0)", - (), - ) - .await - .expect("insert agent A graph entities"); - conn.execute( - "INSERT INTO edge (agent_id, src, dst, relation, weight, valid_from, valid_until) \ - VALUES ('agent-a', 'shared-root', 'shared-leaf', 'points_to', 1.0, 0, NULL)", - (), - ) - .await - .expect("insert agent A graph edge"); + let store = std::sync::Arc::new(support::open_native_store()); + + // Agent A seeds real data through the public API — the id it gets back + // is exactly what an attacker who has "known ids" would have observed. + let mem_a = Memory::from_native_store(std::sync::Arc::clone(&store), Box::new(FakeEmbedder), agent("agent-a")) + .await + .expect("open agent A memory"); + let secret_id = mem_a + .remember("secret token SABLE-777 belongs only to agent A", MemoryLayer::Semantic) + .await + .expect("agent A remembers"); + mem_a + .graph() + .add_entity("shared-root", "secret", "Agent A private graph node") + .await + .expect("entity"); + mem_a + .graph() + .add_entity("shared-leaf", "secret", "Agent A private graph leaf") + .await + .expect("entity"); + mem_a + .graph() + .add_edge("shared-root", "points_to", "shared-leaf", 1.0) + .await + .expect("edge"); let hostile = "agent-b' OR '1'='1"; - let mem_b = Memory::open(store, Box::new(FakeEmbedder), agent(hostile)) + let mem_b = Memory::from_native_store(std::sync::Arc::clone(&store), Box::new(FakeEmbedder), agent(hostile)) .await .expect("open hostile memory"); mem_b @@ -119,27 +109,18 @@ async fn hostile_agent_id_query_and_known_ids_do_not_cross_tenant_boundary() { ); mem_b - .invalidate(secret_id) + .invalidate(&secret_id) .await .expect("foreign invalidate is a scoped no-op"); - mem_b.forget(secret_id).await.expect("foreign forget is a scoped no-op"); - - let mut rows = conn - .query( - "SELECT COUNT(*) FROM memory WHERE id = ?1 AND agent_id = 'agent-a'", - basemyai_core::libsql::params![secret_id], - ) - .await - .expect("count agent A row"); - let still_visible_to_a: i64 = rows - .next() + mem_b + .forget(&secret_id) .await - .expect("row read") - .expect("one row") - .get(0) - .expect("count"); - assert_eq!( - still_visible_to_a, 1, + .expect("foreign forget is a scoped no-op"); + + // Agent A's row must still be intact after B's scoped no-op attempts. + let still_visible_to_a = mem_a.recall("secret token SABLE-777", 10).await.expect("recall as A"); + assert!( + still_visible_to_a.iter().any(|r| r.id == secret_id), "agent B must not invalidate or delete agent A's known id" ); @@ -156,8 +137,8 @@ async fn hostile_agent_id_query_and_known_ids_do_not_cross_tenant_boundary() { #[tokio::test] async fn expired_foreign_memory_stays_hidden_even_with_hostile_text() { - let store = Store::open_in_memory().await.expect("open"); - let mem = Memory::open(store, Box::new(FakeEmbedder), agent("agent-a")) + let store = std::sync::Arc::new(support::open_native_store()); + let mem = Memory::from_native_store(store, Box::new(FakeEmbedder), agent("agent-a")) .await .expect("open memory"); @@ -185,16 +166,3 @@ fn current_unix() -> i64 { i64::try_from(SystemTime::now().duration_since(UNIX_EPOCH).expect("clock").as_secs()).expect("fits i64") } - -fn to_vec_literal(v: &[f32]) -> String { - let mut s = String::with_capacity(v.len() * 8 + 2); - s.push('['); - for (i, x) in v.iter().enumerate() { - if i > 0 { - s.push(','); - } - s.push_str(&x.to_string()); - } - s.push(']'); - s -} diff --git a/crates/basemyai/tests/pool_concurrency.rs b/crates/basemyai/tests/pool_concurrency.rs deleted file mode 100644 index e0e696d..0000000 --- a/crates/basemyai/tests/pool_concurrency.rs +++ /dev/null @@ -1,204 +0,0 @@ -//! Tests du **pool de lecteurs** libSQL (ADR-020, hardening M6) : lectures -//! concurrentes sur un store fichier (WAL), lectures pendant une écriture en -//! cours, dégénérescence en mémoire, et présence du side-car `-wal`. -//! -//! Pilotés via [`Store`] (+ [`LibsqlMemoryStore`] pour le chemin sémantique), -//! comme `storage_contract.rs`. Note : `RUST_TEST_THREADS=1` -//! (`.cargo/config.toml`) sérialise les *tests* entre eux, mais **pas** les -//! tâches `tokio` d'un même test — la concurrence testée ici est interne au -//! runtime async, exactement le chemin du pool en production. - -use std::path::PathBuf; -use std::sync::Arc; - -use basemyai::storage::{LibsqlMemoryStore, MemoryStore}; -use basemyai::temporal::Validity; -use basemyai::{AgentId, MemoryLayer}; -use basemyai_core::{Filter, Metric, Store, Value}; -use tokio::task::JoinSet; - -fn agent(id: &str) -> AgentId { - AgentId::new(id).expect("non-empty agent id") -} - -fn now() -> i64 { - use std::time::{SystemTime, UNIX_EPOCH}; - i64::try_from(SystemTime::now().duration_since(UNIX_EPOCH).expect("clock").as_secs()).expect("fits i64") -} - -fn temp_db_path(name: &str) -> PathBuf { - std::env::temp_dir().join(format!("basemyai-{name}-{}-{}.db", std::process::id(), now())) -} - -/// Vecteur déterministe à la dimension du schéma (`EMBEDDING_DIM`). -fn vec_for(seed: u8) -> Vec { - let dim = basemyai::EMBEDDING_DIM; - let mut v = vec![0.0_f32; dim]; - v[usize::from(seed) % dim] = 1.0; - v[0] += 0.001; - v -} - -/// Filtre `WHERE` agent (sans validité — suffisant ici), pour piloter -/// `vector_knn` du core directement. -fn agent_filter(agent: &AgentId) -> Filter { - Filter::new("agent_id = ?", vec![Value::Text(agent.as_str().to_string())]) -} - -async fn migrated_file_store(path: &std::path::Path) -> Store { - let store = Store::open(path, None).await.expect("open file store"); - store.migrate(&basemyai::schema()).await.expect("migrate"); - store -} - -/// Nettoie le fichier de base + ses side-cars WAL/SHM en fin de test. -fn cleanup(path: &std::path::Path) { - let _ = std::fs::remove_file(path); - let _ = std::fs::remove_file(path.with_extension("db-wal")); - let _ = std::fs::remove_file(path.with_extension("db-shm")); -} - -/// ~64 lectures `recall_vector` concurrentes sur un store fichier (pool actif) -/// renvoient toutes le résultat attendu, sans panique ni erreur. -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn concurrent_reads_on_file_store_all_succeed() { - let path = temp_db_path("pool-concurrent-reads"); - let engine = Arc::new(LibsqlMemoryStore::new(migrated_file_store(&path).await)); - let a = agent("a"); - - for i in 0..8_u8 { - engine - .put_memory( - &format!("m{i}"), - &a, - MemoryLayer::Episodic, - &format!("contenu {i}"), - Validity::since(0), - &vec_for(i + 1), - "user", - ) - .await - .expect("put"); - } - - let mut tasks = JoinSet::new(); - for _ in 0..64 { - let engine = Arc::clone(&engine); - let a = a.clone(); - tasks.spawn(async move { - engine - .recall_vector(&a, &vec_for(1), 5, None, Metric::Cosine, 0) - .await - .expect("concurrent recall") - }); - } - - let mut count = 0; - while let Some(res) = tasks.join_next().await { - let got = res.expect("task join"); - assert!(!got.is_empty(), "every concurrent read returns results"); - assert!(got.iter().any(|r| r.id == "m0"), "nearest neighbour present"); - count += 1; - } - assert_eq!(count, 64); - - cleanup(&path); -} - -/// Sous WAL, les lectures réussissent pendant qu'une transaction d'écriture est -/// ouverte (writer non commité) — le pool lecteur ne se bloque pas sur le writer. -/// On pilote `Store` directement pour tenir le `WriteTxn` ouvert pendant la lecture. -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn reads_succeed_while_write_txn_in_progress() { - let path = temp_db_path("pool-read-during-write"); - let store = Arc::new(migrated_file_store(&path).await); - let a = agent("a"); - - // Seed une ligne via le chemin sémantique (écriture committée). - { - let engine = LibsqlMemoryStore::new(migrated_file_store(&path).await); - engine - .put_memory( - "seed", - &a, - MemoryLayer::Episodic, - "graine", - Validity::since(0), - &vec_for(1), - "user", - ) - .await - .expect("seed put"); - } - - // Ouvre une transaction writer et garde-la ouverte (non committée). - let txn = store.begin_write().await.expect("begin write"); - - // Pendant ce temps, une lecture via le pool (reader()) doit réussir. - let neighbors = store - .vector_knn("memory", &vec_for(1), 5, Some(&agent_filter(&a))) - .await - .expect("read during open write txn"); - assert!( - neighbors.iter().any(|n| n.id == "seed"), - "la lecture pendant une ecriture ouverte voit la donnee committee" - ); - - txn.commit().await.expect("commit"); - cleanup(&path); -} - -/// Le store **en mémoire** (pool dégénéré, `readers` vide) reste fonctionnel -/// de bout en bout : `reader()` retombe sur le writer partagé. -#[tokio::test] -async fn in_memory_store_degenerate_pool_roundtrips() { - let store = Store::open_in_memory().await.expect("open in memory"); - store.migrate(&basemyai::schema()).await.expect("migrate"); - let engine = LibsqlMemoryStore::new(store); - let a = agent("a"); - - engine - .put_memory( - "m1", - &a, - MemoryLayer::Episodic, - "bonjour", - Validity::since(0), - &vec_for(1), - "user", - ) - .await - .expect("put"); - - let got = engine - .recall_vector(&a, &vec_for(1), 5, None, Metric::Cosine, 0) - .await - .expect("recall"); - assert_eq!(got.len(), 1); - assert_eq!(got[0].id, "m1"); -} - -/// Après une écriture sur un store fichier en WAL, le side-car `-wal` existe. -#[tokio::test] -async fn wal_sidecar_exists_after_write() { - let path = temp_db_path("pool-wal-sidecar"); - let engine = LibsqlMemoryStore::new(migrated_file_store(&path).await); - let a = agent("a"); - engine - .put_memory( - "m1", - &a, - MemoryLayer::Episodic, - "x", - Validity::since(0), - &vec_for(1), - "user", - ) - .await - .expect("put"); - - let wal = path.with_extension("db-wal"); - assert!(wal.exists(), "le side-car WAL doit exister apres une ecriture: {wal:?}"); - - cleanup(&path); -} diff --git a/crates/basemyai/tests/porting.rs b/crates/basemyai/tests/porting.rs index f374f56..2cfd1e1 100644 --- a/crates/basemyai/tests/porting.rs +++ b/crates/basemyai/tests/porting.rs @@ -3,7 +3,8 @@ use basemyai::temporal::Validity; use basemyai::{AgentId, Memory, MemoryError, MemoryLayer}; -use basemyai_core::{Embedder, Result, Store}; +use basemyai_core::{Embedder, Result}; +mod support; const DIM: usize = 384; @@ -49,8 +50,8 @@ fn now() -> i64 { } async fn open_memory(agent_id: &str) -> Memory { - let store = Store::open_in_memory().await.expect("open store"); - Memory::open(store, Box::new(FakeEmbedder), agent(agent_id)) + let store = support::open_native_store(); + Memory::from_native_store(std::sync::Arc::new(store), Box::new(FakeEmbedder), agent(agent_id)) .await .expect("open memory") } diff --git a/crates/basemyai/tests/storage_contract.rs b/crates/basemyai/tests/storage_contract.rs index 6f3bd18..1d951af 100644 --- a/crates/basemyai/tests/storage_contract.rs +++ b/crates/basemyai/tests/storage_contract.rs @@ -1,22 +1,21 @@ //! Tests de **contrat** pour [`basemyai::storage::MemoryStore`] (suivi //! ADR-019/ADR-020 : « add backend contract tests before any second backend //! exists »). Pilotés par le trait directement, **pas** par `Memory` — pour -//! qu'ils restent valides verbatim contre une future implémentation autre que -//! [`LibsqlMemoryStore`]. +//! qu'ils restent valides verbatim contre une future seconde implémentation +//! (aujourd'hui, [`NativeMemoryStore`] est l'unique backend, ADR-032). -use basemyai::storage::{LibsqlMemoryStore, MemoryStore, NewMemory}; +use basemyai::storage::{MemoryStore, NativeMemoryStore, NewMemory}; use basemyai::temporal::Validity; use basemyai::{AgentId, MemoryLayer}; -use basemyai_core::{Metric, Store}; +use basemyai_core::Metric; +mod support; fn agent(id: &str) -> AgentId { AgentId::new(id).expect("non-empty agent id") } -async fn engine() -> LibsqlMemoryStore { - let store = Store::open_in_memory().await.expect("open"); - store.migrate(&basemyai::schema()).await.expect("migrate"); - LibsqlMemoryStore::new(store) +async fn engine() -> NativeMemoryStore { + support::open_native_store() } /// Vecteur déterministe à la dimension du schéma (`EMBEDDING_DIM`) : deux diff --git a/crates/basemyai/tests/support/mod.rs b/crates/basemyai/tests/support/mod.rs new file mode 100644 index 0000000..df4c37e --- /dev/null +++ b/crates/basemyai/tests/support/mod.rs @@ -0,0 +1,31 @@ +//! Helpers de tests d'intégration pour ouvrir un store natif via l'API +//! production (`open` / `open_encrypted`) sans dépendre des helpers +//! `test-util`. + +use std::sync::{LazyLock, Mutex}; + +use basemyai::storage::NativeMemoryStore; + +static TEMP_DIR_GUARDS: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); + +fn keep_tempdir_alive(dir: tempfile::TempDir) { + // Garde les répertoires temporaires vivants jusqu'à la fin du processus + // de test pour éviter leur suppression pendant que le store est encore + // ouvert. + TEMP_DIR_GUARDS.lock().expect("tempdir guard mutex poisoned").push(dir); +} + +pub(crate) fn open_native_store() -> NativeMemoryStore { + let dir = tempfile::tempdir().expect("create tempdir"); + let store = NativeMemoryStore::open(dir.path()).expect("open native store"); + keep_tempdir_alive(dir); + store +} + +#[allow(dead_code)] +pub(crate) fn open_encrypted_native_store(key: &str) -> NativeMemoryStore { + let dir = tempfile::tempdir().expect("create tempdir"); + let store = NativeMemoryStore::open_encrypted(dir.path(), key).expect("open encrypted native store"); + keep_tempdir_alive(dir); + store +} diff --git a/docs/ADR.md b/docs/ADR.md index 7624e89..b4883c3 100644 --- a/docs/ADR.md +++ b/docs/ADR.md @@ -18,7 +18,7 @@ Chaque ADR vit dans son propre fichier sous [`docs/adr/`](adr/). Cette page n'es | [ADR-008](adr/ADR-008-active-worker.md) | Active Worker — thread de fond | ✅ Accepted | | [ADR-009](adr/ADR-009-three-binding-surfaces.md) | Trois surfaces de binding + wheels précompilés | ✅ Accepted | | [ADR-010](adr/ADR-010-hardware-aware-model-provisioning.md) | Provisioning du modèle hardware-aware (setup intelligent) | ✅ Accepted | -| [ADR-011](adr/ADR-011-libsql-pivot.md) | Pivot vers libSQL (vecteur natif + chiffrement), traits async | ✅ Accepted | +| [ADR-011](adr/ADR-011-libsql-pivot.md) | Pivot vers libSQL (vecteur natif + chiffrement), traits async | 🔵 Superseded by ADR-033 | | [ADR-012](adr/ADR-012-phase2-cognition.md) | Phase 2 Cognition — Graphe, RRF, Oubli adaptatif, Consolidation | ✅ Accepted | | [ADR-013](adr/ADR-013-llm-inference-model-agnostic.md) | Inférence LLM model-agnostic + provisioning hardware-aware | ✅ Accepted | | [ADR-014](adr/ADR-014-hybrid-search-bm25-rrf.md) | Recherche hybride : full-text BM25 (FTS5) fusionné au vecteur par RRF | ✅ Accepted | @@ -26,9 +26,9 @@ Chaque ADR vit dans son propre fichier sous [`docs/adr/`](adr/). Cette page n'es | [ADR-016](adr/ADR-016-anythingllm-backend.md) | AnythingLLM comme backend LLM de premier rang via API workspace-chat | ✅ Accepted | | [ADR-017](adr/ADR-017-mcp-sampling-consolidation.md) | Consolidation par sampling MCP (emprunter le LLM du client) + politique des modes LLM | ⛔ Superseded by ADR-018 | | [ADR-018](adr/ADR-018-agent-driven-consolidation.md) | Consolidation pilotée par l'agent — politique d'inférence à niveaux | ✅ Accepted | -| [ADR-019](adr/ADR-019-agent-memory-database-format-and-engine.md) | Agent Memory Database, format `.bmai` V1 et frontière StorageEngine | ✅ Accepted | -| [ADR-020](adr/ADR-020-memory-store-trait.md) | `MemoryStore` : contrat d'opérations mémoire dans `basemyai` | ✅ Accepted | -| [ADR-021](adr/ADR-021-libsql-reader-pool.md) | Pool de connexions lecteur libSQL + writer unique sérialisé, sous WAL | ✅ Accepted | +| [ADR-019](adr/ADR-019-agent-memory-database-format-and-engine.md) | Agent Memory Database, format `.bmai` V1 et frontière StorageEngine | 🟡 Amended by ADR-033 | +| [ADR-020](adr/ADR-020-memory-store-trait.md) | `MemoryStore` : contrat d'opérations mémoire dans `basemyai` | 🟡 Amended by ADR-033 | +| [ADR-021](adr/ADR-021-libsql-reader-pool.md) | Pool de connexions lecteur libSQL + writer unique sérialisé, sous WAL | 🔵 Superseded by ADR-033 | | [ADR-022](adr/ADR-022-memory-event-broadcast.md) | `MemoryEvent` : abonnements mémoire en direct via canal tokio broadcast | ✅ Accepted | | ADR-023 | *(numéro non attribué)* | — | | [ADR-024](adr/ADR-024-native-engine.md) | Moteur natif BaseMyAI (stockage/vecteur/graphe/langage maison) — remplace le chemin Turso DB | ✅ Accepted | @@ -39,3 +39,5 @@ Chaque ADR vit dans son propre fichier sous [`docs/adr/`](adr/). Cette page n'es | [ADR-029](adr/ADR-029-license-split-and-trademark-policy.md) | Découpage de licence (open-core, BUSL-1.1 sur `basemyai-engine`) et politique de marque | ⛔ Superseded by ADR-031 | | [ADR-030](adr/ADR-030-native-encryption-at-rest.md) | Chiffrement au repos du moteur natif : AEAD XChaCha20-Poly1305 + enveloppe DEK/KEK, rotation O(1) | ✅ Accepted | | [ADR-031](adr/ADR-031-unified-busl-license.md) | Licence BUSL-1.1 unifiée sur tout le workspace (remplace le découpage open-core) | ✅ Accepted | +| [ADR-032](adr/ADR-032-native-engine-default.md) | Bascule du défaut libSQL→Native (compat V1 conservée) | 🔵 Superseded by ADR-033 | +| [ADR-033](adr/ADR-033-native-only.md) | Migration 100 % moteur natif : retrait libSQL/V1/crypto/dual-backend | ✅ Accepted | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9d6337d..4e9780e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -5,7 +5,7 @@ BaseMyAI est organisée en **deux crates Rust** communiquant via imports Rust natifs : 1. **`basemyai-core`** : socle **business-agnostic** - - Store libSQL async + KNN natif + - Moteur de stockage natif (`basemyai-engine`) + KNN natif - Embeddings in-process (optionnel Candle) - Worker de tâches de maintenance *injectées* - **Aucune connaissance** du temps (`valid_until`), des agents (`agent_id`), ou de métier @@ -39,12 +39,12 @@ grep -rE 'agent_id|valid_until|episodic|semantic|graph|entity|edge|Symbol' crate ### 2. Mécanisme au core, sens au consommateur -- Le core expose **primitives** : `Store`, `KNN`, `Filter` paramétré, `MaintenanceWorker` -- Le consommateur applique le **sens** : filtre temporel via `Filter`, tâches métier injectées +- Le core expose **primitives** : `StorageEngine`, index natifs (vecteur/graphe/FTS), capacités, `MaintenanceWorker` +- Le consommateur applique le **sens** : filtre temporel, tâches métier injectées -**Exemple** : `vector_knn(query, k, filter?)` -- Core : "applique le WHERE fourni après le top-k natif" -- basemyai : "le WHERE est (`valid_from <= now AND valid_until IS NULL OR > now`)" +**Exemple** : recherche KNN `(query, k, filtre?)` +- Core : "applique le filtre fourni après le top-k natif" +- basemyai : "le filtre est (`valid_from <= now AND (valid_until IS NULL OR valid_until > now)`)" ### 3. Pas de téléchargement silencieux (ADR-010) @@ -52,17 +52,17 @@ grep -rE 'agent_id|valid_until|episodic|semantic|graph|entity|edge|Symbol' crate - `setup::provision(consent)` fait le fetch explicite si `consent = true` - Modèles détectés au démarrage, listé à l'utilisateur (même pour LLM via `choose_llm()`) -### 4. Chiffrement obligatoire dans basemyai (ADR-007) +### 4. Chiffrement obligatoire dans basemyai (ADR-007 / ADR-030) -- `basemyai-core` : chiffrement optionnel (feature `crypto`) -- `basemyai` : chiffrement **requis** — `Memory::open` échoue sans clé +- `basemyai-core` : chiffrement au repos natif optionnel (AEAD, aucune feature Cargo) +- `basemyai` : chiffrement **requis** sur disque — l'ouverture échoue sans clé -### 5. Backend = libSQL (ADR-011) +### 5. Backend = moteur natif BaseMyAI (ADR-024→032) - **Pas de DB externe**, pas de services externes obligatoires -- Vecteur **natif** libSQL (`F32_BLOB`, `libsql_vector_idx`, `vector_top_k`) -- Async toujours ; `Embedder` reste **sync** (CPU-bound) -- Chemin futur : Turso DB (pur Rust) +- Moteur `basemyai-engine` **pur Rust** : WAL + memtable + SST, index vectoriel natif LM-DiskANN/Vamana (`F32`), graphe et FTS/BM25 natifs +- Contrat `MemoryStore` async ; `Embedder` reste **sync** (CPU-bound) +- Backend unique, pas de fallback libSQL (ADR-032) --- @@ -70,18 +70,18 @@ grep -rE 'agent_id|valid_until|episodic|semantic|graph|entity|edge|Symbol' crate ### basemyai-core -#### `storage/` — Recherche vectorielle + Store +#### `storage/` — Moteur natif + capacités | Module | Rôle | |--------|------| -| `store.rs` | `Store` async — ouverture, migrations, `vector_upsert`, `vector_knn` | -| `vector.rs` | `Filter`, `Value`, `Neighbor` — paramétrage sans injection | +| `engine.rs` | `EngineKind`, `NativeEngine`, `EngineCapabilities` — moteur natif capability-first | +| `mod.rs` | re-exports du socle de stockage | **Points clés** : -- `Filter { where_sql: String, params: Vec }` — fragment SQL + valeurs liées -- `vector_knn` sur-échantillonne ×8 si filtre présent (ADR-012) +- Moteur `basemyai-engine` : WAL + memtable + SST, batches atomiques `apply_batch`, recovery crash-consistent +- KNN natif LM-DiskANN/Vamana : sur-échantillonne ×8 si filtre présent (ADR-012) - Distance cosinus réelle (recalculée, pas placeholder) -- Chiffrement au repos (libSQL feature `crypto`, optionnel en core) +- Chiffrement au repos natif AEAD (ADR-030, aucune feature Cargo ni CMake) #### `embed/` — Embeddings in-process @@ -99,7 +99,7 @@ grep -rE 'agent_id|valid_until|episodic|semantic|graph|entity|edge|Symbol' crate | Type | Rôle | |------|------| -| `MaintenanceTask` | Trait : `async fn run(&self, store: &Store)` | +| `MaintenanceTask` | Trait : `async fn run(&self)` — tâche injectée par le consommateur | | `MaintenanceWorker` | Planifie + exécute des tâches en tâche de fond (tokio::spawn par tâche) | **Points clés** : @@ -121,15 +121,15 @@ grep -rE 'agent_id|valid_until|episodic|semantic|graph|entity|edge|Symbol' crate | Module | Rôle | |--------|------| | `layer.rs` | `MemoryLayer` (4 couches), `Record`, `AgentStats` | -| `schema.rs` | Migrations SQL (memory v1/v2/v3, entity/edge), `EMBEDDING_DIM` | -| `isolation.rs` | `AgentId` newtype — isolation SQL au level requêtes | +| `isolation.rs` | `AgentId` newtype — scoping structurel dans le layout de clé du moteur | +| `porting.rs` | Export/import JSONL (backup, migration inter-modèles) | | `mod.rs` | Façade `Memory` : remember, recall, invalidate, forget, stats, search_graph | **Points clés** : -- `Memory::open` applique le schéma, scelle par `AgentId` + `Embedder` -- `recall(query, k)` filtre par agent + temps via `Filter` paramétré -- `search_graph` : KNN + EXISTS sur graphe -- Metabase de validité : `valid_from`, `valid_until` (optionnel), `importance`, `last_access` +- L'ouverture scelle par `AgentId` + `Embedder`, contrat embedding porté en KV (`meta/bmai/`) +- `recall(query, k)` filtre par agent + temps via le contrat `MemoryStore` +- `search_graph` : KNN + traversée graphe native +- Métadonnées de validité : `valid_from`, `valid_until` (optionnel), `importance`, `last_access` #### `cognition/` — Pipeline Phase 2 @@ -137,12 +137,12 @@ grep -rE 'agent_id|valid_until|episodic|semantic|graph|entity|edge|Symbol' crate |--------|------| | `inference.rs` | Trait `LlmInference` (object-safe) — abstraction fournisseur | | `consolidation.rs` | `consolidate(memory, llm)` : épisodes → faits + graphe (idempotent) | -| `graph.rs` | `Graph` : add_entity, add_edge, traverse (CTE récursive) | +| `graph.rs` | `Graph` : add_entity, add_edge, traverse (BFS borné natif) | **Points clés** : - Consolidation : texte brut du LLM → JSON structuré → peuplement graphe + promotion semantic -- Graphe : tables `entity`/`edge`, CTE récursive `UNION` (cycle-safe), scopé `agent_id` + `valid_until` -- Traverse : `SELECT ... FROM reach r JOIN entity e ... WHERE r.node <> start ...` +- Graphe : index natif `entity`/`edge` (un nœud/une arête = un enregistrement KV), traversée BFS cycle-safe, scopée `agent_id` + `valid_until` +- Traverse : portage 1:1 de la CTE récursive historique en BFS sur scans préfixés par nœud source #### `provision/` — Provisioning hardware-aware @@ -160,26 +160,22 @@ grep -rE 'agent_id|valid_until|episodic|semantic|graph|entity|edge|Symbol' crate | Module | Rôle | |--------|------| -| `gc.rs` | `ExpiredMemoryGc` — DELETE where `valid_until <= now` | -| `forgetting.rs` | `AdaptiveForgetting` — score rétention (importance + récence hyperbolique) | | `mod.rs` | `ConsolidationTask` — Arc + Arc impl MaintenanceTask | **Points clés** : -- GC : simple, aucun paramètre -- Forgetting : `ROW_NUMBER() OVER (PARTITION BY agent_id ORDER BY retention DESC)` → évince > capacity -- Récence hyperbolique (pas exponentielle) : `H / (H + age)`, reste dans (0, 1], distingue les grands âges -- Consolidation : ignore `_store` (utilise son propre via Arc) +- Consolidation : auto-suffisante (utilise sa propre `Arc`), injectée dans le worker agnostique du core +- GC temporel et oubli adaptatif reposaient sur du fenêtrage SQL (`ROW_NUMBER() OVER`) libSQL-spécifique : **retirés** avec libSQL (ADR-032) plutôt que portés en passant — un portage natif mérite son propre design/tests #### `retrieval.rs`, `temporal.rs` — Racine (utilitaires) | Type | Rôle | |------|------| | `rrf_fuse` | Reciprocal Rank Fusion — fusionne N rankings par score `Σ 1/(k+rang)` | -| `Validity` | Fenêtre `valid_from`/`valid_until` — issu pour construire `Filter` | +| `Validity` | Fenêtre `valid_from`/`valid_until` — sert à construire le filtre de recall | **Points clés** : - RRF : déterministe, k=60 (Cormack et al.), préserve ordre stable -- Validité : simple struct, méthode `is_valid_at(now)` → utiliser pour constructeur Filter +- Validité : simple struct, méthode `is_valid_at(now)` → sert à construire le filtre de recall #### `error.rs` — Racine (transverse) @@ -197,9 +193,10 @@ grep -rE 'agent_id|valid_until|episodic|semantic|graph|entity|edge|Symbol' crate ### 1. Ouverture d'une mémoire ``` -User: Memory::open(store, embedder, agent_id) +User: Memory::open_native(path, key, embedder, agent) ↓ - → store.migrate(&schema()) + → NativeMemoryStore::open_encrypted(path, key) (recovery WAL si besoin) + → vérifie/écrit le contrat embedding (meta/bmai/) → Memory { store, embedder, agent } ``` @@ -209,8 +206,8 @@ User: Memory::open(store, embedder, agent_id) User: memory.remember(text, layer) ↓ → embedder.embed(text) → vec (384d) - → conn.execute("INSERT INTO memory ...") - (id, agent_id, layer, content, valid_from, valid_until, emb) + → store.put_memory(id, agent, layer, text, valid_from, valid_until, emb) + → UN batch WAL atomique : record + vecteur (index natif) + posting FTS ``` ### 3. Recall @@ -219,48 +216,34 @@ User: memory.remember(text, layer) User: memory.recall(query, k) ↓ → embedder.embed(query) - → Filter::new("agent_id = ? AND valid_from <= ? AND (valid_until IS NULL OR valid_until > ?)", [...]) - → store.vector_knn("memory", query_vec, k, Some(&filter)) - → [Neighbor { id, distance }, ...] - → conn.query("SELECT content, layer FROM memory WHERE id = ?") - → [Record { id, text, layer, score }, ...] - → UPDATE last_access on each record (for forgetting) + → filtre = (agent = ? AND valid_from <= now AND (valid_until IS NULL OR valid_until > now)) + → store.recall_vector(agent, query_vec, k, filtre) (KNN natif, oversampling ×8) + → [Record { id, text, layer, score }, ...] (hydratation depuis l'index mémoire) + → touch last_access sur chaque record (écriture brève séparée) ``` ### 4. Consolidation en tâche de fond ``` -MaintenanceWorker::start(store) +MaintenanceWorker::start() → spawn(async { sleep(every).await - task.run(&store).await + task.run().await }) -ConsolidationTask::run(_store) [ignore _store] +ConsolidationTask::run() (auto-suffisante via Arc) → consolidate(&memory, llm) - → recent_episodes(memory, 50) [SELECT episodic where valid] + → recent_episodes(memory, 50) (couche episodic, souvenirs valides) → llm.complete(prompt) → JSON text → parse RawExtraction { facts, entities, relations } - → graph.add_entity, add_edge (ON CONFLICT upsert) + → graph.add_entity, add_edge (upsert idempotent) → memory.remember(fact, Semantic) pour chaque fact → ConsolidationReport ``` -### 5. Oubli adaptatif - -``` -AdaptiveForgetting::run(store) - → conn.execute(""" - DELETE FROM memory WHERE id IN ( - SELECT id FROM ( - SELECT id, ROW_NUMBER() OVER ( - PARTITION BY agent_id - ORDER BY importance + H/(H+age) DESC - ) rn FROM memory - ) WHERE rn > capacity - ) - """) -``` +> **Note** : GC temporel et oubli adaptatif (fenêtrage SQL `ROW_NUMBER()` +> libSQL-spécifique) ont été retirés avec libSQL (ADR-032). Un portage natif +> est un chantier dédié (design + tests), pas un portage en passant. --- @@ -270,8 +253,8 @@ AdaptiveForgetting::run(store) | Trait | Implémentation requise | Injectée où | |-------|---|---| -| `Embedder` | Chargement du modèle + `embed(text)` + `embed_batch(texts)` | `Memory::open` | -| `MaintenanceTask` | `async fn run(&self, store)` | `MaintenanceWorker::register` | +| `Embedder` | Chargement du modèle + `embed(text)` + `embed_batch(texts)` | ouverture de `Memory` | +| `MaintenanceTask` | `async fn run(&self)` | `MaintenanceWorker::register` | | `LlmInference` | `async fn complete(prompt)` + `model_id()` | `ConsolidationTask::new` | Aucune implémentation concrète de ces traits n'est forcée dans le crate — tout est injecté. @@ -281,10 +264,10 @@ Aucune implémentation concrète de ces traits n'est forcée dans le crate — t ## Invariants critiques 1. **Agnosticité core** : zéro mention de `agent_id`, `valid_until`, métier -2. **Paramétrage SQL** : tous les WHERE doivent passer par `Filter { where_sql, params }` -3. **Pas de DB externe** : libSQL embeddée, file-based ou `:memory:` -4. **Isolation agent** : toute requête SELECT/INSERT/UPDATE filtrée par `agent_id = ?` -5. **Chiffrement obligatoire basemyai** : `Memory::open` rejette clé absente +2. **Pas de surface SQL-leaky** : ni `Filter`, ni `Value`, ni `Store` ; le sens passe par le contrat `MemoryStore` +3. **Pas de DB externe** : moteur natif embarqué, store `.bmai` file-based +4. **Isolation agent** : scoping par `agent_id` structurel dans le layout de clé du moteur +5. **Chiffrement obligatoire basemyai** : l'ouverture sur disque rejette une clé absente 6. **Modèles non téléchargés** : `Embedder` reçoit chemin résolu, `LlmInference` ne télécharge pas 7. **Idempotence** : consolidation, graphe upserts, maintenance GC relançables @@ -293,7 +276,7 @@ Aucune implémentation concrète de ces traits n'est forcée dans le crate — t ## Roadmap V2 (ne pas toucher) - Multi-modèles embedding (sélection runtime) -- Migration Turso DB (pur Rust, zéro C) -- Sync multi-device +- Portage natif du GC temporel + oubli adaptatif (design/tests dédiés) +- Sync multi-device (le WAL natif comme primitive de change-capture) - Mémoire partagée inter-agents - Explicabilité / provenance diff --git a/docs/PLAN.md b/docs/PLAN.md index d442924..3679398 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -2,7 +2,7 @@ **Date** : 2026-06 **Statut** : stratégie active — à traduire en ADR et sprints -**Périmètre** : BaseMyAI + ForgeMyAI, de l'état actuel (Phase 2 implémentée, SDKs absents) jusqu'à la sortie V1 des bindings + MCP +**Périmètre** : BaseMyAI, de l'état actuel (Phase 2 implémentée, SDKs absents) jusqu'à la sortie V1 des bindings + MCP --- @@ -113,7 +113,7 @@ basemyai-mcp (nouveau crate dans le workspace) - HTTP : clé API dans header `Authorization: Bearer` ; clé générée au `basemyai setup`, jamais hardcodée - Audit log : chaque tool call → `tracing::info!` avec `tool`, `agent_id`, `outcome`, `time_ms` — jamais le contenu des données -**Implémentation :** `rmcp` (crate déjà prévu dans l'écosystème ForgeMyAI) ou `mcp-rs`. Le crate `basemyai-mcp` dépend de `basemyai`, pas de `basemyai-core`. +**Implémentation :** `rmcp` ou `mcp-rs`. Le crate `basemyai-mcp` dépend de `basemyai`, pas de `basemyai-core`. #### P0.2 — Spike async bridge (débloquer PyO3 + NAPI) @@ -265,7 +265,7 @@ ADR-006 interdit la mémoire partagée en V1. En V2 : `memory.share(memory_id, f #### P2.3 — Export / import `.bmem` (portabilité de la mémoire) -Équivalent du `.idx` de ForgeMyAI pour la mémoire : un format d'archive qui contient le `.db` chiffré + le manifeste (model_id, dim, version). Permet de déplacer la mémoire d'un agent entre machines sans recréer les embeddings. +Format d'archive portable pour la mémoire : contient le store chiffré + le manifeste (model_id, dim, version). Permet de déplacer la mémoire d'un agent entre machines sans recréer les embeddings. #### P2.4 — Interface Surrealism / WASM (si besoin navigateur) @@ -430,4 +430,4 @@ basemyai-node → Binding NAPI-RS, prebuild, API async Promise basemyai-rest → Sidecar REST axum, spec OpenAPI 3.1 ``` -Tous dépendent de `basemyai` (pas de `basemyai-core` directement). Ils ne connaissent ni `Symbol/Edge` (ForgeMyAI), ni les internals du core. La séparation tient. +Tous dépendent de `basemyai` (pas de `basemyai-core` directement). Ils ne connaissent ni les types code-domain (`Symbol`/`Edge`), ni les internals du core. La séparation tient. diff --git a/docs/PRD.md b/docs/PRD.md index b712193..1a540fc 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -4,7 +4,7 @@ **Date** : Juin 2026 **Statut** : Draft — En cours de validation -> **Note backend (ADR-011)** : le socle a pivoté vers **libSQL** (vecteur natif `F32_BLOB` / `vector_top_k`, chiffrement intégré, traits async). Les mentions historiques de `sqlite-vec` / `rusqlite` / `deadpool` / `sqlcipher` ont été réconciliées dans ce document. Source de vérité du backend : `ADR.md`, ADR-011. +> **Note backend (ADR-032)** : le socle est désormais **100 % moteur natif BaseMyAI** (`basemyai-engine` : WAL + memtable + SST pur Rust, index vectoriel LM-DiskANN/Vamana `F32`, graphe et FTS/BM25 natifs, chiffrement au repos AEAD XChaCha20-Poly1305). libSQL/V1 et la feature `crypto` (CMake) ont été **entièrement retirés** du code actif. Les mentions historiques de libSQL/`vector_top_k`/`crypto` sont conservées uniquement dans les ADR et le CHANGELOG (records immuables). Source de vérité du backend : `ADR.md`, ADR-024→032. --- @@ -16,7 +16,7 @@ BaseMyAI est un **moteur de mémoire local** pour agents IA. Il fournit une mém **La thèse produit** : un développeur doit pouvoir donner à son agent une mémoire infinie, isolée par agent et chiffrée, en deux lignes de code, sans qu'aucune donnée ne quitte la machine. -Architecturalement, BaseMyAI est **deux crates dans un seul workspace Cargo** : `basemyai-core` (socle agnostique métier : libSQL durci, vecteur natif libSQL, embeddings Candle, chiffrement libSQL, worker de maintenance) et `basemyai` (la sémantique mémoire posée dessus). Le même core alimente les SDK Python/Node **et** un consommateur Rust natif (ForgeMyAI). +Architecturalement, BaseMyAI est **deux crates dans un seul workspace Cargo** : `basemyai-core` (socle agnostique métier : moteur de stockage natif `basemyai-engine`, index vectoriel/graphe/FTS natifs, embeddings Candle, chiffrement au repos natif, worker de maintenance) et `basemyai` (la sémantique mémoire posée dessus). Le même core alimente les SDK Python/Node **et** peut être consommé directement par des crates Rust tiers. --- @@ -87,7 +87,7 @@ Tout garder en RAM Perdu au redémarrage **Douleur principale** : « Nos données de mémoire sont les plus sensibles du produit. On a besoin de chiffrement au repos et d'une garantie qu'un tenant ne lit jamais la mémoire d'un autre. » -**Succès** : « BaseMyAI nous donne le chiffrement au repos obligatoire (libSQL, feature `crypto`) et l'isolation par `agent_id` au niveau SQL. La fuite cross-agent est structurellement impossible. » +**Succès** : « BaseMyAI nous donne le chiffrement au repos obligatoire (AEAD natif, sans CMake) et l'isolation par `agent_id` structurelle dans le layout de clé. La fuite cross-agent est structurellement impossible. » --- @@ -99,8 +99,8 @@ Tout garder en RAM Perdu au redémarrage 1. Un agent IA peut acquérir une mémoire persistante, temporelle et isolée par agent en moins de 10 lignes de code -2. Tout tourne en local : embeddings in-process, vecteurs natifs libSQL - dans le même fichier, zéro appel réseau par défaut +2. Tout tourne en local : embeddings in-process, vecteurs natifs + dans le même store `.bmai`, zéro appel réseau par défaut 3. Le RAG temporel retourne uniquement ce qui est pertinent ET valide @@ -129,19 +129,18 @@ Tout garder en RAM Perdu au redémarrage ### 5.1 Inclus **`basemyai-core` (socle agnostique)** -- `Store` libSQL durci et **async** : connexion libSQL partagée (clonée ; libSQL sérialise l'accès en interne), WAL, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_timeout` + exponential backoff -- Runner de migrations versionnées (le consommateur déclare son schéma) -- Vecteur **natif libSQL** (`F32_BLOB`, `libsql_vector_idx`, `vector_top_k` ANN) exposé en ops async sur `Store` (`vector_upsert`, `vector_knn(q, k, filtre SQL optionnel)`) — plus de trait `VectorIndex`, pas d'extension à linker +- Moteur de stockage **natif** (`basemyai-engine`) : WAL + memtable + SST pur Rust, batches atomiques (`apply_batch`), recovery crash-consistent, mono-écrivain sync (versionnage de wire gouverné par `format.lock`) +- Index vectoriel **natif** LM-DiskANN/Vamana (`F32`, in-store, pas d'extension à linker) : oversampling ×8 en présence d'un filtre (ADR-012), tombstones + réparation, rebuild depuis la donnée - `Embedder` Candle in-process, **sync** (CPU-bound) : `embed`, `embed_batch`, `model_id()`, `dim()` — modèle `all-MiniLM-L6-v2` (384 dims). **N'auto-télécharge jamais** : reçoit un chemin local. -- Chiffrement au repos **optionnel** via la feature `crypto` de libSQL (clé fournie à l'ouverture, jamais stockée ; exige CMake) +- Chiffrement au repos **natif** AEAD XChaCha20-Poly1305 (enveloppe DEK/KEK, WAL/SST scellés), clé fournie à l'ouverture, jamais stockée — **aucune feature Cargo, aucun CMake** (ADR-030) - `MaintenanceWorker` : boucle de fond async, tâches **injectées par le consommateur** **`basemyai` (sémantique mémoire)** - Les 4 couches mémoire : `short_term`, `episodic`, `procedural`, `semantic` -- RAG temporel : colonnes `valid_from` / `valid_until`, requête hybride cosine + filtre temporel -- Isolation multi-agent : filtrage obligatoire par `agent_id` au niveau SQL -- Chiffrement au repos **obligatoire** (libSQL feature `crypto` ; la DB exige une `encryption_key`) -- Active Worker : GC des lignes expirées (`valid_until` dépassé), `PRAGMA optimize` +- RAG temporel : champs `valid_from` / `valid_until`, requête hybride cosine + filtre temporel +- Isolation multi-agent : scoping obligatoire par `agent_id`, structurel dans le layout de clé du moteur +- Chiffrement au repos **obligatoire** (AEAD natif ADR-030 ; le store exige une clé) +- Active Worker : GC des souvenirs expirés (`valid_until` dépassé), compaction du moteur - **Setup hardware-aware** (`basemyai setup`) : détection matériel, sélection modèle/device, fetch explicite du modèle (ADR-010) **Bindings & surfaces** @@ -156,7 +155,7 @@ Tout garder en RAM Perdu au redémarrage ### 5.2 Explicitement exclus de V1 ``` -✗ Base vectorielle externe (Qdrant, LanceDB) — vecteurs natifs DANS libSQL, toujours +✗ Base vectorielle externe (Qdrant, LanceDB) — vecteurs natifs DANS le store `.bmai`, toujours ✗ Inférence via API cloud (OpenAI embeddings…) — in-process uniquement ✗ Auto-download du modèle par l'Embedder — fetch orchestré par le produit ✗ Sync de mémoire multi-machines / réplication distribuée @@ -172,29 +171,29 @@ Tout garder en RAM Perdu au redémarrage ### 6.1 Socle — `basemyai-core` -**REQ-001** : `Store::open(path, key)` (async) doit ouvrir une connexion libSQL partagée et durcie (WAL, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_timeout`). La connexion est clonée et partagée — libSQL sérialise l'accès en interne ; pas de pool `deadpool`. Sous contention en écriture, les retries appliquent un exponential backoff. +**REQ-001** : l'ouverture d'un store natif (`Engine`/`NativeMemoryStore`) doit être crash-consistent (WAL scellé par enregistrement, recovery au démarrage, batches atomiques `apply_batch`). Le moteur est mono-écrivain sync ; les lectures concurrentes sont servies sous verrou de lecture partagé (RwLock, ADR-N5.5). **REQ-002** : `basemyai-core` ne doit contenir **aucun** concept métier. Un `grep` de `agent_id`, `valid_from`, `valid_until`, `episode`, `Symbol`, `Edge` dans le crate doit retourner zéro (test d'agnosticité). -**REQ-003** : les ops vecteur natives async de `Store` (`vector_knn(q, k, filter)`) doivent retourner les `k` plus proches voisins via le vecteur natif libSQL (`vector_top_k` ANN, distance cosine), en appliquant un filtre SQL **fourni par l'appelant**. Le core ne sait pas ce que le filtre signifie. (`vector_top_k` applique le filtre **après** le top-k → sur-échantillonner pour garantir `k` résultats filtrés.) +**REQ-003** : la recherche vectorielle native (LM-DiskANN/Vamana, distance cosine) doit retourner les `k` plus proches voisins en appliquant un filtre **fourni par l'appelant**. Le core ne sait pas ce que le filtre signifie. Le filtre s'appliquant après le top-k, l'index sur-échantillonne (×8) pour garantir `k` résultats filtrés. **REQ-004** : `Embedder` (trait **sync**, CPU-bound) doit produire des vecteurs de 384 dimensions via Candle (`all-MiniLM-L6-v2`), in-process, sans ONNX. Il reçoit un chemin de modèle local et **ne déclenche jamais** de téléchargement réseau. Le consommateur l'enveloppe dans `spawn_blocking` si besoin depuis un contexte async. -**REQ-005** : le chiffrement au repos (feature `crypto` de libSQL) est optionnel dans `basemyai-core` : `Store::open` accepte une clé `Option`. La clé n'est jamais persistée. La feature `crypto` exige CMake à la compilation. +**REQ-005** : le chiffrement au repos est **natif** (AEAD XChaCha20-Poly1305, enveloppe DEK/KEK, ADR-030) : l'ouverture chiffrée accepte une clé, jamais persistée. Rotation de clé O(1) par re-scellement, sans réouverture. **Aucune feature Cargo ni CMake requis.** **REQ-006** : Le `MaintenanceWorker` exécute des tâches **enregistrées par le consommateur**. Il n'embarque aucune tâche métier en dur. ### 6.2 Sémantique — `basemyai` -**REQ-010** : Les 4 couches mémoire (`short_term`, `episodic`, `procedural`, `semantic`) doivent être persistées dans des tables distinctes, avec index B-Tree adaptés et colonnes `valid_from` / `valid_until` sur chacune. +**REQ-010** : Les 4 couches mémoire (`short_term`, `episodic`, `procedural`, `semantic`) doivent être persistées avec leur couche et leurs champs `valid_from` / `valid_until` par souvenir, chacune interrogeable indépendamment. -**REQ-011** : Toute écriture et toute lecture doivent être filtrées par `agent_id` au niveau SQL. Une requête sans `agent_id` valide doit échouer, jamais retourner des données d'un autre agent. +**REQ-011** : Toute écriture et toute lecture doivent être scopées par `agent_id`, structurellement dans le layout de clé du moteur. Une requête sans `agent_id` valide doit échouer, jamais retourner des données d'un autre agent. -**REQ-012** : La requête de recall doit être **hybride** : similarité cosine (vecteur natif libSQL, `vector_top_k`) **ET** `valid_until > now()` (ou `valid_until IS NULL`). Aucune mémoire expirée ne doit apparaître dans un recall. +**REQ-012** : La requête de recall doit être **hybride** : similarité cosine (index vectoriel natif) **ET** `valid_until > now()` (ou `valid_until IS NULL`). Aucune mémoire expirée ne doit apparaître dans un recall. -**REQ-013** : `basemyai` doit imposer le chiffrement (feature `crypto` de libSQL) : instancier une mémoire sans `encryption_key` doit échouer. +**REQ-013** : `basemyai` doit imposer le chiffrement (AEAD natif ADR-030) : instancier une mémoire sur disque sans clé doit échouer. -**REQ-014** : L'Active Worker doit, en tâche de fond : (a) GC ou archiver les lignes dont le `valid_until` est expiré, (b) lancer `PRAGMA optimize` périodiquement. Il ne doit jamais bloquer le chemin critique d'écriture/lecture. +**REQ-014** : L'Active Worker doit, en tâche de fond : (a) GC ou archiver les souvenirs dont le `valid_until` est expiré, (b) déclencher la compaction du moteur périodiquement. Il ne doit jamais bloquer le chemin critique d'écriture/lecture. **REQ-015** : Un **setup hardware-aware** (`basemyai setup`, ou déclenché au 1ᵉʳ appel SDK si non configuré) doit, façon AnythingLLM : (a) détecter RAM / GPU / VRAM / device / cœurs / OS ; (b) résoudre le device Candle (CUDA > Metal > CPU) ; (c) sélectionner le modèle (baseline `all-MiniLM-L6-v2` en V1) ; (d) fetch explicite avec vérification de checksum, mis en cache dans `~/.basemyai/models/` ; (e) persister `{ model_id, dim, device }`. **Aucun download silencieux** : si le setup n'a pas été fait, le 1ᵉʳ usage échoue proprement avec un message d'invite, jamais un fetch surprise. La détection et la sélection sont faites par le produit ; `basemyai-core.Embedder` reçoit un chemin + device déjà résolus (ADR-010). @@ -212,7 +211,7 @@ Tout garder en RAM Perdu au redémarrage **REQ-031** : L'isolation cross-agent doit être testée avec un dataset adversarial tentant de contourner le filtre `agent_id`. Zéro fuite tolérée. -**REQ-032** : Tous les inputs d'agent insérés dans une requête SQL doivent être paramétrés, jamais interpolés (anti SQL injection). +**REQ-032** : Le moteur natif est sans surface SQL : les inputs d'agent sont encodés dans des clés/valeurs binaires typées, jamais interpolés dans une requête (aucune surface d'injection SQL). --- @@ -242,14 +241,14 @@ Configuration de référence : 4 cœurs, 8 GB RAM, sans GPU (inférence CPU). - Linux (glibc, x86_64), Windows 10+ (MSVC), macOS 12+ (Intel & ARM) - Python 3.9+ (wheels), Node 18+ (prebuilds) - Rust 1.78+ -- `basemyai-core` testé Linux **et** Windows dès son premier commit (il porte le backend libSQL ; le vecteur natif compile sans CMake, seule la feature `crypto` exige CMake) +- `basemyai-core` testé Linux **et** Windows (moteur natif pur Rust : aucune dépendance C, aucun CMake, y compris pour le chiffrement) ### Maintenabilité - Couverture de tests : ≥ 85% sur `basemyai-core`, ≥ 80% sur `basemyai` - Clippy sans warning sur `--all-targets` - Chaque décision architecturale documentée dans un ADR -- Semver strict sur `basemyai-core` (les consommateurs, dont ForgeMyAI, pin une version) +- Semver strict sur `basemyai-core` (les consommateurs tiers pin une version) --- @@ -259,13 +258,13 @@ Configuration de référence : 4 cœurs, 8 GB RAM, sans GPU (inférence CPU). **Contrainte d'agnosticité** : `basemyai-core` est business-agnostic. Les concepts métier (`agent_id`, couches, RAG temporel) vivent exclusivement dans `basemyai`. -**Contrainte vectorielle** : les vecteurs sont stockés **dans** le fichier libSQL via le vecteur **natif** (`F32_BLOB`, `libsql_vector_idx`, `vector_top_k`). Pas d'extension à linker, aucune base vectorielle externe. +**Contrainte vectorielle** : les vecteurs sont stockés **dans** le store `.bmai` via l'index **natif** LM-DiskANN/Vamana (`F32`). Pas d'extension à linker, aucune base vectorielle externe. -**Contrainte backend** : backend = **libSQL** (crate `libsql`, embarqué local), traits `Store` et ops vecteur **async** ; `Embedder` **sync** (CPU-bound). Chemin futur : Turso DB (pur Rust) en production. +**Contrainte backend** : backend unique = **moteur natif BaseMyAI** (`basemyai-engine`, pur Rust, embarqué local, mono-écrivain sync), exposé via le contrat `MemoryStore` **async** ; `Embedder` **sync** (CPU-bound). Pas de fallback libSQL (ADR-032). **Contrainte ML** : inférence pure Rust via Candle (`all-MiniLM-L6-v2`, 384 dims). Pas d'ONNX, pas de fastembed, pas d'API cloud. -**Contrainte chiffrement** : chiffrement au repos via la feature `crypto` de libSQL (exige CMake), optionnel au core, **obligatoire** dans `basemyai`. +**Contrainte chiffrement** : chiffrement au repos via l'enveloppe AEAD **native** (ADR-030, pur Rust, sans CMake), optionnel au core, **obligatoire** dans `basemyai`. **Contrainte réseau** : zéro réseau par défaut. L'`Embedder` ne télécharge jamais le modèle lui-même. @@ -277,11 +276,11 @@ Configuration de référence : 4 cœurs, 8 GB RAM, sans GPU (inférence CPU). | Risque | Probabilité | Impact | Mitigation | |---|---|---|---| -| Fuite cross-agent (contournement du filtre `agent_id`) | Faible | Critique | Filtre au niveau SQL, dataset adversarial en CI, isolation = invariant | -| Le chiffrement libSQL exige CMake (feature `crypto`) | Moyen | Moyen | Feature **opt-in** et déférée ; le vecteur natif compile sans CMake. CMake provisionné en CI pour les builds chiffrés. Réduit à une dépendance de toolchain, pas un risque de code (le risque D4 de linkage sqlite-vec/sqlcipher disparaît avec le vecteur natif libSQL) | -| Dépendance à libSQL (fork C, pas pur Rust) | Faible | Moyen | Assumé jusqu'à ce que Turso DB soit production-ready ; chemin de migration vers Turso DB (pur Rust, zéro C) ouvert (V2/V3) | +| Fuite cross-agent (contournement du scoping `agent_id`) | Faible | Critique | Scoping structurel dans le layout de clé, dataset adversarial en CI, isolation = invariant | +| Bug de durabilité/corruption du moteur natif | Faible | Critique | Harnais crash-consistency (kill réel, 5 modes × 20 cycles, 0 violation), fuzzing, `format.lock` anti-drift, recovery vérifiée | +| Immaturité du moteur natif face à un backend éprouvé | Faible | Moyen | Parité prouvée (19 scénarios `backend_suite!`, recall@10 = 1.0, benchs 3,8×→13,8× vs l'ancien libSQL) avant bascule ADR-032 | | Fuite mémoire de l'inférence ML embarquée | Moyen | Haut | Stress-test 1h + profiling dès la Phase 5, traque des leaks Candle | -| Wheel/prebuild ne compile pas sur une plateforme | Moyen | Haut | `basemyai-core` testé Linux + Windows dès le 1ᵉʳ commit (backend libSQL ; vecteur natif sans CMake) | -| Perf insuffisante (< 100 writes/s) | Moyen | Moyen | Connexion libSQL partagée + batch + WAL ; bench dès la Phase 2 | +| Wheel/prebuild ne compile pas sur une plateforme | Moyen | Haut | Moteur natif **pur Rust** (aucune dépendance C ni CMake), testé Linux + Windows | +| Perf insuffisante (< 100 writes/s) | Moyen | Moyen | Batches WAL atomiques + compaction ; bench mesuré (~2,9× plus rapide en recall bout-en-bout que l'ancien backend) | | Intégrité du modèle téléchargé (supply chain) | Faible | Haut | Fetch orchestré par le produit, vérification de checksum, modèle mis en cache local | | Fuite de la sémantique métier dans `basemyai-core` | Moyen | Moyen | Test d'agnosticité automatisé (grep) en CI | diff --git a/docs/TODO-NATIVE-ENGINE.md b/docs/TODO-NATIVE-ENGINE.md index 91cdd75..b3708ba 100644 --- a/docs/TODO-NATIVE-ENGINE.md +++ b/docs/TODO-NATIVE-ENGINE.md @@ -11,247 +11,247 @@ Constat : audit d'organisation 2026-07-02 (2 agents, référence SurrealDB). ### DX - [x] `xtask` (crate workspace + alias `.cargo/config.toml`) encodant - **exactement** la matrice CI (`ci.yml`) : `cargo xtask check` (fmt + clippy - par-crate avec les vraies combinaisons de features), `cargo xtask test`, - `cargo xtask test-embed`, `cargo xtask test-crypto`, `cargo xtask ci`. - Choix xtask plutôt que justfile/cargo-make : zéro outil à installer (cargo - suffit), pur Rust (2026-07-02) + **exactement** la matrice CI (`ci.yml`) : `cargo xtask check` (fmt + clippy + par-crate avec les vraies combinaisons de features), `cargo xtask test`, + `cargo xtask test-embed`, `cargo xtask test-crypto`, `cargo xtask ci`. + Choix xtask plutôt que justfile/cargo-make : zéro outil à installer (cargo + suffit), pur Rust (2026-07-02) - [x] `CONTRIBUTING.md` et `CLAUDE.md` pointent vers les cibles `cargo xtask` - au lieu de commandes `--workspace` qui ne reproduisent pas la CI (2026-07-02) + au lieu de commandes `--workspace` qui ne reproduisent pas la CI (2026-07-02) - [ ] (optionnel, plus tard) faire appeler les mêmes cibles par `ci.yml` - (single source of truth) + (single source of truth) ### Assainissement docs (P0/P1 de l'audit) - [x] Une seule source de statut : items ouverts de `TODO.md` (racine) fusionnés - dans `docs/status.md`, `TODO.md` racine supprimé, `docs/TODO.md` archivé - sous `docs/archive/` (2026-07-02) + dans `docs/status.md`, `TODO.md` racine supprimé, `docs/TODO.md` archivé + sous `docs/archive/` (2026-07-02) - [x] Un seul changelog : contenu FR non reporté de `docs/CHANGELOG.md` remonté - dans `CHANGELOG.md` racine, puis `docs/CHANGELOG.md` supprimé (2026-07-02) + dans `CHANGELOG.md` racine, puis `docs/CHANGELOG.md` supprimé (2026-07-02) - [x] `AGENTS.md` réduit à un pointeur vers `CLAUDE.md` ; « deux crates » - périmé corrigé dans `CLAUDE.md` (2026-07-02) + périmé corrigé dans `CLAUDE.md` (2026-07-02) - [x] `.agents/skills/` (copie octet-à-octet de `.claude/skills/`) supprimé - (identité vérifiée par diff avant suppression, 2026-07-02) + (identité vérifiée par diff avant suppression, 2026-07-02) - [x] `openapi-sidecar.yaml` déplacé vers `crates/basemyai-rest/openapi.yaml`, - références corrigées (dont le chemin fautif `analayse/openapi-sidecar.yaml` - dans `basemyai-rest/src/lib.rs`) ; mentions restantes dans `docs/*.md` - laissées à la passe docs (2026-07-02) + références corrigées (dont le chemin fautif `analayse/openapi-sidecar.yaml` + dans `basemyai-rest/src/lib.rs`) ; mentions restantes dans `docs/*.md` + laissées à la passe docs (2026-07-02) - [x] `docs/ANALYSIS.md` (snapshot git périmé) supprimé (2026-07-02) - [x] Analyses ponctuelles déplacées sous `docs/research/` : `surrealdb-*.md` - (×5), `type-mapping.md`, `mcp-blueprint.md` (2026-07-02) + (×5), `type-mapping.md`, `mcp-blueprint.md` (2026-07-02) ### Reporté (P2 — décisions séparées, ne pas faire en passant) - [x] Éclater `docs/ADR.md` (monolithe 001–018) en `docs/adr/ADR-0XX-*.md` — - fait le 2026-07-04, décision explicite de l'utilisateur (tous les ADR - 001–025 vivent maintenant sous `docs/adr/`, `docs/ADR.md` réduit à un index) + fait le 2026-07-04, décision explicite de l'utilisateur (tous les ADR + 001–025 vivent maintenant sous `docs/adr/`, `docs/ADR.md` réduit à un index) - [ ] `bindings/` → `crates/` (ou documenter la règle de séparation) - [ ] `basemyai-branding/` → `docs/branding/` ou repo séparé - [ ] `examples/README.md` expliquant racine (SDK par langage) vs - `crates/basemyai/examples/` (Rust) + `crates/basemyai/examples/` (Rust) ## N1 — Spike Couche 1 (Phase 0b) ✅ clos le 2026-07-04 - [x] Vérifier le statut de maintenance 2026 de `redb`, `fjall`, `sled` - (versions, activité, prod-readiness) — ne pas assumer (2026-07-04 : `redb` - 4.1 actif/mûr, `fjall` 3.1 maintenu mais développement de features en net - ralentissement, `sled` écarté sans étude approfondie) + (versions, activité, prod-readiness) — ne pas assumer (2026-07-04 : `redb` + 4.1 actif/mûr, `fjall` 3.1 maintenu mais développement de features en net + ralentissement, `sled` écarté sans étude approfondie) - [x] Prototype jetable A : B-tree copy-on-write (lecture concurrente, GC pages) - — 107 296 inserts/s, 8,7 µs/lecture, ×14,3 amplification espace (pas de - free-list dans le spike), 10/10 PASS crash-consistency + — 107 296 inserts/s, 8,7 µs/lecture, ×14,3 amplification espace (pas de + free-list dans le spike), 10/10 PASS crash-consistency - [x] Prototype jetable B : LSM-tree (write-path, compaction, change-capture) - — 435 894 inserts/s, 3,52 µs/lecture, ×1,05 amplification, 10/10 PASS - crash-consistency + — 435 894 inserts/s, 3,52 µs/lecture, ×1,05 amplification, 10/10 PASS + crash-consistency - [x] Comparatif écrit : perf write/read, complexité de crash recovery, - aptitude au change-capture (pour sync P2P futur) — - `docs/benchmarks/n1-storage-engine-spike-2026-07-04.md` + aptitude au change-capture (pour sync P2P futur) — + `docs/benchmarks/n1-storage-engine-spike-2026-07-04.md` - [x] Décision fondation-maison vs fork/dépendance consciente d'un KV Rust - (critère : propriété des couches 2–4 garantie dans les deux cas) — - fondation-maison, famille LSM (`ADR-025`) + (critère : propriété des couches 2–4 garantie dans les deux cas) — + fondation-maison, famille LSM (`ADR-025`) - [x] Mini-ADR de sortie de spike (architecture Couche 1 figée) — - `docs/ADR-025-native-engine-storage-foundation.md` + `docs/ADR-025-native-engine-storage-foundation.md` ## N2 — Couche 1 : store durable (Phase 1) **Ordre imposé : le harnais d'abord, le moteur ensuite.** - [x] Harnais crash-consistency : kill -9 en boucle sous charge d'écriture, - réouverture, vérification d'intégrité — implémenté (2026-07-04) : - `crates/basemyai-engine/src/bin/crash_writer.rs` (écriture durable d'un - flux compteur, confirmation journalisée avec `sync_all`) + - `tests/crash_consistency.rs` (spawn, `taskkill /F` forcé sur Windows / - `kill -9` sur Unix, réouverture, vérification intégrale), 20 cycles - (rigueur du spike N1), `cargo xtask test-crash-consistency`, job CI - dédié dans `.github/workflows/ci.yml` (matrice ubuntu+windows). Exécuté - réellement 3 fois en conditions réelles, ciblant spécifiquement la - fenêtre flush/rename/truncate (seuils de flush abaissés) : 0 corruption + réouverture, vérification d'intégrité — implémenté (2026-07-04) : + `crates/basemyai-engine/src/bin/crash_writer.rs` (écriture durable d'un + flux compteur, confirmation journalisée avec `sync_all`) + + `tests/crash_consistency.rs` (spawn, `taskkill /F` forcé sur Windows / + `kill -9` sur Unix, réouverture, vérification intégrale), 20 cycles + (rigueur du spike N1), `cargo xtask test-crash-consistency`, job CI + dédié dans `.github/workflows/ci.yml` (matrice ubuntu+windows). Exécuté + réellement 3 fois en conditions réelles, ciblant spécifiquement la + fenêtre flush/rename/truncate (seuils de flush abaissés) : 0 corruption - [x] Fuzzing cargo-fuzz (nightly séparée) : encodage/décodage clés, replay - WAL, parsing pages — infra posée (2026-07-04) : `crates/basemyai-engine/fuzz/` - (layout standard `cargo-fuzz`, isolé du workspace via `[workspace]` vide + - `fuzz/rust-toolchain.toml` → nightly ; jamais dans `cargo xtask`, voir - `fuzz/README.md`), 4 cibles (`key_roundtrip`, `wal_decode`, `sst_decode`, - `sst_decode_structured`) exécutées réellement sous WSL (libFuzzer ne link - pas sur Windows natif — vérifié, pas supposé). `wal_decode` et - `key_roundtrip` : des millions d'exécutions, aucun crash. `sst_decode` - (octets bruts) : aucun crash — attendu, le crc32 pré-décodage bloque la - mutation aléatoire. `sst_decode_structured` (crc32 recalculé, donc le - fuzzer explore au-delà de ce garde-fou) : **crash trouvé en quelques - secondes**, **corrigé et testé (2026-07-04)** — `format::sst::decode` - faisait `Vec::with_capacity(entry_count as usize)` avec `entry_count: u64` - lu tel quel du fichier, avant toute validation contre la taille réelle du - buffer → panic "capacity overflow" sur un fichier de 18 octets avec - `entry_count = u64::MAX` et un crc32 correct (calculable trivialement par - un attaquant, le crc32 n'étant pas cryptographique). Correctif : borne - `entry_count` contre le nombre d'entrées que le buffer restant peut - physiquement contenir avant l'allocation, retourne `CorruptSst` sinon ; - régression `huge_entry_count_is_rejected_not_panicking` ajoutée - (`crates/basemyai-engine/src/format/sst.rs`), `cargo test -p - basemyai-engine` et `cargo clippy -p basemyai-engine --all-targets -- -D - warnings` verts. Reste ouvert : item éventuel de CI nightly planifiée - (pattern `embed`/`crypto` de `ci.yml`, non ajouté ici — décision humaine - sur la cadence, pas devinée) + WAL, parsing pages — infra posée (2026-07-04) : `crates/basemyai-engine/fuzz/` + (layout standard `cargo-fuzz`, isolé du workspace via `[workspace]` vide + + `fuzz/rust-toolchain.toml` → nightly ; jamais dans `cargo xtask`, voir + `fuzz/README.md`), 4 cibles (`key_roundtrip`, `wal_decode`, `sst_decode`, + `sst_decode_structured`) exécutées réellement sous WSL (libFuzzer ne link + pas sur Windows natif — vérifié, pas supposé). `wal_decode` et + `key_roundtrip` : des millions d'exécutions, aucun crash. `sst_decode` + (octets bruts) : aucun crash — attendu, le crc32 pré-décodage bloque la + mutation aléatoire. `sst_decode_structured` (crc32 recalculé, donc le + fuzzer explore au-delà de ce garde-fou) : **crash trouvé en quelques + secondes**, **corrigé et testé (2026-07-04)** — `format::sst::decode` + faisait `Vec::with_capacity(entry_count as usize)` avec `entry_count: u64` + lu tel quel du fichier, avant toute validation contre la taille réelle du + buffer → panic "capacity overflow" sur un fichier de 18 octets avec + `entry_count = u64::MAX` et un crc32 correct (calculable trivialement par + un attaquant, le crc32 n'étant pas cryptographique). Correctif : borne + `entry_count` contre le nombre d'entrées que le buffer restant peut + physiquement contenir avant l'allocation, retourne `CorruptSst` sinon ; + régression `huge_entry_count_is_rejected_not_panicking` ajoutée + (`crates/basemyai-engine/src/format/sst.rs`), `cargo test -p +basemyai-engine` et `cargo clippy -p basemyai-engine --all-targets -- -D +warnings` verts. Reste ouvert : item éventuel de CI nightly planifiée + (pattern `embed`/`crypto` de `ci.yml`, non ajouté ici — décision humaine + sur la cadence, pas devinée) - [x] `format.lock` + check CI (équivalent `revision.lock` SurrealDB) : chaque - type persisté versionné, échec CI si changement sans bump — implémenté - (2026-07-04) : `crates/basemyai-engine/format.lock` (`WalRecord:1`, - `SstFile:1`), hash CRC32 sur un `FormatSpec` (nom+version+champs de wire, - dans l'ordre disque — pas le commentaire prose, pas un dérivé automatique - du struct décodé) défini juste à côté d'`encode`/`decode` dans - `format/{wal,sst}.rs` (`src/format/lock.rs`), vérifié par le test - `crates/basemyai-engine/tests/format_lock.rs`, gatté par - `cargo xtask format-lock` (et inclus dans `check`/`ci`). Testé les deux - sens : perturbation délibérée d'un champ → échec loud avec message - actionnable ; retour à l'état correct → vert + type persisté versionné, échec CI si changement sans bump — implémenté + (2026-07-04) : `crates/basemyai-engine/format.lock` (`WalRecord:1`, + `SstFile:1`), hash CRC32 sur un `FormatSpec` (nom+version+champs de wire, + dans l'ordre disque — pas le commentaire prose, pas un dérivé automatique + du struct décodé) défini juste à côté d'`encode`/`decode` dans + `format/{wal,sst}.rs` (`src/format/lock.rs`), vérifié par le test + `crates/basemyai-engine/tests/format_lock.rs`, gatté par + `cargo xtask format-lock` (et inclus dans `check`/`ci`). Testé les deux + sens : perturbation délibérée d'un champ → échec loud avec message + actionnable ; retour à l'état correct → vert - [x] Crate `crates/basemyai-engine` : `store/`, `key/`, `format/`, `error.rs` - (layout PLAN §3.1) — feature `engine-native`, jamais défaut — implémenté - (2026-07-04) : membre du workspace (pas `default-members`), `publish = - false`, feature `engine-native` déclarée réservée pour le câblage - `EngineKind::Native` (pas encore branchée). Moteur WAL+memtable+SST+ - recovery réel derrière (`Engine::open/put/get/delete/flush/close`), - jugé vert par le harnais crash-consistency ci-dessus. + (layout PLAN §3.1) — feature `engine-native`, jamais défaut — implémenté + (2026-07-04) : membre du workspace (pas `default-members`), `publish = +false`, feature `engine-native` déclarée réservée pour le câblage + `EngineKind::Native` (pas encore branchée). Moteur WAL+memtable+SST+ + recovery réel derrière (`Engine::open/put/get/delete/flush/close`), + jugé vert par le harnais crash-consistency ci-dessus. - [x] WAL + transactions + recovery, jugés sur le harnais — implémenté - (2026-07-04) : `Batch`/`Engine::apply_batch` (`store/engine.rs`), un batch = - **un seul enregistrement WAL externe** (`WalOp::Batch`) contenant une - séquence imbriquée de sous-opérations put/delete, auto-délimitée et - couverte par le même `crc32`/`val_len` que le framing single-record - existant — donc le tolérance-troncature-de-queue déjà présente dans - `Wal::replay` rejette un batch incomplet **en bloc**, sans reconstruction - du replay. Changement de format de wire réel et assumé : `WAL_RECORD_VERSION` - 1→2, `format.lock` mis à jour délibérément (`WalRecord:2(b4570df1)`), - `cargo xtask format-lock` vert. Harnais crash-consistency étendu - (`crash_writer` mode `batch`, `BATCH_SIZE=6`) : 20 cycles réels via - `cargo xtask test-crash-consistency`, **cas intéressant réellement - observé** (batch durci en WAL mais pas encore confirmé au moment d'un kill - réel — présent intégralement ou absent, jamais partiel). `cargo xtask ci` - vert. + (2026-07-04) : `Batch`/`Engine::apply_batch` (`store/engine.rs`), un batch = + **un seul enregistrement WAL externe** (`WalOp::Batch`) contenant une + séquence imbriquée de sous-opérations put/delete, auto-délimitée et + couverte par le même `crc32`/`val_len` que le framing single-record + existant — donc le tolérance-troncature-de-queue déjà présente dans + `Wal::replay` rejette un batch incomplet **en bloc**, sans reconstruction + du replay. Changement de format de wire réel et assumé : `WAL_RECORD_VERSION` + 1→2, `format.lock` mis à jour délibérément (`WalRecord:2(b4570df1)`), + `cargo xtask format-lock` vert. Harnais crash-consistency étendu + (`crash_writer` mode `batch`, `BATCH_SIZE=6`) : 20 cycles réels via + `cargo xtask test-crash-consistency`, **cas intéressant réellement + observé** (batch durci en WAL mais pas encore confirmé au moment d'un kill + réel — présent intégralement ou absent, jamais partiel). `cargo xtask ci` + vert. - [x] Runner de tests déclaratifs multi-backend (`memory-tests`) — scaffold - implémenté et exécuté (2026-07-04) : scénarios déclaratifs - (`crates/basemyai/tests/memory_tests/scenarios.rs` — 5 portés depuis - `storage_contract.rs` : remember/recall round-trip, invalidate, - forget physique, graphe upsert+traverse, borne `valid_until`) + runner - paramétré (`memory_tests/mod.rs`, `run_scenario` — aucune - implémentation concrète dans le runner) + enregistrement des backends par - macro `backend_suite!` (`tests/memory_tests.rs`). Vert contre `Libsql` - (seul backend réel) via `cargo test -p basemyai --features test-util - --test memory_tests`, couvert par la matrice `xtask test` existante. - **Diff multi-backend PAS encore prouvé** — impossible aujourd'hui, `Native` - n'a pas de `MemoryStore` (bloqué N3/N4). Brancher `Native` = implémenter - `MemoryStore`, une factory async, une ligne `backend_suite!`. + implémenté et exécuté (2026-07-04) : scénarios déclaratifs + (`crates/basemyai/tests/memory_tests/scenarios.rs` — 5 portés depuis + `storage_contract.rs` : remember/recall round-trip, invalidate, + forget physique, graphe upsert+traverse, borne `valid_until`) + runner + paramétré (`memory_tests/mod.rs`, `run_scenario` — aucune + implémentation concrète dans le runner) + enregistrement des backends par + macro `backend_suite!` (`tests/memory_tests.rs`). Vert contre `Libsql` + (seul backend réel) via `cargo test -p basemyai --features test-util +--test memory_tests`, couvert par la matrice `xtask test` existante. + **Diff multi-backend PAS encore prouvé** — impossible aujourd'hui, `Native` + n'a pas de `MemoryStore` (bloqué N3/N4). Brancher `Native` = implémenter + `MemoryStore`, une factory async, une ligne `backend_suite!`. - [x] `EngineKind::Native` dans `basemyai-core` (additif, `Libsql` inchangé) — - identité + capacités seulement : `EngineCapabilities::native()` - (`crates/basemyai-core/src/storage/engine.rs`) rapporte l'état honnête - actuel de `basemyai-engine` — `vectors: false`, `full_text: false`, - `recursive_queries: false`, `transactions: true` (batches atomiques réels, - `Engine::apply_batch`), `encrypted: false` (pas de chiffrement au repos, - vérifié dans la source) — plus un wrapper `NativeEngine` - (`crates/basemyai-core/src/storage/native.rs`) autour de - `basemyai_engine::Engine` implémentant `StorageEngine` (pas `MemoryStore`), - derrière la feature `engine-native` (jamais par défaut ; dépendance - optionnelle `basemyai-engine` ajoutée dans `Cargo.toml`). Test dédié - (`native_engine_reports_honest_capabilities`) + matrice `xtask` étendue - (`clippy`/`test` avec `--features engine-native`) + grep d'agnosticité - (`tests/agnosticity.rs`) toujours vert avec la feature activée. `cargo - xtask check`/`test` verts (2026-07-04). + identité + capacités seulement : `EngineCapabilities::native()` + (`crates/basemyai-core/src/storage/engine.rs`) rapporte l'état honnête + actuel de `basemyai-engine` — `vectors: false`, `full_text: false`, + `recursive_queries: false`, `transactions: true` (batches atomiques réels, + `Engine::apply_batch`), `encrypted: false` (pas de chiffrement au repos, + vérifié dans la source) — plus un wrapper `NativeEngine` + (`crates/basemyai-core/src/storage/native.rs`) autour de + `basemyai_engine::Engine` implémentant `StorageEngine` (pas `MemoryStore`), + derrière la feature `engine-native` (jamais par défaut ; dépendance + optionnelle `basemyai-engine` ajoutée dans `Cargo.toml`). Test dédié + (`native_engine_reports_honest_capabilities`) + matrice `xtask` étendue + (`clippy`/`test` avec `--features engine-native`) + grep d'agnosticité + (`tests/agnosticity.rs`) toujours vert avec la feature activée. `cargo +xtask check`/`test` verts (2026-07-04). - [x] impl `MemoryStore` branchée sur `Native` — débloquée par N3+N4, faite - au N5.1 (2026-07-05, ADR-027) : voir la section N5.1 ci-dessous. + au N5.1 (2026-07-05, ADR-027) : voir la section N5.1 ci-dessous. ## N3 — Couche 2 : index vectoriel natif (Phase 2) - [x] Choix HNSW vs DiskANN (critère : profil mémoire vs disque pour mémoire - d'agent locale ; libSQL utilise LM-DiskANN) — **DiskANN, variante - LM-DiskANN sur KV** (`ADR-026`, 2026-07-04) : graphe plat, un nœud = un - enregistrement KV dans le store LSM (pas de sidecar), mises à jour d'index - dans le même `apply_batch` que la donnée (crash consistency héritée du - WAL), donnée = source de vérité (rebuild possible), deletes de premier - ordre (tombstones + réparation paresseuse + consolidation via - `MaintenanceWorker`). Seuils de sortie posés avant mesure (ADR-026 §6) : - requête ≤ parité libSQL (~48-49 ms), build < 78-79 ms/ligne y compris - incrémental, recall@10 ≥ 0,9 y compris après churn insert/delete + d'agent locale ; libSQL utilise LM-DiskANN) — **DiskANN, variante + LM-DiskANN sur KV** (`ADR-026`, 2026-07-04) : graphe plat, un nœud = un + enregistrement KV dans le store LSM (pas de sidecar), mises à jour d'index + dans le même `apply_batch` que la donnée (crash consistency héritée du + WAL), donnée = source de vérité (rebuild possible), deletes de premier + ordre (tombstones + réparation paresseuse + consolidation via + `MaintenanceWorker`). Seuils de sortie posés avant mesure (ADR-026 §6) : + requête ≤ parité libSQL (~48-49 ms), build < 78-79 ms/ligne y compris + incrémental, recall@10 ≥ 0,9 y compris après churn insert/delete - [x] Implémentation dans `basemyai-engine/src/idx/vector/` (découpage - ADR-026) — étapes 1-4 (format/distance/graphe/persistance/deletes) closes - le 2026-07-05, bench de parité M6 mesuré le même jour (voir plus bas) : + ADR-026) — étapes 1-4 (format/distance/graphe/persistance/deletes) closes + le 2026-07-05, bench de parité M6 mesuré le même jour (voir plus bas) : - [x] `node.rs` — bloc nœud LM-DiskANN versionné (`VectorNode:1`, magic - `b"VNOD"`, header u32+u16+u16+u16, vecteur f32 LE, voisins u64 LE, - crc32 trailing, framing/erreurs alignés sur `format/{wal,sst}` ; - `spec()` enregistré dans `format.lock`, leçon fuzzing N2 appliquée : - `dim`/`neighbor_count` bornés contre la taille réelle du buffer AVANT - toute allocation, tests de rejet dédiés) + cible fuzz - `fuzz/fuzz_targets/vector_node_decode.rs` posée sur le modèle - `sst_decode` (non exécutée — libFuzzer ne link pas sous Windows natif, - même contrainte que N2 : run WSL à faire) + `b"VNOD"`, header u32+u16+u16+u16, vecteur f32 LE, voisins u64 LE, + crc32 trailing, framing/erreurs alignés sur `format/{wal,sst}` ; + `spec()` enregistré dans `format.lock`, leçon fuzzing N2 appliquée : + `dim`/`neighbor_count` bornés contre la taille réelle du buffer AVANT + toute allocation, tests de rejet dédiés) + cible fuzz + `fuzz/fuzz_targets/vector_node_decode.rs` posée sur le modèle + `sst_decode` (non exécutée — libFuzzer ne link pas sous Windows natif, + même contrainte que N2 : run WSL à faire) - [x] `distance.rs` — cosine f32, dimension paramétrable (défaut 384), - fold contigu simple (autovectorisation), zéro-norme → 1.0 jamais NaN + fold contigu simple (autovectorisation), zéro-norme → 1.0 jamais NaN - [x] `graph.rs` — Vamana en RAM : greedy beam search (L), robust prune - (α), insert incrémental + liaisons bidirectionnelles avec re-prune des - voisins > R ; `meta.rs` — params (dim=384, R=32, L=128, α=1.2). - **Écart assumé vs les « classiques » L=64** : mesuré sur le harnais, - L=64 donne recall@10 = 0.856 (< seuil) sur données iid uniformes 384d, - L=128 → 0.988 (+~12 % de coût d'insert) ; les setups DiskANN de - référence utilisent L_build ≈ 100-125 + (α), insert incrémental + liaisons bidirectionnelles avec re-prune des + voisins > R ; `meta.rs` — params (dim=384, R=32, L=128, α=1.2). + **Écart assumé vs les « classiques » L=64** : mesuré sur le harnais, + L=64 donne recall@10 = 0.856 (< seuil) sur données iid uniformes 384d, + L=128 → 0.988 (+~12 % de coût d'insert) ; les setups DiskANN de + référence utilisent L_build ≈ 100-125 - [x] Harnais recall (`tests/vector_recall.rs`, oracle brute-force exact, - RNG xorshift64* seedé, zéro dépendance) — **recall@10 = 1.0000 mesuré à - N=2 000 ET N=10 000** (50 requêtes, k=10, ~0.5/1.4 ms/insert en release, - harnais de correctness, pas un bench). Données à **basse dimension - intrinsèque** (latent 16d → map linéaire seedée vers 384d, modèle des - embeddings MiniLM) — choix documenté dans le test : mesuré, l'iid - uniforme 384d (concentration des distances, aucun voisinage exploitable - par AUCUN graphe-ANN) donne 0.664 à 10k même à L=200/R=64→0.946, et 64 - clusters quasi-orthogonaux fragmentent le graphe en îlots (0.282 à - 10k) — deux pathologies notées pour le futur bench parité. N=2 000 - dans le gate (`xtask test` + step Test CI), N=10 000 en `#[ignore]` - (exécuté réellement en release, 2026-07-04) + RNG xorshift64\* seedé, zéro dépendance) — **recall@10 = 1.0000 mesuré à + N=2 000 ET N=10 000** (50 requêtes, k=10, ~0.5/1.4 ms/insert en release, + harnais de correctness, pas un bench). Données à **basse dimension + intrinsèque** (latent 16d → map linéaire seedée vers 384d, modèle des + embeddings MiniLM) — choix documenté dans le test : mesuré, l'iid + uniforme 384d (concentration des distances, aucun voisinage exploitable + par AUCUN graphe-ANN) donne 0.664 à 10k même à L=200/R=64→0.946, et 64 + clusters quasi-orthogonaux fragmentent le graphe en îlots (0.282 à + 10k) — deux pathologies notées pour le futur bench parité. N=2 000 + dans le gate (`xtask test` + step Test CI), N=10 000 en `#[ignore]` + (exécuté réellement en release, 2026-07-04) - [x] persistance KV (`persistent.rs`, pas `cache.rs` — le cache borné vit - dedans) — implémentée et vérifiée (2026-07-05) : - `PersistentVectorIndex` (open/insert/search/rebuild), un nœud = un - enregistrement KV sous le préfixe réservé `idx/vector/` - (`key::vector_index`, ids BE → scan trié), lecture des blocs à la - demande via `Engine::get` (jamais de chargement intégral ; cache - read-through borné 4096 blocs, politique clear-when-full assumée - jusqu'au bench). L'algorithme Vamana est **partagé** (trait - `NodeProvider` + `plan_insert` dans `graph.rs`) entre le graphe RAM et - le persistant — zéro dérive possible. **Atomicité** : chaque insert = - UN `apply_batch` (nœud + voisins re-prunés + méta) ; **méta persistée** - `VectorIndexMeta:1` (params, entry point, epoch, count) versionnée dans - `format.lock` ; **rebuild** depuis les vecteurs des blocs (donnée = - source de vérité) sur méta absente/corrompue/incohérente, crash-safe - par ordre (delete méta → chunks de nœuds → méta epoch+1 en dernier). - Nouveau `Engine::scan_prefix` (merge SSTs+memtable, tombstones filtrés) - pour l'énumération du rebuild. Mesuré réellement : - `tests/vector_persistence.rs` (gate + CI) — round-trip N=400/384d à - travers reopen WAL-replay ET SST, résultats identiques bit-à-bit, - recall@10 = 1.0000 ; rebuild (méta corrompue ET supprimée, N=300) → - recall@10 = 1.0000, epoch bumpé, réouverture propre ensuite ; harnais - crash étendu (`crash_writer` mode `vector` + - `vector_kill_reopen_verify_loop`) exécuté réellement 2× via - `cargo xtask test-crash-consistency` : 20 cycles kill forcé chacun - (1162 puis 1101 vecteurs confirmés cumulés), réouverture **sans - rebuild** à chaque cycle, chaque bloc confirmé byte-exact - (exhaustif) + retrouvable par `search` top-10 (échantillon borné - déterministe : 20 récents + 50 stratifiés), 0 violation. Recall RAM - re-vérifié après le refactor partage-algorithme : 1.0000 à N=2000 - (gate) et N=10000 (release, ~1,70 ms/insert) + dedans) — implémentée et vérifiée (2026-07-05) : + `PersistentVectorIndex` (open/insert/search/rebuild), un nœud = un + enregistrement KV sous le préfixe réservé `idx/vector/` + (`key::vector_index`, ids BE → scan trié), lecture des blocs à la + demande via `Engine::get` (jamais de chargement intégral ; cache + read-through borné 4096 blocs, politique clear-when-full assumée + jusqu'au bench). L'algorithme Vamana est **partagé** (trait + `NodeProvider` + `plan_insert` dans `graph.rs`) entre le graphe RAM et + le persistant — zéro dérive possible. **Atomicité** : chaque insert = + UN `apply_batch` (nœud + voisins re-prunés + méta) ; **méta persistée** + `VectorIndexMeta:1` (params, entry point, epoch, count) versionnée dans + `format.lock` ; **rebuild** depuis les vecteurs des blocs (donnée = + source de vérité) sur méta absente/corrompue/incohérente, crash-safe + par ordre (delete méta → chunks de nœuds → méta epoch+1 en dernier). + Nouveau `Engine::scan_prefix` (merge SSTs+memtable, tombstones filtrés) + pour l'énumération du rebuild. Mesuré réellement : + `tests/vector_persistence.rs` (gate + CI) — round-trip N=400/384d à + travers reopen WAL-replay ET SST, résultats identiques bit-à-bit, + recall@10 = 1.0000 ; rebuild (méta corrompue ET supprimée, N=300) → + recall@10 = 1.0000, epoch bumpé, réouverture propre ensuite ; harnais + crash étendu (`crash_writer` mode `vector` + + `vector_kill_reopen_verify_loop`) exécuté réellement 2× via + `cargo xtask test-crash-consistency` : 20 cycles kill forcé chacun + (1162 puis 1101 vecteurs confirmés cumulés), réouverture **sans + rebuild** à chaque cycle, chaque bloc confirmé byte-exact + (exhaustif) + retrouvable par `search` top-10 (échantillon borné + déterministe : 20 récents + 50 stratifiés), 0 violation. Recall RAM + re-vérifié après le refactor partage-algorithme : 1.0000 à N=2000 + (gate) et N=10000 (release, ~1,70 ms/insert) - [x] deletes (tombstones + réparation paresseuse + consolidation façon - FreshDiskANN) — implémentés et **recall re-mesuré après churn - insert/delete** (ADR-026 §6, le critère le plus fragile), 2026-07-05 : + FreshDiskANN) — implémentés et **recall re-mesuré après churn + insert/delete** (ADR-026 §6, le critère le plus fragile), 2026-07-05 : - **Tombstone dans le bloc nœud** : `VectorNode` v1→v2 (byte `flags`, bit 0 = deleted ; bits réservés rejetés au décodage), bump `format.lock` délibéré (`VectorNode:2(1ba6b40f)`) — marqueur dans le @@ -305,38 +305,38 @@ Constat : audit d'organisation 2026-07-02 (2 agents, référence SurrealDB). côté `basemyai` et attend le câblage `MemoryStore` sur Native (bloqué N3-bench/N4). - [x] Parité bench M6 : mêmes scénarios 10k/100k que - `docs/benchmarks/m6-knn-results-2026-07-01.md`, chiffres archivés - (2026-07-05) — `crates/basemyai-engine/src/bin/vector_bench.rs` (outil - manuel, pas dans `cargo xtask`, comme `crash_writer`), exécuté réellement - en `--release` sur la **même machine** que M6 (13th Gen Intel Core - i7-13620H confirmé identique) : requête moyenne k=10 **7.52 ms à 10k / - 12.67 ms à 100k** (seuil ADR-026 ~48-49 ms — tenu avec large marge, 6,5× - puis 3,8×) ; build incrémental réel (pas de bulk-load-then-index, chemin - `remember` réel) **5.69 ms/ligne à 10k / 17.32 ms/ligne (moyenne pleine - run) à 100k** (seuil < 78-79 ms/ligne — tenu, 13,8× puis 4,5× de marge) ; - recall@10 **1.0000 aux deux tailles** (seuil ≥ 0,9) ; disque **22.26 MiB à - 10k / 177.28 MiB à 100k**. RAM : mesure **incomplète** — le sampler - `Get-Process -WorkingSet64` prévu (façon stress Candle M6) est mort - silencieusement (process de lancement PowerShell arrêté avec son job - d'arrière-plan), seuls des snapshots ponctuels partiels existent - (233-278 Mio, couvrant seulement la première moitié du run 100k) — - documenté honnêtement comme plancher, pas pic, dans le rapport archivé. - Écart de protocole assumé vs M6 : générateur de vecteurs différent - (latent-dim bas, façon `tests/vector_recall.rs`, pas l'iid uniforme de M6 - — pathologie ANN documentée, cf. ADR-026/`tests/common`), donc le nombre - de recall n'est comparable qu'aux autres gates ADR-026 de ce dépôt, pas à - un hypothétique recall libSQL (jamais mesuré par M6). Détail complet, - tableau comparatif et limites : `docs/benchmarks/n3-vector-parity-2026-07-05.md` + `docs/benchmarks/m6-knn-results-2026-07-01.md`, chiffres archivés + (2026-07-05) — `crates/basemyai-engine/src/bin/vector_bench.rs` (outil + manuel, pas dans `cargo xtask`, comme `crash_writer`), exécuté réellement + en `--release` sur la **même machine** que M6 (13th Gen Intel Core + i7-13620H confirmé identique) : requête moyenne k=10 **7.52 ms à 10k / + 12.67 ms à 100k** (seuil ADR-026 ~48-49 ms — tenu avec large marge, 6,5× + puis 3,8×) ; build incrémental réel (pas de bulk-load-then-index, chemin + `remember` réel) **5.69 ms/ligne à 10k / 17.32 ms/ligne (moyenne pleine + run) à 100k** (seuil < 78-79 ms/ligne — tenu, 13,8× puis 4,5× de marge) ; + recall@10 **1.0000 aux deux tailles** (seuil ≥ 0,9) ; disque **22.26 MiB à + 10k / 177.28 MiB à 100k**. RAM : mesure **incomplète** — le sampler + `Get-Process -WorkingSet64` prévu (façon stress Candle M6) est mort + silencieusement (process de lancement PowerShell arrêté avec son job + d'arrière-plan), seuls des snapshots ponctuels partiels existent + (233-278 Mio, couvrant seulement la première moitié du run 100k) — + documenté honnêtement comme plancher, pas pic, dans le rapport archivé. + Écart de protocole assumé vs M6 : générateur de vecteurs différent + (latent-dim bas, façon `tests/vector_recall.rs`, pas l'iid uniforme de M6 + — pathologie ANN documentée, cf. ADR-026/`tests/common`), donc le nombre + de recall n'est comparable qu'aux autres gates ADR-026 de ce dépôt, pas à + un hypothétique recall libSQL (jamais mesuré par M6). Détail complet, + tableau comparatif et limites : `docs/benchmarks/n3-vector-parity-2026-07-05.md` - [x] Cible d'amélioration identifiée sur le coût de build d'index - (libSQL : ~78-79 ms/ligne, quasi-linéaire — c'est LE point faible mesuré - du backend actuel, le moteur natif doit faire mieux) — **tenue aux deux - tailles mesurées** (voir ci-dessus), avec une réserve honnête notée : le - coût marginal natif n'est **pas** plat comme le taux de bulk-load de - libSQL (~78-79 ms/ligne quasi-constant) — il croît en continu pendant un - run (≈10 ms/ligne en début de run 100k, ≈24 ms/ligne en fin), la marge sous - le seuil se réduit donc avec N même si elle reste large aux tailles - testées ; pas extrapolé au-delà de 100k (deux points de mesure seulement, - une courbe non plate rendrait toute extrapolation peu fiable). + (libSQL : ~78-79 ms/ligne, quasi-linéaire — c'est LE point faible mesuré + du backend actuel, le moteur natif doit faire mieux) — **tenue aux deux + tailles mesurées** (voir ci-dessus), avec une réserve honnête notée : le + coût marginal natif n'est **pas** plat comme le taux de bulk-load de + libSQL (~78-79 ms/ligne quasi-constant) — il croît en continu pendant un + run (≈10 ms/ligne en début de run 100k, ≈24 ms/ligne en fin), la marge sous + le seuil se réduit donc avec N même si elle reste large aux tailles + testées ; pas extrapolé au-delà de 100k (deux points de mesure seulement, + une courbe non plate rendrait toute extrapolation peu fiable). **N3 clos le 2026-07-05** : choix d'algorithme (ADR-026), implémentation complète (format/distance/graphe/persistance/deletes-consolidation) et bench @@ -363,61 +363,61 @@ d'un outil manuel sans dépendance). ## N4 — Couche 3 : graphe natif (Phase 3) ✅ clos 2026-07-05 - [x] Stockage d'adjacence dans `idx/graph/` + traversée bornée — - `crates/basemyai-engine/src/idx/graph/{entity,edge,traverse,ram,persistent}.rs`. - Design de clés (`key::graph_index`, `crates/basemyai-engine/src/key/mod.rs`) : - `idx/graph/entity/` (un nœud = un enregistrement - KV, `GraphEntity:1`) et `idx/graph/edge/` - (`GraphEdge:1`) — `relation`/`dst` vivent dans la **clé**, pas la valeur : - c'est ce qui rend « toutes les arêtes sortantes d'un nœud » un simple scan - préfixé (`edge_src_prefix(agent, src)`), le pattern d'accès dont la BFS a - besoin à chaque saut, sans index secondaire. Longueurs préfixées (u32 BE) - choisies plutôt qu'une concaténation brute : sans elles, agent `"ab"` + id - `"c"` et agent `"a"` + id `"bc"` collisionneraient — testé explicitement - (`graph_entity_keys_do_not_collide_across_agent_id_boundary`). Isolation - par agent **structurelle** (dans le layout de clé), jamais un filtre - applicatif après coup. Toute mutation = **un seul `Engine::put`** (pas de - `Batch`/`apply_batch`) — justifié dans le doc du module `persistent.rs` : - contrairement au vecteur (qui touche systématiquement plusieurs - enregistrements — nœud + voisins re-prunés + méta), un upsert de graphe ne - touche jamais plus que l'unique enregistrement qu'il nomme, et `Engine::put` - seul est déjà durable/atomique par clé (WAL fsync avant memtable). Tous les - comptages lus du wire (`kind_len`, `label_len`, `relation_len` dans la clé) - sont bornés contre la taille réelle du buffer avant toute allocation (leçon - fuzzing N2/N3), testé (`lying_kind_len_is_rejected_not_panicking`, - `lying_label_len_is_rejected_not_panicking`, - `graph_edge_relation_dst_rejects_truncated_and_foreign_keys`). Traversée : - BFS **itérative** (`VecDeque`, pas de récursion — pas de stack overflow sur - un graphe profond), ensemble visité garantissant à la fois la - cycle-safety et la propriété « première visite = profondeur minimale » - (`idx/graph/traverse.rs`, `run()`), filtrage temporel `valid_until` - (délibérément **pas** `valid_from` — voir ci-dessous), isolation par - `agent` structurelle dans le layout de clés. `format.lock` : `GraphEntity:1` - et `GraphEdge:1` ajoutés (`crates/basemyai-engine/format.lock`), vérifiés - par `cargo xtask format-lock`. + `crates/basemyai-engine/src/idx/graph/{entity,edge,traverse,ram,persistent}.rs`. + Design de clés (`key::graph_index`, `crates/basemyai-engine/src/key/mod.rs`) : + `idx/graph/entity/` (un nœud = un enregistrement + KV, `GraphEntity:1`) et `idx/graph/edge/` + (`GraphEdge:1`) — `relation`/`dst` vivent dans la **clé**, pas la valeur : + c'est ce qui rend « toutes les arêtes sortantes d'un nœud » un simple scan + préfixé (`edge_src_prefix(agent, src)`), le pattern d'accès dont la BFS a + besoin à chaque saut, sans index secondaire. Longueurs préfixées (u32 BE) + choisies plutôt qu'une concaténation brute : sans elles, agent `"ab"` + id + `"c"` et agent `"a"` + id `"bc"` collisionneraient — testé explicitement + (`graph_entity_keys_do_not_collide_across_agent_id_boundary`). Isolation + par agent **structurelle** (dans le layout de clé), jamais un filtre + applicatif après coup. Toute mutation = **un seul `Engine::put`** (pas de + `Batch`/`apply_batch`) — justifié dans le doc du module `persistent.rs` : + contrairement au vecteur (qui touche systématiquement plusieurs + enregistrements — nœud + voisins re-prunés + méta), un upsert de graphe ne + touche jamais plus que l'unique enregistrement qu'il nomme, et `Engine::put` + seul est déjà durable/atomique par clé (WAL fsync avant memtable). Tous les + comptages lus du wire (`kind_len`, `label_len`, `relation_len` dans la clé) + sont bornés contre la taille réelle du buffer avant toute allocation (leçon + fuzzing N2/N3), testé (`lying_kind_len_is_rejected_not_panicking`, + `lying_label_len_is_rejected_not_panicking`, + `graph_edge_relation_dst_rejects_truncated_and_foreign_keys`). Traversée : + BFS **itérative** (`VecDeque`, pas de récursion — pas de stack overflow sur + un graphe profond), ensemble visité garantissant à la fois la + cycle-safety et la propriété « première visite = profondeur minimale » + (`idx/graph/traverse.rs`, `run()`), filtrage temporel `valid_until` + (délibérément **pas** `valid_from` — voir ci-dessous), isolation par + `agent` structurelle dans le layout de clés. `format.lock` : `GraphEntity:1` + et `GraphEdge:1` ajoutés (`crates/basemyai-engine/format.lock`), vérifiés + par `cargo xtask format-lock`. - [x] Parité `tests/graph.rs` (scoping agent, cycle-safety, profondeur) — - **portage littéral, pas une réinvention** : `idx/graph/traverse.rs` est un - port comportemental 1:1 de `LibsqlMemoryStore::graph_traverse` (la CTE - récursive de `crates/basemyai/src/storage/libsql_store.rs`), y compris un - détail qui ressemble à un bug de l'original mais est délibérément reproduit - à l'identique : la CTE ne filtre **que** sur `valid_until` (jamais - `valid_from`), pour les entités comme pour les arêtes — reproduit tel quel - plutôt que « corrigé », pour ne pas changer silencieusement le - comportement d'un appelant qui compterait sur la parité. **Les 5 scénarios - de `crates/basemyai/tests/graph.rs` sont portés fidèlement** (mêmes - graphes, mêmes profondeurs, mêmes assertions) dans - `crates/basemyai-engine/tests/graph_parity.rs` : - `traverses_multiple_hops`, `isolation_hides_other_agents_edges`, - `agents_can_reuse_same_graph_ids_without_conflict`, - `excludes_expired_entities_and_edges`, `terminates_on_cycle` — **les 5 - passent contre les deux flavors** (`RamGraph` in-RAM d'abord, comme oracle, - puis `PersistentGraph` KV-persisté, `ram_graph_matches_every_ported_scenario` - / `persistent_graph_matches_every_ported_scenario`), plus un test dédié de - round-trip close/reopen (`persistent_graph_survives_close_reopen_round_trip`) - et un test d'exactitude bit-à-bit après réouverture - (`persistent_entity_is_byte_identical_after_reopen`). L'algorithme BFS est - **partagé** (trait `GraphProvider` + `traverse::run`, `idx/graph/traverse.rs`) - entre `RamGraph` et `PersistentGraph` — zéro dérive possible entre les deux - flavors, même discipline que le partage Vamana de N3. + **portage littéral, pas une réinvention** : `idx/graph/traverse.rs` est un + port comportemental 1:1 de `LibsqlMemoryStore::graph_traverse` (la CTE + récursive de `crates/basemyai/src/storage/libsql_store.rs`), y compris un + détail qui ressemble à un bug de l'original mais est délibérément reproduit + à l'identique : la CTE ne filtre **que** sur `valid_until` (jamais + `valid_from`), pour les entités comme pour les arêtes — reproduit tel quel + plutôt que « corrigé », pour ne pas changer silencieusement le + comportement d'un appelant qui compterait sur la parité. **Les 5 scénarios + de `crates/basemyai/tests/graph.rs` sont portés fidèlement** (mêmes + graphes, mêmes profondeurs, mêmes assertions) dans + `crates/basemyai-engine/tests/graph_parity.rs` : + `traverses_multiple_hops`, `isolation_hides_other_agents_edges`, + `agents_can_reuse_same_graph_ids_without_conflict`, + `excludes_expired_entities_and_edges`, `terminates_on_cycle` — **les 5 + passent contre les deux flavors** (`RamGraph` in-RAM d'abord, comme oracle, + puis `PersistentGraph` KV-persisté, `ram_graph_matches_every_ported_scenario` + / `persistent_graph_matches_every_ported_scenario`), plus un test dédié de + round-trip close/reopen (`persistent_graph_survives_close_reopen_round_trip`) + et un test d'exactitude bit-à-bit après réouverture + (`persistent_entity_is_byte_identical_after_reopen`). L'algorithme BFS est + **partagé** (trait `GraphProvider` + `traverse::run`, `idx/graph/traverse.rs`) + entre `RamGraph` et `PersistentGraph` — zéro dérive possible entre les deux + flavors, même discipline que le partage Vamana de N3. **Pas de métadonnée/rebuild** (contrairement à l'index vectoriel) — décision documentée dans le doc du module `idx/graph/persistent.rs`, pas un oubli : @@ -474,252 +474,252 @@ sync↔async mono-écrivain sérialisé, écarts de parité assumés et document ### N5.1 — `NativeMemoryStore` hors FTS/crypto ✅ clos 2026-07-05 - [x] Moteur : `idx/memory/` (`MemoryRecord:1`, `MemoryVecMap:1`, - `MemoryIndexMeta:1` dans `format.lock`, hashes vérifiés par - `cargo xtask format-lock`), clés `key::memory_index` (préfixe réservé - `idx/memory/`, longueurs préfixées u32 BE anti-collision comme N4, - isolation agent structurelle), `PersistentMemoryIndex` - (put/get/update/forget/scan/résolution/touch/purge — composition - crash-critique côté moteur, testable par le futur harnais N5.5). Codecs à - la discipline N2/N3 : comptages bornés contre la taille réelle du buffer - avant toute allocation, tests de troncature à chaque coupure, bit-flip, - longueurs mensongères, version inconnue (2026-07-05) + `MemoryIndexMeta:1` dans `format.lock`, hashes vérifiés par + `cargo xtask format-lock`), clés `key::memory_index` (préfixe réservé + `idx/memory/`, longueurs préfixées u32 BE anti-collision comme N4, + isolation agent structurelle), `PersistentMemoryIndex` + (put/get/update/forget/scan/résolution/touch/purge — composition + crash-critique côté moteur, testable par le futur harnais N5.5). Codecs à + la discipline N2/N3 : comptages bornés contre la taille réelle du buffer + avant toute allocation, tests de troncature à chaque coupure, bit-flip, + longueurs mensongères, version inconnue (2026-07-05) - [x] Moteur : `PersistentVectorIndex::insert_with`/`delete_with` — les ops - du consommateur (`Batch::extend_from`) fusionnées dans le **même** - `apply_batch` que les blocs d'index : un `remember` natif = UN - enregistrement WAL (compteur + vecmap + record + nœud + voisins re-prunés - + méta), présent ou absent en bloc (ADR-027 §3). `delete_with` applique - l'extra même sur tombstone no-op (les compagnons ne survivent pas à un - forget interrompu). `search_scored` expose les distances déjà calculées - par le beam search (RAM et persistant, même surface). `PersistentGraph` : - `entities()` (listing par agent), `edge_meta()` (parité upsert - `weight`-only), `purge_agent()` (batch atomique). Allocateur `vec_id` - monotone persistant, guéri depuis la donnée si méta absente/corrompue — - test dédié anti-réutilisation après forget+consolidate+reopen - (`allocator_is_monotonic_across_reopen_and_forgets`) (2026-07-05) + du consommateur (`Batch::extend_from`) fusionnées dans le **même** + `apply_batch` que les blocs d'index : un `remember` natif = UN + enregistrement WAL (compteur + vecmap + record + nœud + voisins re-prunés + - méta), présent ou absent en bloc (ADR-027 §3). `delete_with` applique + l'extra même sur tombstone no-op (les compagnons ne survivent pas à un + forget interrompu). `search_scored` expose les distances déjà calculées + par le beam search (RAM et persistant, même surface). `PersistentGraph` : + `entities()` (listing par agent), `edge_meta()` (parité upsert + `weight`-only), `purge_agent()` (batch atomique). Allocateur `vec_id` + monotone persistant, guéri depuis la donnée si méta absente/corrompue — + test dédié anti-réutilisation après forget+consolidate+reopen + (`allocator_is_monotonic_across_reopen_and_forgets`) (2026-07-05) - [x] `basemyai` : `NativeMemoryStore` derrière la feature `engine-native` - (`storage/native_store.rs`) — pont `spawn_blocking` + verrou jamais tenu - à travers un `.await`, oversampling ×8 + post-filtre - agent/validité/couche (ADR-012), parité requête par requête avec - `LibsqlMemoryStore` y compris ses non-filtres (`hydrate` et - `exact_fact_exists` sans filtre de validité, `graph_upsert_edge` - préservant `valid_from`), `keyword_ranking_ids` et métriques non-cosinus - en **erreur franche** (N5.2/N5.3), `open_ephemeral()` sous `test-util` - (équivalent natif d'`open_in_memory`) (2026-07-05) + (`storage/native_store.rs`) — pont `spawn_blocking` + verrou jamais tenu + à travers un `.await`, oversampling ×8 + post-filtre + agent/validité/couche (ADR-012), parité requête par requête avec + `LibsqlMemoryStore` y compris ses non-filtres (`hydrate` et + `exact_fact_exists` sans filtre de validité, `graph_upsert_edge` + préservant `valid_from`), `keyword_ranking_ids` et métriques non-cosinus + en **erreur franche** (N5.2/N5.3), `open_ephemeral()` sous `test-util` + (équivalent natif d'`open_in_memory`) (2026-07-05) - [x] `backend_suite!(native)` vert — le diff multi-backend du runner N2 - enfin prouvé : les 5 scénarios déclaratifs rejoués contre `Libsql` ET - `Native`, zéro divergence (`cargo test -p basemyai --features - test-util,engine-native --test memory_tests`) ; matrice `xtask`/`ci.yml` - étendue en miroir strict (clippy + test `engine-native`) ; harnais - crash-consistency re-exécuté après le refactor `insert_with` : 4 modes - (base/batch/vector/graph), 0 violation (2026-07-05) + enfin prouvé : les 5 scénarios déclaratifs rejoués contre `Libsql` ET + `Native`, zéro divergence (`cargo test -p basemyai --features +test-util,engine-native --test memory_tests`) ; matrice `xtask`/`ci.yml` + étendue en miroir strict (clippy + test `engine-native`) ; harnais + crash-consistency re-exécuté après le refactor `insert_with` : 4 modes + (base/batch/vector/graph), 0 violation (2026-07-05) - [x] `EngineCapabilities::native()` mis à jour honnêtement : - `vectors`/`recursive_queries` → `true` (N3/N4 les fournissent, N5.1 les - câble), `full_text`/`encrypted` restent `false` (N5.2/N5.4) ; - `native_engine_reports_honest_capabilities` mis à jour. `cargo xtask ci` - vert au 2026-07-05 (seul incident : le flake connu et pré-existant - `basemyai-mcp --test sampling` STATUS_ACCESS_VIOLATION, re-passé vert 3× - d'affilée sans modification — sans rapport avec ce travail, mcp ne - compile pas `engine-native`) + `vectors`/`recursive_queries` → `true` (N3/N4 les fournissent, N5.1 les + câble), `full_text`/`encrypted` restent `false` (N5.2/N5.4) ; + `native_engine_reports_honest_capabilities` mis à jour. `cargo xtask ci` + vert au 2026-07-05 (seul incident : le flake connu et pré-existant + `basemyai-mcp --test sampling` STATUS_ACCESS_VIOLATION, re-passé vert 3× + d'affilée sans modification — sans rapport avec ce travail, mcp ne + compile pas `engine-native`) ### N5.2 — FTS/BM25 natif ✅ clos 2026-07-06 Décisions structurantes actées par `ADR-028` (2026-07-06) : périmètre borné au sous-ensemble de `match_expr` réellement produit par `fts_match_expr()` -(tokens cités joints par ` OR `, jamais la syntaxe FTS5 complète), tokenizer +(tokens cités joints par `OR`, jamais la syntaxe FTS5 complète), tokenizer casefold+pliage d'accents par table figée (racinisation Porter différée, gap documenté), troisième index moteur `idx/fts` (postings + docterms + stats par agent), atomicité par fusion de batch comme ADR-027 §3, BM25 Okapi `k1=1.2`/`b=0.75` (défauts FTS5). - [x] Moteur : `key::fts_index` (`idx/fts/postings/...`, - `idx/fts/docterms/...`, `idx/fts/meta/...`, longueurs préfixées u32 BE, - isolation agent structurelle, même discipline anti-collision que - `graph_index`/`memory_index`) (2026-07-06) + `idx/fts/docterms/...`, `idx/fts/meta/...`, longueurs préfixées u32 BE, + isolation agent structurelle, même discipline anti-collision que + `graph_index`/`memory_index`) (2026-07-06) - [x] Moteur : `idx/fts/tokenizer.rs` (découpe non-alphanumérique identique - à `fts_match_expr`, minuscule Unicode, table de pliage d'accents - Latin-1/Latin Extended-A courants — zéro dépendance nouvelle) (2026-07-06) + à `fts_match_expr`, minuscule Unicode, table de pliage d'accents + Latin-1/Latin Extended-A courants — zéro dépendance nouvelle) (2026-07-06) - [x] Moteur : codecs `FtsPosting:1` (tf), `FtsDocTerms:1` (liste - `(term, tf)` bornée), `FtsStats:1` (`doc_count`/`total_terms` par agent) - dans `format.lock` — discipline N2/N3/N5.1 complète (comptages bornés, - troncature à chaque coupure, bit-flip, longueur mensongère, version - inconnue) (2026-07-06) + `(term, tf)` bornée), `FtsStats:1` (`doc_count`/`total_terms` par agent) + dans `format.lock` — discipline N2/N3/N5.1 complète (comptages bornés, + troncature à chaque coupure, bit-flip, longueur mensongère, version + inconnue) (2026-07-06) - [x] Moteur : `PersistentFts::stage_insert`/`stage_delete` (composent dans - le `Batch` de l'appelant, jamais leur propre `apply_batch` — même couture - qu'ADR-027 §3) ; `df(t)` dérivé du scan `postings`, jamais un compteur - caché séparé ; stats par agent healées à la demande (paresseux, pas au - niveau `open()` global comme `MemoryIndexMeta`) (2026-07-06) + le `Batch` de l'appelant, jamais leur propre `apply_batch` — même couture + qu'ADR-027 §3) ; `df(t)` dérivé du scan `postings`, jamais un compteur + caché séparé ; stats par agent healées à la demande (paresseux, pas au + niveau `open()` global comme `MemoryIndexMeta`) (2026-07-06) - [x] Moteur : `search_bm25(engine, agent, match_expr, k)` — scoring Okapi, - parsing strict du sous-ensemble `match_expr` (erreur franche hors - périmètre, jamais un résultat partiel silencieux) (2026-07-06) + parsing strict du sous-ensemble `match_expr` (erreur franche hors + périmètre, jamais un résultat partiel silencieux) (2026-07-06) - [x] `PersistentMemoryIndex::put`/`forget`/`purge_agent` gagnent un - paramètre `PersistentFts`, empilé dans le même `extra: Batch` que - `insert_with`/`delete_with` — un `remember` natif reste UN enregistrement - WAL, étendu au troisième index ; re-vérifié sous le harnais - crash-consistency (4 modes base/batch/vector/graph, 0 violation) (2026-07-06) + paramètre `PersistentFts`, empilé dans le même `extra: Batch` que + `insert_with`/`delete_with` — un `remember` natif reste UN enregistrement + WAL, étendu au troisième index ; re-vérifié sous le harnais + crash-consistency (4 modes base/batch/vector/graph, 0 violation) (2026-07-06) - [x] `basemyai` : `NativeMemoryStore::keyword_ranking_ids` branché sur - `search_bm25` (fin de l'erreur franche N5.2) ; oversampling ×8 (ADR-012) - + filtre agent/validité après coup — bug de parité trouvé et corrigé en - cours de route (l'implémentation initiale ignorait `now`, un - `#[allow(unused)]` silencieux aurait laissé passer un souvenir invalidé/ - expiré dans le classement BM25) ; `NativeInner`/`put_one`/`forget`/ - `purge_agent` mis à jour en miroir (2026-07-06) + `search_bm25` (fin de l'erreur franche N5.2) ; oversampling ×8 (ADR-012) + - filtre agent/validité après coup — bug de parité trouvé et corrigé en + cours de route (l'implémentation initiale ignorait `now`, un + `#[allow(unused)]` silencieux aurait laissé passer un souvenir invalidé/ + expiré dans le classement BM25) ; `NativeInner`/`put_one`/`forget`/ + `purge_agent` mis à jour en miroir (2026-07-06) - [x] `backend_suite!` : deux scénarios de parité ajoutés à - `tests/memory_tests/scenarios.rs` - (`keyword_ranking_orders_by_relevance_and_truncates`, - `keyword_ranking_respects_temporal_validity_and_forget` — validité - temporelle + forget, portés depuis `temporal_validity_boundary`/ - `forget_deletes_physically`), rejoués contre `Libsql` ET `Native`, zéro - divergence (`cargo test -p basemyai --features test-util,engine-native - --test memory_tests`). Classements BM25 conçus pour être robustes entre - implémentations (`tf` croissant à `df`/longueur/`idf` égaux — propriété - monotone de BM25 — plutôt que des scores exacts entre termes différents, - plus sensibles aux détails d'implémentation) (2026-07-06) + `tests/memory_tests/scenarios.rs` + (`keyword_ranking_orders_by_relevance_and_truncates`, + `keyword_ranking_respects_temporal_validity_and_forget` — validité + temporelle + forget, portés depuis `temporal_validity_boundary`/ + `forget_deletes_physically`), rejoués contre `Libsql` ET `Native`, zéro + divergence (`cargo test -p basemyai --features test-util,engine-native +--test memory_tests`). Classements BM25 conçus pour être robustes entre + implémentations (`tf` croissant à `df`/longueur/`idf` égaux — propriété + monotone de BM25 — plutôt que des scores exacts entre termes différents, + plus sensibles aux détails d'implémentation) (2026-07-06) - [x] `EngineCapabilities::native().full_text` → `true` (honnête, plus - d'erreur franche pour ce chemin) (2026-07-06) + d'erreur franche pour ce chemin) (2026-07-06) - [x] `xtask`/`ci.yml` : aucune modification nécessaire — le nouveau module - vit sous des entrées déjà couvertes (`--lib` pour `basemyai-engine`, - `--test memory_tests` pour `basemyai --features test-util,engine-native`) - ; `cargo xtask ci` vert (18 étapes), harnais crash-consistency re-exécuté - (4 modes, 0 violation) — le triplet postings/docterms/stats n'a pas son - propre mode dédié dans le harnais (reste un item de suivi non bloquant, - N5.5) mais est exercé indirectement par le mode `batch`/`vector` puisqu'il - chevauche le même `apply_batch` (2026-07-06) + vit sous des entrées déjà couvertes (`--lib` pour `basemyai-engine`, + `--test memory_tests` pour `basemyai --features test-util,engine-native`) + ; `cargo xtask ci` vert (18 étapes), harnais crash-consistency re-exécuté + (4 modes, 0 violation) — le triplet postings/docterms/stats n'a pas son + propre mode dédié dans le harnais (reste un item de suivi non bloquant, + N5.5) mais est exercé indirectement par le mode `batch`/`vector` puisqu'il + chevauche le même `apply_batch` (2026-07-06) - [ ] Item de suivi séparé, non bloquant : racinisation Porter (gap assumé - par ADR-028 §2) — à instruire seulement si mesuré comme un manque de - recall significatif en usage réel + par ADR-028 §2) — à instruire seulement si mesuré comme un manque de + recall significatif en usage réel - [x] Mode `memory` dédié du harnais crash-consistency (couverture directe - du triplet record+vecteur+FTS via `PersistentMemoryIndex::put`/`forget`, - comme les modes `vector`/`graph` existants) — voir N5.5 ci-dessous, clos - 2026-07-07 + du triplet record+vecteur+FTS via `PersistentMemoryIndex::put`/`forget`, + comme les modes `vector`/`graph` existants) — voir N5.5 ci-dessous, clos + 2026-07-07 ### N5.3 — 100 % des contrats sur Native - [x] 100 % de `storage_contract.rs` verts sur `Native` — les 12 scénarios - restants (isolation multi-agent recall/hydrate/purge/exact-fact, batch - atomique+vide, filtre de couche, expiration/pas-encore-valide, stats par - couche, classement vecteur+mot-clé isolé, traversée graphe scopée agent, - épisodes récents) portés dans le runner déclaratif (`memory_tests/ - scenarios.rs`, 7→19 scénarios), `Step`/`Scenario` étendus d'un champ - `agent: Option<&'static str>` par étape pour les séquences multi-agent, 6 - nouvelles variantes (`RememberBatch`/`PurgeAgent`/`ExpectVectorRankingIds`/ - `ExpectHydrate`/`ExpectAgentStatsByLayer`/`ExpectRecentEpisodes`/ - `ExpectExactFactExists`) ; 16/16 tests de `storage_contract.rs` rejoués - verbatim contre Libsql ET Native, zéro divergence. `contracts.rs` hors - scope (teste `Memory`/`Store` libSQL directement, aucune variation par - backend). `cargo xtask check`/`test` verts (2026-07-06). + restants (isolation multi-agent recall/hydrate/purge/exact-fact, batch + atomique+vide, filtre de couche, expiration/pas-encore-valide, stats par + couche, classement vecteur+mot-clé isolé, traversée graphe scopée agent, + épisodes récents) portés dans le runner déclaratif (`memory_tests/ +scenarios.rs`, 7→19 scénarios), `Step`/`Scenario` étendus d'un champ + `agent: Option<&'static str>` par étape pour les séquences multi-agent, 6 + nouvelles variantes (`RememberBatch`/`PurgeAgent`/`ExpectVectorRankingIds`/ + `ExpectHydrate`/`ExpectAgentStatsByLayer`/`ExpectRecentEpisodes`/ + `ExpectExactFactExists`) ; 16/16 tests de `storage_contract.rs` rejoués + verbatim contre Libsql ET Native, zéro divergence. `contracts.rs` hors + scope (teste `Memory`/`Store` libSQL directement, aucune variation par + backend). `cargo xtask check`/`test` verts (2026-07-06). ### N5.4 — Chiffrement au repos natif - [x] Chiffrement au repos (équivalent ADR-007) + rotation de clé (parité - `rotate_key` M6) — **ADR-030** : AEAD XChaCha20-Poly1305 pur Rust (pas de - feature gate, contrairement au `crypto` libSQL/CMake), enveloppe DEK/KEK - (`crypto.meta`, la clé utilisateur n'encrypte jamais la donnée), - enveloppes `WalEnvelope:1` par enregistrement (torn-tail préservé, - batch = une enveloppe) et `SstEnvelope:1` fichier entier — WAL + SST - couvrent mécaniquement tous les blocs d'index. Mauvaise clé / clé - absente / clé sur store en clair = erreurs franches typées à l'ouverture. - `Engine::rotate_key` = re-scellement O(1), commit atomique un-fichier, - **instance utilisable après rotation** (mieux que le `PRAGMA rekey` - libSQL) ; écart DEK-inchangée documenté honnêtement (ADR-030 §4). - Surfaces : `EngineCapabilities::native(encrypted)` (dernière capacité - `false` levée), `NativeEngine::open_encrypted`, - `NativeMemoryStore::{open_encrypted,rotate_key,open_ephemeral_encrypted}`. - Trois formats de plus dans `format.lock` (`CryptoMeta:1`/`WalEnvelope:1`/ - `SstEnvelope:1`). Vérifié : la suite complète des 19 scénarios - `backend_suite!` rejouée contre un backend `native_encrypted` (zéro - divergence avec Libsql/Native en clair), rotation roundtrip niveau - `MemoryStore`, et crash harness kill réel étendu d'un 5e mode - `encrypted_batch` (20 cycles, 0 violation, cas batch-en-vol observé). - `cargo xtask check`/`test`/`test-crash-consistency` verts (2026-07-06). + `rotate_key` M6) — **ADR-030** : AEAD XChaCha20-Poly1305 pur Rust (pas de + feature gate, contrairement au `crypto` libSQL/CMake), enveloppe DEK/KEK + (`crypto.meta`, la clé utilisateur n'encrypte jamais la donnée), + enveloppes `WalEnvelope:1` par enregistrement (torn-tail préservé, + batch = une enveloppe) et `SstEnvelope:1` fichier entier — WAL + SST + couvrent mécaniquement tous les blocs d'index. Mauvaise clé / clé + absente / clé sur store en clair = erreurs franches typées à l'ouverture. + `Engine::rotate_key` = re-scellement O(1), commit atomique un-fichier, + **instance utilisable après rotation** (mieux que le `PRAGMA rekey` + libSQL) ; écart DEK-inchangée documenté honnêtement (ADR-030 §4). + Surfaces : `EngineCapabilities::native(encrypted)` (dernière capacité + `false` levée), `NativeEngine::open_encrypted`, + `NativeMemoryStore::{open_encrypted,rotate_key,open_ephemeral_encrypted}`. + Trois formats de plus dans `format.lock` (`CryptoMeta:1`/`WalEnvelope:1`/ + `SstEnvelope:1`). Vérifié : la suite complète des 19 scénarios + `backend_suite!` rejouée contre un backend `native_encrypted` (zéro + divergence avec Libsql/Native en clair), rotation roundtrip niveau + `MemoryStore`, et crash harness kill réel étendu d'un 5e mode + `encrypted_batch` (20 cycles, 0 violation, cas batch-en-vol observé). + `cargo xtask check`/`test`/`test-crash-consistency` verts (2026-07-06). ### N5.5 — Barre hardening M6 ✅ clos 2026-07-07 - [x] `put_memory_batch` tout-ou-rien : `PersistentVectorIndex::insert_many_with` - (plan chaque insert du groupe contre l'état des inserts précédents via un - `OverlayProvider` — pending par-dessus le cache par-dessus le store —, un - seul `apply_batch` pour tout le groupe) + `PersistentFts::stage_insert_many` - (une seule mise à jour des stats BM25 agrégée sur le groupe, pas un - read-modify-write par document qui lirait les stats obsolètes du document - précédent non encore commité) + `PersistentMemoryIndex::put_many` (les - compose : vérifie tous les doublons — contre le store ET entre eux dans le - lot — *avant* d'écrire quoi que ce soit, alloue les `vec_id` séquentiels, - un seul enregistrement WAL pour tout le groupe). `put` devient un - `put_many` à un seul item (plus de duplication de logique). Résorbe - l'écart initial d'ADR-027 §6 : un `put_memory_batch` natif est désormais - tout-ou-rien, comme la transaction libSQL qu'il remplace. Vérifié : nouveau - test `put_many_duplicate_anywhere_writes_nothing_at_all` (doublon contre le - store ET intra-lot, rien ne survit dans les deux cas) + - `assert_put_memory_batch_is_all_or_nothing` générique sur `MemoryStore` - rejouée verbatim contre Libsql ET Native (`tests/memory_tests.rs`). + (plan chaque insert du groupe contre l'état des inserts précédents via un + `OverlayProvider` — pending par-dessus le cache par-dessus le store —, un + seul `apply_batch` pour tout le groupe) + `PersistentFts::stage_insert_many` + (une seule mise à jour des stats BM25 agrégée sur le groupe, pas un + read-modify-write par document qui lirait les stats obsolètes du document + précédent non encore commité) + `PersistentMemoryIndex::put_many` (les + compose : vérifie tous les doublons — contre le store ET entre eux dans le + lot — _avant_ d'écrire quoi que ce soit, alloue les `vec_id` séquentiels, + un seul enregistrement WAL pour tout le groupe). `put` devient un + `put_many` à un seul item (plus de duplication de logique). Résorbe + l'écart initial d'ADR-027 §6 : un `put_memory_batch` natif est désormais + tout-ou-rien, comme la transaction libSQL qu'il remplace. Vérifié : nouveau + test `put_many_duplicate_anywhere_writes_nothing_at_all` (doublon contre le + store ET intra-lot, rien ne survit dans les deux cas) + + `assert_put_memory_batch_is_all_or_nothing` générique sur `MemoryStore` + rejouée verbatim contre Libsql ET Native (`tests/memory_tests.rs`). - [x] Mode `memory` du harnais crash-consistency : schéma déterministe - put/forget (`harness::memory_op`, période 5 — un forget toutes les 5 - étapes sur un id déjà mis en place, jamais réutilisé) exerçant le triplet - composé record+vecteur+FTS de `PersistentMemoryIndex::put`/`forget` sous - kill réel. Après chaque kill : `PersistentVectorIndex::rebuilt_on_open() - == false` (chaque put/forget est un batch atomique, jamais de - reconstruction après un crash seul), chaque id confirmé-mis-en-place a son - triplet complet intact (record exact, `vec_id` exact, mapping inverse, - trouvable par recherche vectorielle sur son vecteur exact ET par terme - BM25 unique), chaque id confirmé-oublié a les trois retirés (jamais un - reliquat partiel). Variante chiffrée `encrypted_memory_kill_reopen_verify_loop` - (ADR-030) — c'est le mode qui touche les quatre index logiques du moteur - par opération, donc la couverture la plus riche par cycle pour « le - chiffrement doit être transparent aux garanties crash ». 20 cycles réels - × 2 (clair/chiffré), 0 violation, ~1200/580 étapes confirmées. + put/forget (`harness::memory_op`, période 5 — un forget toutes les 5 + étapes sur un id déjà mis en place, jamais réutilisé) exerçant le triplet + composé record+vecteur+FTS de `PersistentMemoryIndex::put`/`forget` sous + kill réel. Après chaque kill : `PersistentVectorIndex::rebuilt_on_open() +== false` (chaque put/forget est un batch atomique, jamais de + reconstruction après un crash seul), chaque id confirmé-mis-en-place a son + triplet complet intact (record exact, `vec_id` exact, mapping inverse, + trouvable par recherche vectorielle sur son vecteur exact ET par terme + BM25 unique), chaque id confirmé-oublié a les trois retirés (jamais un + reliquat partiel). Variante chiffrée `encrypted_memory_kill_reopen_verify_loop` + (ADR-030) — c'est le mode qui touche les quatre index logiques du moteur + par opération, donc la couverture la plus riche par cycle pour « le + chiffrement doit être transparent aux garanties crash ». 20 cycles réels + × 2 (clair/chiffré), 0 violation, ~1200/580 étapes confirmées. - [x] Modèle de concurrence au-delà du mono-écrivain sérialisé de N5.1, - mesuré : le cache borné de `PersistentVectorIndex` devient interior-mutable - (`Mutex`) — ce qui était la seule raison pour laquelle - `search`/`search_scored` exigeaient `&mut self` — donc ces deux méthodes - passent à `&self`. Côté `basemyai`, `NativeMemoryStore` passe d'`Arc>` à `Arc>` : les lectures pures - (`vector_ranking_ids`, `keyword_ranking_ids`, `agent_stats`, - `graph_traverse`, `recent_episodes`, `exact_fact_exists`) prennent un - verrou de lecture et s'exécutent concurremment entre elles ; les chemins - hybrides (`recall_vector`, `recall_graph_filtered`, `hydrate`) font deux - passes — recherche sous verrou de lecture, `touch` de `last_access` sous - un bref verrou d'écriture séparé — plutôt qu'une passe unique sous verrou - exclusif qui bloquerait tout lecteur concurrent pendant toute la - recherche. Les écritures restent sérialisées entre elles (`Engine` reste - mono-écrivain sync, ADR-025 inchangée) — lever *ça* exigerait un moteur - multi-écrivain, explicitement hors périmètre. **Mesuré**, pas juste - affirmé : `tests/memory_tests.rs - native_concurrent_reads_are_correct_and_faster_than_sequential` — 64 - lectures mixtes (classement vecteur/mot-clé, stats, fait exact) - concurrentes toutes correctes, puis 64 lectures séquentielles vs. - concurrentes chronométrées : **~3× plus rapide** en concurrent sur cette - machine (ratio journalisé, pas un seuil dur — trop bruyant pour une - assertion stricte en CI). + mesuré : le cache borné de `PersistentVectorIndex` devient interior-mutable + (`Mutex`) — ce qui était la seule raison pour laquelle + `search`/`search_scored` exigeaient `&mut self` — donc ces deux méthodes + passent à `&self`. Côté `basemyai`, `NativeMemoryStore` passe d'`Arc>` à `Arc>` : les lectures pures + (`vector_ranking_ids`, `keyword_ranking_ids`, `agent_stats`, + `graph_traverse`, `recent_episodes`, `exact_fact_exists`) prennent un + verrou de lecture et s'exécutent concurremment entre elles ; les chemins + hybrides (`recall_vector`, `recall_graph_filtered`, `hydrate`) font deux + passes — recherche sous verrou de lecture, `touch` de `last_access` sous + un bref verrou d'écriture séparé — plutôt qu'une passe unique sous verrou + exclusif qui bloquerait tout lecteur concurrent pendant toute la + recherche. Les écritures restent sérialisées entre elles (`Engine` reste + mono-écrivain sync, ADR-025 inchangée) — lever _ça_ exigerait un moteur + multi-écrivain, explicitement hors périmètre. **Mesuré**, pas juste + affirmé : `tests/memory_tests.rs +native_concurrent_reads_are_correct_and_faster_than_sequential` — 64 + lectures mixtes (classement vecteur/mot-clé, stats, fait exact) + concurrentes toutes correctes, puis 64 lectures séquentielles vs. + concurrentes chronométrées : **~3× plus rapide** en concurrent sur cette + machine (ratio journalisé, pas un seuil dur — trop bruyant pour une + assertion stricte en CI). - [x] Bench KNN via le chemin `MemoryStore` complet (pas l'index nu) — - `crates/basemyai/tests/native_memory_store_bench.rs` (même discipline que - `vector_recall.rs`/`vector_bench.rs` : chiffres réels chronométrés, pas de - Criterion). Variante rapide N=2 000 dans le gate par défaut (`cargo xtask - test`, signal de régression continu) ; variante N=10 000 `#[ignore]`, - manuelle, exécutée `--release` : - `docs/benchmarks/n5.5-memorystore-knn-bench-2026-07-07.md` — insert - ~12.96 ms/`put_memory` (cohérent avec la fourchette déjà mesurée sur - l'index nu par N3, 5.7–17.3 ms/ligne — le chemin `MemoryStore` complet - n'ajoute pas de surcoût significatif), `recall_vector` ~17.03 ms/requête - (oversampling ×8 + hydratation + filtre + `touch` inclus) contre ~48.98 ms - pour le `vector_top_k` **nu** libSQL au même N — malgré plus de travail - par requête, le chemin natif reste ~2.9× plus rapide en bout de chaîne. - Stress long à plus grande échelle (100k+) resté hors scope de cette passe - — item de suivi si un chiffre à cette échelle devient nécessaire. - `cargo xtask check`/`test`/`test-crash-consistency` verts. + `crates/basemyai/tests/native_memory_store_bench.rs` (même discipline que + `vector_recall.rs`/`vector_bench.rs` : chiffres réels chronométrés, pas de + Criterion). Variante rapide N=2 000 dans le gate par défaut (`cargo xtask +test`, signal de régression continu) ; variante N=10 000 `#[ignore]`, + manuelle, exécutée `--release` : + `docs/benchmarks/n5.5-memorystore-knn-bench-2026-07-07.md` — insert + ~12.96 ms/`put_memory` (cohérent avec la fourchette déjà mesurée sur + l'index nu par N3, 5.7–17.3 ms/ligne — le chemin `MemoryStore` complet + n'ajoute pas de surcoût significatif), `recall_vector` ~17.03 ms/requête + (oversampling ×8 + hydratation + filtre + `touch` inclus) contre ~48.98 ms + pour le `vector_top_k` **nu** libSQL au même N — malgré plus de travail + par requête, le chemin natif reste ~2.9× plus rapide en bout de chaîne. + Stress long à plus grande échelle (100k+) resté hors scope de cette passe + — item de suivi si un chiffre à cette échelle devient nécessaire. + `cargo xtask check`/`test`/`test-crash-consistency` verts. ### N5.6 — Bascule du défaut -- [ ] ADR de bascule du défaut libSQL→Native (chiffres à l'appui) — - décision séparée et humaine, jamais prise en passant +- [x] ADR de bascule du défaut libSQL→Native (chiffres à l'appui) — + décision séparée et humaine, jamais prise en passant ( décision prise supprimé libsql est aller sur le natif) ## N6 — Aval (après Phase 4) - [ ] Sync P2P (change-capture du WAL comme primitive ; VISION §5.6) - [ ] Couche 4 : langage de requête (micro-crates `token`→`parser`→`ast`) — - **décision produit préalable** : outil interne/CLI, pas surface agent - (l'avantage `remember`/`recall` sans langage est documenté et se protège) + **décision produit préalable** : outil interne/CLI, pas surface agent + (l'avantage `remember`/`recall` sans langage est documenté et se protège) ## Parallèle — indépendant du moteur - [ ] Multi-modèles d'embedding (catalogue `EMBED_KNOWN_MODELS`, `schema(dim)` - paramétré, `setup --model`) — ne dépend d'aucun backend, peut démarrer - n'importe quand + paramétré, `setup --model`) — ne dépend d'aucun backend, peut démarrer + n'importe quand diff --git a/docs/VISION.md b/docs/VISION.md index 474b2b4..726496c 100644 --- a/docs/VISION.md +++ b/docs/VISION.md @@ -277,7 +277,7 @@ Approche **MVP → hybride → full**, strictement séquencée sur les ADR. Chaq 2. **Le socle est déjà le bon — et il n'impose pas l'ambition trop tôt.** C'est la force du pivot libSQL (ADR-011) : le **même fichier** qui sert la V1 (vecteur natif + temporel + isolation) porte, sans nouveau backend, le graphe (CTE), l'oubli (worker) et le multi-signal (Filter + RRF). L'ambition long terme **ne coûte rien à la V1** — elle est latente dans le socle, activée phase par phase. -3. **L'agnosticité du core protège l'incrément.** Parce que `basemyai-core` reste mécanisme-pur (ADR-001), chaque innovation cognitive s'ajoute **dans `basemyai`** sans toucher au socle ni casser l'autre consommateur (ForgeMyAI). On peut donc livrer V1 maintenant et empiler la cognition ensuite, sans dette de rupture. +3. **L'agnosticité du core protège l'incrément.** Parce que `basemyai-core` reste mécanisme-pur (ADR-001), chaque innovation cognitive s'ajoute **dans `basemyai`** sans toucher au socle ni casser les autres consommateurs du core. On peut donc livrer V1 maintenant et empiler la cognition ensuite, sans dette de rupture. 4. **Le marché 2026–2028 récompense l'infra, pas la feature.** La mémoire est *le* goulet des agents sur cette fenêtre. Les acteurs qui gagnent sont ceux qui en font une couche d'infrastructure défendable (Mem0, Zep), pas un gadget. Le différenciateur de BaseMyAI — l'hybride complet, local-first, mono-fichier — n'existe que si l'on vise l'infra dès le départ. diff --git a/docs/adr/ADR-011-libsql-pivot.md b/docs/adr/ADR-011-libsql-pivot.md index 51fdfc3..4f65ad6 100644 --- a/docs/adr/ADR-011-libsql-pivot.md +++ b/docs/adr/ADR-011-libsql-pivot.md @@ -1,6 +1,6 @@ # ADR-011 — Pivot vers libSQL (vecteur natif + chiffrement), traits async -**Statut** : ✅ Accepted | **Date** : 2026-06 +**Statut** : 🔵 Superseded by ADR-032 | **Date** : 2026-06 **Supersede** : ADR-002 (sqlite-vec). **Amende** : ADR-003 (Candle tient), ADR-007 (chiffrement désormais via libSQL). **Contexte** diff --git a/docs/adr/ADR-019-agent-memory-database-format-and-engine.md b/docs/adr/ADR-019-agent-memory-database-format-and-engine.md index ae15615..98578b0 100644 --- a/docs/adr/ADR-019-agent-memory-database-format-and-engine.md +++ b/docs/adr/ADR-019-agent-memory-database-format-and-engine.md @@ -1,8 +1,8 @@ # ADR-019 — Agent Memory Database, `.bmai` V1, and Storage Engine Boundary -**Status**: Accepted +**Status**: Amended by ADR-032 **Date**: 2026-06-18 -**Context**: supersedes the product framing of "SQLite-backed memory engine" without replacing ADR-011's libSQL V1 backend decision. +**Context**: supersedes the product framing of "SQLite-backed memory engine"; V1/libSQL compatibility has been removed from the active workspace by ADR-032. ## Context diff --git a/docs/adr/ADR-020-memory-store-trait.md b/docs/adr/ADR-020-memory-store-trait.md index 4fb1f18..f477f1b 100644 --- a/docs/adr/ADR-020-memory-store-trait.md +++ b/docs/adr/ADR-020-memory-store-trait.md @@ -1,11 +1,9 @@ # ADR-020 — `MemoryStore` : contrat d'opérations mémoire dans `basemyai` -**Status**: Accepted +**Status**: Amended by ADR-032 **Date**: 2026-06-20 -**Context**: suivi d'ADR-019 (*« Gradually move SQL/libSQL-specific code -behind an engine module… Add backend contract tests before any second -backend exists »*) ; n'amende ni ne supersède ADR-011 (backend libSQL V1) -ni ADR-001 (agnosticité du core). +**Context**: suivi d'ADR-019 ; depuis ADR-032, `NativeMemoryStore` est +l'unique implémentation active du contrat `MemoryStore`. ## Contexte diff --git a/docs/adr/ADR-021-libsql-reader-pool.md b/docs/adr/ADR-021-libsql-reader-pool.md index dd2b5a1..0747024 100644 --- a/docs/adr/ADR-021-libsql-reader-pool.md +++ b/docs/adr/ADR-021-libsql-reader-pool.md @@ -1,10 +1,9 @@ # ADR-021 — Pool de connexions lecteur libSQL + writer unique sérialisé, sous WAL -**Status**: Accepted +**Status**: 🔵 Superseded by ADR-032 **Date**: 2026-06-21 -**Context**: suivi d'ADR-011 (backend libSQL V1) ; n'amende ni ne supersède -ADR-011 ni ADR-019 (`.bmai` libSQL-compatible). Cible le hardening M6 (« bench -KNN, stress test, pool, key rotation ») listé ouvert dans `docs/status.md`. +**Context**: suivi d'ADR-011 (backend libSQL V1). Rendu obsolète par ADR-032 +qui retire le backend libSQL du workspace actif. ## Contexte diff --git a/docs/adr/ADR-032-native-engine-default.md b/docs/adr/ADR-032-native-engine-default.md new file mode 100644 index 0000000..e573c36 --- /dev/null +++ b/docs/adr/ADR-032-native-engine-default.md @@ -0,0 +1,179 @@ +# ADR-032 — Bascule du défaut : le moteur natif remplace libSQL comme backend par défaut + +**Statut** : ✅ Accepted +**Date** : 2026-07-08 +**Relation aux ADR existants** : clôt N5.6, le dernier jalon de la Phase 4 du +chantier acté par ADR-024. S'appuie sur ADR-025 (fondation LSM), ADR-026 +(index vectoriel LM-DiskANN), ADR-027 (`MemoryStore` sur Native), ADR-028 +(FTS/BM25 natif) et ADR-030 (chiffrement au repos natif). **Amende ADR-011 +et ADR-019 sur un point précis** : libSQL cesse d'être le backend par défaut ; +il reste le backend de **compatibilité** pour les fichiers `.bmai` v1 +existants. ADR-007 (chiffrement obligatoire) et ADR-005/006/012 (sémantiques) +sont inchangés et s'appliquent à l'identique au backend natif. + +## Contexte + +ADR-024 posait la condition de bascule : « libSQL reste le défaut jusqu'à +parité prouvée ». ADR-027 §1 en faisait un jalon dédié (N5.6), « décision +séparée et humaine, chiffres à l'appui, jamais prise en passant ». La +décision a été prise explicitement par le propriétaire du projet le +2026-07-08. Cet ADR l'instruit. + +### Les chiffres (tous archivés, mesurés sur la même machine que M6) + +| Critère | libSQL (mesuré M6) | Natif (mesuré N3/N5.5) | Source | +|---|---|---|---| +| Requête KNN k=10, N=10k (index nu) | ~48-49 ms | **7.52 ms** (6,5×) | `docs/benchmarks/n3-vector-parity-2026-07-05.md` | +| Requête KNN k=10, N=100k (index nu) | ~48-49 ms | **12.67 ms** (3,8×) | idem | +| Build incrémental d'index, /ligne | ~78-79 ms (bulk-load ; l'incrémental 100k n'a jamais fini en 3h+) | **5.69 ms** (10k) / **17.32 ms** (100k) | idem | +| `recall_vector` bout-en-bout (chemin `MemoryStore` complet, N=10k) | ~48.98 ms (`vector_top_k` **nu**, sans hydratation) | **17.03 ms** (oversampling ×8 + hydratation + filtre + `touch` inclus, ~2,9×) | `docs/benchmarks/n5.5-memorystore-knn-bench-2026-07-07.md` | +| Recall@10 | jamais mesuré par M6 | **1.0000** (10k et 100k, y compris après churn 20 % delete/réinsert ×3) | ADR-026 §6, `n3-vector-parity` | +| Lectures concurrentes (64 lectures mixtes) | pool lecteur ADR-021 | **~3× plus rapide que séquentiel** (RwLock N5.5) | `tests/memory_tests.rs` | + +### La parité (prouvée, pas affirmée) + +- **19 scénarios `backend_suite!`** — 100 % de `storage_contract.rs` — rejoués + verbatim contre `Libsql`, `Native` **et** `NativeEncrypted` : zéro + divergence (N5.3/N5.4). +- **Crash-consistency sous kill réel** : 5 modes (base/batch/vector/graph/ + encrypted_batch) + mode `memory` (triplet record+vecteur+FTS, clair et + chiffré), 20 cycles chacun, **0 violation** (N2→N5.5). +- **Chiffrement au repos** (ADR-030) : AEAD XChaCha20-Poly1305 pur Rust, + enveloppe DEK/KEK, rotation de clé O(1) **sans réouverture** — mieux que le + `PRAGMA rekey` libSQL, et **sans CMake** (le talon d'Achille DX de la + feature `crypto` libSQL). +- **`EngineCapabilities::native()`** : toutes les capacités à `true` + honnêtement (`vectors`, `full_text`, `recursive_queries`, `transactions`, + `encrypted`). + +## Décision + +**Le moteur natif (`basemyai-engine`) devient le backend par défaut de +BaseMyAI pour toute nouvelle base, de façon définitive.** libSQL passe en +backend de **compatibilité** : les fichiers `.bmai` v1 existants continuent de +s'ouvrir en lecture/écriture, mais aucune surface ne crée plus de base libSQL +par défaut. Le retrait complet de libSQL est une décision future séparée (il +exigera son propre ADR) ; rien dans le présent ADR ne casse un store existant. + +### 1. Format sur disque : `.bmai` v2 = répertoire du moteur natif + +- **`.bmai` v1** (ADR-019) : un fichier unique SQLite/libSQL. Inchangé. +- **`.bmai` v2** : un **répertoire** portant l'extension `.bmai` (ex. + `agent.bmai/`), contenant WAL, SST et méta du moteur natif (`crypto.meta` + si chiffré). Le versionnage de wire reste gouverné par `format.lock`. +- **Détection** : structurelle et non ambiguë — un chemin existant qui est un + **répertoire** est un store natif ; un **fichier** est un store libSQL v1 + (confirmable par le magic `SQLite format 3` en clair, indéterminable si + chiffré — le type de nœud du système de fichiers suffit et reste le + critère). Un chemin inexistant est **créé en natif** (le défaut). + +### 2. Façade `Memory` : backend interne à deux variantes, contrat unique + +`Memory` cesse d'être câblée en dur sur `LibsqlMemoryStore`. Elle porte un +backend interne à deux variantes (`Libsql` / `Native`), et tous les chemins +sémantiques (remember/recall/forget/graphe/stats/consolidation) passent +exclusivement par le contrat `MemoryStore` (ADR-020) — déjà le cas. Les +opérations **backend-spécifiques** (export/import JSONL, contrat embedding +`bmai_meta`, rotation de clé) sont résolues par le backend, sans gonfler le +contrat sémantique avec de la plomberie de portabilité. + +Constructeurs : + +- `Memory::open_native(path, key, embedder, agent)` — **le défaut**. `key` + obligatoire pour un store sur disque (ADR-007 s'applique au natif à + l'identique) ; pas de feature `crypto`/CMake requise (ADR-030). +- `Memory::open(store, embedder, agent)` — inchangé, backend libSQL + (compatibilité v1 ; les bindings et tests existants ne cassent pas). +- `Memory::open_in_memory` (test-util) bascule sur l'équivalent natif + éphémère quand la feature `engine-native` est présente (le défaut) : la + suite de tests façade exerce désormais le backend par défaut réel. + +### 3. Portabilité et contrat embedding sur Native + +- **Contrat embedding** (`embedding_model_id`/`embedding_dim`, ADR-019) : + porté sur Native via des enregistrements KV sous le préfixe réservé + `meta/bmai/` (valeurs UTF-8 brutes, pas de codec versionné — une paire + clé/valeur opaque, même sémantique `INSERT OR IGNORE` puis vérification + stricte que côté libSQL). +- **Export/import JSONL** (migration, backup — et LE chemin de migration + libSQL→Native) : implémenté sur Native à partir des scans existants du + moteur (`PersistentMemoryIndex::scan`, `PersistentGraph::entities`, scan + d'arêtes par agent). Même format JSONL v1, même idempotence (les ids déjà + présents sont comptés `*_skipped`). **Écart d'atomicité assumé** : côté + natif, les souvenirs s'insèrent en un seul batch WAL tout-ou-rien + (`put_many`, N5.5), mais entités/arêtes suivent en upserts individuels + durables — l'import est **idempotent et reprennable**, pas globalement + atomique (même classe d'écart que `purge_agent`, ADR-027 §6 ; se répare en + relançant l'import). + +### 4. Migration des stores v1 existants + +Aucune migration silencieuse, jamais (cohérent ADR-010 : rien d'implicite) : + +- Un `.bmai` v1 s'ouvre en libSQL, indéfiniment, sans avertissement bloquant. +- La migration est **explicite et documentée** : `export_jsonl` depuis le + store v1 → `import_jsonl` vers un store natif neuf (les embeddings sont + recalculés — c'est le chemin de migration déjà défini pour le changement + de modèle d'embedding, réutilisé tel quel). +- La CLI expose ce chemin (`export` / `import` existent déjà) ; un + raccourci `migrate` dédié est un confort différé, pas un préalable. + +### 5. Features et matrice CI + +- `engine-native` entre dans les features **par défaut** de `basemyai` (et + transitivement de la CLI, MCP, REST et des bindings). La feature reste + déclarée (additive) : `--no-default-features` permet toujours un build + libSQL pur. +- La feature `crypto` (libSQL/CMake) devient **optionnelle de fait** pour le + cas nominal : le chiffrement du backend par défaut est natif (ADR-030), + compilé inconditionnellement. `crypto` ne sert plus qu'aux stores v1. +- La matrice `xtask`/CI est mise à jour en miroir strict : les jobs + par défaut compilent désormais `basemyai-engine` ; les combinaisons + `--no-default-features` existantes gardent le chemin libSQL pur couvert. + +### 6. Ce que cet ADR ne décide pas + +- **Le retrait de libSQL** (dépendance, feature `crypto`, pool ADR-021) — + décision future, ADR séparé, conditionnée à l'usage réel des stores v1. +- **La racinisation Porter** (gap FTS assumé, ADR-028 §2) — inchangé. +- **Le multi-écrivain natif** — hors périmètre (ADR-025, mono-écrivain). +- **Les surfaces de sync P2P** (N6) — débloquées par cette bascule (le WAL + natif comme primitive de change-capture), mais non actées ici. + +## Alternatives rejetées + +- **Basculer seulement à `basemyai` 0.2 / attendre plus de terrain.** Rejeté : + les critères de sortie chiffrés posés *avant* mesure (ADR-026 §6) sont tous + tenus avec des marges de 3,8× à 13,8× ; la parité contractuelle est de + 100 % sur les trois backends de test ; attendre n'ajouterait que du temps + calendaire, pas de l'information. La compatibilité v1 borne le risque. +- **Migration automatique des `.bmai` v1 à l'ouverture.** Rejeté : violerait + la règle « jamais d'action implicite » (ADR-010), doublerait transitoirement + l'empreinte disque sans consentement, et re-calculer les embeddings sans + demander est inacceptable sur une base volumineuse. +- **Généraliser `Memory` sur `Arc` pur** (sans variantes de + backend). Rejeté : export/import, contrat embedding et rotation de clé ont + des mécaniques réellement différentes par backend (SQL brut vs scans KV, + `PRAGMA rekey`-et-réouverture vs re-scellement O(1) en place) ; les fondre + dans le contrat sémantique ADR-020 le polluerait de plomberie de + portabilité pour un bénéfice nul (deux backends, connus, énumérables). +- **Nouveau format d'export pour la migration.** Rejeté : le JSONL v1 + existant (embeddings exclus, recalculés à l'import) est déjà le chemin de + migration inter-modèles ; il est backend-agnostique par construction. + +## Conséquences + +- Toute nouvelle base créée par la CLI, MCP, REST ou les bindings est native : + ~2,9× plus rapide en recall bout-en-bout mesuré, ~4,5-13,8× sur le build + d'index, chiffrement sans CMake, rotation de clé sans réouverture. +- Les fichiers `.bmai` v1 restent pleinement fonctionnels ; leur migration + est explicite, outillée et documentée. +- `basemyai-engine` (BUSL-1.1, ADR-031) devient une dépendance par défaut de + tout le workspace — cohérent avec la licence unifiée. +- Le harnais multi-backend (`backend_suite!`) devient le gardien permanent de + la non-régression libSQL : tant que le backend de compatibilité existe, les + 19 scénarios tournent contre les deux moteurs en CI. +- CLAUDE.md (racine écosystème + basemyai), `docs/status.md`, + `docs/PLAN-NATIVE-ENGINE.md` et `docs/TODO-NATIVE-ENGINE.md` sont mis à + jour : la clause « libSQL reste le défaut jusqu'à parité prouvée » est + résolue — la parité est prouvée, la bascule est actée. diff --git a/docs/adr/ADR-033-native-only.md b/docs/adr/ADR-033-native-only.md new file mode 100644 index 0000000..3d62639 --- /dev/null +++ b/docs/adr/ADR-033-native-only.md @@ -0,0 +1,38 @@ +# ADR-033 — Migration 100 % moteur natif (libSQL/V1 retirés) + +**Statut** : ✅ Accepted +**Date** : 2026-07-08 +**Relation** : supersedes le mode compat libSQL décrit dans +`ADR-032-native-engine-default.md`, supersedes ADR-011/ADR-021, amends +ADR-019/ADR-020. + +## Contexte + +La bascule « moteur natif par défaut » est terminée et la phase de compatibilité +libSQL n'est plus maintenue dans le workspace actif : + +- plus de backend libSQL côté runtime produit ; +- plus de `Store` / `LibsqlMemoryStore` / `basemyai_core::libsql` ; +- plus de feature `crypto` libSQL ni de job CI associé ; +- plus de feature de compatibilité `engine-native` : le natif est le chemin + normal, compilé par défaut. + +## Décision + +1. **Backend unique** : BaseMyAI utilise uniquement le moteur natif + `basemyai-engine` dans tout le workspace. +2. **Format actif** : `.bmai` est un conteneur natif (layout moteur natif) ; + la compatibilité V1/libSQL est retirée de la base de code active. +3. **Chiffrement au repos** : assuré par l'enveloppe native ADR-030 + (XChaCha20-Poly1305, `crypto.meta`, WAL/SST chiffrés) ; aucun prérequis + CMake. +4. **Contrats** : `MemoryStore` reste le contrat sémantique, avec + `NativeMemoryStore` comme unique implémentation. + +## Conséquences + +- Simplification de la matrice CI/xtask (suppression `test-crypto` et des + variantes `engine-native`). +- Suppression des reliquats SQL-leaky de la surface produit. +- Tests et examples exécutés sur le backend natif réel, y compris la variante + chiffrée. diff --git a/docs/archive/TODO-2026-06.md b/docs/archive/TODO-2026-06.md index 4b6e24f..5605c55 100644 --- a/docs/archive/TODO-2026-06.md +++ b/docs/archive/TODO-2026-06.md @@ -270,7 +270,7 @@ Reporté volontairement (décision utilisateur du 2026-06-20, pas un oubli) : | [ ] | Sync multi-device (Turso managed) | VISION §7 | | [ ] | Mémoire partagée inter-agents (opt-in) | ADR-006 | | [ ] | Explicabilité / provenance des recalls | VISION §5.4 | -| [ ] | ForgeMyAI : scaffold du moteur de contexte de code | ECOSYSTEM_ARCHITECTURE.md | +| [ ] | Consommateurs tiers du core (contexte code) | ECOSYSTEM_ARCHITECTURE.md | --- diff --git a/docs/cli.md b/docs/cli.md index cad5e1d..e4a5671 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -4,8 +4,8 @@ gives command-line access to the same engine consumed by the Rust crate, Python/Node SDKs, and the REST/MCP sidecars: provisioning, `.bmai` container lifecycle, the full memory lifecycle (`remember`/`recall`/`list`/`forget`/ -`invalidate`/`purge`/`export`/`import`), the entity/relation graph, one-shot -maintenance tasks, and LLM-driven consolidation. +`invalidate`/`purge`/`export`/`import`), the entity/relation graph, and +LLM-driven consolidation (`consolidate`). Binary name: `basemyai`. Crate: `crates/basemyai-cli`. Status: see [`status.md` §6](status.md#6-cli-basemyai). @@ -17,9 +17,9 @@ cargo build -p basemyai-cli --release # binary at target/release/basemyai ``` -Default features are `crypto` + `embed` (mirrors `basemyai-mcp`). Without -them the binary still parses arguments but every command that touches a -`.bmai` file fails with an explicit error — there is no silent degraded mode. +Default feature is `embed` (Candle for `remember`/`recall`). Without it the +binary still parses arguments but every command that needs the full memory +stack fails with an explicit error — there is no silent degraded mode. ## Global flags @@ -32,11 +32,21 @@ them: | `--db ` | `BASEMYAI_DB_PATH` → `~/.basemyai/config.toml` (`db-path`) | Required by every command except `init` (which takes the path positionally — creating a container without saying where would be dangerous to default). | | `--agent ` | `BASEMYAI_AGENT` → `~/.basemyai/config.toml` (`agent`) | Required by every command that touches memory/graph data. | | `--format ` | `BASEMYAI_FORMAT` | `json` makes every command print one machine-readable JSON object on stdout — built so an AI agent can call this CLI as a tool without parsing human text. | +| `--color ` | `NO_COLOR` / `FORCE_COLOR` (when `auto`) | Controls ANSI styling in text mode. `never` is recommended for deterministic snapshots. | +| `--quiet` | — | Suppresses non-essential informational text in `text` mode (errors still print). | +| `--no-progress` | — | Disables spinners/progress bars for long operations. | +| `-v`, `-vv` | — | Enables diagnostic logs on stderr (`info`/`debug`). | Encryption is mandatory (ADR-007): every command that opens a `.bmai` file requires the key via `BASEMYAI_DB_KEY`. There is no flag for the key and no way to open a file in plaintext. +In `text` mode, `basemyai` now uses a terminal-aware presentation layer: +tables for scanability, semantic color tokens, and progress feedback for long +operations. Machine-readable contracts stay unchanged: in `json` mode, stdout +remains clean JSON (no ANSI, no spinner output), while progress/errors stay on +stderr. + ## Exit codes & error shape Stable, additive-only contract — a script can branch on these without parsing @@ -72,8 +82,8 @@ In `--format text` (default), errors print as `error: ` on stderr. Caveat: a *wrong* key (vs. an absent one) on an already-encrypted container isn't always distinguishable from generic storage corruption at this layer — -libSQL surfaces it as a late, generic error on first query rather than a -dedicated one. Only the "env var entirely unset" case reliably gets +the native engine surfaces it as a late, generic error on first access rather +than a dedicated one. Only the "env var entirely unset" case reliably gets `KEY_REQUIRED`/exit 3; a wrong key will usually fall through to the generic exit code 1. @@ -106,10 +116,10 @@ explicit consent in the SDKs). See ## Container lifecycle ```bash -basemyai init ./agent.bmai # create an encrypted .bmai container (migrations + metadata) +basemyai init ./agent.bmai # create an encrypted native .bmai container (metadata) basemyai inspect # container metadata + memory count basemyai verify # validate container: opens, expected format/engine/dim -basemyai migrate # apply pending schema migrations (idempotent) +basemyai migrate # idempotent open (native format applied at open time) basemyai stats # per-layer valid-memory counts for the resolved agent ``` @@ -151,19 +161,24 @@ basemyai graph add-edge shared-root points_to shared-leaf --weight 1.0 basemyai graph traverse shared-root --depth 3 ``` -Entities/relations scoped to the resolved agent; `traverse` is a recursive -SQL CTE (cycle-safe, depth-bounded). +Entities/relations scoped to the resolved agent; `traverse` is a depth-bounded +BFS on the native graph index (cycle-safe). -## Maintenance & consolidation +## Consolidation ```bash -basemyai maintenance gc # delete expired memories (valid_until passed) -basemyai maintenance forget-adaptive --capacity 5000 --half-life-secs 2592000 -basemyai consolidate # episodes -> facts + graph, via the best local LLM detected +basemyai consolidate # episodes -> facts + graph, via the best local LLM detected ``` -`consolidate` requires a local LLM backend (`basemyai llm detect` to -diagnose) — it is never a hard dependency of the rest of the CLI. +`consolidate` is a **root command** (not under `maintenance`). It requires a +local LLM backend (`basemyai llm detect` to diagnose) — it is never a hard +dependency of the rest of the CLI. + +> **Removed in ADR-033 (native-only).** The one-shot CLI commands +> `maintenance gc` and `maintenance forget-adaptive` no longer exist — they +> depended on libSQL-specific SQL windowing. GC temporel and adaptive forgetting +> remain API concepts in the SDK docs; a native port would need its own design. +> Use `invalidate`/`forget`/`purge` for explicit lifecycle management today. ## Shell completions @@ -186,17 +201,13 @@ basemyai --db ./agent.bmai --agent demo recall "UI preference" --hybrid | jq '.r ## What's not here yet -- No `gc --agent-id ` scoping (today `maintenance gc` runs across all - agents in the container). - No published binary release (`cargo-dist` or equivalent) — build from source today. -- `assert_cmd` integration tests exist (`crates/basemyai-cli/tests/cli.rs`, - `cargo test -p basemyai-cli`) for every command that doesn't need the - Candle embedder — `init`/`inspect`/`verify`/`migrate`/`list`/`forget`/ - `invalidate`/`purge`/`graph`/`maintenance gc`, plus the key/agent/ - confirmation/already-exists/not-configured error paths. **Not yet wired - into CI** (`.github/workflows/ci.yml` has no job building both `crypto` - and `embed` together, which the CLI's default features require) and - `remember`/`recall`/`stats`/`export`/`import`/`consolidate` are still - untested (they load the embedding model, unavailable offline in CI). See - `docs/archive/TODO-2026-06.md` M5. +- `assert_cmd` integration tests (`crates/basemyai-cli/tests/cli.rs`, + `cargo test -p basemyai-cli`, **wired in CI gate**) cover every command that + doesn't need the Candle embedder — `init`/`inspect`/`verify`/`migrate`/ + `list`/`forget`/`invalidate`/`purge`/`graph`, plus key/agent/confirmation/ + already-exists/not-configured paths and the explicit absence of + `maintenance *`. `remember`/`recall`/`stats`/`export`/`import`/ + `consolidate` are still untested in CI (they load the embedding model, + unavailable offline in CI). diff --git a/docs/format/bmai-v1.md b/docs/format/bmai-v1.md index 3951b24..f4d7384 100644 --- a/docs/format/bmai-v1.md +++ b/docs/format/bmai-v1.md @@ -1,78 +1,135 @@ -# `.bmai` Format V1 +# `.bmai` Format Specification -**Status**: Draft, V1 container contract -**Date**: 2026-06-18 +**Status**: Active (native engine, ADR-033) +**Date**: 2026-07-08 (rewritten for native-only; supersedes the 2026-06-18 libSQL draft) -`.bmai` is the public BaseMyAI memory database file format. In V1, a `.bmai` -file is implemented as an encrypted libSQL-compatible database with BaseMyAI -schema and metadata tables. The storage backend is an implementation detail: -applications should treat the file as a BaseMyAI memory database, not as a -general-purpose SQLite database. +`.bmai` is the public BaseMyAI agent memory database artifact. Since ADR-033, +it is implemented as an **encrypted native engine directory** (`basemyai-engine`), +not as a SQLite/libSQL file. Applications must open it through BaseMyAI APIs +(`Memory::open_native`, CLI, MCP, REST, bindings) — not as a generic database +file. -## Decision +## Product identity -V1 uses libSQL internally because it already provides the operational properties -BaseMyAI needs now: +- **Extension**: `.bmai` (unchanged from ADR-019). +- **Physical layout**: a **directory** named e.g. `agent.bmai/` containing the + engine's WAL, SST files, and `crypto.meta` (see [On-disk layout](#on-disk-layout)). +- **Logical identity**: `format=basemyai-memory`, `storage_engine=native`. +- **Encryption**: mandatory at the product layer (`basemyai`, ADR-007/ADR-030). + Production paths use `NativeMemoryStore::open_encrypted` / `BASEMYAI_DB_KEY`. + No CMake, no SQLCipher — XChaCha20-Poly1305 pure Rust (ADR-030). -- embedded local file; -- transactional writes; -- native vector search; -- FTS5-compatible keyword search; -- recursive SQL for graph traversal; -- optional encryption in `basemyai-core`, mandatory encryption in `basemyai`. +## Retired: libSQL V1 (`format_version=1`, `storage_engine=libsql`) -The `.bmai` extension is exposed immediately so SDKs, CLI tools and docs can -stabilize around a BaseMyAI-owned artifact. A future native backend can keep the -same public extension while changing the internal storage engine behind the -`StorageEngine` contract. +The first public release (`0.1.0`, 2026-06) used a **single encrypted libSQL +file** as `.bmai`. That implementation is **removed** from the active workspace +(ADR-033). There is no automatic in-place upgrade: -## Container Metadata +1. Open the old file with BaseMyAI `0.1.0` (or export while libSQL support still + exists in your checkout). +2. `Memory::export_jsonl` / `basemyai export` for each agent. +3. Create a new native `.bmai` directory and `import` / `Memory::import_jsonl`. -Every V1 file has a `bmai_meta` table: +Do not point BaseMyAI `0.2.0+` at a libSQL-era `.bmai` file and expect recovery. -```sql -CREATE TABLE IF NOT EXISTS bmai_meta ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL -); -``` +## Container metadata + +Metadata is stored as UTF-8 key/value pairs in the native KV store under the +prefix `meta/bmai/` (semantic equivalent of the historical `bmai_meta` SQL +table). Keys are seeded idempotently on every open (`ensure_container_meta`). -Required keys: +Required keys at creation: -| Key | V1 value | Meaning | +| Key | Current value | Meaning | |---|---|---| | `format` | `basemyai-memory` | Identifies the file as a BaseMyAI memory container. | -| `format_version` | `1` | Public `.bmai` container format version. | -| `storage_engine` | `libsql` | Internal V1 backend. | -| `schema_family` | `agent-memory` | Product schema family. | -| `embedding_dim` | `384` | Baseline embedding dimension. | +| `format_version` | `2` | Public container format version (`BMAI_FORMAT_VERSION`). | +| `storage_engine` | `native` | Active backend (`basemyai-engine`). | +| `schema_family` | `agent-memory` | Product schema family (unchanged). | +| `embedding_dim` | `384` | Baseline embedding dimension (`all-MiniLM-L6-v2`). | + +Optional keys written on first open with an embedder: + +| Key | Meaning | +|---|---| +| `embedding_model_id` | Baseline model id from provisioning. Reopening with a different model or dimension is rejected — use JSONL export/import to re-embed. | + +Read metadata via `NativeMemoryStore::container_metadata()`, CLI `inspect`, or +`verify`. + +## On-disk layout + +A production `.bmai` directory typically contains: + +| Path | Role | +|---|---| +| `crypto.meta` | DEK/KEK envelope (ADR-030). Presence means the store is encrypted. | +| `wal.log` | Write-ahead log (per-record envelopes when encrypted). | +| `*.sst` | Sorted string table files (whole-file envelopes when encrypted). | + +Index data (vector LM-DiskANN, graph, memory records, FTS postings) lives in +the engine KV space under versioned key layouts governed by +`crates/basemyai-engine/format.lock` — not in a separate SQL schema. + +Wire formats (`WalRecord`, `SstFile`, `CryptoMeta`, index record types) are +versioned in `format.lock`; CI fails if they drift without a deliberate bump. -`Memory::open` also writes `embedding_model_id` on first open with the supplied -embedder. Reopening a container with a different `model_id` or dimension is -rejected so callers do not silently query vectors generated by another model. -Use JSONL export/import to re-embed the memory with a new model. +## Engine boundary + +Applications depend on: + +- `basemyai::storage::MemoryStore` — semantic operations (`put_memory`, + `recall_vector`, `graph_traverse`, …). +- `basemyai::Memory` — embedding, temporal validity, agent isolation, hybrid + recall, consolidation. + +They must **not** depend on: + +- SQL, libSQL, or SQLite tools; +- raw KV key layouts (engine-internal, may evolve within `format.lock` bumps); +- direct `basemyai-engine` APIs unless building a fork of the storage layer. + +Zones intentionally outside the portable `MemoryStore` contract (documented in +ADR-020): JSONL porting (`memory/porting.rs`) and background maintenance tasks +beyond `ConsolidationTask` (GC / adaptive forgetting removed with libSQL, +ADR-033). + +## Compatibility rules + +- `format_version` and `storage_engine` must match what `basemyai verify` expects. +- Additive metadata keys under `meta/bmai/` are allowed within the same + `format_version`. +- Changing the meaning of an existing key or the on-disk engine layout requires + a new `format_version` and an ADR. +- Patch releases must read containers written by the same `format_version`. + +## Verification + +```bash +export BASEMYAI_DB_KEY='your-key' +basemyai verify --db ./agent.bmai +basemyai inspect --db ./agent.bmai +``` -The metadata is intentionally small. Detailed runtime state, provisioning -choices and migration history live in their dedicated tables. +`verify` checks `format`, `format_version`, and `storage_engine`. `inspect` +lists all `meta/bmai/*` entries plus aggregate memory counts. -## Compatibility Rules +## Non-goals -- Existing V1 files must remain readable across patch releases. -- Additive metadata keys are allowed. -- Changing the meaning of an existing key requires a new `format_version`. -- The V1 file may be opened by libSQL-compatible tools for diagnostics, but the - supported API boundary is BaseMyAI. -- SDKs and CLI should prefer `.bmai` in examples and generated paths. +This spec does **not** define: -## Non-Goals +- a stable raw KV API for third parties; +- cross-device sync (VISION §5.6 / TODO N6); +- multi-model embedding catalogs (V2); +- opening `.bmai` with off-the-shelf SQLite tools. -V1 does not define: +Those remain product or engine follow-ups, not part of the public container +contract. -- a custom page format; -- custom crash recovery; -- a native append-only engine; -- a custom vector index; -- cross-device sync; -- a stable SQL API for external applications. +## References -Those are backend concerns and remain behind the engine contract. +- ADR-019 — product framing and `.bmai` identity +- ADR-033 — native-only migration (retires libSQL V1) +- ADR-030 — encryption at rest +- `docs/adr/ADR-025-native-engine-storage-foundation.md` — LSM foundation +- `crates/basemyai/tests/format.rs` — metadata contract test diff --git a/docs/status.md b/docs/status.md index 3be576d..25feb6f 100644 --- a/docs/status.md +++ b/docs/status.md @@ -1,6 +1,6 @@ # BaseMyAI — Implementation Status Matrix -**Date : 2026-07-04** (dernière mise à jour : clôture N2 moteur natif) +**Date : 2026-07-08** (dernière mise à jour : migration natif-only ADR-033) **Statut : SOURCE DE VÉRITÉ.** Ce fichier réconcilie les contradictions entre les docs internes (TODO.md — archivé depuis sous `docs/archive/TODO-2026-06.md` —, CLAUDE.md, VISION.md, ADR-019, la recherche stratégique @@ -13,6 +13,14 @@ Steps Before Refactor », item 3) précisément parce que certaines docs disent dans une déclaration de doc. Quand une doc et le code divergent, le **code fait foi** et l'écart est noté. +**Mise à jour ADR-033 (2026-07-08)** : + +- workspace actif **100 % moteur natif** ; +- libSQL/V1, `Store`, `LibsqlMemoryStore`, feature `crypto` libSQL et + dual-backend supprimés ; +- `.bmai` actif = format natif ; +- chiffrement au repos = enveloppe native ADR-030. + **Légende statut :** - ✅ **Implemented** — code présent ET testé dans le repo. @@ -27,22 +35,23 @@ prêt prod ». La colonne Notes le précise systématiquement. --- -## 1. Core storage / engine (`basemyai-core`) +## 1. Core storage / engine (`basemyai-core` + `basemyai-engine`) | Domaine / Feature | Statut | Preuve (vérifiée) | Notes | |---|---|---|---| -| `Store` libSQL async (open, migrate, txn) | ✅ | `crates/basemyai-core/src/storage/store.rs` ; tests `tests/store.rs`, `tests/libsql_smoke.rs` | Backend ADR-011. Connexion partagée clonée (pas de pool — voir M6). | -| Recherche vectorielle native (`vector_knn`, cosine, `F32_BLOB`) | ✅ | `storage/store.rs`, `storage/vector.rs` ; `tests/store.rs` | In-DB, pas d'extension. Oversampling ×n pour métriques non-cosine. | -| `Filter` paramétré (fragment SQL + valeurs liées) | ✅ | `storage/vector.rs` (`Filter`, `Value`, `Neighbor`) | Anti-injection ADR-006. Confiné depuis ADR-020 à `basemyai::storage::LibsqlMemoryStore` — plus aucun consommateur de `basemyai` (memory/cognition) ne le manipule directement. | -| `StorageEngine` trait + `EngineCapabilities` | ✅ | `storage/engine.rs` (`StorageEngine`, `EngineCapabilities`, `EngineKind::Libsql`) ; `basemyai::storage::{MemoryStore, LibsqlMemoryStore}` (ADR-020) ; `tests/storage_contract.rs` | Contrat d'identité/capacités (core, inchangé) **+** contrat d'opérations mémoire (`put_memory`, `recall_vector`, `graph_upsert_entity`…) dans `basemyai` (ADR-020, suivi ADR-019). Tests de contrat pilotés par le trait. Reste hors périmètre : `memory/porting.rs` (export/import bas niveau) et `maintenance/{gc,forgetting}` (raison documentée dans ADR-020). | -| Chiffrement au repos (feature `crypto`) | ✅ | `storage/store.rs` (`is_encrypted`, `EncryptionKey`) ; job CI `crypto` | Optionnel au core, obligatoire dans `basemyai`. Exige CMake. | -| Key rotation (`PRAGMA rekey`) | ✅ | `storage/store.rs` (`Store::rotate_key`) ; `basemyai/src/memory/mod.rs` (`Memory::rotate_key`) ; `tests/store.rs`, `tests/key_rotation.rs` | Ajouté 2026-07-02. Rotation exige de rouvrir `Store`/`Memory` (le pool de lecteurs et `libsql::Database` figent la clé à l'ouverture — pas de rafraîchissement en place dans libsql 0.9.30). Bascule temporaire WAL→DELETE→rekey→WAL (SQLite3MultipleCiphers refuse `rekey` en WAL). | -| FTS5 / full-text (mécanisme) | ✅ | utilisé via `Store::connect()` dans `basemyai` ; schéma `memory_fts` | Le core expose la connexion ; le schéma FTS vit dans `basemyai`. | -| Migrations (`Migration`, `migrate`) | ✅ | `storage/store.rs` ; `tests/store.rs` | Versionnées, idempotentes. | +| Moteur natif LSM (`basemyai-engine`) | ✅ | `crates/basemyai-engine/` ; WAL+memtable+SST, `apply_batch`, `format.lock` ; `cargo xtask test-crash-consistency` | Fondation ADR-025. Backend **unique** depuis ADR-033 (2026-07-08). | +| `NativeEngine` + `StorageEngine` trait | ✅ | `basemyai-core/src/storage/{engine,native}.rs` ; `EngineKind::Native` | Capacités honnêtes (`vectors`, `full_text`, `recursive_queries`, `encrypted`). | +| Index vectoriel LM-DiskANN | ✅ | `basemyai-engine/src/idx/vector/` ; `tests/vector_recall.rs`, `vector_churn.rs` | Recall@10 = 1.0 (10k/100k, persistant, après churn). ADR-026. | +| Index graphe natif (BFS) | ✅ | `basemyai-engine/src/idx/graph/` ; `tests/graph_parity.rs` | Isolation agent structurelle dans le layout de clé. ADR-027/N4. | +| Index FTS/BM25 natif | ✅ | `basemyai-engine/src/idx/fts/` ; scénarios `memory_tests` | Tokenizer casefold+accents ; Porter différé (gap documenté). ADR-028. | +| `MemoryStore` / `NativeMemoryStore` | ✅ | `basemyai/src/storage/native_store.rs` ; `tests/memory_tests.rs`, `storage_contract.rs` | Contrat ADR-020 ; unique implémentation active. Clair + chiffré (`native_encrypted`). | +| Chiffrement au repos natif | ✅ | ADR-030 ; `crypto.meta`, enveloppes WAL/SST ; `NativeMemoryStore::rotate_key` | XChaCha20-Poly1305 pur Rust, **pas de CMake**. Rotation O(1) (re-scellement DEK). | +| Métrique vectorielle (`Metric` enum) | ✅ | `basemyai-core/src/storage/vector.rs` | Cosine/Euclidean/Hamming — mécanisme pur, sans SQL. | | `MaintenanceWorker` + tâches injectées | ✅ | `maintenance.rs` ; `tests/maintenance_worker.rs` (dans `basemyai`) | Mécanisme d'injection ; le sens (GC, oubli, consolidation) vit dans `basemyai`. | | Embedder trait (object-safe, sync) | ✅ | `embed/mod.rs` (`Embedder`, `Device`) ; `tests/embed.rs` | Ne télécharge jamais (invariant ADR-010). | -| Candle BERT (`CandleEmbedder`, `all-MiniLM-L6-v2`, 384d) | ✅ | `embed/candle.rs` (feature `embed`) ; job CI `embed` ; `tests/candle_stress.rs` ; `docs/benchmarks/m6-candle-stress-results-2026-07-01.md` | Stress 1h (3300s) exécuté sur machine cible le 2026-07-02 : `ok`, mémoire stable (min 61.2 MB / max 193.1 MB / moy. 88.6 MB sur 102 échantillons, pas de tendance de croissance), pas de fuite observée. Lourd (Candle). | +| Candle BERT (`CandleEmbedder`, `all-MiniLM-L6-v2`, 384d) | ✅ | `embed/candle.rs` (feature `embed`) ; job CI `embed` ; `docs/benchmarks/m6-candle-stress-results-2026-07-01.md` | Stress 1h stable (2026-07-02). Lourd (Candle) — job CI `embed` séparé. | | Agnosticité du core (zéro `agent_id`/`Symbol`/`Edge`) | ✅ | `tests/agnosticity.rs`, `tests/contracts.rs` | Invariant ADR-001 testé. | +| libSQL / `Store` / `Filter` SQL | ⛔ retiré | — | Supprimé du workspace actif (ADR-033). Référence historique : ADR-011 (superseded). | --- @@ -58,7 +67,7 @@ Toutes les méthodes listées dans `TODO.md` M0.1 sont implémentées **et dépa | `recall` (temporel, met à jour `last_access`) | ✅ | `memory/mod.rs:170` ; `tests/memory.rs` | Filtre agent + validité combiné. | | `recall_by_layer` | ✅ | `memory/mod.rs:419` | | | `recall_with_metric` (Cosine/Euclidean/Hamming) | ✅ | `memory/mod.rs:236` | **Non listé dans TODO.** | -| `recall_hybrid` (vecteur + BM25 fusionnés RRF) | ✅ | `memory/mod.rs:309` ; FTS5 `memory_fts` | ADR-014. La recherche stratégique le classait V1.5 — **déjà livré en V1**. | +| `recall_hybrid` (vecteur + BM25 fusionnés RRF) | ✅ | `memory/mod.rs` ; index FTS natif ADR-028 | ADR-014. Hybride vecteur + BM25 natif, fusion RRF. | | `invalidate` (soft-delete, `valid_until = now`) | ✅ | `memory/mod.rs:483` ; `tests/contracts.rs` | | | `forget` (suppression physique, RGPD) | ✅ | `memory/mod.rs:500` | memory + FTS atomique. | | `purge_agent` (purge totale agent) | ✅ | `memory/mod.rs:525` | **Non listé dans TODO.** memory+entity+edge+fts. | @@ -76,12 +85,12 @@ Toutes les méthodes listées dans `TODO.md` M0.1 sont implémentées **et dépa | Feature | Statut | Preuve (vérifiée) | Notes | |---|---|---|---| -| Graphe entités/relations (`add_entity`, `add_edge`, `traverse` CTE récursive) | ✅ | `cognition/graph.rs` ; `tests/graph.rs` | Scopé agent + profondeur, cycle-safe (UNION). Tables `entity`/`edge` (schéma V2/V6). | +| Graphe entités/relations (`add_entity`, `add_edge`, `traverse` BFS natif) | ✅ | `cognition/graph.rs` ; `tests/graph.rs` ; `basemyai-engine/src/idx/graph/` | Scopé agent + profondeur, cycle-safe. Index KV natif (plus de tables SQL). | | Consolidation épisodes→faits (`consolidate`) | ✅ | `cognition/consolidation.rs` ; `tests/consolidation.rs`, `tests/consolidation_e2e.rs` | Idempotente. `LlmInference` injecté. ADR-012/ADR-018. | | Trait `LlmInference` (object-safe, injecté) | ✅ | `cognition/inference.rs` | Modèle jamais codé en dur. | -| Oubli adaptatif (`AdaptiveForgetting`, décroissance hyperbolique) | ✅ | `maintenance/forgetting.rs` ; `tests/forgetting.rs` | `H/(H+age)` (libSQL n'a pas `exp`). | -| GC mémoires expirées (`ExpiredMemoryGc`) | ✅ | `maintenance/gc.rs` | ADR-005. | -| Wiring consolidation dans `MaintenanceWorker` | ✅ | `maintenance/mod.rs` (`ConsolidationTask`) ; `tests/maintenance_worker.rs` | **CLAUDE.md dit « reste ouvert » — c'est obsolète.** Le code et TODO M0.2 le marquent fait. Voir Contradictions. | +| Oubli adaptatif (`AdaptiveForgetting`) | ⛔ retiré CLI/worker | — | Dépendait de SQL fenêtré libSQL ; retiré ADR-033. Portage natif = chantier de suivi. | +| GC mémoires expirées (`ExpiredMemoryGc`) | ⛔ retiré CLI/worker | — | Idem — `invalidate`/`forget`/`purge` couvrent le cycle de vie explicite. | +| Wiring consolidation dans `MaintenanceWorker` | ✅ | `maintenance/mod.rs` (`ConsolidationTask`) ; `tests/maintenance_worker.rs` | Seule tâche de maintenance active post-ADR-033. | > **Note de positionnement.** La recherche stratégique 2026-06-18 (Risks) avertit > que « trop de LLM/consolidation en V1 peut détourner du noyau memory DB » et @@ -124,16 +133,16 @@ Toutes les méthodes listées dans `TODO.md` M0.1 sont implémentées **et dépa | Feature | Statut | Preuve (vérifiée) | Notes | |---|---|---|---| -| Crate `basemyai-cli` (clap) | ✅ | `crates/basemyai-cli/` (binaire `basemyai`) dans `Cargo.toml` members ; build + `clippy --workspace --all-targets -D warnings` verts (2026-06-20) | Features `embed`+`crypto` (défaut), miroir `basemyai-mcp`. Clé via `BASEMYAI_DB_KEY`. Référence complète : `docs/cli.md`. | +| Crate `basemyai-cli` (clap) | ✅ | `crates/basemyai-cli/` (binaire `basemyai`) ; `cargo xtask check` vert (2026-07-08) | Feature `embed` (défaut), backend natif unique (ADR-033). Clé via `BASEMYAI_DB_KEY`. Référence : `docs/cli.md`. | | Commandes V1 indispensables (`init`, `inspect`, `stats`, `recall`, `verify`, `migrate`) | ✅ | smoke test end-to-end : init→remember→recall(+`--hybrid`)→stats→inspect→verify ; isolation agent vérifiée ; mauvaise clé → refus | Couvre exactement les *indispensables V1* de la recherche stratégique. + `remember`. | | Cycle de vie mémoire complet (`list`, `forget`, `invalidate`, `purge --yes`, `export`, `import`) | ✅ | `commands/memory.rs` | `list`/`forget`/`invalidate`/`purge` passent par `basemyai::storage::MemoryStore` directement (pas de chargement Candle pour des mutations sans embedding). **Non listé dans `TODO.md` M5** — code plus avancé que le plan. | | Graphe (`graph add-entity`, `graph add-edge`, `graph traverse`) | ✅ | `commands/graph.rs` | Miroir CLI de `basemyai::Graph`. **Non listé dans `TODO.md` M5.** | -| Maintenance one-shot (`maintenance gc`, `maintenance forget-adaptive`) et `consolidate` | ✅ | `commands/maintenance.rs` | `gc` était listé comme restant — **fait**. `consolidate` exige un LLM local détecté (`llm detect`). **`maintenance gc` n'est pas scopé par agent** (tourne sur tout le conteneur) — pas de `--agent-id` comme envisagé dans `TODO.md`. | +| `consolidate` (commande racine) | ✅ | `commands/maintenance.rs` ; `cli.rs` (`Command::Consolidate`) | Exige un LLM local (`llm detect`). **`maintenance gc` / `maintenance forget-adaptive` retirés** (ADR-033). | | `config show/set/unset`, `completions` | ✅ | `commands/config.rs`, `persisted_config.rs` | Résolution `--db`/`--agent` : flag > env (`BASEMYAI_DB_PATH`/`BASEMYAI_AGENT`) > `~/.basemyai/config.toml` > erreur explicite. `--format json` sur toutes les commandes (agent-as-tool). | | `setup [--fetch]`, `status`, `llm detect`, `llm suggest` | ✅ | `commands/provision.rs` ; testé contre modèle provisionné + détection LLM locale | `setup` respecte le consentement explicite (ADR-010). Persistance via `provision.json`. | | Erreurs/exit codes stables (`error.rs`/`exit.rs`), JSON `{"error":{"code","message"}}` | ✅ | `error.rs`, `exit.rs`, `output.rs` | Voir `docs/cli.md` §Exit codes & error shape. | | Distribution binaire (cargo-dist) | 🟡 | — | Reste ouvert (M5). | -| Tests CLI automatisés | ✅ (partiel) | `tests/cli.rs` (`assert_cmd`, 12 tests) | Couvre les commandes sans embedder. Pas encore wiré en CI (aucun job `crypto`+`embed` combiné) ; `remember`/`recall`/`stats`/`export`/`import`/`consolidate` non couverts (nécessitent le modèle Candle). | +| Tests CLI automatisés | ✅ (partiel) | `tests/cli.rs` (`assert_cmd`, 12 tests) | Couvre les commandes sans embedder, câblé dans le gate CI (`cargo test -p basemyai-cli`). `remember`/`recall`/`stats`/`export`/`import`/`consolidate` hors CI (nécessitent Candle provisionné). | --- @@ -141,10 +150,10 @@ Toutes les méthodes listées dans `TODO.md` M0.1 sont implémentées **et dépa | Feature | Statut | Preuve (vérifiée) | Notes | |---|---|---|---| -| Table `bmai_meta` (format, version, engine, dim) | ✅ | `memory/schema.rs:15` (`BMAI_META_SCHEMA_V5`, migration v5) ; `tests/format.rs` (`schema_writes_bmai_container_metadata`) | `format=basemyai-memory`, `format_version=1`, `storage_engine=libsql`, `embedding_dim=384`. | -| `BMAI_FORMAT_VERSION` constante | ✅ | `memory/schema.rs:13` (`= 1`) | | -| Spec format documentée | ✅ | `docs/format/bmai-v1.md` ; ADR-019 | Conforme à la recherche stratégique (item 2). | -| Extension `.bmai` (vs magic header) | ✅ | conteneur libSQL chiffré + metadata | Choix assumé ADR-019 : pas de header custom en V1 (casserait l'ouverture SQLite). | +| Métadonnées conteneur (`bmai_meta` KV) | ✅ | `basemyai/src/storage/native_store.rs` ; `tests/format.rs` (`native_store_writes_bmai_container_metadata`) | `format=basemyai-memory`, `format_version=1`, `storage_engine=native`, `embedding_dim=384`. | +| `BMAI_FORMAT_VERSION` constante | ✅ | `basemyai::storage::BMAI_FORMAT_VERSION` | | +| Spec format documentée | ✅ | `docs/format/bmai-v1.md` ; ADR-019/ADR-033 | Spec native (répertoire moteur, `format_version=2`, `storage_engine=native`). | +| Extension `.bmai` | ✅ | répertoire moteur natif (WAL/SST/`crypto.meta`) | Conteneur natif chiffré ADR-030. **Pas de compatibilité** avec les `.bmai` V1/libSQL (export JSONL avant migration). | --- @@ -154,31 +163,24 @@ Toutes les méthodes listées dans `TODO.md` M0.1 sont implémentées **et dépa |---|---|---|---| | Isolation agent (adversarial) | ✅ | `tests/contracts.rs`, `tests/memory.rs` | Indispensable V1 couvert. | | Validité temporelle | ✅ | `tests/contracts.rs` (`validity_*`), `temporal.rs` | Horloge implicite `now_unix`. | -| Anti-injection SQL / isolation adversariale | ✅ | `Filter` paramétré + `AgentId` newtype ; `tests/contracts.rs` ; `tests/p1_isolation_adversarial.rs` | Le test adversarial dédié réclamé par la recherche stratégique (item 6) existe désormais : `agent_id` hostile façon `"agent-b' OR '1'='1"`, texte/requêtes FTS hostiles, ids connus d'un autre agent — vecteur, hybride BM25, invalidate/forget/traverse scopés vérifiés. Documenté publiquement dans `SECURITY.md` (commande `cargo test -p basemyai --features test-util --test p1_isolation_adversarial`). | -| Migration idempotente | ✅ | `tests/store.rs`, `tests/format.rs` | | +| Anti-injection / isolation adversariale | ✅ | `AgentId` newtype + isolation structurelle clés KV ; `tests/p1_isolation_adversarial.rs` | Test adversarial dédié : `agent_id` hostile, requêtes FTS hostiles, ids d'un autre agent — vecteur, hybride, invalidate/forget/traverse scopés. **Câblé dans le gate CI.** Voir `SECURITY.md`. | +| Migration idempotente | ⛔ N/A | — | Plus de migrations SQL ; format natif versionné via `format.lock`. | | Format / metadata | ✅ | `tests/format.rs` | | -| Encryption required (product-level) | ✅ | `tests/contracts.rs` | | -| Graphe / consolidation / oubli | ✅ | `tests/graph.rs`, `tests/consolidation*.rs`, `tests/forgetting.rs` | | +| Encryption required (product-level) | ✅ | `tests/contracts.rs` | Enveloppe native ADR-030. | +| Graphe / consolidation / oubli | ✅ | `tests/graph.rs`, `tests/consolidation*.rs`, `tests/maintenance_worker.rs` | GC/oubli adaptatif retirés (ADR-033) ; consolidation active. | +| Contrats `MemoryStore` | ✅ | `tests/memory_tests.rs` (19 scénarios), `tests/storage_contract.rs` | Runner déclaratif : `native` + `native_encrypted`. | | Contrats core | ✅ | `crates/basemyai-core/tests/contracts.rs` | | | Roundtrip bindings | ✅ | Node `__tests__/roundtrip.test.js`, Py `tests/test_roundtrip.py` | | -| CI multi-OS × features | ✅ | `.github/workflows/ci.yml` | CI actuelle : `gate` sur Ubuntu + Windows, `embed` sur Ubuntu, `crypto` sur Ubuntu + Windows. Tests par crate (évite OOM Windows et coût macOS). | -| Workflows release / prebuild | 🟡 | `release.yml`, `node-prebuilds.yml`, `python-wheels.yml`, `codeql.yml`, `supply-chain.yml` | Workflows présents. **Publication effective confirmée pour crates.io et PyPI** le 2026-06-22 ; **npm reste à re-vérifier** car le registre public ne résout pas `basemyai` depuis cette machine. | -| Bench KNN (10k/100k/1M), stress 1h | 🟡 | `crates/basemyai-core/benches/knn_scalability.rs`, `crates/basemyai-core/tests/candle_stress.rs`, `docs/benchmarks/m6-knn-results-2026-07-01.md`, `docs/benchmarks/m6-candle-stress-results-2026-07-01.md` | Stress 1h fait (✅, voir §1). KNN : 10k et 100k réels archivés le 2026-07-02 (latence quasi stable ~40-58ms entre les deux tailles, confirme un vrai ANN sous-linéaire côté requête) ; **1M non exécuté** — la construction de `libsql_vector_idx` est linéaire en coût absolu (~78-79 ms/ligne aux deux échelles mesurées), soit ~22h extrapolées pour 1M, jugé non réalisable en session. Caractéristique backend documentée, pas juste un aléa de bench. | +| CI multi-OS × features | ✅ | `.github/workflows/ci.yml` ; `cargo xtask ci` | Gate : Ubuntu + Windows (clippy + test). Jobs séparés : `embed`, `crash-consistency`. Plus de job `crypto` libSQL. | +| Workflows release / prebuild | 🟡 | `release.yml`, `node-prebuilds.yml`, `python-wheels.yml` | crates.io + PyPI publiés en `0.1.0` (2026-06-22). **Release 0.2.0 native-only à préparer** (breaking). npm toujours à vérifier. | +| Bench KNN natif (10k/100k) | ✅ | `docs/benchmarks/n3-vector-parity-2026-07-05.md`, `n5.5-memorystore-knn-bench-2026-07-07.md` ; `tests/native_memory_store_bench.rs` | N=10k mesuré (~17 ms `recall_vector` bout-en-bout). **100k+ hors scope** ; bench libSQL M6 archivé (référence historique). | -> **Items ouverts (repris de TODO.md racine, 2026-07-02)** — CI & Release, -> partiellement câblés mais non validés de bout en bout : +> **Items ouverts — CI & Release (2026-07-08)** : > -> - Ajouter `basemyai-cli` à la matrice GitHub Actions (absent de `ci.yml`). -> - Ajouter un job dédié pour le test `p1_isolation_adversarial` (isolation -> adversariale ADR-018). -> - Valider les workflows release sur un tag staging avec de vrais secrets : -> `release.yml` (gate crates.io présent, non prouvé sur tag live), -> `python-wheels.yml` (build/publish PyPI, non prouvé sur tag live), -> `node-prebuilds.yml` (prebuild/publish npm, non prouvé sur tag live). -> - Dry-run de publication vers staging avant toute annonce. -> - Cleanup optionnel : arrêter le conteneur Qdrant du bench -> (`docker compose -f benchmarks/p1-market/docker-compose.qdrant.yml down`) -> et décharger les modèles Ollama inutilisés (`ollama rm`). +> - Valider les workflows release sur un tag staging (`release.yml`, wheels, prebuilds npm). +> - Dry-run publication **0.2.0** (breaking : native-only). +> - Bench Mem0+Qdrant : harnais prêt, chiffres pas publiés. +> - (optionnel) Faire appeler `ci.yml` par `cargo xtask` — single source of truth (N0). --- @@ -192,17 +194,17 @@ Toutes les méthodes listées dans `TODO.md` M0.1 sont implémentées **et dépa --- -## 10. Backend natif / extensions futures +## 10. Extensions futures (post N5.6) | Feature | Statut | Preuve | Notes | |---|---|---|---| -| Moteur natif BaseMyAI (stockage/vecteur/graphe/langage maison) | 🟡 | `docs/adr/ADR-024-native-engine.md` ; `docs/adr/ADR-025-native-engine-storage-foundation.md` ; `docs/PLAN-NATIVE-ENGINE.md` ; `docs/TODO-NATIVE-ENGINE.md` ; `docs/benchmarks/n1-storage-engine-spike-2026-07-04.md` | Pari long terme **acté** (ADR-024, 2026-07-02) : remplace « migration Turso » — on construit notre propre moteur pur Rust plutôt que d'adopter celui d'un tiers. Strangler fig : libSQL reste le défaut jusqu'à parité prouvée. Chantier 0 (DX) ✅. Spike N1 (Couche 1) ✅ clos 2026-07-04 : LSM bat B-tree CoW (débit 4,1×, lecture 2,5×, amplification ×1,05 vs ×14,3, 10/10 crash-consistency) → fondation maison famille LSM actée (ADR-025), pas de fork `redb`/`fjall`. **N2 (Couche 1 : store durable) ✅ clos 2026-07-04** : `crates/basemyai-engine` (WAL+memtable+SST+recovery, batches atomiques multi-clés `apply_batch` — `WAL_RECORD_VERSION` 2), harnais crash-consistency 20 cycles kill réel en CI (0 corruption, atomicité batch prouvée sous kill), fuzzing 4 cibles (1 vrai panic capacity-overflow trouvé/corrigé dans `format/sst.rs`), `format.lock` anti-drift gaté CI, `EngineKind::Native` + wrapper `NativeEngine` capability-only dans `basemyai-core` (feature `engine-native`), runner déclaratif multi-backend `memory-tests` (5 scénarios, vert sur Libsql). **N3 (index vectoriel natif) ✅ clos 2026-07-05** : décision LM-DiskANN/Vamana actée sans spike (ADR-026, évidence gap-analysis/M6/N1 suffisante) ; `crates/basemyai-engine/src/idx/vector/` (node/distance/graph/meta/persistent) — recall@10 = 1.0 en RAM, persistant, et après churn insert/delete (tombstones + `consolidate()` FreshDiskANN, batch-atomique) ; bench de parité M6 (`docs/benchmarks/n3-vector-parity-2026-07-05.md`) tient les 3 seuils avec grande marge : requête 7,5 ms/12,7 ms (10k/100k) vs plafond ~48-49 ms libSQL, build incrémental réel 5,7/17,3 ms/ligne vs 78-79 ms/ligne libSQL (jamais fini en incrémental à 100k) ; crash harness étendu deletes/consolidate, 0 violation. **N4 (graphe natif) ✅ clos 2026-07-05** : `idx/graph/{entity,edge,traverse,ram,persistent}.rs` — nœud/arête = un enregistrement KV (`relation`/`dst` dans la clé pour scan préfixé par nœud à chaque saut BFS), isolation agent structurelle, `GraphEntity:1`/`GraphEdge:1` dans `format.lock` ; traversée = portage 1:1 de la CTE récursive SQL, les 5 scénarios de `tests/graph.rs` verts contre RAM et persistant (BFS partagé, zéro dérive) ; pas de méta/rebuild nécessaire (aucun état de navigation global) ; crash harness mode `graph` 20 cycles réels, 0 violation. **N5.1 (`NativeMemoryStore` hors FTS/crypto) ✅ clos 2026-07-05** (découpage N5 acté par ADR-027) : `idx/memory/` moteur (`MemoryRecord:1`/`MemoryVecMap:1`/`MemoryIndexMeta:1` dans `format.lock`, allocateur `vec_id` monotone auto-guérissant), `insert_with`/`delete_with` (un `remember` natif = UN enregistrement WAL : record + vecmap + compteur + nœud vectoriel + voisins + méta, atomicité transaction-libSQL retrouvée), `NativeMemoryStore` dans `basemyai` (feature `engine-native`, parité requête par requête, oversampling ×8 ADR-012, FTS et métriques non-cosinus en erreur franche) — **le diff multi-backend du runner N2 enfin prouvé** : `backend_suite!` vert sur Libsql ET Native, matrice xtask/CI étendue en miroir, capacités `NativeEngine` mises à jour honnêtement (`vectors`/`recursive_queries` true). **N5.2 (FTS/BM25 natif) ✅ clos 2026-07-06** (ADR-028) : `idx/fts/` moteur — index inversé (`FtsPosting:1`) + index direct (`FtsDocTerms:1`, nécessaire au delete précis + longueur de document sans dépendre d'`idx::memory`) + stats BM25 par agent healables (`FtsStats:1`), tokenizer casefold+pliage d'accents par table figée (racinisation Porter différée, gap documenté) ; `PersistentFts::stage_insert`/`stage_delete` composent dans le `Batch` de l'appelant (jamais leur propre `apply_batch`) — `PersistentMemoryIndex::put`/`forget`/`purge_agent` les fusionnent dans le même `extra` batch que vecteur+mémoire, un `remember` natif reste UN enregistrement WAL étendu au troisième index ; scoring Okapi `k1=1.2`/`b=0.75` (défauts FTS5), `df` dérivé du scan des postings (pas de compteur caché). `NativeMemoryStore::keyword_ranking_ids` branché (fin de l'erreur franche) — un bug de parité (filtre de validité temporelle absent) trouvé et corrigé pendant l'implémentation, avant tout commit. Deux scénarios de parité `backend_suite!` (classement par pertinence + validité temporelle/forget) rejoués contre Libsql ET Native, zéro divergence ; `EngineCapabilities::native().full_text` → `true` ; aucune extension xtask/CI nécessaire (le nouveau module vit sous des entrées déjà couvertes) ; `cargo xtask ci` vert (18 étapes) + crash-consistency re-exécuté (4 modes, 0 violation). **N5.3 (100 % des contrats sur Native) ✅ clos 2026-07-06** : les 12 scénarios `storage_contract.rs` restants (isolation multi-agent recall/hydrate/purge/exact-fact, batch atomique+vide, filtre de couche, expiration/pas-encore-valide, stats par couche, classement vecteur+mot-clé isolé, traversée graphe scopée agent, épisodes récents) portés dans le runner déclaratif (`memory_tests/scenarios.rs`, 19 scénarios au total) — le `Step`/`Scenario` du harnais gagne un champ `agent: Option<&'static str>` par étape (`None` = agent par défaut du scénario, `Some(id)` l'outrepasse) pour rejouer des séquences multi-agent dans un seul scénario, plus 6 nouvelles variantes (`RememberBatch`, `PurgeAgent`, `ExpectVectorRankingIds`, `ExpectHydrate`, `ExpectAgentStatsByLayer`, `ExpectRecentEpisodes`, `ExpectExactFactExists`). 100 % de la surface de `storage_contract.rs` (16/16 tests) désormais rejouée verbatim contre Libsql ET Native, zéro divergence (`cargo test -p basemyai --features test-util,engine-native --test memory_tests`) ; `contracts.rs` reste hors scope (teste `Memory`/`Store` libSQL directement, aucune variation par backend). `cargo xtask check` + `cargo xtask test` verts ; seul incident rencontré : le flake connu et pré-existant `basemyai-mcp --test server` (`isolation_between_agents`, `STATUS_ACCESS_VIOLATION`), re-passé vert isolément, sans rapport avec ce travail. **N5.4 (chiffrement au repos natif) ✅ clos 2026-07-06** (ADR-030) : AEAD XChaCha20-Poly1305 pur Rust (aucune feature gate — contrairement au `crypto` libSQL/CMake), enveloppe DEK/KEK — la clé utilisateur n'encrypte jamais la donnée, elle dérive une KEK (SHA-256 salée+domaine) qui scelle une DEK aléatoire dans `crypto.meta` ; WAL scellé par enregistrement (`WalEnvelope:1`, torn-tail préservé, un batch = une enveloppe), SST scellé fichier entier (`SstEnvelope:1`) — tous les index (vecteur/graphe/mémoire/FTS) transitent par WAL+SST donc sont couverts mécaniquement. Erreurs franches typées à l'ouverture (mauvaise clé via descellement de la DEK, clé absente, clé sur store en clair — pas de chiffrement a posteriori). `Engine::rotate_key` = re-scellement O(1) commit atomique un-fichier, **instance utilisable après rotation** (mieux que `PRAGMA rekey` : pas de réouverture) ; écart assumé documenté : la DEK ne change pas (ADR-030 §4, ré-encryption complète = chantier de suivi explicite). Surfaces : `EngineCapabilities::native(encrypted)` (dernière capacité `false` du backend natif levée), `NativeEngine::open_encrypted`, `NativeMemoryStore::{open_encrypted,rotate_key}`. 3 formats de plus dans `format.lock`. Vérifié : les 19 scénarios `backend_suite!` rejoués contre un 3e backend `native_encrypted` (zéro divergence), rotation roundtrip niveau `MemoryStore` (ancienne clé rejetée, données intactes, instance vivante), crash harness étendu d'un 5e mode `encrypted_batch` — 5 modes × 20 cycles kill réels, 0 violation. `cargo xtask check`/`test`/`test-crash-consistency` verts. **N5.5 (barre hardening M6) ✅ clos 2026-07-07** : `put_memory_batch` tout-ou-rien (`PersistentVectorIndex::insert_many_with` via un `OverlayProvider` qui planifie chaque insert du groupe contre l'état que les inserts précédents produiront + `PersistentFts::stage_insert_many` une seule mise à jour BM25 agrégée + `PersistentMemoryIndex::put_many` qui vérifie tous les doublons — contre le store et intra-lot — avant d'écrire quoi que ce soit, un seul enregistrement WAL pour tout le groupe ; résorbe l'écart initial d'ADR-027 §6, `put` devient un `put_many` à un item). Mode `memory` du harnais crash-consistency (schéma déterministe put/forget exerçant le triplet record+vecteur+FTS sous kill réel, plus variante chiffrée) : 20 cycles × 2 (clair/chiffré), 0 violation. Concurrence mesurée : cache de `PersistentVectorIndex` devenu interior-mutable (`Mutex`, `search`/`search_scored` passent à `&self`), `NativeMemoryStore` passe d'`Arc` à `Arc` — lectures pures sous verrou de lecture concurrent, chemins hybrides (`recall_vector`/`hydrate`) en deux passes (recherche en lecture, `touch` en écriture brève séparée), écritures restent sérialisées (`Engine` mono-écrivain sync inchangée) ; mesuré ~3× plus rapide en concurrent sur 64 lectures mixtes (`native_concurrent_reads_are_correct_and_faster_than_sequential`). Bench KNN via le chemin `MemoryStore` complet (`docs/benchmarks/n5.5-memorystore-knn-bench-2026-07-07.md`) : à N=10 000 `--release`, insert ~12.96 ms/`put_memory` (cohérent avec la fourchette N3 sur l'index nu, 5.7–17.3 ms/ligne) et `recall_vector` ~17.03 ms/requête contre ~48.98 ms pour le `vector_top_k` nu libSQL — ~2.9× plus rapide malgré strictement plus de travail par requête. `cargo xtask check`/`test`/`test-crash-consistency` verts. Reste N5.6 : ADR de bascule du défaut libSQL→Native (décision humaine séparée, jamais prise en passant) ; stress long à 100k+ resté hors scope de cette passe (item de suivi si nécessaire). | -| Multi-modèles d'embedding | ⏸️ | — | V2 (baseline unique en V1, compat `.idx`). | -| Sync multi-device | ⏸️ | — | V2 (VISION §7). | -| Mémoire partagée inter-agents | ⏸️ | — | V2 (ADR-006). | -| Key rotation (`PRAGMA rekey`) | ✅ | voir §1 (`storage/store.rs`) | Fait 2026-07-02. | -| Pool de connexions libSQL | ✅ | `storage/store.rs` (pool lecteurs round-robin + writer sérialisé, ADR-021) | Fait — cette ligne était périmée : `TODO.md` M6 le coche déjà avant cette révision. `:memory:` dégénère en taille 1. | -| Licence BUSL-1.1 unifiée + politique de marque | ✅ | `docs/adr/ADR-031-unified-busl-license.md` (remplace `docs/adr/ADR-029-license-split-and-trademark-policy.md`) ; `LICENSE` ; `TRADEMARK_POLICY.md` | Clos 2026-07-06 : décision initiale (ADR-029, open-core — `basemyai-engine` seul en BUSL, reste du workspace en MIT/Apache) révisée en ADR-031 après clarification que le risque à couvrir est le fork-produit-concurrent de `basemyai-core`/`basemyai` eux-mêmes, pas seulement du moteur natif. **Toute la surface publiable** (`basemyai-core`, `basemyai`, CLI, MCP, REST, `basemyai-engine`, bindings Python/Node) passe sous **un seul** BUSL-1.1 (conversion Apache-2.0 à 4 ans, `Cargo.toml` racine + `license.workspace = true` partout, un seul `LICENSE` consolidé, `crates/basemyai-engine/LICENSE` supprimé). Additional Use Grant à test fonctionnel (pas de clause anti-concurrence subjective) : libre pour dépendance/embarquement dans un produit tiers même commercial, usage interne, recherche, et usage intra-écosystème (ForgeMyAI) ; bloqué seulement pour republication/fork sous n'importe quel nom comme substitut de BaseMyAI/ForgeMyAI, ou hébergement SaaS sans contribution. 125 fichiers source uniformément tagués `SPDX-License-Identifier: BUSL-1.1` ; `cargo check --workspace` vert après coup. Le `0.1.0` déjà publié crates.io/PyPI le 2026-06-22 reste MIT pour toujours pour qui l'a déjà récupéré (limite structurelle du droit d'auteur, pas un oubli). Marque « BaseMyAI »/« ForgeMyAI » toujours couverte séparément par `TRADEMARK_POLICY.md`. DCO (`git commit -s`) dans `CONTRIBUTING.md`, désormais sur tout le workspace. Reste ouvert : dépôt formel de la marque (USPTO/INPI) et provisionnement réel de `licensing@basemyai.com` (contact temporaire : `security@basemyai.com`) — décisions/démarches humaines séparées. | +| Moteur natif BaseMyAI (N0→N5.6) | ✅ | `docs/TODO-NATIVE-ENGINE.md` (toutes cases cochées) ; ADR-024→033 | **Clos 2026-07-08** (ADR-033). Chantier complet : LSM, vecteur, graphe, FTS, chiffrement, hardening M6. Historique détaillé dans `TODO-NATIVE-ENGINE.md`. | +| Sync P2P (change-capture WAL) | 📋 | `TODO-NATIVE-ENGINE.md` §N6 | V2. Primitive WAL posée. | +| Langage de requête (Couche 4) | 📋 | ADR-024 §vision | Décision produit préalable. | +| Multi-modèles d'embedding | ⏸️ | — | V2 (baseline unique en V1). | +| Key rotation native (`rotate_key`) | ✅ | ADR-030 ; `NativeMemoryStore::rotate_key` | Re-scellement O(1). Ré-encryption complète DEK = chantier de suivi. | +| Bench KNN 100k+ (MemoryStore) | 🟡 | `native_memory_store_bench.rs` (`#[ignore]` N=10k) | Item de suivi post-N5.5. | +| Licence BUSL-1.1 unifiée | ✅ | ADR-031 | Tout le workspace. `0.1.0` crates.io/PyPI reste MIT pour les early adopters. | --- @@ -258,62 +260,29 @@ agent locale, pas une base vectorielle de plus », liés depuis `README.md` 2 transports, auth, audit, sampling) alors que `TODO.md` ne mentionne que REST en M4. Le plan documentaire n'a jamais intégré MCP. -6. **`StorageEngine` : suivi ADR-019 fait (ADR-020, 2026-06-20).** Le trait - d'opérations mémoire (`put_memory`/`recall_vector`/`graph_upsert_entity`/…) - décrit par la recherche existe désormais : `basemyai::storage::MemoryStore` - + `LibsqlMemoryStore`, avec tests de contrat - (`crates/basemyai/tests/storage_contract.rs`). `Filter`/`Value` ne fuient - plus dans `memory/mod.rs` ni `cognition/{graph,consolidation}.rs` — confinés - à `LibsqlMemoryStore`. Restent volontairement hors périmètre (raison - documentée ADR-020) : `memory/porting.rs` (export/import bas niveau) et - `maintenance/{gc,forgetting}` (signature `MaintenanceTask::run` du core - agnostique). Donc ✅ pour le gap documenté, avec deux zones résiduelles - connues et assumées. - -7. **« indispensables V1 » de la recherche stratégique — état réel.** - Présents et testés : `.bmai`, libSQL chiffré, `remember/recall/invalidate/forget/stats`, +6. **`MemoryStore` : ADR-020 + ADR-033.** Le trait d'opérations mémoire vit dans + `basemyai::storage::MemoryStore` ; **`NativeMemoryStore` est l'unique + implémentation** depuis ADR-033. Tests de contrat : `memory_tests.rs` + (19 scénarios, clair + chiffré). Zones hors trait assumées : + `memory/porting.rs` (export/import bas niveau). + +7. **« indispensables V1 » — état réel post-ADR-033.** + Présents et testés : `.bmai` natif chiffré, `remember/recall/invalidate/forget/stats`, couches, `valid_from/until`, isolation `agent_id`, embedding explicite, - MCP **et** REST. **Mis à jour 2026-06-20 :** le **CLI** et le **test - d'injection SQL adversarial dédié** — les deux derniers manquants listés - ici — sont désormais livrés (CLI §6, test §8/§11). Reste un écart - « indispensable V1 » documenté vs code réel : aucun, sur ce point précis. + MCP **et** REST, CLI, test d'isolation adversarial en CI. Pas de compatibilité + `.bmai` V1/libSQL — export JSONL avant upgrade. --- ## Synthèse exécutive -- **Moteur mémoire (core + Phase 1 + Phase 2) : ✅ implémenté et testé.** Plus - avancé que ce que CLAUDE.md et TODO.md laissent croire. -- **Surfaces d'intégration : largement en place** (MCP ✅, REST ✅, bindings Node - 🟡, Python ✅). **Publication confirmée pour crates.io et PyPI** ; **npm reste - à re-vérifier** pour `basemyai` depuis cette machine. -- **CLI (M5) : surface complète livrée** (2026-06-20) — `basemyai-cli` couvre - les indispensables V1 (`init/inspect/stats/recall/verify/migrate`) **et** - le cycle de vie complet (`list/forget/invalidate/purge/export/import`), le - graphe (`graph add-entity/add-edge/traverse`), la maintenance - (`maintenance gc/forget-adaptive`, `consolidate`), `config`, `completions`. - Référence : `docs/cli.md`. Restent : distribution binaire (cargo-dist), - tests CLI automatisés en CI (`TODO.md` M5 sous-documente cette surface — - à mettre à jour). -- **Publication : crates.io et PyPI confirmés** le 2026-06-22 (`basemyai`, - `basemyai-core`, `basemyai 0.1.0` sur PyPI). **Le point résiduel est npm** : - le README et les workflows visent `basemyai`, mais le registre public renvoie - encore `404` depuis cette machine. -- **`StorageEngine` : ✅ fait (ADR-020, 2026-06-20)** — `basemyai::storage::MemoryStore` - cache désormais `Filter`/SQL derrière un trait d'opérations mémoire, avec - tests de contrat. Zones résiduelles assumées : `memory/porting.rs`, - `maintenance/{gc,forgetting}`. -- **Preuves publiques P1 : ✅ ajoutées** (§11) — positionnement « pas une - vector DB », zéro réseau après setup, test d'isolation adversarial, démos - de remplacement temporel (Rust/Node/Python). Benchmark concurrentiel vs - Mem0+Qdrant : harnais prêt, **chiffres pas encore publiés**. -- **Studio, Tauri, backend natif, Turso, sync, multi-modèles : ⏸️ correctement - reportés** (V1.5 / V2), pas de dette cachée. -- **Hardening (M6) : 🟡 en cours, avancée majeure le 2026-07-02.** Pool lecteur - libSQL (ADR-021) ✅, key rotation (`PRAGMA rekey`) ✅, stress Candle 1h ✅ - (stable, pas de fuite), bench KNN 10k/100k ✅ (avec un vrai finding : coût de - build de l'index natif ~78-79 ms/ligne, quasi-linéaire — 1M jugé irréaliste - en session et documenté comme tel plutôt qu'exécuté). Live subscriptions - vague 2 (ADR-022) : REST SSE ✅, MCP notifications ✅, PyO3 ✅, NAPI reporté. - Restent : CUDA/NVML réel, résultats KNN 1M (nécessite une machine dédiée sur - plusieurs heures), NAPI live subscriptions. +- **Moteur natif (N0→N5.6 + ADR-033) : ✅ clos.** Backend unique `basemyai-engine` + dans tout le workspace. libSQL/V1 retirés. `cargo xtask ci` vert. +- **Mémoire (Phase 1 + 2) : ✅** — remember/recall/hybrid/graphe/consolidation/oubli, + toutes surfaces (MCP, REST, CLI, bindings). +- **Publication : `0.1.0` sur crates.io + PyPI** (2026-06-22, encore libSQL). + **Prochaine étape : release `0.2.0` native-only** (breaking). npm à vérifier. +- **CLI : ✅ surface complète** ; tests partiels en CI ; **cargo-dist** pas encore fait. +- **Reste ouvert (priorité)** : release 0.2.0, bench Mem0+Qdrant, NAPI live + subscriptions, CUDA/NVML, image Docker REST. +- **V2 reporté** : Studio, Tauri, sync P2P, multi-modèles, langage de requête. diff --git a/docs/strategy/2026-06-18-agent-memory-database-research.md b/docs/strategy/2026-06-18-agent-memory-database-research.md index f50b553..6c1b136 100644 --- a/docs/strategy/2026-06-18-agent-memory-database-research.md +++ b/docs/strategy/2026-06-18-agent-memory-database-research.md @@ -123,7 +123,7 @@ Donner à un agent une mémoire persistante, temporelle et privée dans un seul Veut `pip install basemyai` / `npm install basemyai`, `remember`, `recall`, zéro infra. 2. **Développeur de coding agents / MCP tools** - Veut une mémoire cross-session locale pour Claude Code, Cursor, Codex, ForgeMyAI, outils internes. + Veut une mémoire cross-session locale pour Claude Code, Cursor, Codex, outils internes. 3. **Équipe privacy/compliance** Ne peut pas envoyer mémoire utilisateur, conversations, préférences ou embeddings vers un service tiers. diff --git a/xtask/src/main.rs b/xtask/src/main.rs index f789ca4..d3289bc 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -5,27 +5,15 @@ //! interdit `--features` à la racine). Ce binaire encode cette matrice pour //! qu'un run local vert implique une CI verte. //! -//! Usage : `cargo xtask `. +//! Usage : `cargo xtask `. //! //! Toute évolution de `ci.yml` doit être répercutée ici (et inversement). use std::process::{Command, exit}; /// Matrice clippy du job `gate` (ci.yml, étape « Clippy (-D warnings) »). -/// NB : `basemyai-cli` n'est pas dans le gate CI — fidélité stricte à ci.yml. const CLIPPY: &[&[&str]] = &[ &["clippy", "-p", "basemyai-core", "--all-targets", "--", "-D", "warnings"], - &[ - "clippy", - "-p", - "basemyai-core", - "--features", - "engine-native", - "--all-targets", - "--", - "-D", - "warnings", - ], &[ "clippy", "-p", @@ -46,17 +34,6 @@ const CLIPPY: &[&[&str]] = &[ "-D", "warnings", ], - &[ - "clippy", - "-p", - "basemyai", - "--features", - "test-util,engine-native", - "--all-targets", - "--", - "-D", - "warnings", - ], &[ "clippy", "-p", @@ -81,6 +58,7 @@ const CLIPPY: &[&[&str]] = &[ "-D", "warnings", ], + &["clippy", "-p", "basemyai-cli", "--all-targets", "--", "-D", "warnings"], &[ "clippy", "-p", @@ -107,10 +85,9 @@ const CLIPPY: &[&[&str]] = &[ ], ]; -/// Matrice de tests du job `gate` (config légère : ni Candle ni CMake). +/// Matrice de tests du job `gate` (config légère : ni Candle ni tests `#[ignore]`). const TEST: &[&[&str]] = &[ &["test", "-p", "basemyai-core"], - &["test", "-p", "basemyai-core", "--features", "engine-native"], // `--lib --bins --test basic --test vector_recall --test vector_persistence // --test vector_churn --test graph_parity` : les tests unitaires du // moteur natif + le harnais recall de l'index vectoriel (N3, oracle @@ -141,18 +118,27 @@ const TEST: &[&[&str]] = &[ "graph_parity", ], &["test", "-p", "basemyai", "--features", "test-util"], - // `--test memory_tests` : le runner déclaratif multi-backend (N2) avec le - // backend Native branché (N5.1, ADR-027) — mêmes scénarios rejoués contre - // Libsql ET le moteur natif, zéro divergence tolérée. + // `--test memory_tests` : runner déclaratif du contrat MemoryStore sur le + // backend natif (clair + chiffré), zéro divergence tolérée. &[ "test", "-p", "basemyai", "--features", - "test-util,engine-native", + "test-util", "--test", "memory_tests", ], + // Isolation adversariale P1 (ADR-006) — agent_id hostile, FTS hostile, etc. + &[ + "test", + "-p", + "basemyai", + "--features", + "test-util", + "--test", + "p1_isolation_adversarial", + ], // `--test native_memory_store_bench` : KNN via le chemin `MemoryStore` // complet (N5.5) — la variante N=2000 seule tourne ici (rapide) ; la // variante N=10000 reste `#[ignore]`, run manuel (même convention que @@ -162,7 +148,7 @@ const TEST: &[&[&str]] = &[ "-p", "basemyai", "--features", - "test-util,engine-native", + "test-util", "--test", "native_memory_store_bench", ], @@ -182,6 +168,7 @@ const TEST: &[&[&str]] = &[ "--features", "test-util", ], + &["test", "-p", "basemyai-cli"], ]; /// Job `embed` (compile Candle — lourd ; les tests réels sont `#[ignore]`). @@ -190,12 +177,6 @@ const TEST_EMBED: &[&[&str]] = &[ &["test", "-p", "basemyai", "--features", "embed"], ]; -/// Job `crypto` (chiffrement libSQL — EXIGE CMake installé). -const TEST_CRYPTO: &[&[&str]] = &[ - &["test", "-p", "basemyai-core", "--features", "crypto"], - &["test", "-p", "basemyai", "--features", "crypto"], -]; - /// `format.lock` anti-drift check (ADR-025, `docs/PLAN-NATIVE-ENGINE.md` /// §3.1/§4) : chaque type persisté de `basemyai-engine` (WAL, SST) est /// versionné et son hash de format doit matcher `format.lock`, sinon échec. @@ -229,13 +210,6 @@ fn main() { } "test" => run_all(TEST), "test-embed" => run_all(TEST_EMBED), - "test-crypto" => { - eprintln!( - "⚠ la feature `crypto` compile libsql avec chiffrement : CMake doit être \ - installé (et `cp` dans le PATH sous Windows — Git usr/bin le fournit)." - ); - run_all(TEST_CRYPTO); - } "format-lock" => run_all(FORMAT_LOCK), "test-crash-consistency" => run_all(TEST_CRASH_CONSISTENCY), "ci" => { @@ -244,9 +218,9 @@ fn main() { run_all(TEST); run_all(FORMAT_LOCK); println!( - "\nGate CI léger vert. Les jobs `embed` et `crypto` sont séparés en CI :\n\ - lance `cargo xtask test-embed` (Candle, lourd) et `cargo xtask test-crypto` \ - (CMake requis) pour la matrice complète." + "\nGate CI léger vert. Le job `embed` reste séparé en CI :\n\ + lance `cargo xtask test-embed` (Candle, compilation lourde) \ + pour couvrir la matrice complète." ); } "" | "help" | "-h" | "--help" => usage(0), @@ -263,12 +237,11 @@ fn usage(code: i32) -> ! { USAGE : cargo xtask \n\n\ SOUS-COMMANDES :\n\ \x20 check fmt --check + clippy par crate, features CI, -D warnings + format.lock\n\ - \x20 test tests par crate, config légère (sans embed/crypto)\n\ + \x20 test tests par crate, config légère (sans embed)\n\ \x20 test-embed tests du job CI `embed` (Candle — compilation lourde)\n\ - \x20 test-crypto tests du job CI `crypto` (chiffrement libSQL — CMake requis)\n\ \x20 format-lock vérifie basemyai-engine/format.lock contre les specs de format actuelles\n\ \x20 test-crash-consistency kill/reopen/verify en boucle sur basemyai-engine (~20 cycles)\n\ - \x20 ci check + test (embed/crypto/crash-consistency restent des jobs séparés)\n\ + \x20 ci check + test (embed/crash-consistency restent des jobs séparés)\n\ \x20 help affiche cette aide\n\n\ NB : `cargo clippy --workspace` ne reproduit PAS la CI (features par crate)." ); From 0a16aa27c42df8481dee14c035ec0358ce78f6fe Mon Sep 17 00:00:00 2001 From: MikeRoss27 Date: Wed, 8 Jul 2026 08:59:37 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix(ci):=20unblock=20PR=20#49=20=E2=80=94?= =?UTF-8?q?=20BUSL-1.1=20in=20deny.toml,=20rmcp=20deprecations=20+=20E0515?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow BUSL-1.1 workspace crates in cargo-deny. Fix client_supports_sampling lifetime (E0515) and allow deprecated rmcp sampling/logging APIs until SEP-2577 replacements land. Co-authored-by: Cursor --- crates/basemyai-mcp/src/sampling.rs | 1 + crates/basemyai-mcp/src/server.rs | 5 +++-- deny.toml | 1 + 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/basemyai-mcp/src/sampling.rs b/crates/basemyai-mcp/src/sampling.rs index a3b8511..f48a71d 100644 --- a/crates/basemyai-mcp/src/sampling.rs +++ b/crates/basemyai-mcp/src/sampling.rs @@ -79,6 +79,7 @@ impl SamplingBackend { #[async_trait::async_trait] impl LlmInference for SamplingBackend { + #[allow(deprecated)] // SEP-2577 : `create_message` déprécié ; conservé tant qu'aucun remplaçant rmcp n'existe (ADR-017). async fn complete(&self, prompt: &str) -> Result { let messages = vec![SamplingMessage::new(Role::User, SamplingMessageContent::text(prompt))]; diff --git a/crates/basemyai-mcp/src/server.rs b/crates/basemyai-mcp/src/server.rs index b0db7cd..c52e72e 100644 --- a/crates/basemyai-mcp/src/server.rs +++ b/crates/basemyai-mcp/src/server.rs @@ -307,8 +307,8 @@ async fn relay_memory_events(mem: Arc, agent_id: String, layer: Option) -> bool { peer.peer_info() - .and_then(|info| info.capabilities.sampling.as_ref()) - .is_some() + .map(|info| info.capabilities.sampling.is_some()) + .unwrap_or(false) } /// Définition des outils MCP. La macro génère `Self::tool_router()` et le JSON @@ -516,6 +516,7 @@ const LAYERS_DOC: &str = "\ /// méthodes absentes). #[tool_handler(router = self.tool_router)] impl ServerHandler for McpServer { + #[allow(deprecated)] // SEP-2577 : `enable_logging` déprécié ; requis pour notifications/message (ADR-022). fn get_info(&self) -> ServerInfo { ServerInfo::new( ServerCapabilities::builder() diff --git a/deny.toml b/deny.toml index a1122c6..1094d3f 100644 --- a/deny.toml +++ b/deny.toml @@ -9,6 +9,7 @@ allow = [ "Apache-2.0 WITH LLVM-exception", "BSD-2-Clause", "BSD-3-Clause", + "BUSL-1.1", "BSL-1.0", "CDLA-Permissive-2.0", "ISC",