Skip to main content

semantic_memory/
lib.rs

1#![allow(deprecated)]
2
3//! # semantic-memory
4//!
5//! Local-first semantic memory backed by authoritative SQLite state and an optional recoverable
6//! HNSW sidecar.
7//!
8//! The crate stores facts, chunked documents, conversation messages, and searchable episodes in
9//! SQLite. Search combines BM25 (FTS5) and vector retrieval with Reciprocal Rank Fusion, and
10//! `search_explained()` returns the exact scoring breakdown from the live pipeline.
11//!
12//! Concurrency uses one writer connection plus a pool of WAL-enabled reader connections.
13//! Durable writes are committed to SQLite first; any required HNSW sidecar mutations are journaled
14//! in SQLite and replayed on open, flush, rebuild, or reconcile.
15//!
16//! `search()` targets facts, document chunks, and episodes by default. Message retrieval is
17//! available through `search_conversations()` or by opting into
18//! [`SearchSourceType::Messages`](crate::SearchSourceType::Messages).
19//!
20//! Integrity tooling is strict about malformed stored data: invalid roles, JSON, enums, embedding
21//! blobs, quantized blobs, and sidecar drift are surfaced through `verify_integrity()` instead of
22//! being silently converted into defaults. `reconcile()` can rebuild FTS or fully re-embed and
23//! rebuild derived state from SQLite.
24//!
25//! `store.graph_view()` exposes a deterministic graph traversal layer over namespaces, facts,
26//! documents, chunks, sessions, messages, episodes, and semantic/temporal/causal links derived
27//! from SQLite state.
28//!
29//! ## Quick Start
30//!
31//! ```rust,no_run
32//! use semantic_memory::{MemoryConfig, MemoryStore};
33//!
34//! # async fn example() -> Result<(), semantic_memory::MemoryError> {
35//! let store = MemoryStore::open(MemoryConfig::default())?;
36//!
37//! // Store a fact
38//! store.add_fact("general", "Rust was first released in 2015", None, None).await?;
39//!
40//! // Search
41//! let results = store.search("when was Rust released", None, None, None).await?;
42//! # Ok(())
43//! # }
44//! ```
45//!
46//! ## Operational Notes
47//!
48//! - SQLite is authoritative for all durable records and embeddings.
49//! - HNSW is an acceleration sidecar. Pending sidecar mutations are journaled in SQLite, so a
50//!   sidecar failure does not imply the SQLite write rolled back.
51//! - WAL mode plus pooled reader connections allows concurrent reads while writes serialize through
52//!   the writer connection.
53//! - `search_explained()` reflects the exact ranking math used by the active search pipeline,
54//!   including reranking from exact f32 cosine similarity when configured.
55
56// At least one search backend must be enabled.
57#[cfg(not(any(feature = "hnsw", feature = "brute-force")))]
58compile_error!("At least one search backend feature must be enabled: 'hnsw' or 'brute-force'");
59
60pub mod chunker;
61pub mod config;
62pub(crate) mod conversation;
63pub mod db;
64pub(crate) mod documents;
65pub mod embedder;
66pub(crate) mod episodes;
67pub mod error;
68mod graph;
69#[cfg(feature = "hnsw")]
70pub mod hnsw;
71#[cfg(feature = "hnsw")]
72mod hnsw_ops;
73mod json_compat_import;
74pub(crate) mod knowledge;
75#[cfg(feature = "poly-kv-codec")]
76pub mod poly_kv_bridge;
77mod pool;
78mod projection_batch;
79mod projection_derivation;
80/// Compatibility-only legacy import surface.
81///
82/// This module exists only for migration compatibility with pre-V11 import paths.
83#[deprecated(
84    since = "0.6.0",
85    note = "Legacy V10 import path is migration-only. Use `import_projection_batch()` with `ProjectionImportBatchV3` on the canonical lane."
86)]
87#[doc(hidden)]
88pub mod projection_import;
89mod projection_lane;
90mod projection_legacy_compat;
91pub(crate) mod projection_storage;
92pub mod quantize;
93pub mod quantize_governed;
94#[cfg(feature = "fib-quant-codec")]
95pub mod scoring;
96pub mod search;
97pub mod storage;
98mod store_support;
99pub mod tokenizer;
100pub mod types;
101pub mod vector_codec;
102
103// Re-export primary public types.
104pub use config::{
105    ChunkingConfig, DerivedVectorBackendPolicy, EmbeddingConfig, MemoryConfig, MemoryLimits,
106    PoolConfig, SearchConfig,
107};
108pub use db::{IntegrityReport, ReconcileAction, VerifyMode};
109pub use embedder::{Embedder, MockEmbedder, OllamaEmbedder};
110pub use error::MemoryError;
111#[cfg(feature = "hnsw")]
112pub use hnsw::{HnswConfig, HnswHit, HnswIndex};
113pub(crate) use projection_lane::projection_import_failure_id;
114pub use projection_lane::{
115    ProjectionImportFailureReceiptEntry, ProjectionImportLogEntry, ProjectionImportResult,
116};
117pub use quantize::{pack_quantized, unpack_quantized, QuantizedVector, Quantizer};
118#[cfg(feature = "fib-quant-codec")]
119pub use scoring::fib_scorer::{FibGramScorer, PreparedFibQuery};
120pub use storage::StoragePaths;
121pub use tokenizer::{EstimateTokenCounter, TokenCounter};
122pub use types::{
123    ChunkManifestChunkMapping, ChunkManifestEntry, ChunkManifestIngestOptions,
124    ChunkManifestIngestResult, Document, EmbeddingDisplacement, EpisodeAsOfReceiptV1, EpisodeMeta,
125    EpisodeOutcome, ExactnessProfile, ExplainedResult, ExplainedResultAnswerV1,
126    ExplainedSearchResponse, Fact, GraphDirection, GraphEdge, GraphEdgeType, GraphView,
127    MemoryStats, Message, NamespaceDeleteReport, ProjectionClaimVersion, ProjectionEntityAlias,
128    ProjectionEpisode, ProjectionEvidenceRef, ProjectionQuery, ProjectionRelationVersion,
129    ReceiptMode, Role, ScoreBreakdown, SearchContext, SearchReceiptAnswersV1, SearchReplayReportV1,
130    SearchResponse, SearchResult, SearchSource, SearchSourceType, Session, TextChunk,
131    VectorArtifactBuildReceiptV1, VectorSearchReceiptV1, VerificationStatus,
132};
133#[cfg(feature = "turbo-quant-codec")]
134pub use vector_codec::TurboQuantCodec;
135#[cfg(feature = "fib-quant-codec")]
136pub use vector_codec::{FibQuantCodec, FibQuantPreparedQuery};
137pub use vector_codec::{
138    RawF32Codec, Sq8Codec, VectorArtifactV1, VectorCodec, VectorCodecProfileV1,
139};
140
141use std::sync::Arc;
142
143const MAX_TOP_K: usize = 1_000;
144#[cfg(feature = "hnsw")]
145const MAX_HNSW_CANDIDATES: usize = 10_000;
146
147pub(crate) use store_support::{
148    as_str_slice, build_episode_search_text, merge_trace_ctx, to_owned_string_vec,
149    verification_status_for_outcome,
150};
151
152#[cfg(feature = "hnsw")]
153fn verify_hnsw_key_level_integrity(
154    conn: &rusqlite::Connection,
155    dimensions: usize,
156    node_vectors: &std::collections::HashMap<usize, Vec<f32>>,
157    sidecar_files_exist: bool,
158) -> Result<Vec<String>, MemoryError> {
159    let mut issues = Vec::new();
160    let mut live_rows: std::collections::HashMap<String, Vec<f32>> =
161        std::collections::HashMap::new();
162
163    let mut live_stmt = conn.prepare(
164        "SELECT 'fact:' || id, embedding FROM facts WHERE embedding IS NOT NULL
165         UNION ALL
166         SELECT 'chunk:' || id, embedding FROM chunks WHERE embedding IS NOT NULL
167         UNION ALL
168         SELECT 'msg:' || id, embedding FROM messages WHERE embedding IS NOT NULL
169         UNION ALL
170         SELECT 'episode:' || episode_id, embedding FROM episodes WHERE embedding IS NOT NULL",
171    )?;
172    let live_iter = live_stmt.query_map([], |row| {
173        Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?))
174    })?;
175    for row in live_iter {
176        let (key, blob) = row?;
177        match db::decode_f32_le(&blob, dimensions) {
178            Ok(vector) => {
179                live_rows.insert(key, vector);
180            }
181            Err(err) => issues.push(format!(
182                "HNSW live embedding row {key} has invalid vector: {err}"
183            )),
184        }
185    }
186
187    if !live_rows.is_empty() && !sidecar_files_exist {
188        issues.push(format!(
189            "HNSW sidecar files are missing while {} embedded rows exist in SQLite",
190            live_rows.len()
191        ));
192    }
193
194    let keymap_exists: bool = conn
195        .query_row(
196            "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='hnsw_keymap'",
197            [],
198            |row| row.get(0),
199        )
200        .unwrap_or(false);
201    if !keymap_exists {
202        if !live_rows.is_empty() {
203            issues.push("HNSW keymap table missing while embedded SQLite rows exist".to_string());
204        }
205        return Ok(issues);
206    }
207
208    let mut active_keymap: std::collections::HashMap<String, usize> =
209        std::collections::HashMap::new();
210    let mut keymap_stmt =
211        conn.prepare("SELECT node_id, item_key FROM hnsw_keymap WHERE deleted = 0")?;
212    let keymap_iter = keymap_stmt.query_map([], |row| {
213        Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
214    })?;
215    for row in keymap_iter {
216        let (node_id_raw, key) = row?;
217        let Some((domain, raw_id)) = key.split_once(':') else {
218            issues.push(format!("HNSW keymap entry has malformed key: {key}"));
219            continue;
220        };
221        if !matches!(domain, "fact" | "chunk" | "msg" | "episode") || raw_id.is_empty() {
222            issues.push(format!(
223                "HNSW keymap entry has unsupported key domain: {key}"
224            ));
225            continue;
226        }
227        if domain == "msg" && raw_id.parse::<i64>().is_err() {
228            issues.push(format!("HNSW message key has non-integer row id: {key}"));
229            continue;
230        }
231        let node_id = match usize::try_from(node_id_raw) {
232            Ok(node_id) => node_id,
233            Err(err) => {
234                issues.push(format!(
235                    "HNSW keymap node_id {node_id_raw} is invalid: {err}"
236                ));
237                continue;
238            }
239        };
240        active_keymap.insert(key, node_id);
241    }
242
243    for key in live_rows.keys() {
244        if !active_keymap.contains_key(key) {
245            issues.push(format!(
246                "HNSW keymap missing live embedded SQLite row: {key}"
247            ));
248        }
249    }
250
251    for (key, node_id) in &active_keymap {
252        let Some(live_vector) = live_rows.get(key) else {
253            issues.push(format!(
254                "HNSW keymap has stale active entry without live embedded SQLite row: {key}"
255            ));
256            continue;
257        };
258        let Some(index_vector) = node_vectors.get(node_id) else {
259            issues.push(format!(
260                "HNSW keymap entry {key} points to missing in-memory node vector {node_id}"
261            ));
262            continue;
263        };
264        if index_vector.len() != live_vector.len()
265            || index_vector
266                .iter()
267                .zip(live_vector)
268                .any(|(left, right)| left.to_bits() != right.to_bits())
269        {
270            issues.push(format!(
271                "HNSW keymap entry {key} points to node {node_id} whose vector does not match the authoritative SQLite embedding"
272            ));
273        }
274    }
275
276    if active_keymap.len() != live_rows.len() {
277        issues.push(format!(
278            "HNSW keymap drift: {} active keymap rows vs {} embedded SQLite rows",
279            active_keymap.len(),
280            live_rows.len()
281        ));
282    }
283
284    Ok(issues)
285}
286
287/// Compatibility-only public access to retained legacy surfaces.
288#[doc(hidden)]
289pub mod compat {
290    #[deprecated(
291        since = "0.5.0",
292        note = "Legacy ImportEnvelope is migration-only. New integrations should use `ProjectionImportBatchV3` on the canonical lane."
293    )]
294    #[doc(hidden)]
295    #[allow(deprecated)]
296    pub mod legacy_import_envelope {
297        pub use crate::projection_import::{
298            ImportEnvelope, ImportProjectionFreshness, ImportReceipt, ImportRecord, ImportStatus,
299        };
300        pub use stack_ids::EnvelopeId;
301    }
302
303    #[deprecated(
304        since = "0.5.0",
305        note = "Legacy trace_id is migration-only. Use `stack_ids::TraceCtx`."
306    )]
307    #[doc(hidden)]
308    #[allow(deprecated)]
309    pub mod compat_trace_id {
310        pub use crate::types::TraceId;
311    }
312}
313
314/// Thread-safe handle to the memory database.
315///
316/// Clone is cheap (Arc internals). `Send + Sync`.
317#[derive(Clone)]
318pub struct MemoryStore {
319    inner: Arc<MemoryStoreInner>,
320}
321
322struct MemoryStoreInner {
323    pool: pool::SqlitePool,
324    embedder: Box<dyn Embedder>,
325    embedding_permits: Arc<tokio::sync::Semaphore>,
326    config: MemoryConfig,
327    paths: StoragePaths,
328    token_counter: Arc<dyn TokenCounter>,
329    #[cfg(feature = "hnsw")]
330    hnsw_index: std::sync::RwLock<HnswIndex>,
331}
332
333#[cfg(feature = "hnsw")]
334impl Drop for MemoryStoreInner {
335    fn drop(&mut self) {
336        if !self.paths.hnsw_dir.exists() {
337            tracing::debug!(
338                path = %self.paths.hnsw_dir.display(),
339                "Skipping HNSW drop flush because the sidecar directory no longer exists"
340            );
341            return;
342        }
343
344        let pending_ops = match self.pool.with_read_conn(db::pending_index_op_count) {
345            Ok(count) => count,
346            Err(err) => {
347                tracing::warn!("Failed to inspect pending HNSW work on drop: {}", err);
348                0
349            }
350        };
351
352        if pending_ops > 0 {
353            if let Err(err) =
354                hnsw_ops::recover_hnsw_sidecar_sync(&self.pool, &self.paths, &self.config.hnsw)
355            {
356                tracing::error!("Failed to recover and flush HNSW on drop: {}", err);
357            }
358            return;
359        }
360
361        let hnsw_guard = match self.hnsw_index.read() {
362            Ok(g) => g,
363            Err(_) => {
364                tracing::warn!("HNSW RwLock poisoned on drop — skipping save");
365                return;
366            }
367        };
368
369        if let Err(err) = hnsw_ops::save_hnsw_sidecar(
370            &hnsw_guard,
371            &self.paths.hnsw_dir,
372            &self.paths.hnsw_basename,
373        ) {
374            tracing::error!("Failed to save HNSW index on drop: {}", err);
375        }
376
377        // Flush key mappings to SQLite
378        if let Err(e) = self
379            .pool
380            .with_write_conn(|conn| hnsw_guard.flush_keymap(conn))
381        {
382            tracing::error!("Failed to flush HNSW keymap on drop: {}", e);
383        }
384    }
385}
386
387impl MemoryStore {
388    /// Run read-only work on a pooled reader connection on a blocking thread.
389    ///
390    /// This prevents SQLite I/O from stalling the tokio executor while allowing
391    /// multiple concurrent readers under WAL mode.
392    async fn with_read_conn<F, T>(&self, f: F) -> Result<T, MemoryError>
393    where
394        F: FnOnce(&rusqlite::Connection) -> Result<T, MemoryError> + Send + 'static,
395        T: Send + 'static,
396    {
397        let inner = self.inner.clone();
398        tokio::task::spawn_blocking(move || -> Result<T, MemoryError> {
399            inner.pool.with_read_conn(f)
400        })
401        .await
402        .map_err(|e| MemoryError::Other(format!("Blocking task panicked: {}", e)))?
403    }
404
405    /// Run write-capable work on the single writer connection on a blocking thread.
406    async fn with_write_conn<F, T>(&self, f: F) -> Result<T, MemoryError>
407    where
408        F: FnOnce(&rusqlite::Connection) -> Result<T, MemoryError> + Send + 'static,
409        T: Send + 'static,
410    {
411        let inner = self.inner.clone();
412        tokio::task::spawn_blocking(move || -> Result<T, MemoryError> {
413            inner.pool.with_write_conn(f)
414        })
415        .await
416        .map_err(|e| MemoryError::Other(format!("Blocking task panicked: {}", e)))?
417    }
418
419    async fn persist_search_receipt(
420        &self,
421        receipt: &VectorSearchReceiptV1,
422    ) -> Result<(), MemoryError> {
423        let receipt = receipt.clone();
424        self.with_write_conn(move |conn| db::store_search_receipt(conn, &receipt))
425            .await
426    }
427
428    /// Run HNSW search on a blocking thread to avoid holding std::sync::RwLock
429    /// across await points (CONC-001).
430    #[cfg(feature = "hnsw")]
431    async fn hnsw_search_blocking(
432        &self,
433        query_embedding: Vec<f32>,
434        candidates: usize,
435    ) -> Vec<HnswHit> {
436        let inner = self.inner.clone();
437        tokio::task::spawn_blocking(move || {
438            let guard = inner.hnsw_index.read().unwrap_or_else(|e| e.into_inner());
439            match guard.search(&query_embedding, candidates) {
440                Ok(hits) => hits,
441                Err(e) => {
442                    tracing::error!(
443                        "HNSW search failed, falling back to brute-force vector search: {}",
444                        e
445                    );
446                    Vec::new()
447                }
448            }
449        })
450        .await
451        .unwrap_or_else(|e| {
452            tracing::error!("HNSW search blocking task panicked: {}", e);
453            Vec::new()
454        })
455    }
456
457    #[cfg(feature = "hnsw")]
458    fn sync_pending_hnsw_ops_blocking(&self) -> Result<usize, MemoryError> {
459        hnsw_ops::sync_pending_hnsw_sidecar(&self.inner)
460    }
461
462    #[cfg(feature = "hnsw")]
463    async fn sync_pending_hnsw_ops(&self) -> Result<usize, MemoryError> {
464        let inner = self.inner.clone();
465        tokio::task::spawn_blocking(move || hnsw_ops::sync_pending_hnsw_sidecar(&inner))
466            .await
467            .map_err(|e| MemoryError::Other(format!("Blocking task panicked: {}", e)))?
468    }
469
470    #[cfg(feature = "hnsw")]
471    async fn sync_pending_hnsw_ops_best_effort(&self, operation: &'static str) {
472        if let Err(err) = self.sync_pending_hnsw_ops().await {
473            tracing::warn!(
474                operation,
475                error = %err,
476                "SQLite write committed but HNSW sidecar sync is still pending"
477            );
478        } else {
479            self.maybe_flush_hnsw();
480        }
481    }
482
483    /// Open or create a memory store at the configured base directory.
484    ///
485    /// Creates the directory if it doesn't exist, opens/creates SQLite,
486    /// runs migrations, and initializes the HNSW index.
487    pub fn open(config: MemoryConfig) -> Result<Self, MemoryError> {
488        let config = config.normalize_and_validate()?;
489        let embedder = Box::new(OllamaEmbedder::try_new(&config.embedding)?);
490        Self::open_with_embedder(config, embedder)
491    }
492
493    /// Open with a custom embedder (for testing or non-Ollama providers).
494    #[allow(unused_mut)] // `config` is mutated only when the `hnsw` feature is enabled
495    pub fn open_with_embedder(
496        mut config: MemoryConfig,
497        embedder: Box<dyn Embedder>,
498    ) -> Result<Self, MemoryError> {
499        config = config.normalize_and_validate()?;
500        if embedder.dimensions() != config.embedding.dimensions {
501            return Err(MemoryError::DimensionMismatch {
502                expected: config.embedding.dimensions,
503                actual: embedder.dimensions(),
504            });
505        }
506        config.embedding.model = embedder.model_name().to_string();
507
508        let paths = StoragePaths::new(&config.base_dir);
509
510        // Create directory if needed
511        std::fs::create_dir_all(&paths.base_dir).map_err(|e| {
512            MemoryError::StorageError(format!(
513                "Failed to create directory {}: {}",
514                paths.base_dir.display(),
515                e
516            ))
517        })?;
518
519        let pool = pool::SqlitePool::open(&paths.sqlite_path, &config.pool, &config.limits)?;
520        pool.with_write_conn(|conn| db::check_embedding_metadata(conn, &config.embedding))?;
521
522        // Ensure HNSW dimensions match the embedding config
523        #[cfg(feature = "hnsw")]
524        {
525            config.hnsw.dimensions = config.embedding.dimensions;
526        }
527
528        let token_counter = config
529            .token_counter
530            .clone()
531            .unwrap_or_else(tokenizer::default_token_counter);
532
533        #[cfg(feature = "hnsw")]
534        let hnsw_index = {
535            let hnsw_config = config.hnsw.clone();
536
537            let embeddings_dirty = pool.with_read_conn(db::is_embeddings_dirty)?;
538            let pending_index_ops = pool.with_read_conn(db::pending_index_op_count)?;
539
540            if embeddings_dirty {
541                // Embedding model changed — old HNSW index is useless.
542                // Create a fresh index; reembed_all() will rebuild it.
543                tracing::warn!(
544                    "Embedding model changed — creating fresh HNSW index (old index is stale)"
545                );
546                pool.with_write_conn(|conn| {
547                    db::clear_all_pending_index_ops(conn)?;
548                    db::set_sidecar_dirty(conn, false)?;
549                    Ok(())
550                })?;
551                HnswIndex::new(hnsw_config)?
552            } else if pending_index_ops > 0 || pool.with_read_conn(db::is_sidecar_dirty)? {
553                tracing::warn!(
554                    pending_index_ops,
555                    "Recovering HNSW sidecar from SQLite because durable sidecar work exists"
556                );
557                hnsw_ops::recover_hnsw_sidecar_sync(&pool, &paths, &hnsw_config)?
558            } else if paths.hnsw_files_exist() {
559                tracing::info!("Loading HNSW index from {:?}", paths.hnsw_dir);
560                match HnswIndex::load(&paths.hnsw_dir, &paths.hnsw_basename, hnsw_config.clone()) {
561                    Ok(index) => {
562                        // Load key mappings from SQLite
563                        if let Err(e) = pool.with_write_conn(|conn| index.load_keymap(conn)) {
564                            tracing::warn!("Failed to load HNSW key mappings: {}. Mappings will be empty until rebuild.", e);
565                        }
566
567                        // Stale index detection: compare HNSW entry count vs SQLite
568                        // embedding count. A mismatch means the app crashed before
569                        // flushing HNSW, or keys were lost.
570                        let hnsw_count = index.len();
571                        let sqlite_count: i64 = pool.with_read_conn(|conn| {
572                            Ok(conn.query_row(
573                                    "SELECT (SELECT COUNT(*) FROM facts WHERE embedding IS NOT NULL) +
574                                        (SELECT COUNT(*) FROM chunks WHERE embedding IS NOT NULL) +
575                                        (SELECT COUNT(*) FROM messages WHERE embedding IS NOT NULL) +
576                                        (SELECT COUNT(*) FROM episodes WHERE embedding IS NOT NULL)",
577                                    [],
578                                    |row| row.get(0),
579                                )?)
580                        })?;
581
582                        let drift = (sqlite_count - hnsw_count as i64).abs();
583                        if drift > 0 {
584                            tracing::warn!(
585                                hnsw_count,
586                                sqlite_count,
587                                drift,
588                                "HNSW index is stale — {} entries differ from SQLite. \
589                                 Likely caused by unclean shutdown. Triggering inline rebuild.",
590                                drift
591                            );
592                            // Discard the stale index and rebuild from SQLite
593                            let rebuilt =
594                                hnsw_ops::recover_hnsw_sidecar_sync(&pool, &paths, &hnsw_config)?;
595                            tracing::info!(
596                                active = rebuilt.len(),
597                                "HNSW index rebuilt after stale detection"
598                            );
599                            rebuilt
600                        } else {
601                            tracing::info!(
602                                "HNSW index loaded ({} active keys, in sync with SQLite)",
603                                hnsw_count
604                            );
605                            index
606                        }
607                    }
608                    Err(e) => {
609                        tracing::warn!(
610                            "Failed to load HNSW index: {}. Rebuilding sidecar from authoritative SQLite rows.",
611                            e
612                        );
613                        hnsw_ops::recover_hnsw_sidecar_sync(&pool, &paths, &hnsw_config)?
614                    }
615                }
616            } else {
617                // Check if SQLite has embeddings that should be in the index.
618                // This happens when: sidecar files were deleted, data dir was
619                // partially copied, app crashed before first flush, or HNSW was
620                // added after data already existed.
621                let orphan_count: i64 = pool.with_read_conn(|conn| {
622                    Ok(conn.query_row(
623                        "SELECT (SELECT COUNT(*) FROM facts WHERE embedding IS NOT NULL) +
624                                (SELECT COUNT(*) FROM chunks WHERE embedding IS NOT NULL) +
625                                (SELECT COUNT(*) FROM messages WHERE embedding IS NOT NULL) +
626                                (SELECT COUNT(*) FROM episodes WHERE embedding IS NOT NULL)",
627                        [],
628                        |row| row.get(0),
629                    )?)
630                })?;
631
632                if orphan_count > 0 {
633                    tracing::warn!(
634                        orphan_count,
635                        "HNSW sidecar files missing but {} embeddings exist in SQLite — \
636                         rebuilding index inline",
637                        orphan_count
638                    );
639                    let new_index =
640                        hnsw_ops::recover_hnsw_sidecar_sync(&pool, &paths, &hnsw_config)?;
641                    tracing::info!(
642                        active = new_index.len(),
643                        "HNSW index rebuilt from SQLite embeddings"
644                    );
645                    new_index
646                } else {
647                    tracing::info!("Creating new empty HNSW index (no embeddings in SQLite)");
648                    HnswIndex::new(hnsw_config)?
649                }
650            }
651        };
652
653        let store = Self {
654            inner: Arc::new(MemoryStoreInner {
655                pool,
656                embedder,
657                embedding_permits: Arc::new(tokio::sync::Semaphore::new(
658                    config.limits.max_embedding_concurrency,
659                )),
660                config,
661                paths,
662                token_counter,
663                #[cfg(feature = "hnsw")]
664                hnsw_index: std::sync::RwLock::new(hnsw_index),
665            }),
666        };
667
668        #[cfg(feature = "hnsw")]
669        if let Err(err) = store.sync_pending_hnsw_ops_blocking() {
670            tracing::warn!(
671                error = %err,
672                "Failed to reconcile pending HNSW sidecar ops during open; sidecar replay remains pending"
673            );
674        }
675
676        Ok(store)
677    }
678
679    async fn with_embedding_permit(
680        &self,
681    ) -> Result<tokio::sync::OwnedSemaphorePermit, MemoryError> {
682        self.inner
683            .embedding_permits
684            .clone()
685            .acquire_owned()
686            .await
687            .map_err(|e| MemoryError::Other(format!("embedding semaphore closed: {e}")))
688    }
689
690    async fn embed_text_internal(&self, text: &str) -> Result<Vec<f32>, MemoryError> {
691        let _permit = self.with_embedding_permit().await?;
692        let embedding = self.inner.embedder.embed(text).await?;
693        db::validate_embedding(&embedding, self.inner.config.embedding.dimensions)?;
694        Ok(embedding)
695    }
696
697    async fn embed_batch_internal(&self, texts: Vec<String>) -> Result<Vec<Vec<f32>>, MemoryError> {
698        let requested = texts.len();
699        let _permit = self.with_embedding_permit().await?;
700        let embeddings = self.inner.embedder.embed_batch(texts).await?;
701        db::validate_embedding_batch(
702            &embeddings,
703            requested,
704            self.inner.config.embedding.dimensions,
705        )?;
706        Ok(embeddings)
707    }
708
709    fn validate_embedding_dimensions(&self, embedding: &[f32]) -> Result<(), MemoryError> {
710        db::validate_embedding(embedding, self.inner.config.embedding.dimensions)
711    }
712
713    fn validate_content(&self, field: &'static str, content: &str) -> Result<(), MemoryError> {
714        if content.is_empty() {
715            return Err(MemoryError::InvalidConfig {
716                field,
717                reason: "content must not be empty".to_string(),
718            });
719        }
720
721        let limit = self.inner.config.limits.max_content_bytes;
722        if content.len() > limit {
723            return Err(MemoryError::ContentTooLarge {
724                size: content.len(),
725                limit,
726            });
727        }
728
729        Ok(())
730    }
731
732    fn validate_confidence(confidence: f32) -> Result<(), MemoryError> {
733        if !confidence.is_finite() || !(0.0..=1.0).contains(&confidence) {
734            return Err(MemoryError::InvalidConfig {
735                field: "episodes.confidence",
736                reason: "confidence must be finite and within [0.0, 1.0]".to_string(),
737            });
738        }
739        Ok(())
740    }
741
742    // ─── HNSW Management ───────────────────────────────────────
743
744    /// Rebuild feature-gated TurboQuant artifacts from authoritative SQLite f32 embeddings.
745    #[cfg(feature = "turbo-quant-codec")]
746    pub async fn rebuild_vector_artifacts(
747        &self,
748    ) -> Result<VectorArtifactBuildReceiptV1, MemoryError> {
749        let dim = self.inner.config.embedding.dimensions;
750        let search = self.inner.config.search.clone();
751        self.with_write_conn(move |conn| {
752            db::rebuild_turbo_quant_artifacts(
753                conn,
754                dim,
755                search.turbo_quant_bits,
756                search.turbo_quant_projections,
757                search.turbo_quant_seed,
758            )
759        })
760        .await
761    }
762
763    /// Rebuild PerDim artifacts directly against an existing SQLite database.
764    #[cfg(feature = "per-dim-codec")]
765    pub fn rebuild_per_dim_artifacts_at(
766        path: &std::path::Path,
767        dim: usize,
768        bits: u8,
769    ) -> Result<VectorArtifactBuildReceiptV1, MemoryError> {
770        let conn = rusqlite::Connection::open(path)?;
771        db::rebuild_per_dim_artifacts(&conn, dim, bits)
772    }
773
774    /// Rebuild FibQuant artifacts directly against an existing SQLite database.
775    #[cfg(feature = "fib-quant-codec")]
776    pub fn rebuild_fib_quant_artifacts_at(
777        path: &std::path::Path,
778        dim: usize,
779        block_count: usize,
780    ) -> Result<VectorArtifactBuildReceiptV1, MemoryError> {
781        let conn = rusqlite::Connection::open(path)?;
782        db::rebuild_fib_quant_artifacts(&conn, dim, block_count)
783    }
784
785    /// Rebuild feature-gated FibQuant artifacts from authoritative SQLite f32 embeddings.
786    #[cfg(feature = "fib-quant-codec")]
787    pub async fn rebuild_fib_quant_artifacts(
788        &self,
789    ) -> Result<VectorArtifactBuildReceiptV1, MemoryError> {
790        let dim = self.inner.config.embedding.dimensions;
791        let search = self.inner.config.search.clone();
792        self.with_write_conn(move |conn| {
793            db::rebuild_fib_quant_artifacts(conn, dim, search.fib_quant_block_count)
794        })
795        .await
796    }
797
798    /// Rebuild the HNSW index from SQLite f32 embeddings.
799    ///
800    /// Call this if sidecar files are missing, corrupted, or after `reembed_all()`.
801    #[cfg(feature = "hnsw")]
802    pub async fn rebuild_hnsw_index(
803        &self,
804    ) -> Result<crate::types::VectorArtifactBuildReceiptV1, MemoryError> {
805        tracing::info!("Rebuilding HNSW index from SQLite embeddings...");
806        let hnsw_config = self.inner.config.hnsw.clone();
807        let (new_index, build_receipt) = self
808            .with_read_conn(move |conn| hnsw_ops::rebuild_hnsw_from_sqlite(conn, &hnsw_config))
809            .await?;
810
811        {
812            let mut guard = self
813                .inner
814                .hnsw_index
815                .write()
816                .unwrap_or_else(|e| e.into_inner());
817            *guard = new_index.clone();
818        }
819
820        hnsw_ops::save_hnsw_sidecar(
821            &new_index,
822            &self.inner.paths.hnsw_dir,
823            &self.inner.paths.hnsw_basename,
824        )?;
825        self.inner.pool.with_write_conn(|conn| {
826            new_index.flush_keymap(conn)?;
827            db::clear_all_pending_index_ops(conn)?;
828            db::set_sidecar_dirty(conn, false)?;
829            Ok(())
830        })?;
831
832        tracing::info!(active = new_index.len(), receipt_generation_id = ?build_receipt.generation_id, "HNSW index rebuilt");
833
834        Ok(build_receipt)
835    }
836
837    /// Opportunistically flush HNSW if the configured interval has elapsed.
838    ///
839    /// Cheap no-op when `flush_interval_secs` is None or the interval hasn't
840    /// elapsed yet (just an atomic load + epoch comparison).
841    #[cfg(feature = "hnsw")]
842    fn maybe_flush_hnsw(&self) {
843        if let Some(interval) = self.inner.config.hnsw.flush_interval_secs {
844            let guard = self
845                .inner
846                .hnsw_index
847                .read()
848                .unwrap_or_else(|e| e.into_inner());
849            if guard.should_flush(interval) {
850                drop(guard); // release read lock before flushing
851                if let Err(e) = self.flush_hnsw() {
852                    tracing::warn!("Opportunistic HNSW flush failed: {}", e);
853                } else {
854                    let guard = self
855                        .inner
856                        .hnsw_index
857                        .read()
858                        .unwrap_or_else(|e| e.into_inner());
859                    guard.update_last_flush_epoch();
860                    tracing::info!("Opportunistic HNSW flush completed");
861                }
862            }
863        }
864    }
865
866    /// Persist the HNSW graph, vector data, and key mappings to disk.
867    ///
868    /// Called automatically on drop, but can be called explicitly for durability.
869    #[cfg(feature = "hnsw")]
870    pub fn flush_hnsw(&self) -> Result<(), MemoryError> {
871        let pending_ops = self.inner.pool.with_read_conn(db::pending_index_op_count)?;
872        if pending_ops > 0 {
873            tracing::info!(
874                pending_ops,
875                "Flushing HNSW via authoritative SQLite rebuild because pending durable sidecar work exists"
876            );
877            let rebuilt = hnsw_ops::recover_hnsw_sidecar_sync(
878                &self.inner.pool,
879                &self.inner.paths,
880                &self.inner.config.hnsw,
881            )?;
882            let mut guard = self
883                .inner
884                .hnsw_index
885                .write()
886                .unwrap_or_else(|e| e.into_inner());
887            *guard = rebuilt;
888            return Ok(());
889        }
890
891        let index = self
892            .inner
893            .hnsw_index
894            .write()
895            .unwrap_or_else(|e| e.into_inner());
896        hnsw_ops::save_hnsw_sidecar(
897            &index,
898            &self.inner.paths.hnsw_dir,
899            &self.inner.paths.hnsw_basename,
900        )?;
901
902        // Flush key mappings to SQLite
903        self.inner.pool.with_write_conn(|conn| {
904            index.flush_keymap(conn)?;
905            db::clear_all_pending_index_ops(conn)?;
906            db::set_sidecar_dirty(conn, false)?;
907            Ok(())
908        })?;
909        Ok(())
910    }
911
912    /// Compact the HNSW index by rebuilding without tombstones.
913    ///
914    /// Only rebuilds if the deleted ratio exceeds the compaction threshold.
915    #[cfg(feature = "hnsw")]
916    pub async fn compact_hnsw(&self) -> Result<(), MemoryError> {
917        if !self
918            .inner
919            .hnsw_index
920            .read()
921            .unwrap_or_else(|e| e.into_inner())
922            .needs_compaction()
923        {
924            tracing::info!("HNSW compaction not needed (deleted ratio below threshold)");
925            return Ok(());
926        }
927        let _receipt = self.rebuild_hnsw_index().await?;
928        Ok(())
929    }
930
931    // ─── Integrity & Diagnostics ────────────────────────────────
932
933    /// Verify database integrity.
934    ///
935    /// In `Quick` mode, checks table existence and row counts.
936    /// In `Full` mode, also verifies FTS consistency and runs SQLite integrity_check.
937    pub async fn verify_integrity(
938        &self,
939        mode: db::VerifyMode,
940    ) -> Result<db::IntegrityReport, MemoryError> {
941        let use_writer = mode == db::VerifyMode::Full;
942        let mut report = if use_writer {
943            self.with_write_conn(move |conn| db::verify_integrity_sync(conn, mode))
944                .await?
945        } else {
946            self.with_read_conn(move |conn| db::verify_integrity_sync(conn, mode))
947                .await?
948        };
949
950        #[cfg(feature = "hnsw")]
951        {
952            let hnsw_vectors = self
953                .inner
954                .hnsw_index
955                .read()
956                .unwrap_or_else(|e| e.into_inner())
957                .vector_snapshot();
958            let hnsw_dims = self.inner.config.embedding.dimensions;
959            let hnsw_files_exist = self.inner.paths.hnsw_files_exist();
960
961            let hnsw_issues = if use_writer {
962                let hnsw_vectors = hnsw_vectors.clone();
963                self.with_write_conn(move |conn| {
964                    verify_hnsw_key_level_integrity(
965                        conn,
966                        hnsw_dims,
967                        &hnsw_vectors,
968                        hnsw_files_exist,
969                    )
970                })
971                .await?
972            } else {
973                let hnsw_vectors = hnsw_vectors.clone();
974                self.with_read_conn(move |conn| {
975                    verify_hnsw_key_level_integrity(
976                        conn,
977                        hnsw_dims,
978                        &hnsw_vectors,
979                        hnsw_files_exist,
980                    )
981                })
982                .await?
983            };
984            report.issues.extend(hnsw_issues);
985        }
986
987        report.ok = report.issues.is_empty();
988        Ok(report)
989    }
990
991    /// Reconcile detected integrity issues.
992    ///
993    /// - `ReportOnly`: no-op, just returns the integrity report.
994    /// - `RebuildFts`: rebuilds all FTS indexes from source data.
995    /// - `ReEmbed`: not yet implemented (requires async embedding calls).
996    pub async fn reconcile(
997        &self,
998        action: db::ReconcileAction,
999    ) -> Result<db::IntegrityReport, MemoryError> {
1000        match action {
1001            db::ReconcileAction::ReportOnly => self.verify_integrity(db::VerifyMode::Full).await,
1002            db::ReconcileAction::RebuildFts => {
1003                self.with_write_conn(db::reconcile_fts).await?;
1004                #[cfg(feature = "hnsw")]
1005                self.sync_pending_hnsw_ops_best_effort("reconcile_rebuild_fts")
1006                    .await;
1007                self.verify_integrity(db::VerifyMode::Full).await
1008            }
1009            db::ReconcileAction::ReEmbed => {
1010                self.reembed_all().await?;
1011                self.verify_integrity(db::VerifyMode::Full).await
1012            }
1013        }
1014    }
1015
1016    /// Get the current configuration.
1017    pub fn config(&self) -> &MemoryConfig {
1018        &self.inner.config
1019    }
1020
1021    /// View the store as a derived graph over documents, chunks, facts, sessions, messages,
1022    /// episodes, namespaces, and semantic similarity edges.
1023    pub fn graph_view(&self) -> Arc<dyn GraphView> {
1024        graph::graph_view(self.inner.clone())
1025    }
1026
1027    // ─── Search ─────────────────────────────────────────────────
1028
1029    /// Hybrid search across facts, document chunks, and searchable episodes.
1030    pub async fn search(
1031        &self,
1032        query: &str,
1033        top_k: Option<usize>,
1034        namespaces: Option<&[&str]>,
1035        source_types: Option<&[SearchSourceType]>,
1036    ) -> Result<Vec<SearchResult>, MemoryError> {
1037        Ok(self
1038            .search_with_context(
1039                query,
1040                top_k,
1041                namespaces,
1042                source_types,
1043                SearchContext::default_now(),
1044            )
1045            .await?
1046            .results)
1047    }
1048
1049    /// Hybrid search with an explicit deterministic context and optional receipt.
1050    pub async fn search_with_context(
1051        &self,
1052        query: &str,
1053        top_k: Option<usize>,
1054        namespaces: Option<&[&str]>,
1055        source_types: Option<&[SearchSourceType]>,
1056        context: SearchContext,
1057    ) -> Result<SearchResponse, MemoryError> {
1058        let k = top_k
1059            .unwrap_or(self.inner.config.search.default_top_k)
1060            .min(MAX_TOP_K);
1061
1062        let query_embedding = self.embed_text_internal(query).await?;
1063
1064        #[cfg(feature = "hnsw")]
1065        let hnsw_hits = if context.exactness_profile == ExactnessProfile::PreferExact
1066            || self.inner.config.search.uses_turbo_quant_backend()
1067        {
1068            Vec::new()
1069        } else {
1070            let candidates = self
1071                .inner
1072                .config
1073                .search
1074                .candidate_pool_size
1075                .max(k.saturating_mul(3))
1076                .min(MAX_HNSW_CANDIDATES);
1077            self.hnsw_search_blocking(query_embedding.clone(), candidates)
1078                .await
1079        };
1080
1081        let q = query.to_string();
1082        let config = self.inner.config.search.clone();
1083        let ns_owned = to_owned_string_vec(namespaces);
1084        let st_owned: Option<Vec<SearchSourceType>> = source_types.map(|s| s.to_vec());
1085        let context_owned = context.clone();
1086
1087        #[cfg(feature = "hnsw")]
1088        let hnsw_hits_owned = hnsw_hits;
1089
1090        let response = self
1091            .with_read_conn(move |conn| {
1092                if db::is_embeddings_dirty(conn)? {
1093                    tracing::warn!(
1094                        "Embeddings are stale after model change — search quality is degraded. \
1095                     Call reembed_all() to regenerate embeddings."
1096                    );
1097                }
1098                let ns_refs = as_str_slice(&ns_owned);
1099                let ns_slice: Option<&[&str]> = ns_refs.as_deref();
1100                let st_slice: Option<&[SearchSourceType]> = st_owned.as_deref();
1101
1102                #[cfg(feature = "hnsw")]
1103                {
1104                    let mut execution = if hnsw_hits_owned.is_empty() {
1105                        search::hybrid_search_detailed_with_context(
1106                            conn,
1107                            &q,
1108                            &query_embedding,
1109                            &config,
1110                            &context_owned,
1111                            k,
1112                            ns_slice,
1113                            st_slice,
1114                            None,
1115                        )
1116                    } else {
1117                        search::hybrid_search_with_hnsw_detailed_with_context(
1118                            conn,
1119                            &q,
1120                            &query_embedding,
1121                            &config,
1122                            &context_owned,
1123                            k,
1124                            ns_slice,
1125                            st_slice,
1126                            None,
1127                            &hnsw_hits_owned,
1128                        )
1129                    }?;
1130                    if context_owned.receipts_enabled()
1131                        && context_owned.exactness_profile == ExactnessProfile::PreferExact
1132                    {
1133                        if let Some(receipt) = execution.receipt.as_mut() {
1134                            receipt.search_profile = "hybrid_prefer_exact".to_string();
1135                        }
1136                    }
1137                    Ok(SearchResponse {
1138                        results: execution
1139                            .results
1140                            .into_iter()
1141                            .map(|result| result.result)
1142                            .collect(),
1143                        receipt: execution.receipt,
1144                    })
1145                }
1146                #[cfg(not(feature = "hnsw"))]
1147                {
1148                    let execution = search::hybrid_search_detailed_with_context(
1149                        conn,
1150                        &q,
1151                        &query_embedding,
1152                        &config,
1153                        &context_owned,
1154                        k,
1155                        ns_slice,
1156                        st_slice,
1157                        None,
1158                    )?;
1159                    Ok(SearchResponse {
1160                        results: execution
1161                            .results
1162                            .into_iter()
1163                            .map(|result| result.result)
1164                            .collect(),
1165                        receipt: execution.receipt,
1166                    })
1167                }
1168            })
1169            .await?;
1170        if let Some(receipt) = &response.receipt {
1171            self.persist_search_receipt(receipt).await?;
1172        }
1173        Ok(response)
1174    }
1175
1176    /// Full-text search only (no embeddings needed).
1177    pub async fn search_fts_only(
1178        &self,
1179        query: &str,
1180        top_k: Option<usize>,
1181        namespaces: Option<&[&str]>,
1182        source_types: Option<&[SearchSourceType]>,
1183    ) -> Result<Vec<SearchResult>, MemoryError> {
1184        let k = top_k
1185            .unwrap_or(self.inner.config.search.default_top_k)
1186            .min(MAX_TOP_K);
1187        let q = query.to_string();
1188        let config = self.inner.config.search.clone();
1189        let ns_owned = to_owned_string_vec(namespaces);
1190        let st_owned: Option<Vec<SearchSourceType>> = source_types.map(|s| s.to_vec());
1191        self.with_read_conn(move |conn| {
1192            let ns_refs = as_str_slice(&ns_owned);
1193            let ns_slice: Option<&[&str]> = ns_refs.as_deref();
1194            let st_slice: Option<&[SearchSourceType]> = st_owned.as_deref();
1195            search::fts_only_search(conn, &q, &config, k, ns_slice, st_slice, None)
1196        })
1197        .await
1198    }
1199
1200    /// Vector similarity search only (no FTS).
1201    pub async fn search_vector_only(
1202        &self,
1203        query: &str,
1204        top_k: Option<usize>,
1205        namespaces: Option<&[&str]>,
1206        source_types: Option<&[SearchSourceType]>,
1207    ) -> Result<Vec<SearchResult>, MemoryError> {
1208        Ok(self
1209            .search_vector_only_with_context(
1210                query,
1211                top_k,
1212                namespaces,
1213                source_types,
1214                SearchContext::default_now(),
1215            )
1216            .await?
1217            .results)
1218    }
1219
1220    /// Vector similarity search with an explicit deterministic context and optional receipt.
1221    pub async fn search_vector_only_with_context(
1222        &self,
1223        query: &str,
1224        top_k: Option<usize>,
1225        namespaces: Option<&[&str]>,
1226        source_types: Option<&[SearchSourceType]>,
1227        context: SearchContext,
1228    ) -> Result<SearchResponse, MemoryError> {
1229        let k = top_k
1230            .unwrap_or(self.inner.config.search.default_top_k)
1231            .min(MAX_TOP_K);
1232        let query_embedding = self.embed_text_internal(query).await?;
1233
1234        #[cfg(feature = "hnsw")]
1235        let hnsw_hits = if context.exactness_profile == ExactnessProfile::PreferExact
1236            || self.inner.config.search.uses_turbo_quant_backend()
1237        {
1238            Vec::new()
1239        } else {
1240            let candidates = self
1241                .inner
1242                .config
1243                .search
1244                .candidate_pool_size
1245                .max(k.saturating_mul(3))
1246                .min(MAX_HNSW_CANDIDATES);
1247            self.hnsw_search_blocking(query_embedding.clone(), candidates)
1248                .await
1249        };
1250
1251        let config = self.inner.config.search.clone();
1252        let ns_owned = to_owned_string_vec(namespaces);
1253        let st_owned: Option<Vec<SearchSourceType>> = source_types.map(|s| s.to_vec());
1254        let context_owned = context.clone();
1255
1256        #[cfg(feature = "hnsw")]
1257        let hnsw_hits_owned = hnsw_hits;
1258
1259        let response = self
1260            .with_read_conn(move |conn| {
1261                if db::is_embeddings_dirty(conn)? {
1262                    tracing::warn!(
1263                        "Embeddings are stale after model change — search quality is degraded. \
1264                     Call reembed_all() to regenerate embeddings."
1265                    );
1266                }
1267                let ns_refs = as_str_slice(&ns_owned);
1268                let ns_slice: Option<&[&str]> = ns_refs.as_deref();
1269                let st_slice: Option<&[SearchSourceType]> = st_owned.as_deref();
1270
1271                #[cfg(feature = "hnsw")]
1272                {
1273                    let mut execution = if hnsw_hits_owned.is_empty() {
1274                        search::vector_only_search_detailed_with_context(
1275                            conn,
1276                            &query_embedding,
1277                            &config,
1278                            &context_owned,
1279                            k,
1280                            ns_slice,
1281                            st_slice,
1282                            None,
1283                        )
1284                    } else {
1285                        search::vector_only_search_with_hnsw_detailed_with_context(
1286                            conn,
1287                            &query_embedding,
1288                            &config,
1289                            &context_owned,
1290                            k,
1291                            ns_slice,
1292                            st_slice,
1293                            None,
1294                            &hnsw_hits_owned,
1295                        )
1296                    }?;
1297                    if context_owned.receipts_enabled()
1298                        && context_owned.exactness_profile == ExactnessProfile::PreferExact
1299                    {
1300                        if let Some(receipt) = execution.receipt.as_mut() {
1301                            receipt.search_profile = "vector_only_prefer_exact".to_string();
1302                        }
1303                    }
1304                    Ok(SearchResponse {
1305                        results: execution
1306                            .results
1307                            .into_iter()
1308                            .map(|result| result.result)
1309                            .collect(),
1310                        receipt: execution.receipt,
1311                    })
1312                }
1313                #[cfg(not(feature = "hnsw"))]
1314                {
1315                    let execution = search::vector_only_search_detailed_with_context(
1316                        conn,
1317                        &query_embedding,
1318                        &config,
1319                        &context_owned,
1320                        k,
1321                        ns_slice,
1322                        st_slice,
1323                        None,
1324                    )?;
1325                    Ok(SearchResponse {
1326                        results: execution
1327                            .results
1328                            .into_iter()
1329                            .map(|result| result.result)
1330                            .collect(),
1331                        receipt: execution.receipt,
1332                    })
1333                }
1334            })
1335            .await?;
1336        if let Some(receipt) = &response.receipt {
1337            self.persist_search_receipt(receipt).await?;
1338        }
1339        Ok(response)
1340    }
1341
1342    // ─── Explainable Search ───────────────────────────────────
1343
1344    /// Search with full score breakdown for each result.
1345    pub async fn search_explained(
1346        &self,
1347        query: &str,
1348        top_k: Option<usize>,
1349        namespaces: Option<&[&str]>,
1350        source_types: Option<&[SearchSourceType]>,
1351    ) -> Result<Vec<types::ExplainedResult>, MemoryError> {
1352        Ok(self
1353            .search_explained_with_context(
1354                query,
1355                top_k,
1356                namespaces,
1357                source_types,
1358                SearchContext::default_now(),
1359            )
1360            .await?
1361            .results)
1362    }
1363
1364    /// Search with full score breakdown under an explicit deterministic context.
1365    pub async fn search_explained_with_context(
1366        &self,
1367        query: &str,
1368        top_k: Option<usize>,
1369        namespaces: Option<&[&str]>,
1370        source_types: Option<&[SearchSourceType]>,
1371        context: SearchContext,
1372    ) -> Result<types::ExplainedSearchResponse, MemoryError> {
1373        let k = top_k
1374            .unwrap_or(self.inner.config.search.default_top_k)
1375            .min(MAX_TOP_K);
1376        let query_embedding = self.embed_text_internal(query).await?;
1377
1378        #[cfg(feature = "hnsw")]
1379        let hnsw_hits = if context.exactness_profile == ExactnessProfile::PreferExact {
1380            Vec::new()
1381        } else {
1382            let candidates = self
1383                .inner
1384                .config
1385                .search
1386                .candidate_pool_size
1387                .max(k.saturating_mul(3))
1388                .min(MAX_HNSW_CANDIDATES);
1389            self.hnsw_search_blocking(query_embedding.clone(), candidates)
1390                .await
1391        };
1392
1393        let q = query.to_string();
1394        let config = self.inner.config.search.clone();
1395        let ns_owned = to_owned_string_vec(namespaces);
1396        let st_owned: Option<Vec<SearchSourceType>> = source_types.map(|value| value.to_vec());
1397        let context_owned = context.clone();
1398
1399        #[cfg(feature = "hnsw")]
1400        let hnsw_hits_owned = hnsw_hits;
1401
1402        let response = self
1403            .with_read_conn(move |conn| {
1404                let ns_refs = as_str_slice(&ns_owned);
1405                let ns_slice: Option<&[&str]> = ns_refs.as_deref();
1406                let st_slice: Option<&[SearchSourceType]> = st_owned.as_deref();
1407
1408                #[cfg(feature = "hnsw")]
1409                {
1410                    let mut execution = if hnsw_hits_owned.is_empty() {
1411                        search::hybrid_search_detailed_with_context(
1412                            conn,
1413                            &q,
1414                            &query_embedding,
1415                            &config,
1416                            &context_owned,
1417                            k,
1418                            ns_slice,
1419                            st_slice,
1420                            None,
1421                        )
1422                    } else {
1423                        search::hybrid_search_with_hnsw_detailed_with_context(
1424                            conn,
1425                            &q,
1426                            &query_embedding,
1427                            &config,
1428                            &context_owned,
1429                            k,
1430                            ns_slice,
1431                            st_slice,
1432                            None,
1433                            &hnsw_hits_owned,
1434                        )
1435                    }?;
1436                    if context_owned.receipts_enabled()
1437                        && context_owned.exactness_profile == ExactnessProfile::PreferExact
1438                    {
1439                        if let Some(receipt) = execution.receipt.as_mut() {
1440                            receipt.search_profile = "hybrid_prefer_exact".to_string();
1441                        }
1442                    }
1443                    Ok(types::ExplainedSearchResponse {
1444                        results: execution.results,
1445                        receipt: execution.receipt,
1446                    })
1447                }
1448                #[cfg(not(feature = "hnsw"))]
1449                {
1450                    let execution = search::hybrid_search_detailed_with_context(
1451                        conn,
1452                        &q,
1453                        &query_embedding,
1454                        &config,
1455                        &context_owned,
1456                        k,
1457                        ns_slice,
1458                        st_slice,
1459                        None,
1460                    )?;
1461                    Ok(types::ExplainedSearchResponse {
1462                        results: execution.results,
1463                        receipt: execution.receipt,
1464                    })
1465                }
1466            })
1467            .await?;
1468        if let Some(receipt) = &response.receipt {
1469            self.persist_search_receipt(receipt).await?;
1470        }
1471        Ok(response)
1472    }
1473
1474    /// Load a durable search receipt by receipt/request ID.
1475    pub async fn get_search_receipt(
1476        &self,
1477        receipt_id: &str,
1478    ) -> Result<Option<VectorSearchReceiptV1>, MemoryError> {
1479        let receipt_id = receipt_id.to_string();
1480        self.with_read_conn(move |conn| db::get_search_receipt(conn, &receipt_id))
1481            .await
1482    }
1483
1484    /// Replay a durable search receipt with caller-supplied query text and filters.
1485    ///
1486    /// Receipts intentionally do not store query text or filter values. The
1487    /// caller supplies those inputs, and the stored receipt supplies the
1488    /// deterministic evaluation time and retrieval family for comparison.
1489    pub async fn replay_search_receipt(
1490        &self,
1491        receipt_id: &str,
1492        query: &str,
1493        top_k: Option<usize>,
1494        namespaces: Option<&[&str]>,
1495        source_types: Option<&[SearchSourceType]>,
1496    ) -> Result<SearchReplayReportV1, MemoryError> {
1497        let original_receipt = self.get_search_receipt(receipt_id).await?.ok_or_else(|| {
1498            MemoryError::SearchReceiptNotFound {
1499                receipt_id: receipt_id.to_string(),
1500            }
1501        })?;
1502
1503        let vector_only = original_receipt.search_profile.starts_with("vector_only");
1504        let replay_top_k = top_k.or_else(|| Some(original_receipt.result_ids.len().max(1)));
1505        let replay_receipt_id = format!("{receipt_id}:replay:{}", uuid::Uuid::new_v4());
1506        let mut context = SearchContext::at(original_receipt.evaluation_time);
1507        context.receipt_mode = ReceiptMode::ReturnReceipt;
1508        context.request_id = Some(replay_receipt_id.clone());
1509        context.trace_id = original_receipt.trace_id.clone();
1510        context.attempt_family_id = original_receipt
1511            .attempt_family_id
1512            .clone()
1513            .or_else(|| Some(original_receipt.receipt_id.clone()));
1514        context.attempt_id = Some(replay_receipt_id.clone());
1515        context.replay_of = Some(original_receipt.receipt_id.clone());
1516        context.query_text_digest = original_receipt.query_text_digest.clone();
1517        context.query_input_digest = original_receipt.query_input_digest.clone();
1518        context.filter_digest = original_receipt.filter_digest.clone();
1519        context.redaction_state = original_receipt.redaction_state.clone();
1520        context.budget_id = original_receipt.budget_id.clone();
1521        context.exactness_profile = if original_receipt.approximate {
1522            ExactnessProfile::AllowApproximate
1523        } else {
1524            ExactnessProfile::PreferExact
1525        };
1526
1527        let replay_response = if vector_only {
1528            self.search_vector_only_with_context(
1529                query,
1530                replay_top_k,
1531                namespaces,
1532                source_types,
1533                context,
1534            )
1535            .await?
1536        } else {
1537            self.search_with_context(query, replay_top_k, namespaces, source_types, context)
1538                .await?
1539        };
1540        let replay_receipt = replay_response
1541            .receipt
1542            .ok_or_else(|| MemoryError::Other("replay did not produce a receipt".to_string()))?;
1543
1544        let query_embedding_digest_matches =
1545            original_receipt.query_embedding_digest == replay_receipt.query_embedding_digest;
1546        let result_ids_match = original_receipt.result_ids == replay_receipt.result_ids;
1547        let missing_result_ids = original_receipt
1548            .result_ids
1549            .iter()
1550            .filter(|id| !replay_receipt.result_ids.contains(*id))
1551            .cloned()
1552            .collect();
1553        let added_result_ids = replay_receipt
1554            .result_ids
1555            .iter()
1556            .filter(|id| !original_receipt.result_ids.contains(*id))
1557            .cloned()
1558            .collect();
1559
1560        Ok(SearchReplayReportV1 {
1561            receipt_id: original_receipt.receipt_id.clone(),
1562            replay_receipt_id,
1563            original_receipt,
1564            replay_receipt,
1565            query_embedding_digest_matches,
1566            result_ids_match,
1567            missing_result_ids,
1568            added_result_ids,
1569            vector_only,
1570        })
1571    }
1572
1573    // ─── Embedding Displacement ───────────────────────────────
1574
1575    /// Compute embedding displacement between two texts.
1576    pub async fn embedding_displacement(
1577        &self,
1578        text_a: &str,
1579        text_b: &str,
1580    ) -> Result<types::EmbeddingDisplacement, MemoryError> {
1581        let emb_a = self.embed_text_internal(text_a).await?;
1582        let emb_b = self.embed_text_internal(text_b).await?;
1583        Self::embedding_displacement_from_vecs(&emb_a, &emb_b)
1584    }
1585
1586    /// Compute embedding displacement from pre-computed vectors.
1587    pub fn embedding_displacement_from_vecs(
1588        a: &[f32],
1589        b: &[f32],
1590    ) -> Result<types::EmbeddingDisplacement, MemoryError> {
1591        if a.len() != b.len() {
1592            return Err(MemoryError::DimensionMismatch {
1593                expected: a.len(),
1594                actual: b.len(),
1595            });
1596        }
1597        let cosine_sim = search::cosine_similarity(a, b)?;
1598
1599        let euclidean_dist: f32 = a
1600            .iter()
1601            .zip(b.iter())
1602            .map(|(x, y)| (x - y) * (x - y))
1603            .sum::<f32>()
1604            .sqrt();
1605
1606        let mag_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
1607        let mag_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
1608
1609        Ok(types::EmbeddingDisplacement {
1610            cosine_similarity: cosine_sim,
1611            euclidean_distance: euclidean_dist,
1612            magnitude_a: mag_a,
1613            magnitude_b: mag_b,
1614        })
1615    }
1616
1617    // ─── Utility ────────────────────────────────────────────────
1618
1619    /// Chunk text using the configured strategy and token counter.
1620    pub fn chunk_text(&self, text: &str) -> Vec<TextChunk> {
1621        chunker::chunk_text(
1622            text,
1623            &self.inner.config.chunking,
1624            self.inner.token_counter.as_ref(),
1625        )
1626    }
1627
1628    /// Embed a single text via the configured provider.
1629    pub async fn embed(&self, text: &str) -> Result<Vec<f32>, MemoryError> {
1630        self.embed_text_internal(text).await
1631    }
1632
1633    /// Embed multiple texts in a batch.
1634    pub async fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, MemoryError> {
1635        let owned: Vec<String> = texts.iter().map(|s| s.to_string()).collect();
1636        self.embed_batch_internal(owned).await
1637    }
1638
1639    /// Get database statistics.
1640    pub async fn stats(&self) -> Result<MemoryStats, MemoryError> {
1641        let db_path = self.inner.paths.sqlite_path.clone();
1642        self.with_read_conn(move |conn| {
1643            let total_facts: u64 =
1644                conn.query_row("SELECT COUNT(*) FROM facts", [], |r| r.get(0))?;
1645            let total_documents: u64 =
1646                conn.query_row("SELECT COUNT(*) FROM documents", [], |r| r.get(0))?;
1647            let total_chunks: u64 =
1648                conn.query_row("SELECT COUNT(*) FROM chunks", [], |r| r.get(0))?;
1649            let total_sessions: u64 =
1650                conn.query_row("SELECT COUNT(*) FROM sessions", [], |r| r.get(0))?;
1651            let total_messages: u64 =
1652                conn.query_row("SELECT COUNT(*) FROM messages", [], |r| r.get(0))?;
1653
1654            let db_size = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0);
1655
1656            let (model, dims): (Option<String>, Option<usize>) = conn
1657                .query_row(
1658                    "SELECT model_name, dimensions FROM embedding_metadata WHERE id = 1",
1659                    [],
1660                    |r| Ok((Some(r.get(0)?), Some(r.get(1)?))),
1661                )
1662                .unwrap_or((None, None));
1663
1664            Ok(MemoryStats {
1665                total_facts,
1666                total_documents,
1667                total_chunks,
1668                total_sessions,
1669                total_messages,
1670                database_size_bytes: db_size,
1671                embedding_model: model,
1672                embedding_dimensions: dims,
1673            })
1674        })
1675        .await
1676    }
1677
1678    /// Return distinct scope_domain values stored in document metadata.
1679    ///
1680    /// Queries `json_extract(metadata, '$.scope_domain')` across all documents
1681    /// and returns the unique non-null values. Used by the Recall app to populate
1682    /// the scope picker dynamically instead of relying on a hardcoded list.
1683    pub async fn list_scope_domains(&self) -> Result<Vec<String>, MemoryError> {
1684        self.with_read_conn(|conn| {
1685            let mut stmt = conn.prepare(
1686                "SELECT DISTINCT json_extract(metadata, '$.scope_domain') \
1687                 FROM documents \
1688                 WHERE json_extract(metadata, '$.scope_domain') IS NOT NULL",
1689            )?;
1690            let domains: Vec<String> = stmt
1691                .query_map([], |row| row.get::<_, String>(0))?
1692                .filter_map(|r| r.ok())
1693                .collect();
1694            Ok(domains)
1695        })
1696        .await
1697    }
1698
1699    /// Check if embeddings need re-generation after a model change.
1700    pub async fn embeddings_are_dirty(&self) -> Result<bool, MemoryError> {
1701        self.with_read_conn(db::is_embeddings_dirty).await
1702    }
1703
1704    /// Re-embed all facts, chunks, messages, and episodes. Call after changing embedding models.
1705    pub async fn reembed_all(&self) -> Result<usize, MemoryError> {
1706        let mut count = 0usize;
1707        let batch_size = self.inner.config.embedding.batch_size;
1708        let dims = self.inner.config.embedding.dimensions;
1709
1710        // ─── Facts ──────────────────────────────────────────────────
1711        let fact_contents: Vec<(String, String)> = self
1712            .with_read_conn(|conn| {
1713                let mut stmt = conn.prepare("SELECT id, content FROM facts")?;
1714                let result = stmt
1715                    .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
1716                    .collect::<Result<Vec<_>, _>>()?;
1717                Ok(result)
1718            })
1719            .await?;
1720
1721        let mut fact_count = 0usize;
1722        for batch in fact_contents.chunks(batch_size) {
1723            let texts: Vec<String> = batch.iter().map(|(_, c)| c.clone()).collect();
1724            let embeddings = self.embed_batch_internal(texts).await?;
1725            for embedding in &embeddings {
1726                self.validate_embedding_dimensions(embedding)?;
1727            }
1728
1729            let quantizer = Quantizer::new(dims);
1730            let updates: Vec<(String, Vec<u8>, Option<Vec<u8>>)> = batch
1731                .iter()
1732                .zip(embeddings.iter())
1733                .map(|((id, _), emb)| {
1734                    // INTENTIONAL: q8 quantization is an optional search optimization; missing q8 is non-fatal
1735                    let q8 = quantizer
1736                        .quantize(emb)
1737                        .map(|qv| quantize::pack_quantized(&qv))
1738                        .ok();
1739                    (id.clone(), db::embedding_to_bytes(emb), q8)
1740                })
1741                .collect();
1742
1743            self.with_write_conn(move |conn| {
1744                db::with_transaction(conn, |tx| {
1745                    for (fid, bytes, q8) in &updates {
1746                        tx.execute(
1747                            "UPDATE facts SET embedding = ?1, embedding_q8 = ?2, updated_at = datetime('now') WHERE id = ?3",
1748                            rusqlite::params![bytes, q8.as_deref(), fid],
1749                        )?;
1750                        #[cfg(feature = "hnsw")]
1751                        db::queue_pending_index_op(
1752                            tx,
1753                            &format!("fact:{fid}"),
1754                            "fact",
1755                            db::IndexOpKind::Upsert,
1756                        )?;
1757                        db::invalidate_derived_vector_artifact(tx, &format!("fact:{fid}"))?;
1758                    }
1759                    Ok(())
1760                })
1761            })
1762            .await?;
1763
1764            fact_count += batch.len();
1765            count += batch.len();
1766            if fact_count % 100 == 0 || fact_count == count {
1767                tracing::info!(fact_count, "Re-embedded {} facts so far", fact_count);
1768            }
1769        }
1770
1771        // ─── Chunks ─────────────────────────────────────────────────
1772        let chunk_data: Vec<(String, String)> = self
1773            .with_read_conn(|conn| {
1774                let mut stmt = conn.prepare("SELECT id, content FROM chunks")?;
1775                let result = stmt
1776                    .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
1777                    .collect::<Result<Vec<_>, _>>()?;
1778                Ok(result)
1779            })
1780            .await?;
1781
1782        let mut chunk_count = 0usize;
1783        for batch in chunk_data.chunks(batch_size) {
1784            let texts: Vec<String> = batch.iter().map(|(_, c)| c.clone()).collect();
1785            let embeddings = self.embed_batch_internal(texts).await?;
1786            for embedding in &embeddings {
1787                self.validate_embedding_dimensions(embedding)?;
1788            }
1789
1790            let quantizer = Quantizer::new(dims);
1791            let updates: Vec<(String, Vec<u8>, Option<Vec<u8>>)> = batch
1792                .iter()
1793                .zip(embeddings.iter())
1794                .map(|((id, _), emb)| {
1795                    // INTENTIONAL: q8 quantization is an optional search optimization; missing q8 is non-fatal
1796                    let q8 = quantizer
1797                        .quantize(emb)
1798                        .map(|qv| quantize::pack_quantized(&qv))
1799                        .ok();
1800                    (id.clone(), db::embedding_to_bytes(emb), q8)
1801                })
1802                .collect();
1803
1804            self.with_write_conn(move |conn| {
1805                db::with_transaction(conn, |tx| {
1806                    for (cid, bytes, q8) in &updates {
1807                        tx.execute(
1808                            "UPDATE chunks SET embedding = ?1, embedding_q8 = ?2 WHERE id = ?3",
1809                            rusqlite::params![bytes, q8.as_deref(), cid],
1810                        )?;
1811                        #[cfg(feature = "hnsw")]
1812                        db::queue_pending_index_op(
1813                            tx,
1814                            &format!("chunk:{cid}"),
1815                            "chunk",
1816                            db::IndexOpKind::Upsert,
1817                        )?;
1818                        db::invalidate_derived_vector_artifact(tx, &format!("chunk:{cid}"))?;
1819                    }
1820                    Ok(())
1821                })
1822            })
1823            .await?;
1824
1825            chunk_count += batch.len();
1826            count += batch.len();
1827            if chunk_count % 100 == 0 {
1828                tracing::info!(chunk_count, "Re-embedded {} chunks so far", chunk_count);
1829            }
1830        }
1831
1832        // ─── Messages ───────────────────────────────────────────────
1833        let message_data: Vec<(i64, String)> = self
1834            .with_read_conn(|conn| {
1835                let mut stmt = conn.prepare("SELECT id, content FROM messages")?;
1836                let result = stmt
1837                    .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
1838                    .collect::<Result<Vec<_>, _>>()?;
1839                Ok(result)
1840            })
1841            .await?;
1842
1843        let mut msg_count = 0usize;
1844        for batch in message_data.chunks(batch_size) {
1845            let texts: Vec<String> = batch.iter().map(|(_, c)| c.clone()).collect();
1846            let embeddings = self.embed_batch_internal(texts).await?;
1847            for embedding in &embeddings {
1848                self.validate_embedding_dimensions(embedding)?;
1849            }
1850
1851            let quantizer = Quantizer::new(dims);
1852            let updates: Vec<(i64, Vec<u8>, Option<Vec<u8>>)> = batch
1853                .iter()
1854                .zip(embeddings.iter())
1855                .map(|((id, _), emb)| {
1856                    // INTENTIONAL: q8 quantization is an optional search optimization; missing q8 is non-fatal
1857                    let q8 = quantizer
1858                        .quantize(emb)
1859                        .map(|qv| quantize::pack_quantized(&qv))
1860                        .ok();
1861                    (*id, db::embedding_to_bytes(emb), q8)
1862                })
1863                .collect();
1864
1865            self.with_write_conn(move |conn| {
1866                db::with_transaction(conn, |tx| {
1867                    for (mid, bytes, q8) in &updates {
1868                        tx.execute(
1869                            "UPDATE messages SET embedding = ?1, embedding_q8 = ?2 WHERE id = ?3",
1870                            rusqlite::params![bytes, q8.as_deref(), mid],
1871                        )?;
1872                        #[cfg(feature = "hnsw")]
1873                        db::queue_pending_index_op(
1874                            tx,
1875                            &format!("msg:{mid}"),
1876                            "message",
1877                            db::IndexOpKind::Upsert,
1878                        )?;
1879                        db::invalidate_derived_vector_artifact(tx, &format!("msg:{mid}"))?;
1880                    }
1881                    Ok(())
1882                })
1883            })
1884            .await?;
1885
1886            msg_count += batch.len();
1887            count += batch.len();
1888            if msg_count % 100 == 0 {
1889                tracing::info!(msg_count, "Re-embedded {} messages so far", msg_count);
1890            }
1891        }
1892
1893        // ─── Episodes ───────────────────────────────────────────────
1894        let episode_data: Vec<(String, String)> = self
1895            .with_read_conn(|conn| {
1896                let mut stmt = conn.prepare("SELECT episode_id, search_text FROM episodes")?;
1897                let result = stmt
1898                    .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
1899                    .collect::<Result<Vec<_>, _>>()?;
1900                Ok(result)
1901            })
1902            .await?;
1903
1904        let mut episode_count = 0usize;
1905        for batch in episode_data.chunks(batch_size) {
1906            let texts: Vec<String> = batch.iter().map(|(_, text)| text.clone()).collect();
1907            let embeddings = self.embed_batch_internal(texts).await?;
1908            for embedding in &embeddings {
1909                self.validate_embedding_dimensions(embedding)?;
1910            }
1911
1912            let quantizer = Quantizer::new(dims);
1913            let updates: Vec<(String, Vec<u8>, Option<Vec<u8>>)> = batch
1914                .iter()
1915                .zip(embeddings.iter())
1916                .map(|((episode_id, _), embedding)| {
1917                    // INTENTIONAL: q8 quantization is an optional search optimization; missing q8 is non-fatal
1918                    let q8 = quantizer
1919                        .quantize(embedding)
1920                        .map(|vector| quantize::pack_quantized(&vector))
1921                        .ok();
1922                    (episode_id.clone(), db::embedding_to_bytes(embedding), q8)
1923                })
1924                .collect();
1925
1926            self.with_write_conn(move |conn| {
1927                db::with_transaction(conn, |tx| {
1928                    for (episode_id, bytes, q8) in &updates {
1929                        tx.execute(
1930                            "UPDATE episodes
1931                             SET embedding = ?1,
1932                                 embedding_q8 = ?2,
1933                                 updated_at = datetime('now')
1934                             WHERE episode_id = ?3",
1935                            rusqlite::params![bytes, q8.as_deref(), episode_id],
1936                        )?;
1937                        #[cfg(feature = "hnsw")]
1938                        db::queue_pending_index_op(
1939                            tx,
1940                            &episodes::episode_item_key(episode_id),
1941                            "episode",
1942                            db::IndexOpKind::Upsert,
1943                        )?;
1944                        db::invalidate_derived_vector_artifact(
1945                            tx,
1946                            &episodes::episode_item_key(episode_id),
1947                        )?;
1948                    }
1949                    Ok(())
1950                })
1951            })
1952            .await?;
1953
1954            episode_count += batch.len();
1955            count += batch.len();
1956            if episode_count % 100 == 0 {
1957                tracing::info!(
1958                    episode_count,
1959                    "Re-embedded {} episodes so far",
1960                    episode_count
1961                );
1962            }
1963        }
1964
1965        // Clear the dirty flag
1966        self.with_write_conn(db::clear_embeddings_dirty).await?;
1967
1968        tracing::info!(
1969            facts = fact_count,
1970            chunks = chunk_count,
1971            messages = msg_count,
1972            episodes = episode_count,
1973            total = count,
1974            "Re-embedding complete"
1975        );
1976
1977        // Rebuild HNSW after re-embedding
1978        #[cfg(feature = "hnsw")]
1979        {
1980            tracing::info!("Rebuilding HNSW index after re-embedding...");
1981            let _receipt = self.rebuild_hnsw_index().await?;
1982        }
1983
1984        Ok(count)
1985    }
1986
1987    /// Vacuum the database (reclaim space after deletions).
1988    pub async fn vacuum(&self) -> Result<(), MemoryError> {
1989        self.with_write_conn(|conn| {
1990            conn.execute_batch("VACUUM")?;
1991            Ok(())
1992        })
1993        .await
1994    }
1995
1996    // ─── Projection Import ─────────────────────────────────────
1997
1998    /// Import a projection envelope atomically (V10 legacy path).
1999    ///
2000    /// ## Phase status: compatibility / migration-only
2001    ///
2002    /// This method is the V10 legacy import path. New integrations should use
2003    /// [`import_projection_batch()`](Self::import_projection_batch) instead,
2004    /// which accepts the canonical `ProjectionImportBatchV3` format from
2005    /// `forge-memory-bridge`.
2006    ///
2007    /// **Removal condition**: removed when all callers migrate to the bridge pipeline.
2008    ///
2009    /// **Idempotent**: re-importing the same envelope (same `envelope_id` +
2010    /// `schema_version` + `content_digest`) returns a receipt with
2011    /// `was_duplicate = true` and does not modify data.
2012    ///
2013    /// **Atomic**: all records are committed in a single transaction. On any
2014    /// failure the entire import is rolled back — no partial visibility.
2015    ///
2016    /// **Provenance**: each imported record's metadata is tagged with the
2017    /// envelope_id and source_authority for traceability.
2018    #[deprecated(
2019        since = "0.5.0",
2020        note = "Legacy V10 import envelope path is compatibility-only. Use `import_projection_batch()` and `ProjectionImportBatchV3` on the canonical lane."
2021    )]
2022    #[doc(hidden)]
2023    #[allow(deprecated)]
2024    pub async fn import_envelope(
2025        &self,
2026        envelope: &projection_import::ImportEnvelope,
2027    ) -> Result<projection_import::ImportReceipt, MemoryError> {
2028        projection_legacy_compat::import_envelope(self, envelope).await
2029    }
2030
2031    /// Check whether an envelope has already been imported.
2032    #[deprecated(
2033        since = "0.5.0",
2034        note = "Legacy V10 import envelope status reads are compatibility-only. Prefer the projection import log."
2035    )]
2036    #[doc(hidden)]
2037    #[allow(deprecated)]
2038    pub async fn import_status(
2039        &self,
2040        envelope_id: &projection_import::EnvelopeId,
2041    ) -> Result<Vec<projection_import::ImportReceipt>, MemoryError> {
2042        projection_legacy_compat::import_status(self, envelope_id).await
2043    }
2044
2045    /// List recent imports, optionally filtered by namespace.
2046    #[deprecated(
2047        since = "0.5.0",
2048        note = "Legacy V10 import log access is compatibility-only. Prefer new projection-import metadata."
2049    )]
2050    #[doc(hidden)]
2051    #[allow(deprecated)]
2052    pub async fn list_imports(
2053        &self,
2054        namespace: Option<&str>,
2055        limit: usize,
2056    ) -> Result<Vec<projection_import::ImportReceipt>, MemoryError> {
2057        projection_legacy_compat::list_imports(self, namespace, limit).await
2058    }
2059
2060    /// Get the most recent successful import timestamp for a namespace.
2061    #[allow(deprecated)]
2062    pub async fn last_import_at(&self, namespace: &str) -> Result<Option<String>, MemoryError> {
2063        projection_legacy_compat::last_import_at(self, namespace).await
2064    }
2065
2066    /// Query imported claim projection rows through the supported public read surface.
2067    pub async fn query_claim_versions(
2068        &self,
2069        query: ProjectionQuery,
2070    ) -> Result<Vec<ProjectionClaimVersion>, MemoryError> {
2071        self.with_read_conn(move |conn| projection_storage::query_claim_versions(conn, &query))
2072            .await
2073    }
2074
2075    /// Query imported relation projection rows through the supported public read surface.
2076    pub async fn query_relation_versions(
2077        &self,
2078        query: ProjectionQuery,
2079    ) -> Result<Vec<ProjectionRelationVersion>, MemoryError> {
2080        self.with_read_conn(move |conn| projection_storage::query_relation_versions(conn, &query))
2081            .await
2082    }
2083
2084    /// Query imported episode projection rows through the supported public read surface.
2085    pub async fn query_episodes(
2086        &self,
2087        query: ProjectionQuery,
2088    ) -> Result<Vec<ProjectionEpisode>, MemoryError> {
2089        self.with_read_conn(move |conn| projection_storage::query_episode_rows(conn, &query))
2090            .await
2091    }
2092
2093    /// Query imported entity-alias rows through the supported public read surface.
2094    pub async fn query_entity_aliases(
2095        &self,
2096        query: ProjectionQuery,
2097    ) -> Result<Vec<ProjectionEntityAlias>, MemoryError> {
2098        self.with_read_conn(move |conn| projection_storage::query_entity_aliases(conn, &query))
2099            .await
2100    }
2101
2102    /// Query imported evidence-reference rows through the supported public read surface.
2103    pub async fn query_evidence_refs(
2104        &self,
2105        query: ProjectionQuery,
2106    ) -> Result<Vec<ProjectionEvidenceRef>, MemoryError> {
2107        self.with_read_conn(move |conn| projection_storage::query_evidence_refs(conn, &query))
2108            .await
2109    }
2110
2111    /// Execute raw SQL. For testing only — not part of the stable public API.
2112    #[cfg(any(test, feature = "testing"))]
2113    pub async fn raw_execute(&self, sql: &str, params: Vec<String>) -> Result<usize, MemoryError> {
2114        let sql = sql.to_string();
2115        self.with_write_conn(move |conn| {
2116            let param_refs: Vec<&dyn rusqlite::types::ToSql> = params
2117                .iter()
2118                .map(|s| s as &dyn rusqlite::types::ToSql)
2119                .collect();
2120            Ok(conn.execute(&sql, &*param_refs)?)
2121        })
2122        .await
2123    }
2124}