mirror of
https://github.com/samvallad33/vestige.git
synced 2026-06-22 21:28:08 +02:00
Introduce two trait boundaries that the rest of the stack now sits above,
landing Phase 1 of ADR 0001 (pluggable storage and network access).
Rebased onto v2.1.22 Sanhedrin from the original April work.
MemoryStore / LocalMemoryStore (crates/vestige-core/src/storage/memory_store.rs):
One trait, ~25 methods, covering CRUD, hybrid / FTS / vector search,
FSRS scheduling, graph edges, and the forthcoming domain surface.
trait_variant::make generates a Send-bound MemoryStore alias over the
base LocalMemoryStore so Arc<dyn MemoryStore> works under tokio/axum.
Storage errors map through a dedicated MemoryStoreError.
Embedder / LocalEmbedder (crates/vestige-core/src/embedder/):
Pluggable text-to-vector encoder. FastembedEmbedder wraps the existing
EmbeddingService; storage never calls fastembed directly anymore.
Embedder::signature() produces the ModelSignature consumed by the
store's embedding_model registry.
SqliteMemoryStore (crates/vestige-core/src/storage/sqlite.rs):
Storage renamed to SqliteMemoryStore; the old name lives on as a
pub type alias so Arc<Storage> consumers in vestige-mcp stay intact.
All existing inherent methods are untouched; the trait impl is
purely additive and dispatches into them. The db_path field added
by v2.1.1 portable-sync is preserved.
Migration V14 (crates/vestige-core/src/storage/migrations.rs):
Renumbered from V12 (the original April number) to V14 to slot in
cleanly after upstream's V12 (v2.1.1 sync_tombstones) and V13
(v2.1.2 purge tombstones).
- embedding_model registry table (CHECK id = 1, code enforces the
single-row invariant).
- knowledge_nodes.domains / domain_scores TEXT columns (JSON arrays
default '[]' / '{}'), domains catalogue table, supporting indexes.
Phase 4 populates these columns; Phase 1 just exposes the schema.
Consolidation and other cognitive pathways now accept a
&dyn LocalMemoryStore (sync) or Arc<dyn MemoryStore> (async) rather
than a concrete Storage.
Tests:
- trait-method unit tests colocated in sqlite.rs and migrations.rs
- embedder/fastembed.rs tests for name/dimension/hash stability
- new integration crate tests/phase_1 (added to workspace members):
trait_round_trip (8), embedding_model_registry (7),
domain_column_migration (5), cognitive_module_isolation (4),
send_bound_variant (2), embedder_trait (2).
Acceptance gate post-rebase:
- cargo build --workspace --all-targets: ok
- cargo clippy --workspace --all-targets -- -D warnings: clean
- cargo test -p vestige-core --lib: 428 pass
- cargo test -p vestige-phase-1-tests: 28 pass
- cargo test -p vestige-mcp --lib: 380 pass (Storage alias preserves
every existing call site)
Co-existence with v2.1.1 portable-sync: this trait extraction is
additive. Portable-sync's tombstone migrations (V12, V13) remain
on the concrete SqliteMemoryStore; Phase 2 (Postgres) will decide
which of those surfaces graduate into the trait.
217 lines
6.6 KiB
Rust
217 lines
6.6 KiB
Rust
//! Phase 1 integration tests: round-trip of every trait method through SqliteMemoryStore.
|
|
|
|
use chrono::Utc;
|
|
use std::sync::Arc;
|
|
use tempfile::tempdir;
|
|
use uuid::Uuid;
|
|
use vestige_core::storage::{
|
|
MemoryEdge, MemoryRecord, MemoryStore, SearchQuery, SqliteMemoryStore,
|
|
};
|
|
|
|
fn make_store() -> Arc<dyn MemoryStore> {
|
|
let dir = tempdir().unwrap();
|
|
let db = dir.path().join("test.db");
|
|
// keep the dir alive by leaking it -- this is fine for tests
|
|
std::mem::forget(dir);
|
|
let store = SqliteMemoryStore::new(Some(db)).expect("create store");
|
|
Arc::new(store)
|
|
}
|
|
|
|
fn make_record(content: &str) -> MemoryRecord {
|
|
MemoryRecord {
|
|
id: Uuid::new_v4(),
|
|
domains: vec![],
|
|
domain_scores: Default::default(),
|
|
content: content.to_string(),
|
|
node_type: "fact".to_string(),
|
|
tags: vec!["integration".to_string()],
|
|
embedding: None,
|
|
created_at: Utc::now(),
|
|
updated_at: Utc::now(),
|
|
metadata: serde_json::json!({}),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn insert_get_update_delete() {
|
|
let store = make_store();
|
|
let rec = make_record("round-trip CRUD test");
|
|
let id = rec.id;
|
|
|
|
store.insert(&rec).await.expect("insert");
|
|
let got = store.get(id).await.expect("get").expect("exists");
|
|
assert_eq!(got.content, "round-trip CRUD test");
|
|
assert_eq!(got.node_type, "fact");
|
|
assert!(got.domains.is_empty());
|
|
assert!(got.domain_scores.is_empty());
|
|
|
|
let mut updated = got;
|
|
updated.content = "updated content".to_string();
|
|
store.update(&updated).await.expect("update");
|
|
|
|
let after_update = store
|
|
.get(id)
|
|
.await
|
|
.expect("get after update")
|
|
.expect("exists");
|
|
assert_eq!(after_update.content, "updated content");
|
|
|
|
store.delete(id).await.expect("delete");
|
|
let after_delete = store.get(id).await.expect("get after delete");
|
|
assert!(after_delete.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn scheduling_upsert_and_due_scan() {
|
|
use vestige_core::storage::SchedulingState;
|
|
let store = make_store();
|
|
|
|
for i in 0..3usize {
|
|
let rec = make_record(&format!("sched memory {i}"));
|
|
let id = rec.id;
|
|
store.insert(&rec).await.expect("insert");
|
|
let next_review = Utc::now() - chrono::Duration::days((i as i64) + 1);
|
|
let state = SchedulingState {
|
|
memory_id: id,
|
|
stability: 1.0,
|
|
difficulty: 0.3,
|
|
retrievability: 0.7,
|
|
last_review: Some(Utc::now()),
|
|
next_review: Some(next_review),
|
|
reps: 1,
|
|
lapses: 0,
|
|
};
|
|
store
|
|
.update_scheduling(&state)
|
|
.await
|
|
.expect("update scheduling");
|
|
}
|
|
|
|
let due = store
|
|
.get_due_memories(Utc::now(), 10)
|
|
.await
|
|
.expect("get_due_memories");
|
|
assert_eq!(due.len(), 3, "all 3 should be due");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn edge_crud() {
|
|
let store = make_store();
|
|
let rec_a = make_record("edge node A");
|
|
let rec_b = make_record("edge node B");
|
|
let id_a = rec_a.id;
|
|
let id_b = rec_b.id;
|
|
store.insert(&rec_a).await.expect("insert a");
|
|
store.insert(&rec_b).await.expect("insert b");
|
|
|
|
let edge = MemoryEdge {
|
|
source_id: id_a,
|
|
target_id: id_b,
|
|
edge_type: "semantic".to_string(),
|
|
weight: 0.85,
|
|
created_at: Utc::now(),
|
|
};
|
|
store.add_edge(&edge).await.expect("add edge");
|
|
|
|
let edges = store.get_edges(id_a, None).await.expect("get edges");
|
|
assert!(!edges.is_empty());
|
|
|
|
store.remove_edge(id_a, id_b).await.expect("remove edge");
|
|
let after = store.get_edges(id_a, None).await.expect("get edges after");
|
|
assert!(after.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn count_and_stats_track_inserts() {
|
|
let store = make_store();
|
|
for i in 0..10usize {
|
|
let rec = make_record(&format!("stats memory {i}"));
|
|
store.insert(&rec).await.expect("insert");
|
|
}
|
|
assert_eq!(store.count().await.expect("count"), 10);
|
|
let stats = store.get_stats().await.expect("stats");
|
|
assert_eq!(stats.total_memories, 10);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn vacuum_after_deletes_reclaims() {
|
|
let dir = tempdir().unwrap();
|
|
let db = dir.path().join("vacuum_test.db");
|
|
let store = SqliteMemoryStore::new(Some(db)).expect("create store");
|
|
let store: Arc<dyn MemoryStore> = Arc::new(store);
|
|
|
|
let mut ids = Vec::new();
|
|
for i in 0..50usize {
|
|
let rec = make_record(&format!("vacuum memory {i}"));
|
|
let id = store.insert(&rec).await.expect("insert");
|
|
ids.push(id);
|
|
}
|
|
for id in &ids[..40] {
|
|
store.delete(*id).await.expect("delete");
|
|
}
|
|
// vacuum should not error
|
|
store.vacuum().await.expect("vacuum");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_domains_empty_then_upsert_then_delete() {
|
|
use vestige_core::storage::Domain;
|
|
let store = make_store();
|
|
|
|
let domains = store.list_domains().await.expect("list empty");
|
|
assert!(domains.is_empty());
|
|
|
|
let d = Domain {
|
|
id: "test-domain".to_string(),
|
|
label: "Test Domain".to_string(),
|
|
centroid: vec![0.1f32, 0.2, 0.3],
|
|
top_terms: vec!["term1".to_string()],
|
|
memory_count: 5,
|
|
created_at: Utc::now(),
|
|
};
|
|
store.upsert_domain(&d).await.expect("upsert domain");
|
|
let after = store.list_domains().await.expect("list after upsert");
|
|
assert_eq!(after.len(), 1);
|
|
assert_eq!(after[0].id, "test-domain");
|
|
|
|
store
|
|
.delete_domain("test-domain")
|
|
.await
|
|
.expect("delete domain");
|
|
let after_delete = store.list_domains().await.expect("list after delete");
|
|
assert!(after_delete.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn classify_with_no_domains_returns_empty() {
|
|
let store = make_store();
|
|
let result = store.classify(&[0.1f32, 0.2, 0.3]).await.expect("classify");
|
|
assert!(result.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn search_hybrid_returns_results() {
|
|
let store = make_store();
|
|
let rec = make_record("quantum entanglement superposition physics");
|
|
store.insert(&rec).await.expect("insert");
|
|
|
|
// Verify fts_search works first (sanity check)
|
|
let fts_results = store.fts_search("quantum", 10).await.expect("fts_search");
|
|
assert!(
|
|
!fts_results.is_empty(),
|
|
"fts_search must find 'quantum' after insert"
|
|
);
|
|
|
|
let query = SearchQuery {
|
|
text: Some("quantum physics".to_string()),
|
|
limit: 10,
|
|
..Default::default()
|
|
};
|
|
let results = store.search(&query).await.expect("search");
|
|
// FTS results should include our inserted record
|
|
assert!(
|
|
!results.is_empty(),
|
|
"search must return results for 'quantum physics'"
|
|
);
|
|
assert!(results[0].score >= 0.0);
|
|
}
|