semantic-memory 0.5.1

Local-first hybrid semantic search (SQLite + FTS5 + usearch 2.25) with bitemporal truth and typed receipts
Documentation
#![allow(clippy::expect_used)]

use semantic_memory::{
    EpisodeMeta, EpisodeOutcome, GraphDirection, MemoryConfig, MemoryStore, MockEmbedder,
    SearchConfig, SearchSource, SearchSourceType, VerificationStatus,
};
#[cfg(feature = "testing")]
use semantic_memory::{ReconcileAction, Role, VerifyMode};
use tempfile::TempDir;

fn open_store(dir: &TempDir) -> MemoryStore {
    let config = MemoryConfig {
        base_dir: dir.path().to_path_buf(),
        search: SearchConfig {
            bm25_weight: 2.0,
            vector_weight: 3.0,
            rrf_k: 10.0,
            recency_half_life_days: Some(30.0),
            recency_weight: 0.5,
            min_similarity: -1.0,
            ..Default::default()
        },
        ..Default::default()
    };
    let embedder = Box::new(MockEmbedder::new(config.embedding.dimensions));
    MemoryStore::open_with_embedder(config, embedder).expect("open store")
}

fn approx_eq(left: f64, right: f64, epsilon: f64) {
    assert!(
        (left - right).abs() <= epsilon,
        "left={left} right={right} epsilon={epsilon}"
    );
}

#[tokio::test]
async fn explainable_search_matches_configured_rrf_math() {
    let dir = TempDir::new().unwrap();
    let store = open_store(&dir);

    store
        .add_fact("general", "fusion proof fact", None, None)
        .await
        .unwrap();

    let explained = store
        .search_explained(
            "fusion proof fact",
            Some(1),
            None,
            Some(&[SearchSourceType::Facts]),
        )
        .await
        .unwrap();

    let top = &explained[0];
    let breakdown = &top.breakdown;
    let expected_bm25 = 2.0 / 11.0;
    let expected_vector = 3.0 / 11.0;
    let expected_recency = 0.5 / 11.0;

    assert_eq!(breakdown.bm25_rank, Some(1));
    assert_eq!(breakdown.vector_rank, Some(1));
    approx_eq(breakdown.bm25_contribution.unwrap(), expected_bm25, 1e-6);
    approx_eq(
        breakdown.vector_contribution.unwrap(),
        expected_vector,
        1e-6,
    );
    approx_eq(breakdown.recency_score.unwrap(), expected_recency, 5e-3);
    approx_eq(
        breakdown.rrf_score,
        expected_bm25 + expected_vector + breakdown.recency_score.unwrap(),
        5e-3,
    );
    approx_eq(top.result.score, breakdown.rrf_score, 1e-9);
    assert_eq!(breakdown.bm25_weight, 2.0);
    assert_eq!(breakdown.vector_weight, 3.0);
    assert_eq!(breakdown.rrf_k, 10.0);
}

#[tokio::test]
async fn episodes_participate_in_generic_search_and_explanations() {
    let dir = TempDir::new().unwrap();
    let store = open_store(&dir);

    store
        .ingest_document(
            "Auth Incident",
            "The auth module regression caused repeated login failures.",
            "ops",
            None,
            None,
        )
        .await
        .unwrap();
    let document_id = store.list_documents("ops", 10, 0).await.unwrap()[0]
        .id
        .clone();

    store
        .ingest_episode(
            &document_id,
            &EpisodeMeta {
                cause_ids: vec!["fact-auth-1".to_string()],
                effect_type: "regression".to_string(),
                outcome: EpisodeOutcome::Pending,
                confidence: 0.8,
                verification_status: VerificationStatus::Unverified,
                experiment_id: None,
                valid_time: None,
                fact_digest: None,
            },
        )
        .await
        .unwrap();

    let results = store
        .search(
            "login failure regression",
            Some(5),
            Some(&["ops"]),
            Some(&[SearchSourceType::Episodes]),
        )
        .await
        .unwrap();
    assert!(!results.is_empty());
    assert!(matches!(
        &results[0].source,
        SearchSource::Episode { document_id: id, .. } if id == &document_id
    ));

    let explained = store
        .search_explained(
            "login failure regression",
            Some(5),
            Some(&["ops"]),
            Some(&[SearchSourceType::Episodes]),
        )
        .await
        .unwrap();
    assert!(!explained.is_empty());
    approx_eq(
        explained[0].result.score,
        explained[0].breakdown.rrf_score,
        1e-9,
    );
}

#[tokio::test]
async fn graph_view_exposes_document_chunk_and_episode_causal_links() {
    let dir = TempDir::new().unwrap();
    let store = open_store(&dir);

    let fact_id = store
        .add_fact("ops", "auth service timeout cause", None, None)
        .await
        .unwrap();
    store
        .ingest_document(
            "Incident Report",
            "The report describes the auth service timeout and downstream login failures.",
            "ops",
            None,
            None,
        )
        .await
        .unwrap();
    let document_id = store.list_documents("ops", 10, 0).await.unwrap()[0]
        .id
        .clone();
    let episode_id = store
        .ingest_episode(
            &document_id,
            &EpisodeMeta {
                cause_ids: vec![fact_id.clone()],
                effect_type: "incident".to_string(),
                outcome: EpisodeOutcome::Confirmed,
                confidence: 0.9,
                verification_status: VerificationStatus::Verified {
                    method: "manual".to_string(),
                    at: "2026-03-06 00:00:00".to_string(),
                },
                experiment_id: Some("exp-incident".to_string()),
                valid_time: None,
                fact_digest: None,
            },
        )
        .await
        .unwrap();

    let graph = store.graph_view();
    let doc_edges = graph
        .neighbors(
            &format!("document:{document_id}"),
            GraphDirection::Outgoing,
            1,
        )
        .unwrap();
    assert!(doc_edges
        .iter()
        .any(|edge| edge.target.starts_with("chunk:")));
    assert!(doc_edges
        .iter()
        .any(|edge| edge.target == format!("episode:{episode_id}")));

    let path = graph
        .path(
            &format!("episode:{episode_id}"),
            &format!("fact:{fact_id}"),
            2,
        )
        .unwrap()
        .unwrap();
    assert_eq!(
        path,
        vec![format!("episode:{episode_id}"), format!("fact:{fact_id}")]
    );
}

#[cfg(feature = "testing")]
#[tokio::test]
async fn corruption_is_visible_in_live_reads_and_integrity_reports() {
    let dir = TempDir::new().unwrap();
    let store = open_store(&dir);

    let session_id = store.create_session("chat").await.unwrap();
    let message_id = store
        .add_message_fts(&session_id, Role::User, "corruption seam", None, None)
        .await
        .unwrap();

    store
        .raw_execute(
            "UPDATE messages SET metadata = ?1 WHERE id = ?2",
            vec!["{not-json".to_string(), message_id.to_string()],
        )
        .await
        .unwrap();

    let err = store
        .get_recent_messages(&session_id, 10)
        .await
        .unwrap_err();
    assert_eq!(err.kind(), "corrupt_data");

    let report = store.verify_integrity(VerifyMode::Full).await.unwrap();
    assert!(report
        .issues
        .iter()
        .any(|issue| issue.contains("invalid metadata")));
}

#[cfg(feature = "testing")]
#[tokio::test]
async fn reconcile_reembed_repairs_missing_quantized_embeddings() {
    let dir = TempDir::new().unwrap();
    let store = open_store(&dir);

    store
        .add_fact("ops", "quantized repair target", None, None)
        .await
        .unwrap();

    store
        .raw_execute("UPDATE facts SET embedding_q8 = NULL", vec![])
        .await
        .unwrap();

    let before = store.verify_integrity(VerifyMode::Full).await.unwrap();
    assert!(before
        .issues
        .iter()
        .any(|issue| issue.contains("missing quantized embedding")));

    let after = store.reconcile(ReconcileAction::ReEmbed).await.unwrap();
    assert!(
        after.ok,
        "reconcile should repair missing q8 blobs: {:?}",
        after.issues
    );
}