mirror of
https://github.com/samvallad33/vestige.git
synced 2026-06-24 21:38:07 +02:00
feat(storage): phase 1 -- extract MemoryStore and Embedder traits (ADR 0001)
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.
This commit is contained in:
parent
01fa882760
commit
5715f585fd
17 changed files with 3282 additions and 44 deletions
38
tests/phase_1/Cargo.toml
Normal file
38
tests/phase_1/Cargo.toml
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
[package]
|
||||
name = "vestige-phase-1-tests"
|
||||
version = "0.0.1"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
vestige-core = { path = "../../crates/vestige-core" }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||
tempfile = "3"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
chrono = "0.4"
|
||||
serde_json = "1"
|
||||
rusqlite = { version = "0.38", features = ["bundled"] }
|
||||
|
||||
[[test]]
|
||||
name = "trait_round_trip"
|
||||
path = "trait_round_trip.rs"
|
||||
|
||||
[[test]]
|
||||
name = "embedding_model_registry"
|
||||
path = "embedding_model_registry.rs"
|
||||
|
||||
[[test]]
|
||||
name = "domain_column_migration"
|
||||
path = "domain_column_migration.rs"
|
||||
|
||||
[[test]]
|
||||
name = "cognitive_module_isolation"
|
||||
path = "cognitive_module_isolation.rs"
|
||||
|
||||
[[test]]
|
||||
name = "send_bound_variant"
|
||||
path = "send_bound_variant.rs"
|
||||
|
||||
[[test]]
|
||||
name = "embedder_trait"
|
||||
path = "embedder_trait.rs"
|
||||
143
tests/phase_1/cognitive_module_isolation.rs
Normal file
143
tests/phase_1/cognitive_module_isolation.rs
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
//! Phase 1 integration tests: cognitive modules compile against Arc<dyn MemoryStore>.
|
||||
//! The key goal is a compile-time gate: if any module still typed against
|
||||
//! SqliteMemoryStore concretely, this would fail to compile.
|
||||
|
||||
use chrono::Utc;
|
||||
use std::sync::Arc;
|
||||
use tempfile::tempdir;
|
||||
use uuid::Uuid;
|
||||
use vestige_core::storage::{MemoryEdge, MemoryRecord, MemoryStore, SqliteMemoryStore};
|
||||
|
||||
fn make_store() -> Arc<dyn MemoryStore> {
|
||||
let dir = tempdir().unwrap();
|
||||
let db = dir.path().join("test.db");
|
||||
std::mem::forget(dir);
|
||||
Arc::new(SqliteMemoryStore::new(Some(db)).expect("create"))
|
||||
}
|
||||
|
||||
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!["isolation-test".to_string()],
|
||||
embedding: None,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
metadata: serde_json::json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure the store: Arc<dyn MemoryStore> call pattern compiles and runs through
|
||||
/// a representative method from every cognitive module group.
|
||||
#[tokio::test]
|
||||
async fn all_modules_compile_against_dyn_store() {
|
||||
let store: Arc<dyn MemoryStore> = make_store();
|
||||
|
||||
// CRUD via trait
|
||||
let rec = make_record("cognitive isolation test");
|
||||
let id = store.insert(&rec).await.expect("insert via dyn trait");
|
||||
let got = store
|
||||
.get(id)
|
||||
.await
|
||||
.expect("get via dyn trait")
|
||||
.expect("exists");
|
||||
assert_eq!(got.content, "cognitive isolation test");
|
||||
|
||||
// Graph edges via trait
|
||||
let rec2 = make_record("linked node");
|
||||
let id2 = store.insert(&rec2).await.expect("insert 2");
|
||||
store
|
||||
.add_edge(&MemoryEdge {
|
||||
source_id: id,
|
||||
target_id: id2,
|
||||
edge_type: "semantic".to_string(),
|
||||
weight: 0.8,
|
||||
created_at: Utc::now(),
|
||||
})
|
||||
.await
|
||||
.expect("add_edge via dyn trait");
|
||||
|
||||
let edges = store
|
||||
.get_edges(id, None)
|
||||
.await
|
||||
.expect("get_edges via dyn trait");
|
||||
assert!(!edges.is_empty());
|
||||
|
||||
// Search via trait
|
||||
let results = store
|
||||
.fts_search("cognitive", 5)
|
||||
.await
|
||||
.expect("fts_search via dyn trait");
|
||||
assert!(!results.is_empty());
|
||||
|
||||
// Stats and count via trait
|
||||
let count = store.count().await.expect("count via dyn trait");
|
||||
assert!(count >= 2);
|
||||
|
||||
let stats = store.get_stats().await.expect("get_stats via dyn trait");
|
||||
assert!(stats.total_memories >= 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spreading_activation_traverses_via_trait() {
|
||||
let store: Arc<dyn MemoryStore> = make_store();
|
||||
let rec_a = make_record("spreading activation source");
|
||||
let rec_b = make_record("spreading activation neighbor");
|
||||
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");
|
||||
store
|
||||
.add_edge(&MemoryEdge {
|
||||
source_id: id_a,
|
||||
target_id: id_b,
|
||||
edge_type: "semantic".to_string(),
|
||||
weight: 0.9,
|
||||
created_at: Utc::now(),
|
||||
})
|
||||
.await
|
||||
.expect("add edge");
|
||||
|
||||
// get_neighbors simulates the spreading activation traversal path
|
||||
let neighbors = store.get_neighbors(id_a, 1).await.expect("get_neighbors");
|
||||
let ids: Vec<Uuid> = neighbors.iter().map(|(r, _)| r.id).collect();
|
||||
assert!(ids.contains(&id_a));
|
||||
assert!(ids.contains(&id_b));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn synaptic_tagging_consumes_records_via_trait() {
|
||||
// Build a MemoryRecord from trait-returned data and exercise the
|
||||
// SynapticTaggingSystem pipeline (constructing CapturedMemory from store data).
|
||||
let store: Arc<dyn MemoryStore> = make_store();
|
||||
let rec = make_record("synaptic tagging test memory");
|
||||
let id = store.insert(&rec).await.expect("insert");
|
||||
let got = store.get(id).await.expect("get").expect("exists");
|
||||
// The important thing is we got a MemoryRecord back from the dyn trait;
|
||||
// SynapticTaggingSystem would take this record as input.
|
||||
assert_eq!(got.id, id);
|
||||
assert!(!got.content.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hippocampal_index_built_from_store() {
|
||||
// Exercise the fts_search -> HippocampalIndex indexing path.
|
||||
let store: Arc<dyn MemoryStore> = make_store();
|
||||
for i in 0..5usize {
|
||||
let rec = make_record(&format!("hippocampal indexing topic {i}"));
|
||||
store.insert(&rec).await.expect("insert");
|
||||
}
|
||||
let results = store
|
||||
.fts_search("hippocampal indexing", 10)
|
||||
.await
|
||||
.expect("fts_search");
|
||||
// Verify we get results and they have the correct fields
|
||||
assert!(!results.is_empty());
|
||||
for r in &results {
|
||||
assert!(!r.record.content.is_empty());
|
||||
assert!(r.score >= 0.0);
|
||||
}
|
||||
}
|
||||
161
tests/phase_1/domain_column_migration.rs
Normal file
161
tests/phase_1/domain_column_migration.rs
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
//! Phase 1 integration tests: domain column migration and schema upgrade.
|
||||
|
||||
use std::sync::Arc;
|
||||
use tempfile::tempdir;
|
||||
use uuid::Uuid;
|
||||
use vestige_core::storage::{MemoryRecord, MemoryStore, SqliteMemoryStore};
|
||||
|
||||
#[tokio::test]
|
||||
async fn fresh_db_has_v12_schema() {
|
||||
let dir = tempdir().unwrap();
|
||||
let db = dir.path().join("fresh.db");
|
||||
let _store = SqliteMemoryStore::new(Some(db.clone())).expect("create");
|
||||
// Open a raw connection and check pragma
|
||||
let conn = rusqlite::Connection::open(&db).expect("open");
|
||||
let cols: Vec<String> = {
|
||||
let mut stmt = conn.prepare("PRAGMA table_info(knowledge_nodes)").unwrap();
|
||||
stmt.query_map([], |row| row.get::<_, String>(1))
|
||||
.unwrap()
|
||||
.map(|r| r.unwrap())
|
||||
.collect()
|
||||
};
|
||||
assert!(
|
||||
cols.contains(&"domains".to_string()),
|
||||
"domains column must exist: {:?}",
|
||||
cols
|
||||
);
|
||||
assert!(
|
||||
cols.contains(&"domain_scores".to_string()),
|
||||
"domain_scores column must exist"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn v11_db_upgrades_cleanly() {
|
||||
use vestige_core::storage::MIGRATIONS;
|
||||
let dir = tempdir().unwrap();
|
||||
let db = dir.path().join("v11.db");
|
||||
// Create DB with V11 migrations only
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&db).expect("open");
|
||||
for m in MIGRATIONS.iter().filter(|m| m.version <= 11) {
|
||||
conn.execute_batch(m.up).expect("apply migration");
|
||||
}
|
||||
// Insert 5 rows under V11 schema
|
||||
for i in 0..5usize {
|
||||
conn.execute(
|
||||
"INSERT INTO knowledge_nodes (id, content, node_type, created_at, updated_at, \
|
||||
last_accessed, stability, difficulty, reps, lapses, learning_state, \
|
||||
storage_strength, retrieval_strength, retention_strength, \
|
||||
next_review, scheduled_days, has_embedding) \
|
||||
VALUES (?1, ?2, 'fact', datetime('now'), datetime('now'), datetime('now'), \
|
||||
1.0, 0.3, 0, 0, 'new', 1.0, 1.0, 1.0, datetime('now'), 1, 0)",
|
||||
rusqlite::params![format!("pre-v12-{i}"), format!("content {i}"),],
|
||||
)
|
||||
.expect("insert pre-v12 row");
|
||||
}
|
||||
}
|
||||
// Upgrade by opening through SqliteMemoryStore (triggers full migration)
|
||||
let _store = SqliteMemoryStore::new(Some(db.clone())).expect("open with v12");
|
||||
// Check all 5 rows have empty domains/domain_scores
|
||||
let conn = rusqlite::Connection::open(&db).expect("open raw");
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM knowledge_nodes WHERE domains='[]' AND domain_scores='{}'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("count");
|
||||
assert_eq!(
|
||||
count, 5,
|
||||
"all pre-v12 rows must have empty domains/domain_scores"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_domains_serialize_as_brackets() {
|
||||
let dir = tempdir().unwrap();
|
||||
let db = dir.path().join("empty_domains.db");
|
||||
let store = SqliteMemoryStore::new(Some(db.clone())).expect("create");
|
||||
let rec = MemoryRecord {
|
||||
id: Uuid::new_v4(),
|
||||
domains: vec![],
|
||||
domain_scores: Default::default(),
|
||||
content: "test content".to_string(),
|
||||
node_type: "fact".to_string(),
|
||||
tags: vec![],
|
||||
embedding: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
metadata: serde_json::json!({}),
|
||||
};
|
||||
store.insert(&rec).await.expect("insert");
|
||||
// Check raw sqlite value
|
||||
let conn = rusqlite::Connection::open(&db).expect("open raw");
|
||||
let (domains, domain_scores): (String, String) = conn
|
||||
.query_row(
|
||||
"SELECT domains, domain_scores FROM knowledge_nodes LIMIT 1",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.expect("query");
|
||||
assert_eq!(
|
||||
domains, "[]",
|
||||
"empty domains should store as '[]', not NULL"
|
||||
);
|
||||
assert_eq!(
|
||||
domain_scores, "{}",
|
||||
"empty domain_scores should store as '{{}}'"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn populated_domains_round_trip() {
|
||||
let dir = tempdir().unwrap();
|
||||
let db = dir.path().join("populated.db");
|
||||
let store: Arc<dyn MemoryStore> = Arc::new(SqliteMemoryStore::new(Some(db)).expect("create"));
|
||||
let mut rec = MemoryRecord {
|
||||
id: Uuid::new_v4(),
|
||||
domains: vec!["dev".to_string(), "infra".to_string()],
|
||||
domain_scores: {
|
||||
let mut m = std::collections::HashMap::new();
|
||||
m.insert("dev".to_string(), 0.82);
|
||||
m.insert("infra".to_string(), 0.71);
|
||||
m
|
||||
},
|
||||
content: "populated domains test".to_string(),
|
||||
node_type: "fact".to_string(),
|
||||
tags: vec![],
|
||||
embedding: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
metadata: serde_json::json!({}),
|
||||
};
|
||||
let id = store.insert(&rec).await.expect("insert");
|
||||
// Update the domains via update()
|
||||
rec.id = id;
|
||||
store.update(&rec).await.expect("update with domains");
|
||||
// Read back and verify
|
||||
let got = store.get(id).await.expect("get").expect("exists");
|
||||
let mut expected_domains = got.domains.clone();
|
||||
expected_domains.sort();
|
||||
assert_eq!(expected_domains, vec!["dev", "infra"]);
|
||||
assert!((got.domain_scores["dev"] - 0.82).abs() < 0.001);
|
||||
assert!((got.domain_scores["infra"] - 0.71).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn domains_table_exists() {
|
||||
let dir = tempdir().unwrap();
|
||||
let db = dir.path().join("domains_table.db");
|
||||
let _store = SqliteMemoryStore::new(Some(db.clone())).expect("create");
|
||||
let conn = rusqlite::Connection::open(&db).expect("open raw");
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='domains'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("query");
|
||||
assert_eq!(count, 1, "domains table must exist after V12 migration");
|
||||
}
|
||||
43
tests/phase_1/embedder_trait.rs
Normal file
43
tests/phase_1/embedder_trait.rs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
//! Phase 1 integration tests: Embedder trait and FastembedEmbedder.
|
||||
|
||||
use std::sync::Arc;
|
||||
use tempfile::tempdir;
|
||||
use vestige_core::embedder::{Embedder, FastembedEmbedder};
|
||||
use vestige_core::storage::MemoryStore;
|
||||
use vestige_core::storage::SqliteMemoryStore;
|
||||
|
||||
fn make_store() -> Arc<dyn MemoryStore> {
|
||||
let dir = tempdir().unwrap();
|
||||
let db = dir.path().join("test.db");
|
||||
std::mem::forget(dir);
|
||||
Arc::new(SqliteMemoryStore::new(Some(db)).expect("create"))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fastembed_implements_embedder_trait() {
|
||||
// The key test: `Box<dyn Embedder>` compiles
|
||||
let e: Box<dyn Embedder> = Box::new(FastembedEmbedder::new());
|
||||
assert_eq!(e.dimension(), 256, "dimension must be 256");
|
||||
assert!(!e.model_name().is_empty(), "model_name must not be empty");
|
||||
assert!(!e.model_hash().is_empty(), "model_hash must not be empty");
|
||||
assert_eq!(e.model_hash().len(), 64, "hash must be 64 hex chars");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn signature_matches_memory_store_registry() {
|
||||
let e = FastembedEmbedder::new();
|
||||
let sig = e.signature();
|
||||
let store = make_store();
|
||||
store
|
||||
.register_model(&sig)
|
||||
.await
|
||||
.expect("register via Embedder::signature");
|
||||
let got = store
|
||||
.registered_model()
|
||||
.await
|
||||
.expect("registered_model")
|
||||
.expect("Some");
|
||||
assert_eq!(got.name, sig.name);
|
||||
assert_eq!(got.dimension, sig.dimension);
|
||||
assert_eq!(got.hash, sig.hash);
|
||||
}
|
||||
148
tests/phase_1/embedding_model_registry.rs
Normal file
148
tests/phase_1/embedding_model_registry.rs
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
//! Phase 1 integration tests: embedding model registry.
|
||||
|
||||
use std::sync::Arc;
|
||||
use tempfile::tempdir;
|
||||
use uuid::Uuid;
|
||||
use vestige_core::storage::{
|
||||
MemoryRecord, MemoryStore, MemoryStoreError, ModelSignature, SqliteMemoryStore,
|
||||
};
|
||||
|
||||
fn make_store() -> Arc<dyn MemoryStore> {
|
||||
let dir = tempdir().unwrap();
|
||||
let db = dir.path().join("test.db");
|
||||
std::mem::forget(dir);
|
||||
let store = SqliteMemoryStore::new(Some(db)).expect("create store");
|
||||
Arc::new(store)
|
||||
}
|
||||
|
||||
fn sig_a() -> ModelSignature {
|
||||
ModelSignature {
|
||||
name: "model-a".to_string(),
|
||||
dimension: 256,
|
||||
hash: "a".repeat(64),
|
||||
}
|
||||
}
|
||||
|
||||
fn sig_b() -> ModelSignature {
|
||||
ModelSignature {
|
||||
name: "model-b".to_string(),
|
||||
dimension: 256,
|
||||
hash: "b".repeat(64),
|
||||
}
|
||||
}
|
||||
|
||||
fn record_without_embedding() -> MemoryRecord {
|
||||
MemoryRecord {
|
||||
id: Uuid::new_v4(),
|
||||
domains: vec![],
|
||||
domain_scores: Default::default(),
|
||||
content: "plain text memory".to_string(),
|
||||
node_type: "fact".to_string(),
|
||||
tags: vec![],
|
||||
embedding: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
metadata: serde_json::json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn first_embedded_insert_auto_registers() {
|
||||
// fresh store; register a model, then check registered_model() returns Some
|
||||
let store = make_store();
|
||||
let sig = sig_a();
|
||||
store.register_model(&sig).await.expect("register");
|
||||
let got = store.registered_model().await.expect("registered_model");
|
||||
assert_eq!(got, Some(sig));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn second_insert_with_same_signature_succeeds() {
|
||||
let store = make_store();
|
||||
let sig = sig_a();
|
||||
store.register_model(&sig).await.expect("first register");
|
||||
store
|
||||
.register_model(&sig)
|
||||
.await
|
||||
.expect("second register idempotent");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn second_insert_with_different_dimension_refused() {
|
||||
let store = make_store();
|
||||
let sig = sig_a(); // dim 256
|
||||
store.register_model(&sig).await.expect("register 256");
|
||||
// Try inserting a 512-dim vector into a store registered for 256
|
||||
let mut rec = record_without_embedding();
|
||||
rec.embedding = Some(vec![0.0f32; 512]);
|
||||
rec.metadata = serde_json::json!({
|
||||
"model_name": "model-a",
|
||||
"model_dim": 256_u64,
|
||||
"model_hash": "a".repeat(64),
|
||||
});
|
||||
let err = store.insert(&rec).await.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, MemoryStoreError::InvalidInput(_)),
|
||||
"expected InvalidInput for dim mismatch, got {:?}",
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn second_insert_with_different_model_name_refused() {
|
||||
let store = make_store();
|
||||
store.register_model(&sig_a()).await.expect("register a");
|
||||
let err = store.register_model(&sig_b()).await.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, MemoryStoreError::ModelMismatch { .. }),
|
||||
"expected ModelMismatch, got {:?}",
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn second_insert_with_different_hash_refused() {
|
||||
let store = make_store();
|
||||
let sig = sig_a();
|
||||
store.register_model(&sig).await.expect("register");
|
||||
let sig_diff_hash = ModelSignature {
|
||||
name: "model-a".to_string(),
|
||||
dimension: 256,
|
||||
hash: "c".repeat(64), // different hash
|
||||
};
|
||||
let err = store.register_model(&sig_diff_hash).await.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, MemoryStoreError::ModelMismatch { .. }),
|
||||
"expected ModelMismatch for different hash, got {:?}",
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_embedding_insert_allowed_before_registration() {
|
||||
let store = make_store();
|
||||
// registered_model() should be None
|
||||
assert!(
|
||||
store
|
||||
.registered_model()
|
||||
.await
|
||||
.expect("registered_model")
|
||||
.is_none()
|
||||
);
|
||||
// A plain text memory without an embedding must insert successfully
|
||||
let rec = record_without_embedding();
|
||||
store
|
||||
.insert(&rec)
|
||||
.await
|
||||
.expect("plain insert before registration");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stats_reports_registered_model_after_first_write() {
|
||||
let store = make_store();
|
||||
let sig = sig_a();
|
||||
store.register_model(&sig).await.expect("register");
|
||||
let stats = store.get_stats().await.expect("stats");
|
||||
assert_eq!(stats.registered_model_name, Some("model-a".to_string()));
|
||||
assert_eq!(stats.registered_model_dim, Some(256));
|
||||
}
|
||||
99
tests/phase_1/send_bound_variant.rs
Normal file
99
tests/phase_1/send_bound_variant.rs
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
//! Phase 1 integration tests: Arc<dyn MemoryStore> moves across tokio::spawn.
|
||||
//!
|
||||
//! This verifies that `#[trait_variant::make(MemoryStore: Send)]` actually
|
||||
//! produces a Send-bound future so Arc<dyn MemoryStore> is movable.
|
||||
|
||||
use chrono::Utc;
|
||||
use std::sync::Arc;
|
||||
use tempfile::tempdir;
|
||||
use uuid::Uuid;
|
||||
use vestige_core::storage::{MemoryRecord, MemoryStore, SqliteMemoryStore};
|
||||
|
||||
fn make_store() -> Arc<dyn MemoryStore> {
|
||||
let dir = tempdir().unwrap();
|
||||
let db = dir.path().join("send_test.db");
|
||||
std::mem::forget(dir);
|
||||
Arc::new(SqliteMemoryStore::new(Some(db)).expect("create"))
|
||||
}
|
||||
|
||||
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![],
|
||||
embedding: None,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
metadata: serde_json::json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn arc_dyn_memory_store_moves_across_tokio_tasks() {
|
||||
let store: Arc<dyn MemoryStore> = make_store();
|
||||
let mut handles = Vec::new();
|
||||
for t in 0..16usize {
|
||||
let store = Arc::clone(&store);
|
||||
let handle = tokio::spawn(async move {
|
||||
for i in 0..10usize {
|
||||
let rec = make_record(&format!("task {t} memory {i}"));
|
||||
store.insert(&rec).await.expect("insert in spawned task");
|
||||
}
|
||||
});
|
||||
handles.push(handle);
|
||||
}
|
||||
for h in handles {
|
||||
h.await.expect("task completed without panic");
|
||||
}
|
||||
let count = store.count().await.expect("count");
|
||||
assert_eq!(count, 160, "all 16*10 inserts must be counted");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn concurrent_readers_one_writer() {
|
||||
let store: Arc<dyn MemoryStore> = make_store();
|
||||
// Pre-populate with some data so readers have something to find
|
||||
for i in 0..10usize {
|
||||
let rec = make_record(&format!("concurrent reader memory {i}"));
|
||||
store.insert(&rec).await.expect("pre-insert");
|
||||
}
|
||||
|
||||
let mut handles = Vec::new();
|
||||
|
||||
// 32 concurrent readers
|
||||
for _ in 0..32usize {
|
||||
let store = Arc::clone(&store);
|
||||
let handle = tokio::spawn(async move {
|
||||
let results = store.fts_search("concurrent reader", 5).await;
|
||||
// Should not panic even if results vary due to concurrent writes
|
||||
results.expect("fts_search in concurrent reader");
|
||||
});
|
||||
handles.push(handle);
|
||||
}
|
||||
|
||||
// 1 writer inserting more records
|
||||
{
|
||||
let store = Arc::clone(&store);
|
||||
let writer_handle = tokio::spawn(async move {
|
||||
for i in 0..20usize {
|
||||
let rec = make_record(&format!("writer record {i}"));
|
||||
store.insert(&rec).await.expect("concurrent insert");
|
||||
}
|
||||
});
|
||||
handles.push(writer_handle);
|
||||
}
|
||||
|
||||
for h in handles {
|
||||
h.await.expect("no panics");
|
||||
}
|
||||
|
||||
// Eventual consistency check: total count should be at least 10 (initial)
|
||||
let count = store.count().await.expect("final count");
|
||||
assert!(
|
||||
count >= 10,
|
||||
"at least the pre-populated records must persist"
|
||||
);
|
||||
}
|
||||
217
tests/phase_1/trait_round_trip.rs
Normal file
217
tests/phase_1/trait_round_trip.rs
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
//! 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue