use crate::config::Config;
use crate::embedding_profiles::EmbeddingRole;
use crate::memory::{MemoryStore, SearchOptions};
use crate::sqlite::Database;
fn write_marker(conn: &rusqlite::Connection, target: &crate::sqlite::identity::ModelIdentity) {
conn.execute(
"INSERT INTO model_identity (id, model_id, model_revision, migration_marker)
VALUES (1, NULL, NULL, ?1)
ON CONFLICT(id) DO UPDATE SET migration_marker = excluded.migration_marker",
[format!("migrating to {}", target.display())],
)
.unwrap();
}
fn record_identity(conn: &rusqlite::Connection, identity: &crate::sqlite::identity::ModelIdentity) {
conn.execute(
"INSERT INTO model_identity (id, model_id, model_revision, migration_marker)
VALUES (1, ?1, ?2, NULL)
ON CONFLICT(id) DO UPDATE SET model_id = excluded.model_id,
model_revision = excluded.model_revision,
migration_marker = NULL",
(&identity.model_id, &identity.revision),
)
.unwrap();
}
fn prepared_store(
model_id: &str,
prepare: impl FnOnce(&Database),
) -> (tempfile::TempDir, MemoryStore) {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("test.db");
let db = Database::open(&path).unwrap();
prepare(&db);
let store = MemoryStore {
db,
embedder: None,
model_id: model_id.to_string(),
config: Config::default(),
#[cfg(test)]
test_embedder: Some(Box::new(crate::memory::crud::test_fake_embedder)),
};
(dir, store)
}
pub(crate) fn e5_model_id() -> &'static str {
"intfloat/multilingual-e5-small"
}
#[test]
fn add_refuses_unrecorded_store_with_nondefault_model() {
let (_dir, mut store) = prepared_store(e5_model_id(), |_| {});
let result = store.add_with_conflict(
"p",
"hello",
None,
false,
crate::memory::lifecycle::MemoryType::Fact,
crate::memory::lifecycle::MemoryStatus::Active,
);
let err = result.expect_err("add must refuse");
let msg = err.to_string();
assert!(msg.contains("model identity mismatch"), "{msg}");
assert!(msg.contains(e5_model_id()), "{msg}");
assert!(msg.contains("vipune reindex --force"), "{msg}");
}
#[test]
fn add_refuses_while_migration_marker_present() {
let (_dir, mut store) = prepared_store(e5_model_id(), |db| {
write_marker(
db.conn(),
&crate::sqlite::identity::ModelIdentity {
model_id: e5_model_id().to_string(),
revision: "614241f622f53c4eeff9890bdc4f31cfecc418b3".to_string(),
},
);
});
let result = store.add_with_conflict(
"p",
"hello",
None,
false,
crate::memory::lifecycle::MemoryType::Fact,
crate::memory::lifecycle::MemoryStatus::Active,
);
let err = result.expect_err("add must refuse while migrating");
let msg = err.to_string();
assert!(msg.contains("migrating"), "{msg}");
assert!(msg.contains("vipune reindex --force"), "{msg}");
}
#[test]
fn search_refuses_on_mismatch() {
let (_dir, mut store) = prepared_store(e5_model_id(), |_| {});
let result = store.search("p", "query", 5, 0.0, SearchOptions::default());
let err = result.expect_err("search must refuse");
let msg = err.to_string();
assert!(msg.contains("model identity mismatch"), "{msg}");
assert!(msg.contains("vipune reindex --force"), "{msg}");
}
#[test]
fn search_refuses_while_migration_marker_present() {
let (_dir, mut store) = prepared_store(e5_model_id(), |db| {
write_marker(
db.conn(),
&crate::sqlite::identity::ModelIdentity {
model_id: e5_model_id().to_string(),
revision: "rev".to_string(),
},
);
});
let result = store.search("p", "query", 5, 0.0, SearchOptions::default());
let err = result.expect_err("search must refuse while migrating");
assert!(err.to_string().contains("migrating"));
}
#[test]
fn update_refuses_on_mismatch_when_content_changes() {
let (_dir, mut store) = prepared_store(e5_model_id(), |_| {});
let db_path = crate::memory::store::MemoryStore::test_db_path();
let db = Database::open(&db_path).unwrap();
let emb = crate::memory::crud::test_fake_embedder("orig").unwrap();
let id = db
.insert("p", "orig", &emb, None, "fact", "active")
.unwrap();
let emb2 = crate::memory::crud::test_fake_embedder("orig2").unwrap();
store
.db
.insert("p", "orig2", &emb2, None, "fact", "active")
.unwrap();
let result = store.update(
&id,
"p",
crate::memory::UpdateParams {
text: Some("new content"),
..Default::default()
},
);
let err = result.expect_err("update with content must refuse");
assert!(err.to_string().contains("model identity mismatch"));
let _ = db;
}
#[test]
fn batch_ingest_refuses_each_item_on_mismatch() {
let (_dir, mut store) = prepared_store(e5_model_id(), |_| {});
let items = vec![("one", None), ("two", None)];
let result = store.batch_ingest("p", items, crate::memory_types::IngestPolicy::Force);
let batch = result.expect("batch_ingest returns per-item results");
assert_eq!(batch.results.len(), 2);
for item in &batch.results {
let msg = match item {
crate::memory_types::BatchIngestItemResult::Error { message } => message.clone(),
other => panic!("expected per-item Error, got {other:?}"),
};
assert!(msg.contains("model identity mismatch"), "{msg}");
}
}
#[test]
fn add_succeeds_when_identity_matches() {
let (_dir, mut store) = prepared_store(crate::embedding::EMBED_MODEL_ID, |db| {
record_identity(
db.conn(),
&crate::sqlite::identity::ModelIdentity::default_identity(),
);
});
let result = store
.add_with_conflict(
"p",
"hello world",
None,
false,
crate::memory::lifecycle::MemoryType::Fact,
crate::memory::lifecycle::MemoryStatus::Active,
)
.expect("add must succeed when identity matches");
assert!(
matches!(result, crate::memory_types::AddResult::Added { .. }),
"expected Added, got {result:?}"
);
}
#[test]
fn fresh_unrecorded_store_with_default_model_adds() {
let (_dir, mut store) = prepared_store(crate::embedding::EMBED_MODEL_ID, |_| {});
let result = store
.add_with_conflict(
"p",
"fresh store content",
None,
false,
crate::memory::lifecycle::MemoryType::Fact,
crate::memory::lifecycle::MemoryStatus::Active,
)
.expect("add must succeed on a fresh store with the default model");
assert!(matches!(
result,
crate::memory_types::AddResult::Added { .. }
));
}
#[test]
fn refusal_does_not_require_the_real_embedder() {
let (_dir, mut store) = prepared_store(e5_model_id(), |_| {});
let _ = store.get_embedding("hello", EmbeddingRole::Passage);
let err = store
.get_embedding("hello", EmbeddingRole::Passage)
.expect_err("must refuse before embedding");
assert!(err.to_string().contains("model identity mismatch"));
}
fn recording_embedder(
recorded: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
) -> crate::memory::store::TestEmbedder {
Box::new(move |content: &str| {
recorded.lock().unwrap().push(content.to_string());
crate::memory::crud::test_fake_embedder(content)
})
}
pub(crate) fn e5_store_with_real_prefix_contract() -> (tempfile::TempDir, MemoryStore) {
let (dir, store) = prepared_store(e5_model_id(), |_| {});
record_identity(
store.db.conn(),
&crate::sqlite::identity::ModelIdentity {
model_id: e5_model_id().to_string(),
revision: "614241f622f53c4eeff9890bdc4f31cfecc418b3".to_string(),
},
);
(dir, store)
}
#[test]
fn add_stores_content_without_passage_prefix() {
let (_dir, mut store) = e5_store_with_real_prefix_contract();
let result = store
.add_with_conflict(
"p",
"the quick brown fox",
None,
false,
crate::memory::lifecycle::MemoryType::Fact,
crate::memory::lifecycle::MemoryStatus::Active,
)
.expect("add must succeed when identity matches");
let crate::memory_types::AddResult::Added { id } = result else {
panic!("expected Added");
};
let memory = store.get(&id, "p").unwrap().expect("row exists");
assert_eq!(memory.content, "the quick brown fox");
}
#[test]
fn e5_engine_input_is_exactly_passage_prefixed() {
let recorded = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let (_dir, mut store) = e5_store_with_real_prefix_contract();
store.set_test_embedder(recording_embedder(recorded.clone()));
let result = store
.add_with_conflict(
"p",
"fox content",
None,
false,
crate::memory::lifecycle::MemoryType::Fact,
crate::memory::lifecycle::MemoryStatus::Active,
)
.expect("add must succeed when identity matches");
let crate::memory_types::AddResult::Added { id } = result else {
panic!("expected Added");
};
assert_eq!(
recorded.lock().unwrap().clone(),
vec!["passage: fox content"]
);
store
.update(
&id,
"p",
crate::memory::UpdateParams {
text: Some("new fox content"),
..Default::default()
},
)
.expect("update must succeed when identity matches");
assert_eq!(
recorded.lock().unwrap().clone(),
vec!["passage: fox content", "passage: new fox content"]
);
store
.search("p", "fox", 5, 0.0, SearchOptions::default())
.expect("search must succeed when identity matches");
assert_eq!(
recorded.lock().unwrap().clone(),
vec![
"passage: fox content",
"passage: new fox content",
"query: fox"
]
);
let batch = store
.batch_ingest(
"p",
vec![("batch item one", None), ("batch item two", None)],
crate::memory_types::IngestPolicy::Force,
)
.expect("batch_ingest must succeed when identity matches");
assert_eq!(batch.results.len(), 2);
let recorded_after_batch = recorded.lock().unwrap().clone();
assert_eq!(recorded_after_batch.len(), 5);
assert_eq!(recorded_after_batch[3], "passage: batch item one");
assert_eq!(recorded_after_batch[4], "passage: batch item two");
}
#[test]
fn bge_engine_input_is_unprefixed() {
let recorded = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let (_dir, mut store) = prepared_store(crate::embedding::EMBED_MODEL_ID, |_| {});
store.set_test_embedder(recording_embedder(recorded.clone()));
let result = store
.add_with_conflict(
"p",
"plain bge content",
None,
false,
crate::memory::lifecycle::MemoryType::Fact,
crate::memory::lifecycle::MemoryStatus::Active,
)
.expect("add must succeed on a fresh store with the default model");
let crate::memory_types::AddResult::Added { .. } = result else {
panic!("expected Added");
};
assert_eq!(recorded.lock().unwrap().clone(), vec!["plain bge content"]);
store
.search("p", "plain query", 5, 0.0, SearchOptions::default())
.expect("search must succeed on a fresh store with the default model");
assert_eq!(
recorded.lock().unwrap().clone(),
vec!["plain bge content", "plain query"]
);
}
#[test]
fn search_does_not_store_prefix_in_fts_or_content() {
let (_dir, mut store) = e5_store_with_real_prefix_contract();
let result = store
.add_with_conflict(
"p",
"remember to water the plants",
None,
false,
crate::memory::lifecycle::MemoryType::Fact,
crate::memory::lifecycle::MemoryStatus::Active,
)
.expect("add must succeed");
let crate::memory_types::AddResult::Added { id } = result else {
panic!("expected Added");
};
let memories = store
.search("p", "water plants", 5, 0.0, SearchOptions::default())
.expect("search must succeed when identity matches");
let row = memories.iter().find(|m| m.id == id).expect("row found");
assert_eq!(row.content, "remember to water the plants");
let hybrid = store
.search_hybrid("p", "water", 5, 0.0, SearchOptions::default())
.expect("hybrid search must succeed");
assert!(
hybrid.iter().any(|m| m.id == id),
"BM25 search on raw word must find the unprefixed row"
);
}
#[test]
fn bge_add_stores_content_without_prefix() {
let (_dir, mut store) = prepared_store(crate::embedding::EMBED_MODEL_ID, |_| {});
let result = store
.add_with_conflict(
"p",
"plain text no prefix",
None,
false,
crate::memory::lifecycle::MemoryType::Fact,
crate::memory::lifecycle::MemoryStatus::Active,
)
.expect("add must succeed");
let crate::memory_types::AddResult::Added { id } = result else {
panic!("expected Added");
};
let memory = store.get(&id, "p").unwrap().expect("row exists");
assert_eq!(memory.content, "plain text no prefix");
}