Skip to main content

semantic_memory/
lib.rs

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