Skip to main content

semantic_memory/
vector_snapshot.rs

1//! Deterministic snapshots over authoritative SQLite f32 embeddings.
2//!
3//! These snapshots are used only to build/check rebuildable derived candidate artifacts. The
4//! authoritative vectors remain the f32 embeddings in SQLite.
5
6use crate::db;
7use crate::error::MemoryError;
8use rusqlite::Connection;
9use serde::{Deserialize, Serialize};
10
11/// One authoritative embedding row included in a derived candidate snapshot.
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
13pub struct EmbeddingSnapshotRow {
14    pub item_id: String,
15    pub source_type: String,
16    pub embedding: Vec<f32>,
17}
18
19/// Stable deterministic digest envelope for authoritative f32 embedding rows.
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
21pub struct EmbeddingSnapshotV1 {
22    pub embedding_snapshot_digest: String,
23    pub source_digest: String,
24    pub vector_dim: usize,
25    pub rows: Vec<EmbeddingSnapshotRow>,
26}
27
28fn digest_hex(
29    domain: &str,
30    vector_dim: usize,
31    rows: &[EmbeddingSnapshotRow],
32    include_embeddings: bool,
33) -> String {
34    let mut sorted = rows.to_vec();
35    sorted.sort_by(|a, b| {
36        (a.source_type.as_str(), a.item_id.as_str())
37            .cmp(&(b.source_type.as_str(), b.item_id.as_str()))
38    });
39
40    let mut hasher = blake3::Hasher::new();
41    hasher.update(domain.as_bytes());
42    hasher.update(&[0]);
43    hasher.update(&(vector_dim as u64).to_le_bytes());
44    hasher.update(&[0]);
45    for row in &sorted {
46        hasher.update(row.source_type.as_bytes());
47        hasher.update(&[0]);
48        hasher.update(row.item_id.as_bytes());
49        hasher.update(&[0]);
50        if include_embeddings {
51            for value in &row.embedding {
52                hasher.update(&value.to_le_bytes());
53            }
54            hasher.update(&[0]);
55        }
56    }
57    format!("blake3:{}", hasher.finalize().to_hex())
58}
59
60/// Build a stable snapshot from in-memory authoritative f32 rows.
61pub fn build_embedding_snapshot(
62    rows: Vec<EmbeddingSnapshotRow>,
63    vector_dim: usize,
64) -> Result<EmbeddingSnapshotV1, MemoryError> {
65    for row in &rows {
66        if row.item_id.is_empty() || row.source_type.is_empty() {
67            return Err(MemoryError::Other(
68                "embedding snapshot rows require non-empty item_id and source_type".to_string(),
69            ));
70        }
71        if row.embedding.len() != vector_dim {
72            return Err(MemoryError::DimensionMismatch {
73                expected: vector_dim,
74                actual: row.embedding.len(),
75            });
76        }
77    }
78    Ok(EmbeddingSnapshotV1 {
79        embedding_snapshot_digest: digest_hex(
80            "semantic-memory.embedding_snapshot.v1",
81            vector_dim,
82            &rows,
83            true,
84        ),
85        source_digest: digest_hex(
86            "semantic-memory.embedding_source.v1",
87            vector_dim,
88            &rows,
89            false,
90        ),
91        vector_dim,
92        rows,
93    })
94}
95
96/// Load all authoritative embeddings from SQLite and build a deterministic snapshot.
97pub fn load_embedding_snapshot_from_db(
98    conn: &Connection,
99    vector_dim: usize,
100) -> Result<EmbeddingSnapshotV1, MemoryError> {
101    let mut stmt = conn.prepare(
102        "SELECT id, 'fact', embedding FROM facts WHERE embedding IS NOT NULL
103         UNION ALL
104         SELECT id, 'chunk', embedding FROM chunks WHERE embedding IS NOT NULL
105         UNION ALL
106         SELECT CAST(id AS TEXT), 'message', embedding FROM messages WHERE embedding IS NOT NULL
107         UNION ALL
108         SELECT episode_id, 'episode', embedding FROM episodes WHERE embedding IS NOT NULL",
109    )?;
110    let rows = stmt.query_map([], |row| {
111        Ok((
112            row.get::<_, String>(0)?,
113            row.get::<_, String>(1)?,
114            row.get::<_, Vec<u8>>(2)?,
115        ))
116    })?;
117
118    let mut snapshot_rows = Vec::new();
119    for row in rows {
120        let (item_id, source_type, blob) = row?;
121        snapshot_rows.push(EmbeddingSnapshotRow {
122            item_id,
123            source_type,
124            embedding: db::decode_f32_le(&blob, vector_dim)?,
125        });
126    }
127    build_embedding_snapshot(snapshot_rows, vector_dim)
128}
129
130/// Stable digest for a single authoritative embedding row.
131pub fn embedding_row_digest(
132    row: &EmbeddingSnapshotRow,
133    vector_dim: usize,
134) -> Result<String, MemoryError> {
135    Ok(build_embedding_snapshot(vec![row.clone()], vector_dim)?.embedding_snapshot_digest)
136}