use anyhow::{Result, anyhow};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
use tracing::{debug, info};
use crate::embeddings::{DEFAULT_REQUIRED_DIMENSION, EmbeddingClient, EmbeddingConfig};
use crate::rag::{SearchOptions, SearchResult, SliceLayer};
use crate::search::{
BM25Config, BM25Index, HybridConfig, HybridSearchResult, HybridSearcher, SearchMode,
};
use crate::storage::{ChromaDocument, StorageManager};
pub use crate::rag::SearchResult as Document;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemexConfig {
pub app_name: String,
pub namespace: String,
#[serde(default)]
pub db_path: Option<String>,
#[serde(default = "default_dimension")]
pub dimension: usize,
#[serde(default)]
pub embedding_config: EmbeddingConfig,
#[serde(default)]
pub enable_bm25: bool,
#[serde(default)]
pub bm25_config: Option<BM25Config>,
#[serde(default = "default_enable_hybrid")]
pub enable_hybrid: bool,
#[serde(default)]
pub hybrid_config: Option<HybridConfig>,
}
fn default_enable_hybrid() -> bool {
true }
fn default_dimension() -> usize {
DEFAULT_REQUIRED_DIMENSION
}
impl Default for MemexConfig {
fn default() -> Self {
Self {
app_name: "memex".to_string(),
namespace: "default".to_string(),
db_path: None,
dimension: default_dimension(),
embedding_config: EmbeddingConfig::default(),
enable_bm25: false,
bm25_config: None,
enable_hybrid: default_enable_hybrid(),
hybrid_config: None,
}
}
}
impl MemexConfig {
pub fn new(app_name: impl Into<String>, namespace: impl Into<String>) -> Self {
Self {
app_name: app_name.into(),
namespace: namespace.into(),
..Default::default()
}
}
pub fn with_db_path(mut self, path: impl Into<String>) -> Self {
self.db_path = Some(path.into());
self
}
pub fn with_dimension(mut self, dimension: usize) -> Self {
self.dimension = dimension;
self.embedding_config.required_dimension = dimension;
self
}
pub fn with_embedding_config(mut self, config: EmbeddingConfig) -> Self {
self.dimension = config.required_dimension;
self.embedding_config = config;
self
}
fn sync_dimension_fields(&mut self) -> Result<()> {
if self.dimension == self.embedding_config.required_dimension {
return Ok(());
}
let default_dim = default_dimension();
if self.dimension == default_dim {
self.dimension = self.embedding_config.required_dimension;
return Ok(());
}
if self.embedding_config.required_dimension == default_dim {
self.embedding_config.required_dimension = self.dimension;
return Ok(());
}
Err(anyhow!(
"MemexConfig.dimension={} conflicts with embedding_config.required_dimension={}. \
Set them to the same value or use with_dimension()/with_embedding_config() so one source of truth updates both.",
self.dimension,
self.embedding_config.required_dimension
))
}
pub fn with_bm25(mut self, config: BM25Config) -> Self {
self.enable_bm25 = true;
self.bm25_config = Some(config);
self
}
pub fn effective_db_path(&self) -> String {
self.db_path
.clone()
.unwrap_or_else(|| format!("~/.rmcp-servers/{}/lancedb", self.app_name))
}
pub fn effective_bm25_path(&self) -> String {
self.bm25_config
.as_ref()
.map(|c| c.index_path.clone())
.unwrap_or_else(|| format!("~/.rmcp-servers/{}/bm25", self.app_name))
}
fn hybrid_uses_bm25(&self) -> bool {
self.enable_hybrid
&& self.hybrid_config.clone().unwrap_or_default().mode != SearchMode::Vector
}
fn normalize_bm25_config(&self, mut config: BM25Config) -> BM25Config {
if config.index_path == BM25Config::default().index_path {
config.index_path = self.effective_bm25_path();
}
config
}
fn resolved_bm25_config(&self) -> Option<BM25Config> {
if !self.enable_bm25 && !self.hybrid_uses_bm25() {
return None;
}
let config = self
.bm25_config
.clone()
.or_else(|| {
self.hybrid_config
.as_ref()
.filter(|cfg| cfg.mode != SearchMode::Vector)
.map(|cfg| cfg.bm25.clone())
})
.unwrap_or_default();
Some(self.normalize_bm25_config(config))
}
fn resolved_hybrid_config(&self) -> HybridConfig {
let mut config = self.hybrid_config.clone().unwrap_or_default();
if let Some(bm25) = self.resolved_bm25_config() {
config.bm25 = bm25;
}
config
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MetaFilter {
#[serde(skip_serializing_if = "Option::is_none")]
pub patient_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub visit_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub doc_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub date_from: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub date_to: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub custom: Vec<(String, String)>,
}
impl MetaFilter {
pub fn for_patient(patient_id: impl Into<String>) -> Self {
Self {
patient_id: Some(patient_id.into()),
..Default::default()
}
}
pub fn for_visit(visit_id: impl Into<String>) -> Self {
Self {
visit_id: Some(visit_id.into()),
..Default::default()
}
}
pub fn with_custom(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.custom.push((key.into(), value.into()));
self
}
pub fn matches(&self, metadata: &Value) -> bool {
if let Some(ref patient_id) = self.patient_id
&& metadata.get("patient_id").and_then(|v| v.as_str()) != Some(patient_id)
{
return false;
}
if let Some(ref visit_id) = self.visit_id
&& metadata.get("visit_id").and_then(|v| v.as_str()) != Some(visit_id)
{
return false;
}
if let Some(ref doc_type) = self.doc_type
&& metadata.get("doc_type").and_then(|v| v.as_str()) != Some(doc_type)
{
return false;
}
if let Some(ref date_from) = self.date_from
&& let Some(doc_date) = metadata.get("date").and_then(|v| v.as_str())
&& doc_date < date_from.as_str()
{
return false;
}
if let Some(ref date_to) = self.date_to
&& let Some(doc_date) = metadata.get("date").and_then(|v| v.as_str())
&& doc_date > date_to.as_str()
{
return false;
}
for (key, value) in &self.custom {
if metadata.get(key).and_then(|v| v.as_str()) != Some(value) {
return false;
}
}
true
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoreItem {
pub id: String,
pub text: String,
#[serde(default)]
pub metadata: Value,
}
impl StoreItem {
pub fn new(id: impl Into<String>, text: impl Into<String>) -> Self {
Self {
id: id.into(),
text: text.into(),
metadata: Value::Object(serde_json::Map::new()),
}
}
pub fn with_metadata(mut self, metadata: Value) -> Self {
self.metadata = metadata;
self
}
}
#[derive(Debug, Clone)]
pub struct BatchResult {
pub success_count: usize,
pub failure_count: usize,
pub failed_ids: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LayerStats {
pub total_chunks: usize,
pub avg_score: f32,
pub top_keywords: Vec<String>,
}
impl LayerStats {
pub fn empty() -> Self {
Self {
total_chunks: 0,
avg_score: 0.0,
top_keywords: vec![],
}
}
pub fn from_results(results: &[SearchResult]) -> Self {
if results.is_empty() {
return Self::empty();
}
let total_chunks = results.len();
let avg_score = results.iter().map(|r| r.score).sum::<f32>() / total_chunks as f32;
let mut keyword_counts: HashMap<String, usize> = HashMap::new();
for result in results {
for keyword in &result.keywords {
*keyword_counts.entry(keyword.clone()).or_insert(0) += 1;
}
}
let mut keywords: Vec<_> = keyword_counts.into_iter().collect();
keywords.sort_by_key(|b| std::cmp::Reverse(b.1));
let top_keywords = keywords.into_iter().take(10).map(|(k, _)| k).collect();
Self {
total_chunks,
avg_score,
top_keywords,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiveResult {
pub layer: SliceLayer,
pub results: Vec<SearchResult>,
pub layer_stats: LayerStats,
}
pub struct MemexEngine {
storage: Arc<StorageManager>,
embeddings: Arc<Mutex<EmbeddingClient>>,
bm25: Option<Arc<BM25Index>>,
hybrid_searcher: Option<HybridSearcher>,
namespace: String,
config: MemexConfig,
}
impl MemexEngine {
pub async fn new(mut config: MemexConfig) -> Result<Self> {
config.sync_dimension_fields()?;
let db_path = config.effective_db_path();
info!(
"Initializing MemexEngine: app={}, namespace={}, db={}",
config.app_name, config.namespace, db_path
);
let storage = StorageManager::new_lance_only(&db_path).await?;
storage.ensure_collection().await?;
let embeddings = EmbeddingClient::new(&config.embedding_config).await?;
info!(
"Connected to embedding provider: {} (dim={})",
embeddings.connected_to(),
embeddings.required_dimension()
);
let bm25 = config
.resolved_bm25_config()
.map(|bm25_config| BM25Index::new(&bm25_config).map(Arc::new))
.transpose()?;
let storage_arc = Arc::new(storage);
let hybrid_searcher = if config.enable_hybrid {
let hybrid_config = config.resolved_hybrid_config();
Some(if let Some(ref bm25_index) = bm25 {
HybridSearcher::with_bm25_index(
storage_arc.clone(),
bm25_index.clone(),
hybrid_config,
)
} else {
HybridSearcher::new(storage_arc.clone(), hybrid_config).await?
})
} else {
None
};
Ok(Self {
storage: storage_arc,
embeddings: Arc::new(Mutex::new(embeddings)),
bm25,
hybrid_searcher,
namespace: config.namespace.clone(),
config,
})
}
pub async fn for_app(app_name: &str, namespace: &str) -> Result<Self> {
let config = MemexConfig::new(app_name, namespace);
Self::new(config).await
}
pub async fn for_vista() -> Result<Self> {
use crate::embeddings::ProviderConfig;
let config = MemexConfig {
app_name: "vista".to_string(),
namespace: "default".to_string(),
db_path: Some("~/.rmcp-servers/vista/lancedb".to_string()),
dimension: 1024,
embedding_config: EmbeddingConfig {
required_dimension: 1024,
providers: vec![ProviderConfig {
name: "ollama-vista".to_string(),
base_url: "http://localhost:11434".to_string(),
model: "qwen3-embedding:0.6b".to_string(),
priority: 1,
endpoint: "/v1/embeddings".to_string(),
}],
..EmbeddingConfig::default()
},
enable_bm25: false,
bm25_config: None,
enable_hybrid: true, hybrid_config: None,
};
Self::new(config).await
}
pub fn namespace(&self) -> &str {
&self.namespace
}
pub fn config(&self) -> &MemexConfig {
&self.config
}
pub fn storage(&self) -> Arc<StorageManager> {
self.storage.clone()
}
pub async fn store(&self, id: &str, text: &str, metadata: Value) -> Result<()> {
debug!("Storing document: id={}, text_len={}", id, text.len());
let embedding = self.embeddings.lock().await.embed(text).await?;
let doc = ChromaDocument::new_flat(
id.to_string(),
self.namespace.clone(),
embedding,
metadata.clone(),
text.to_string(),
);
self.storage.add_to_store(vec![doc]).await?;
if let Some(ref bm25) = self.bm25 {
bm25.add_documents(&[(id.to_string(), self.namespace.clone(), text.to_string())])
.await?;
}
debug!("Stored document: id={}", id);
Ok(())
}
pub async fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchResult>> {
debug!("Searching: query='{}', limit={}", query, limit);
let query_embedding = self.embeddings.lock().await.embed(query).await?;
let candidates = self
.storage
.search_store(Some(&self.namespace), query_embedding, limit)
.await?;
let results: Vec<SearchResult> = candidates
.into_iter()
.enumerate()
.map(|(idx, doc)| {
let score = 1.0 - (idx as f32 / (limit as f32 + 1.0));
let layer = doc.slice_layer();
SearchResult {
id: doc.id,
namespace: doc.namespace,
text: doc.document,
score,
metadata: doc.metadata,
layer,
parent_id: doc.parent_id,
children_ids: doc.children_ids,
keywords: doc.keywords,
}
})
.collect();
debug!("Search returned {} results", results.len());
Ok(results)
}
pub async fn search_hybrid(
&self,
query: &str,
limit: usize,
) -> Result<Vec<HybridSearchResult>> {
debug!("Hybrid search: query='{}', limit={}", query, limit);
let hybrid = self.hybrid_searcher.as_ref().ok_or_else(|| {
anyhow!("Hybrid search not enabled. Set enable_hybrid: true in MemexConfig.")
})?;
let query_embedding = self.embeddings.lock().await.embed(query).await?;
let results = hybrid
.search(
query,
query_embedding,
Some(&self.namespace),
limit,
SearchOptions::default(),
)
.await?;
debug!("Hybrid search returned {} results", results.len());
Ok(results)
}
pub async fn search_with_mode(
&self,
query: &str,
limit: usize,
mode: SearchMode,
) -> Result<Vec<HybridSearchResult>> {
debug!("Search with mode: query='{}', mode={:?}", query, mode);
match mode {
SearchMode::Vector => {
let results = self.search(query, limit).await?;
Ok(results
.into_iter()
.map(|r| HybridSearchResult {
id: r.id,
namespace: r.namespace,
document: r.text,
combined_score: r.score,
vector_score: Some(r.score),
bm25_score: None,
metadata: r.metadata,
layer: r.layer,
parent_id: r.parent_id,
children_ids: r.children_ids,
keywords: r.keywords,
})
.collect())
}
SearchMode::Keyword | SearchMode::Hybrid => {
self.search_hybrid(query, limit).await
}
}
}
pub async fn get(&self, id: &str) -> Result<Option<SearchResult>> {
debug!("Getting document: id={}", id);
if let Some(doc) = self.storage.get_document(&self.namespace, id).await? {
let layer = doc.slice_layer();
return Ok(Some(SearchResult {
id: doc.id,
namespace: doc.namespace,
text: doc.document,
score: 1.0,
metadata: doc.metadata,
layer,
parent_id: doc.parent_id,
children_ids: doc.children_ids,
keywords: doc.keywords,
}));
}
Ok(None)
}
pub async fn delete(&self, id: &str) -> Result<bool> {
debug!("Deleting document: id={}", id);
let deleted = self.storage.delete_document(&self.namespace, id).await?;
if let Some(ref bm25) = self.bm25 {
bm25.delete_documents(&[id.to_string()]).await?;
}
Ok(deleted > 0)
}
pub async fn store_batch(&self, items: Vec<StoreItem>) -> Result<BatchResult> {
if items.is_empty() {
return Ok(BatchResult {
success_count: 0,
failure_count: 0,
failed_ids: vec![],
});
}
info!("Batch storing {} documents", items.len());
let texts: Vec<String> = items.iter().map(|i| i.text.clone()).collect();
let embeddings = self.embeddings.lock().await.embed_batch(&texts).await?;
let mut docs = Vec::with_capacity(items.len());
let mut bm25_docs = Vec::new();
for (item, embedding) in items.iter().zip(embeddings) {
let doc = ChromaDocument::new_flat(
item.id.clone(),
self.namespace.clone(),
embedding,
item.metadata.clone(),
item.text.clone(),
);
docs.push(doc);
if self.bm25.is_some() {
bm25_docs.push((item.id.clone(), self.namespace.clone(), item.text.clone()));
}
}
self.storage.add_to_store(docs).await?;
if let Some(ref bm25) = self.bm25 {
bm25.add_documents(&bm25_docs).await?;
}
Ok(BatchResult {
success_count: items.len(),
failure_count: 0,
failed_ids: vec![],
})
}
pub async fn search_filtered(
&self,
query: &str,
filter: MetaFilter,
limit: usize,
) -> Result<Vec<SearchResult>> {
let candidates = self.search(query, limit * 3).await?;
let filtered: Vec<SearchResult> = candidates
.into_iter()
.filter(|r| filter.matches(&r.metadata))
.take(limit)
.collect();
debug!(
"Filtered search: query='{}', filter={:?}, results={}",
query,
filter,
filtered.len()
);
Ok(filtered)
}
pub async fn delete_by_filter(&self, filter: MetaFilter) -> Result<usize> {
info!("Deleting documents by filter: {:?}", filter);
let mut deleted_ids = Vec::new();
const BATCH_SIZE: usize = 1000;
let mut offset = 0;
loop {
let candidates = self
.storage
.all_documents_page(Some(&self.namespace), offset, BATCH_SIZE)
.await?;
if candidates.is_empty() {
break;
}
let page_len = candidates.len();
for doc in candidates {
if filter.matches(&doc.metadata) {
deleted_ids.push(doc.id);
}
}
if page_len < BATCH_SIZE {
break;
}
offset += page_len;
}
for id in &deleted_ids {
self.storage.delete_document(&self.namespace, id).await?;
}
if let Some(ref bm25) = self.bm25
&& !deleted_ids.is_empty()
{
bm25.delete_documents(&deleted_ids).await?;
}
let deleted_count = deleted_ids.len();
info!("Deleted {} documents by filter", deleted_count);
Ok(deleted_count)
}
pub async fn purge_namespace(&self) -> Result<usize> {
info!("Purging namespace: {}", self.namespace);
let deleted = self
.storage
.delete_namespace_documents(&self.namespace)
.await?;
if let Some(ref bm25) = self.bm25 {
bm25.delete_namespace_term(&self.namespace).await?;
}
Ok(deleted)
}
#[deprecated(
since = "0.3.1",
note = "Use search_hybrid() with HybridSearcher instead"
)]
pub async fn search_bm25_fusion(
&self,
query: &str,
limit: usize,
bm25_weight: f32,
) -> Result<Vec<SearchResult>> {
let bm25 = self
.bm25
.as_ref()
.ok_or_else(|| anyhow!("BM25 not enabled. Set enable_bm25: true in MemexConfig."))?;
let bm25_results = bm25.search(query, Some(&self.namespace), limit * 2)?;
let bm25_max_score = bm25_results.first().map(|(_, _, s)| *s).unwrap_or(1.0);
let vector_results = self.search(query, limit * 2).await?;
use std::collections::HashMap;
let mut scores: HashMap<String, (f32, Option<SearchResult>)> = HashMap::new();
for (id, _namespace, score) in bm25_results {
let normalized = score / bm25_max_score.max(0.001);
scores.insert(id, (normalized * bm25_weight, None));
}
let vector_weight = 1.0 - bm25_weight;
for result in vector_results {
let entry = scores.entry(result.id.clone()).or_insert((0.0, None));
entry.0 += result.score * vector_weight;
entry.1 = Some(result);
}
let mut combined: Vec<_> = scores
.into_iter()
.filter_map(|(_id, (score, result))| {
result.map(|mut r| {
r.score = score;
r
})
})
.collect();
combined.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
combined.truncate(limit);
Ok(combined)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_meta_filter_matches() {
let filter = MetaFilter::for_patient("P-123");
let matching = serde_json::json!({
"patient_id": "P-123",
"visit_id": "V-456"
});
assert!(filter.matches(&matching));
let not_matching = serde_json::json!({
"patient_id": "P-999",
"visit_id": "V-456"
});
assert!(!filter.matches(¬_matching));
}
#[test]
fn test_meta_filter_custom() {
let filter = MetaFilter::default()
.with_custom("doc_type", "soap_note")
.with_custom("status", "active");
let matching = serde_json::json!({
"doc_type": "soap_note",
"status": "active"
});
assert!(filter.matches(&matching));
let missing_field = serde_json::json!({
"doc_type": "soap_note"
});
assert!(!filter.matches(&missing_field));
}
#[test]
fn test_memex_config_defaults() {
let config = MemexConfig::default();
assert_eq!(config.dimension, DEFAULT_REQUIRED_DIMENSION);
assert_eq!(
config.embedding_config.required_dimension,
DEFAULT_REQUIRED_DIMENSION
);
assert_eq!(config.namespace, "default");
assert_eq!(config.effective_db_path(), "~/.rmcp-servers/memex/lancedb");
}
#[test]
fn test_memex_config_builder() {
let config = MemexConfig::new("vista", "patients")
.with_dimension(1024)
.with_db_path("/custom/path/db");
assert_eq!(config.app_name, "vista");
assert_eq!(config.namespace, "patients");
assert_eq!(config.dimension, 1024);
assert_eq!(config.embedding_config.required_dimension, 1024);
assert_eq!(config.effective_db_path(), "/custom/path/db");
}
#[test]
fn test_memex_config_with_embedding_config_syncs_dimension() {
let embedding_config = EmbeddingConfig {
required_dimension: 768,
..EmbeddingConfig::default()
};
let config = MemexConfig::new("sync-test", "ns").with_embedding_config(embedding_config);
assert_eq!(config.dimension, 768);
assert_eq!(config.embedding_config.required_dimension, 768);
}
#[test]
fn test_memex_config_sync_dimension_fields_uses_non_default_embedding_dimension() {
let mut config = MemexConfig::default();
config.embedding_config.required_dimension = 1024;
config.sync_dimension_fields().unwrap();
assert_eq!(config.dimension, 1024);
assert_eq!(config.embedding_config.required_dimension, 1024);
}
#[test]
fn test_memex_config_sync_dimension_fields_rejects_true_conflict() {
let mut config = MemexConfig {
dimension: 768,
..MemexConfig::default()
};
config.embedding_config.required_dimension = 1024;
let err = config.sync_dimension_fields().unwrap_err().to_string();
assert!(err.contains("conflicts with embedding_config.required_dimension"));
}
#[test]
fn test_store_item() {
let item = StoreItem::new("doc-1", "Hello world")
.with_metadata(serde_json::json!({"type": "greeting"}));
assert_eq!(item.id, "doc-1");
assert_eq!(item.text, "Hello world");
assert_eq!(item.metadata["type"], "greeting");
}
#[test]
fn test_store_item_default_metadata() {
let item = StoreItem::new("doc-1", "Hello world");
assert_eq!(item.id, "doc-1");
assert_eq!(item.text, "Hello world");
assert!(item.metadata.is_object());
assert!(item.metadata.as_object().unwrap().is_empty());
}
#[test]
fn test_meta_filter_empty_matches_all() {
let filter = MetaFilter::default();
let any_metadata = serde_json::json!({
"patient_id": "P-123",
"visit_id": "V-456",
"random_field": "value"
});
assert!(filter.matches(&any_metadata));
let empty = serde_json::json!({});
assert!(filter.matches(&empty));
}
#[test]
fn test_meta_filter_date_range() {
let filter = MetaFilter {
date_from: Some("2024-01-01".to_string()),
date_to: Some("2024-12-31".to_string()),
..Default::default()
};
let in_range = serde_json::json!({
"date": "2024-06-15"
});
assert!(filter.matches(&in_range));
let before = serde_json::json!({
"date": "2023-12-31"
});
assert!(!filter.matches(&before));
let after = serde_json::json!({
"date": "2025-01-01"
});
assert!(!filter.matches(&after));
let no_date = serde_json::json!({
"patient_id": "P-123"
});
assert!(filter.matches(&no_date));
}
#[test]
fn test_meta_filter_for_visit() {
let filter = MetaFilter::for_visit("V-789");
let matching = serde_json::json!({
"visit_id": "V-789",
"patient_id": "P-123"
});
assert!(filter.matches(&matching));
let not_matching = serde_json::json!({
"visit_id": "V-other",
"patient_id": "P-123"
});
assert!(!filter.matches(¬_matching));
}
#[test]
fn test_meta_filter_combined() {
let filter = MetaFilter {
patient_id: Some("P-123".to_string()),
doc_type: Some("soap_note".to_string()),
..Default::default()
};
let both_match = serde_json::json!({
"patient_id": "P-123",
"doc_type": "soap_note"
});
assert!(filter.matches(&both_match));
let wrong_type = serde_json::json!({
"patient_id": "P-123",
"doc_type": "prescription"
});
assert!(!filter.matches(&wrong_type));
let missing = serde_json::json!({
"patient_id": "P-123"
});
assert!(!filter.matches(&missing));
}
#[test]
fn test_batch_result_struct() {
let result = BatchResult {
success_count: 10,
failure_count: 2,
failed_ids: vec!["doc-5".to_string(), "doc-8".to_string()],
};
assert_eq!(result.success_count, 10);
assert_eq!(result.failure_count, 2);
assert_eq!(result.failed_ids.len(), 2);
assert!(result.failed_ids.contains(&"doc-5".to_string()));
}
#[test]
fn test_memex_config_with_bm25() {
use crate::search::BM25Config;
let bm25_config = BM25Config::default();
let config = MemexConfig::new("test-app", "docs").with_bm25(bm25_config);
assert!(config.enable_bm25);
assert!(config.bm25_config.is_some());
}
#[test]
fn test_memex_config_effective_bm25_path() {
let config = MemexConfig::new("my-app", "docs");
assert_eq!(config.effective_bm25_path(), "~/.rmcp-servers/my-app/bm25");
}
#[test]
fn test_resolved_bm25_config_uses_app_specific_path_for_hybrid_defaults() {
let config = MemexConfig::new("my-app", "docs");
let bm25 = config
.resolved_bm25_config()
.expect("hybrid defaults should provision BM25");
assert_eq!(bm25.index_path, "~/.rmcp-servers/my-app/bm25");
}
#[test]
fn test_resolved_hybrid_config_reuses_resolved_bm25_path() {
let config = MemexConfig::new("my-app", "docs");
let hybrid = config.resolved_hybrid_config();
assert_eq!(hybrid.bm25.index_path, "~/.rmcp-servers/my-app/bm25");
}
#[test]
fn test_meta_filter_serialization() {
let filter = MetaFilter::for_patient("P-123").with_custom("status", "active");
let json = serde_json::to_string(&filter).unwrap();
let deserialized: MetaFilter = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.patient_id, Some("P-123".to_string()));
assert_eq!(deserialized.custom.len(), 1);
assert_eq!(
deserialized.custom[0],
("status".to_string(), "active".to_string())
);
}
#[test]
fn test_memex_config_serialization() {
let config = MemexConfig::new("test", "ns")
.with_dimension(512)
.with_db_path("/tmp/test");
let json = serde_json::to_string(&config).unwrap();
let deserialized: MemexConfig = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.app_name, "test");
assert_eq!(deserialized.namespace, "ns");
assert_eq!(deserialized.dimension, 512);
assert_eq!(deserialized.embedding_config.required_dimension, 512);
assert_eq!(deserialized.db_path, Some("/tmp/test".to_string()));
}
#[test]
fn test_store_item_serialization() {
let item =
StoreItem::new("id-1", "content").with_metadata(serde_json::json!({"key": "value"}));
let json = serde_json::to_string(&item).unwrap();
let deserialized: StoreItem = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.id, "id-1");
assert_eq!(deserialized.text, "content");
assert_eq!(deserialized.metadata["key"], "value");
}
}