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