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