Skip to main content

semantic_memory/
lib.rs

1#![allow(deprecated)]
2#![allow(unused_imports, unused_variables, unreachable_code)]
3#![allow(
4    clippy::bool_assert_comparison,
5    clippy::collapsible_if,
6    clippy::empty_line_after_doc_comments,
7    clippy::expect_used,
8    clippy::field_reassign_with_default,
9    clippy::if_same_then_else,
10    clippy::iter_cloned_collect,
11    clippy::let_and_return,
12    clippy::manual_div_ceil,
13    clippy::manual_pattern_char_comparison,
14    clippy::manual_range_contains,
15    clippy::manual_slice_size_calculation,
16    clippy::manual_unwrap_or_default,
17    clippy::needless_range_loop,
18    clippy::ptr_arg,
19    clippy::redundant_closure,
20    clippy::skip_while_next,
21    clippy::too_many_arguments,
22    clippy::type_complexity,
23    clippy::unnecessary_cast,
24    clippy::unnecessary_sort_by
25)]
26
27//! # semantic-memory
28//!
29//! Local-first semantic memory backed by authoritative SQLite state and an optional recoverable
30//! HNSW sidecar.
31//!
32//! The crate stores facts, chunked documents, conversation messages, and searchable episodes in
33//! SQLite. Search combines BM25 (FTS5) and vector retrieval with Reciprocal Rank Fusion, and
34//! `search_explained()` returns the exact scoring breakdown from the live pipeline.
35//!
36//! Concurrency uses one writer connection plus a pool of WAL-enabled reader connections.
37//! Durable writes are committed to SQLite first; any required HNSW sidecar mutations are journaled
38//! in SQLite and replayed on open, flush, rebuild, or reconcile.
39//!
40//! `search()` targets facts, document chunks, and episodes by default. Message retrieval is
41//! available through `search_conversations()` or by opting into
42//! [`SearchSourceType::Messages`].
43//!
44//! Integrity tooling is strict about malformed stored data: invalid roles, JSON, enums, embedding
45//! blobs, quantized blobs, and sidecar drift are surfaced through `verify_integrity()` instead of
46//! being silently converted into defaults. `reconcile()` can rebuild FTS or fully re-embed and
47//! rebuild derived state from SQLite.
48//!
49//! `store.graph_view()` exposes a deterministic graph traversal layer over namespaces, facts,
50//! documents, chunks, sessions, messages, episodes, and semantic/temporal/causal links derived
51//! from SQLite state.
52//!
53//! ## Quick Start
54//!
55//! ```rust,no_run
56//! use semantic_memory::{MemoryConfig, MemoryStore};
57//!
58//! # async fn example() -> Result<(), semantic_memory::MemoryError> {
59//! let store = MemoryStore::open(MemoryConfig::default())?;
60//!
61//! // Store a fact
62//! store.add_fact("general", "Rust was first released in 2015", None, None).await?;
63//!
64//! // Search
65//! let results = store.search("when was Rust released", None, None, None).await?;
66//! # Ok(())
67//! # }
68//! ```
69//!
70//! ## Operational Notes
71//!
72//! - SQLite is authoritative for all durable records and embeddings.
73//! - HNSW is an acceleration sidecar. Pending sidecar mutations are journaled in SQLite, so a
74//!   sidecar failure does not imply the SQLite write rolled back.
75//! - WAL mode plus pooled reader connections allows concurrent reads while writes serialize through
76//!   the writer connection.
77//! - `search_explained()` reflects the exact ranking math used by the active search pipeline,
78//!   including reranking from exact f32 cosine similarity when configured.
79
80// At least one search backend must be enabled.
81#[cfg(not(any(feature = "hnsw", feature = "brute-force", feature = "usearch-backend")))]
82compile_error!(
83    "At least one search backend feature must be enabled: 'hnsw', 'usearch-backend', or 'brute-force'"
84);
85
86mod authority;
87pub mod authority_contracts;
88pub mod chunker;
89pub mod config;
90pub(crate) mod conversation;
91pub(crate) mod db;
92/// Bounded evidence-gap retrieval and state-aware reranking over existing authority/search paths.
93pub mod evidence_gap;
94mod forgetting;
95mod procedural_memory;
96pub mod transition_contracts;
97mod transition_verifier;
98pub use db::{bytes_to_embedding, decode_f32_le, embedding_to_bytes};
99pub use evidence_gap::{
100    rerank_state_aware, EvidenceAblationReceiptV1, EvidenceGapOutcomeV1, EvidenceGapReasonV1,
101    EvidenceGapRequestV1, EvidenceGapV1, EvidencePacketItemV1, EvidencePacketV1,
102    EvidenceRetrievalRouteV1, EvidenceRouteReceiptV1, EvidenceTerminalOutcome,
103    EvidenceTerminalOutcomeV1, StateRerankCandidateV1, StateRerankWeightsV1, EVIDENCE_GAP_V1,
104    EVIDENCE_PACKET_V1, EVIDENCE_ROUTE_RECEIPT_V1,
105};
106/// Phase 9b: benchmark harness for routing quality.
107#[cfg(feature = "benchmark")]
108pub mod benchmark;
109/// Leiden community detection with contradiction tracking.
110#[cfg(feature = "community")]
111pub mod community;
112/// Phase 8: simplified compression governor (importance scoring only).
113#[cfg(feature = "compression-governor")]
114pub mod compression_governor;
115/// Content-based contradiction detection (lexical, deterministic).
116#[cfg(feature = "decoder")]
117pub mod contradiction_detect;
118/// Phase 6: decoder architecture (syndromes and corrections).
119#[cfg(feature = "decoder")]
120pub mod decoder;
121/// Discord-structured second-order retrieval (graph-neighbour discovery).
122#[cfg(feature = "discord")]
123pub mod discord;
124pub(crate) mod documents;
125pub mod embedder;
126pub(crate) mod episodes;
127pub mod error;
128/// Contradiction-detection evaluation harness (RAMDocs-style P/R/F1).
129#[cfg(feature = "decoder")]
130pub mod eval_contradiction;
131/// Factor graph unification of heterogeneous graph edges (semantic,
132/// temporal, causal, entity) with belief propagation. The single most
133/// novel combination: unified probabilistic reasoning over all edge types.
134#[cfg(feature = "integration")]
135pub mod factor_graph;
136mod graph;
137/// First-class stored graph edges (durable, typed relationships).
138pub(crate) mod graph_edges;
139#[cfg(feature = "hnsw")]
140pub mod hnsw;
141#[cfg(feature = "hnsw")]
142mod hnsw_backend;
143#[cfg(feature = "hnsw")]
144mod hnsw_ops;
145/// Claim-bounded scoring and receipt invariants for the hostile memory benchmark.
146pub mod hostile_benchmark;
147/// Deterministic CPU-only hubness scoring over dense embedding collections.
148pub mod hubness;
149/// Phase 10: cross-feature integration wiring.
150#[cfg(feature = "integration")]
151pub mod integration;
152mod json_compat_import;
153pub(crate) mod knowledge;
154/// Immutable origin-bound authority labels and governed access decisions.
155pub mod origin_authority;
156pub use authority::MemoryAuthority;
157pub use authority_contracts::{
158    AuthorityAdmission, AuthorityFaultStage, AuthorityOperationKind, AuthorityPermit,
159    AuthorityReceiptV1, AuthoritySnapshotId, AuthorityStateV1, CapabilityManifestV1, Confidence,
160    CosineSimilarity, InjectionDecisionV1, InjectionDisposition, MemoryEnvelopeV1,
161    NonNegativeWeight, Probability, RetrievalEpoch, RetrievalResponseV1, RetrievalWitnessV1,
162    StageOutcomeV1, SupersessionReceiptV1,
163};
164pub use forgetting::{
165    ForgettingClosureReceiptV1, ForgettingClosureRequestV1, ForgettingDispositionV1,
166    ForgettingEpochsV1, ForgettingSurfaceRefV1, ForgettingVerificationV1,
167    FORGETTING_CLOSURE_RECEIPT_V1,
168};
169pub use knowledge::StateView;
170pub use origin_authority::{
171    evaluate_governed_access_v1, AudienceV1, AuthorityScopeV1, AuthorityScopesV1,
172    CallerPrincipalV1, DelegationElevationLeaseV1, ElevationRequirementV1, GovernedAccessPurposeV1,
173    GovernedAccessRequestV1, GovernedFactAccessV1, GovernedFactListResponseV1,
174    GovernedGraphResponseV1, GovernedProjectionResponseV1, GovernedReplayResponseV1,
175    GovernedSearchResponseV1, GovernedStateResolutionResponseV1, NamespaceScopeV1,
176    OriginAuthorityDecisionV1, OriginAuthorityLabelV1, OriginAuthorityRecordV1, OriginClassV1,
177    OriginDerivationKindV1, OriginRiskV1, PolicyDecisionV1, RevocationStatusV1, SubjectPrincipalV1,
178};
179pub use procedural_memory::{
180    validate_procedure_artifact_v1, verify_procedure_lifecycle_receipt_v1,
181    verify_procedure_test_receipt_v1, AllowedProcedureToolV1, ApplicabilityOperatorV1,
182    ApplicabilityPredicateV1, GovernedProcedureDecisionV1, GovernedProcedureRetrievalV1,
183    ProceduralMemoryArtifactV1, ProcedureAccessPathV1, ProcedureActionPermitV1, ProcedureActionV1,
184    ProcedureCapabilityV1, ProcedureEffectV1, ProcedureEvidenceTestEnvelopeV1,
185    ProcedureFixtureReceiptV1, ProcedureFixtureV1, ProcedureLifecycleDispositionV1,
186    ProcedureLifecyclePermitV1, ProcedureLifecycleReceiptV1, ProcedurePreconditionV1,
187    ProcedureRetrievalRequestV1, ProcedureRevocationV1, ProcedureRiskV1, ProcedureStepV1,
188    ProcedureTestReceiptV1, ProcedureValidationV1, PROCEDURAL_MEMORY_ARTIFACT_V1,
189    PROCEDURE_LIFECYCLE_RECEIPT_V1, PROCEDURE_TEST_RECEIPT_V1,
190};
191pub use shadow_policy::{
192    compare_shadow_execution_v1, evaluate_shadow_policy_promotion_v1, shadow_policy_digest,
193    ActiveShadowPolicyV1, PromotionDecisionReceiptV1, PromotionDispositionV1, PromotionEvidenceV1,
194    PromotionGateDecisionV1, ShadowEvaluationWindowV1, ShadowExecutionComparisonV1,
195    ShadowPolicyKindV1, ShadowPolicyPromotionPermitV1, ShadowPolicyProposalV1,
196    ShadowPolicyProvenanceV1, ShadowPolicyRiskV1, ShadowPolicyStatusV1,
197    PROMOTION_DECISION_RECEIPT_V1, SHADOW_POLICY_PROPOSAL_V1,
198};
199pub use state_epistemics::{
200    answer_policy_for, resolve_dependency_states, AnswerDisposition, AnswerPolicy,
201    AnswerPolicyDecision, BeliefAlternativeV1, DependencyResolutionV1, DependencyState,
202    PremiseStatus, ResolvedAssertionV1, ResolvedMemoryAnswerV1, StateDependencyEdgeV1,
203    StateResolutionMode, StateResolutionReceiptV1, StateResolvedRetrievalResponseV1,
204    STATE_RESOLUTION_RECEIPT_V1, STATE_RESOLVED_RETRIEVAL_V1,
205};
206pub use transition_contracts::{
207    ActiveHeadSimulationV1, AssertionDraftV1, DependencySimulationV1, MemoryTransitionCandidateV1,
208    MemoryTransitionOutcomeV1, MemoryTransitionRecordV1, MemoryTransitionVerificationV1,
209    OmittedSourceSpanV1, SourceArtifactV1, SourceSpanRefV1, SupersessionDraftV1,
210    TransitionDisposition, TransitionOperation, UnsupportedAssertionSpanV1, VerificationScore,
211};
212/// ColBERT-style late interaction multi-vector retrieval.
213#[cfg(feature = "late-interaction")]
214pub mod late_interaction;
215/// Matryoshka Representation Learning: multi-resolution embedding truncation.
216#[cfg(feature = "matryoshka")]
217pub mod matryoshka;
218/// Multiscale retrieval scheduling pipeline (staged search with budgets).
219#[cfg(feature = "multiscale")]
220pub mod pipeline;
221/// Compatibility-only legacy import surface.
222///
223/// This module exists only for migration compatibility with pre-V11 import paths.
224#[deprecated(
225    since = "0.6.0",
226    note = "Legacy V10 import path is migration-only. Use `import_projection_batch()` with `ProjectionImportBatchV3` on the canonical lane."
227)]
228#[doc(hidden)]
229#[cfg(feature = "poly-kv-codec")]
230pub mod poly_kv_bridge;
231mod pool;
232mod projection_batch;
233mod projection_derivation;
234pub mod projection_import;
235mod projection_lane;
236mod projection_legacy_compat;
237pub(crate) mod projection_storage;
238/// Phase 2: semiring provenance (Boolean/Tropical/Probability/Confidence).
239#[cfg(feature = "provenance")]
240pub mod provenance;
241pub mod quantize;
242pub mod quantize_governed;
243/// Contextual reinstatement scoring building blocks.
244pub mod reinstatement;
245/// RL-trained retrieval routing on receipt replay data.
246#[cfg(feature = "rl-routing")]
247pub mod rl_routing;
248/// Phase 9: adaptive retrieval routing (query-aware stage selection).
249#[cfg(feature = "routing")]
250pub mod routing;
251pub mod search;
252pub mod shadow_policy;
253pub mod state_epistemics;
254pub mod storage;
255mod store_support;
256/// Reasoning subgraph pruning with lawful subtraction.
257#[cfg(feature = "subgraph-pruning")]
258pub mod subgraph_pruning;
259/// Phase 7: lawful subtraction engine.
260#[cfg(feature = "subtraction")]
261pub mod subtraction;
262/// Phase 3: temporal field provenance (computed temporal_weight scores).
263#[cfg(feature = "temporal")]
264pub mod temporal;
265pub mod tokenizer;
266/// Persistent homology and topological void detection for knowledge graphs.
267#[cfg(feature = "topology")]
268pub mod topology;
269pub mod types;
270#[cfg(feature = "usearch-backend")]
271mod usearch_backend;
272pub mod vector_backend;
273pub mod vector_codec;
274pub mod vector_snapshot;
275
276// Re-export primary public types.
277pub use config::{
278    ChunkingConfig, ChunkingStrategy, DerivedVectorBackendPolicy, EmbeddingConfig, MemoryConfig,
279    MemoryLimits, PoolConfig, SearchConfig,
280};
281pub use db::{IntegrityReport, ReconcileAction, VerifyMode};
282#[cfg(feature = "candle-embedder")]
283pub use embedder::CandleEmbedder;
284pub use embedder::{
285    BgeM3DeriveConfig, BgeM3Embedder, Embedder, MockEmbedder, MultiEmbedBatchFuture,
286    MultiEmbedFuture, MultiFunctionEmbedder, MultiFunctionEmbedding, MultiVectorEmbedding,
287    OllamaEmbedder, OptionalMultiEmbedBatchFuture, OptionalMultiEmbedFuture, SparseWeights,
288};
289pub use error::MemoryError;
290#[cfg(feature = "hnsw")]
291pub use hnsw::{HnswConfig, HnswHit, HnswIndex};
292// Type aliases for the new VectorBackend trait. The Hnsw* names are kept
293// for source compatibility; new code should prefer the Vector* names.
294pub use graph_edges::{AddGraphEdgeParams, StoredGraphEdge};
295pub(crate) use projection_lane::projection_import_failure_id;
296pub use projection_lane::{
297    ProjectionImportFailureReceiptEntry, ProjectionImportLogEntry, ProjectionImportResult,
298};
299pub use quantize::{pack_quantized, unpack_quantized, QuantizedVector, Quantizer};
300pub use storage::StoragePaths;
301pub use tokenizer::{EstimateTokenCounter, TokenCounter};
302pub use types::{
303    ChunkManifestChunkMapping, ChunkManifestEntry, ChunkManifestIngestOptions,
304    ChunkManifestIngestResult, DerivedCandidateReceiptV1, Document, EmbeddingDisplacement,
305    EpisodeAsOfReceiptV1, EpisodeMeta, EpisodeOutcome, ExactnessProfile, ExplainedResult,
306    ExplainedResultAnswerV1, ExplainedSearchResponse, Fact, GraphDirection, GraphEdge,
307    GraphEdgeType, GraphView, MemoryStats, Message, NamespaceDeleteReport, ProjectionClaimVersion,
308    ProjectionEntityAlias, ProjectionEpisode, ProjectionEvidenceRef, ProjectionQuery,
309    ProjectionRelationVersion, ProveKvPoolArtifactBuildReceiptV1, ProveKvPoolArtifactStatusV1,
310    ProveKvPoolGenerationStatus, ProveKvPoolGenerationV1, ProveKvPoolItemMapEntryV1, ReceiptMode,
311    ReplayMode, Role, ScoreBreakdown, SearchContext, SearchReceiptAnswersV1, SearchReplayReportV1,
312    SearchResponse, SearchResult, SearchSource, SearchSourceType, Session, SparseRankReceiptV1,
313    TextChunk, VectorArtifactBuildReceiptV1, VectorSearchReceiptV1, VerificationStatus,
314};
315pub use vector_backend::{VectorBackend, VectorHit, VectorIndex, VectorIndexConfig};
316#[cfg(feature = "turbo-quant-codec")]
317pub use vector_codec::TurboQuantCodec;
318pub use vector_codec::{
319    RawF32Codec, Sq8Codec, VectorArtifactV1, VectorCodec, VectorCodecProfileV1,
320};
321pub use vector_snapshot::{build_embedding_snapshot, EmbeddingSnapshotRow, EmbeddingSnapshotV1};
322
323use std::sync::Arc;
324
325const MAX_TOP_K: usize = 1_000;
326#[cfg(feature = "hnsw")]
327const MAX_HNSW_CANDIDATES: usize = 10_000;
328
329pub(crate) use store_support::{
330    as_str_slice, build_episode_search_text, merge_trace_ctx, to_owned_string_vec,
331    verification_status_for_outcome,
332};
333
334/// Deduplicate search results by content fingerprint within the same source type.
335///
336/// Removes results with near-identical content from the SAME source type
337/// (fact vs chunk). Keeps cross-source-type results even if content matches,
338/// since a fact and a chunk with identical content have different provenance.
339fn dedup_by_content(results: Vec<types::SearchResult>) -> Vec<types::SearchResult> {
340    use std::collections::HashSet;
341    let mut seen: HashSet<String> = HashSet::new();
342    let deduped_result: Vec<types::SearchResult> = results
343        .into_iter()
344        .filter(|r| {
345            let fingerprint: String = r
346                .content
347                .split_whitespace()
348                .take(30)
349                .collect::<Vec<_>>()
350                .join(" ")
351                .to_lowercase();
352            // Include source type (not full source with IDs) in the key
353            // so cross-source-type results with identical content are kept,
354            // but same-source-type results with identical content are deduped
355            let source_type = match &r.source {
356                types::SearchSource::Fact { .. } => "fact",
357                types::SearchSource::Chunk { .. } => "chunk",
358                types::SearchSource::Message { .. } => "message",
359                types::SearchSource::Episode { .. } => "episode",
360                types::SearchSource::Projection { .. } => "projection",
361            };
362            let key = format!("{}:{}", source_type, fingerprint);
363            seen.insert(key)
364        })
365        .collect::<Vec<_>>();
366    let mut deduped = deduped_result;
367
368    // Pass 2: document diversity -- max 2 chunks per document_id
369    let mut doc_counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
370    deduped.retain(|r| {
371        if let types::SearchSource::Chunk { document_id, .. } = &r.source {
372            let count = doc_counts.entry(document_id.clone()).or_insert(0);
373            if *count >= 2 {
374                return false;
375            }
376            *count += 1;
377        }
378        true
379    });
380
381    // Pass 3: heuristic embedding similarity dedup within same source type.
382    // When two same-type results have cosine scores within 0.01 of each other
383    // and their first-30-word Jaccard similarity is ≥ 0.8, drop the lower scorer.
384    {
385        let word_set = |r: &types::SearchResult| -> std::collections::HashSet<String> {
386            r.content
387                .split_whitespace()
388                .take(30)
389                .map(|w| w.to_lowercase())
390                .collect()
391        };
392        let source_type_tag = |r: &types::SearchResult| -> &'static str {
393            match &r.source {
394                types::SearchSource::Fact { .. } => "fact",
395                types::SearchSource::Chunk { .. } => "chunk",
396                types::SearchSource::Message { .. } => "message",
397                types::SearchSource::Episode { .. } => "episode",
398                types::SearchSource::Projection { .. } => "projection",
399            }
400        };
401        let n = deduped.len();
402        let mut drop: std::collections::HashSet<usize> = std::collections::HashSet::new();
403        for i in 0..n {
404            if drop.contains(&i) {
405                continue;
406            }
407            for j in (i + 1)..n {
408                if drop.contains(&j) {
409                    continue;
410                }
411                let ri = &deduped[i];
412                let rj = &deduped[j];
413                if source_type_tag(ri) != source_type_tag(rj) {
414                    continue;
415                }
416                let (Some(ci), Some(cj)) = (ri.cosine_similarity, rj.cosine_similarity) else {
417                    continue;
418                };
419                if (ci - cj).abs() > 0.01 {
420                    continue;
421                }
422                let wi = word_set(ri);
423                let wj = word_set(rj);
424                let inter = wi.intersection(&wj).count();
425                let uni = wi.union(&wj).count();
426                if uni == 0 {
427                    continue;
428                }
429                if inter as f64 / uni as f64 >= 0.8 {
430                    if ri.score >= rj.score {
431                        drop.insert(j);
432                    } else {
433                        drop.insert(i);
434                        break;
435                    }
436                }
437            }
438        }
439        if !drop.is_empty() {
440            let mut idx = 0usize;
441            deduped.retain(|_| {
442                let keep = !drop.contains(&idx);
443                idx += 1;
444                keep
445            });
446        }
447    }
448
449    deduped
450}
451
452/// SimpleMem-style semantic content compression for search results.
453///
454/// Shortens result content to the first sentence plus key terms, capped at 150 chars.
455/// This reduces token consumption for downstream LLM consumption while preserving
456/// the most salient information.
457///
458/// The algorithm:
459/// 1. Extract the first sentence (up to `. `, `! `, or `? `).
460/// 2. If the first sentence is already <= 150 chars, return it.
461/// 3. Otherwise, take the first 150 chars of the first sentence, trying to break
462///    at a word boundary.
463pub fn compress_search_results(results: Vec<types::SearchResult>) -> Vec<types::SearchResult> {
464    results
465        .into_iter()
466        .map(|r| {
467            let compressed = compress_content(&r.content);
468            types::SearchResult {
469                content: compressed,
470                ..r
471            }
472        })
473        .collect()
474}
475
476/// Compress a single content string to first sentence + key terms, capped at 150 chars.
477fn compress_content(content: &str) -> String {
478    const MAX_CHARS: usize = 150;
479
480    // Find the first sentence boundary.
481    let first_sentence = content
482        .find(|c| c == '.' || c == '!' || c == '?')
483        .map(|idx| {
484            // Include the punctuation.
485            let end = idx + 1;
486            &content[..end.min(content.len())]
487        })
488        .unwrap_or(content);
489
490    if first_sentence.len() <= MAX_CHARS {
491        return first_sentence.trim().to_string();
492    }
493
494    // Truncate to MAX_CHARS at a word boundary.
495    let truncated = &first_sentence[..MAX_CHARS];
496    if let Some(last_space) = truncated.rfind(' ') {
497        let at_word_boundary = &truncated[..last_space];
498        format!("{}…", at_word_boundary.trim())
499    } else {
500        format!("{}…", truncated.trim())
501    }
502}
503
504#[cfg(feature = "hnsw")]
505fn verify_hnsw_key_level_integrity(
506    conn: &rusqlite::Connection,
507    dimensions: usize,
508    node_vectors: &std::collections::HashMap<usize, Vec<f32>>,
509    sidecar_files_exist: bool,
510) -> Result<Vec<String>, MemoryError> {
511    let mut issues = Vec::new();
512    let mut live_rows: std::collections::HashMap<String, Vec<f32>> =
513        std::collections::HashMap::new();
514
515    let mut live_stmt = conn.prepare(
516        "SELECT 'fact:' || id, embedding FROM facts WHERE embedding IS NOT NULL
517         UNION ALL
518         SELECT 'chunk:' || id, embedding FROM chunks WHERE embedding IS NOT NULL
519         UNION ALL
520         SELECT 'msg:' || id, embedding FROM messages WHERE embedding IS NOT NULL
521         UNION ALL
522         SELECT 'episode:' || episode_id, embedding FROM episodes WHERE embedding IS NOT NULL",
523    )?;
524    let live_iter = live_stmt.query_map([], |row| {
525        Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?))
526    })?;
527    for row in live_iter {
528        let (key, blob) = row?;
529        match db::decode_f32_le(&blob, dimensions) {
530            Ok(vector) => {
531                live_rows.insert(key, vector);
532            }
533            Err(err) => issues.push(format!(
534                "HNSW live embedding row {key} has invalid vector: {err}"
535            )),
536        }
537    }
538
539    if !live_rows.is_empty() && !sidecar_files_exist {
540        issues.push(format!(
541            "HNSW sidecar files are missing while {} embedded rows exist in SQLite",
542            live_rows.len()
543        ));
544    }
545
546    let keymap_exists: bool = conn
547        .query_row(
548            "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='hnsw_keymap'",
549            [],
550            |row| row.get(0),
551        )
552        .unwrap_or(false);
553    if !keymap_exists {
554        if !live_rows.is_empty() {
555            issues.push("HNSW keymap table missing while embedded SQLite rows exist".to_string());
556        }
557        return Ok(issues);
558    }
559
560    let mut active_keymap: std::collections::HashMap<String, usize> =
561        std::collections::HashMap::new();
562    let mut keymap_stmt =
563        conn.prepare("SELECT node_id, item_key FROM hnsw_keymap WHERE deleted = 0")?;
564    let keymap_iter = keymap_stmt.query_map([], |row| {
565        Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
566    })?;
567    for row in keymap_iter {
568        let (node_id_raw, key) = row?;
569        let Some((domain, raw_id)) = key.split_once(':') else {
570            issues.push(format!("HNSW keymap entry has malformed key: {key}"));
571            continue;
572        };
573        if !matches!(domain, "fact" | "chunk" | "msg" | "episode") || raw_id.is_empty() {
574            issues.push(format!(
575                "HNSW keymap entry has unsupported key domain: {key}"
576            ));
577            continue;
578        }
579        if domain == "msg" && raw_id.parse::<i64>().is_err() {
580            issues.push(format!("HNSW message key has non-integer row id: {key}"));
581            continue;
582        }
583        let node_id = match usize::try_from(node_id_raw) {
584            Ok(node_id) => node_id,
585            Err(err) => {
586                issues.push(format!(
587                    "HNSW keymap node_id {node_id_raw} is invalid: {err}"
588                ));
589                continue;
590            }
591        };
592        active_keymap.insert(key, node_id);
593    }
594
595    for key in live_rows.keys() {
596        if !active_keymap.contains_key(key) {
597            issues.push(format!(
598                "HNSW keymap missing live embedded SQLite row: {key}"
599            ));
600        }
601    }
602
603    for (key, node_id) in &active_keymap {
604        let Some(live_vector) = live_rows.get(key) else {
605            issues.push(format!(
606                "HNSW keymap has stale active entry without live embedded SQLite row: {key}"
607            ));
608            continue;
609        };
610        let Some(index_vector) = node_vectors.get(node_id) else {
611            issues.push(format!(
612                "HNSW keymap entry {key} points to missing in-memory node vector {node_id}"
613            ));
614            continue;
615        };
616        if index_vector.len() != live_vector.len()
617            || index_vector
618                .iter()
619                .zip(live_vector)
620                .any(|(left, right)| left.to_bits() != right.to_bits())
621        {
622            issues.push(format!(
623                "HNSW keymap entry {key} points to node {node_id} whose vector does not match the authoritative SQLite embedding"
624            ));
625        }
626    }
627
628    if active_keymap.len() != live_rows.len() {
629        issues.push(format!(
630            "HNSW keymap drift: {} active keymap rows vs {} embedded SQLite rows",
631            active_keymap.len(),
632            live_rows.len()
633        ));
634    }
635
636    Ok(issues)
637}
638
639/// Compatibility-only public access to retained legacy surfaces.
640#[doc(hidden)]
641pub mod compat {
642    #[deprecated(
643        since = "0.5.0",
644        note = "Legacy ImportEnvelope is migration-only. New integrations should use `ProjectionImportBatchV3` on the canonical lane."
645    )]
646    #[doc(hidden)]
647    #[allow(deprecated)]
648    pub mod legacy_import_envelope {
649        pub use crate::projection_import::{
650            ImportEnvelope, ImportProjectionFreshness, ImportReceipt, ImportRecord, ImportStatus,
651        };
652        pub use stack_ids::EnvelopeId;
653    }
654
655    #[deprecated(
656        since = "0.5.0",
657        note = "Legacy trace_id is migration-only. Use `stack_ids::TraceCtx`."
658    )]
659    #[doc(hidden)]
660    #[allow(deprecated)]
661    pub mod compat_trace_id {
662        pub use crate::types::TraceId;
663    }
664}
665
666/// Thread-safe handle to the memory database.
667///
668/// Clone is cheap (Arc internals). `Send + Sync`.
669#[derive(Clone)]
670pub struct MemoryStore {
671    inner: Arc<MemoryStoreInner>,
672}
673
674struct MemoryStoreInner {
675    pool: pool::SqlitePool,
676    embedder: Box<dyn Embedder>,
677    embedding_permits: Arc<tokio::sync::Semaphore>,
678    config: MemoryConfig,
679    paths: StoragePaths,
680    token_counter: Arc<dyn TokenCounter>,
681    /// LRU cache for query embeddings. Key is the text hash, value is the
682    /// embedding vector. Capped at 256 entries (~768KB for 768d f32).
683    embedding_cache: std::sync::Mutex<lru::LruCache<String, Vec<f32>>>,
684    /// LRU cache for search results. Key is "query:top_k", value is results.
685    /// Capped at 64 entries.
686    search_cache: std::sync::Mutex<lru::LruCache<String, CachedSearchResult>>,
687    pub(crate) authority_fault:
688        Arc<std::sync::Mutex<Option<authority_contracts::AuthorityFaultStage>>>,
689    #[cfg(feature = "hnsw")]
690    hnsw_index: std::sync::RwLock<HnswIndex>,
691}
692
693#[derive(Clone)]
694struct CachedSearchResult {
695    results: Vec<types::SearchResult>,
696    retrieval_epoch: RetrievalEpoch,
697}
698
699#[cfg(feature = "hnsw")]
700impl Drop for MemoryStoreInner {
701    fn drop(&mut self) {
702        if !self.paths.hnsw_dir.exists() {
703            tracing::debug!(
704                path = %self.paths.hnsw_dir.display(),
705                "Skipping HNSW drop flush because the sidecar directory no longer exists"
706            );
707            return;
708        }
709
710        let pending_ops = match self.pool.with_read_conn(db::pending_index_op_count) {
711            Ok(count) => count,
712            Err(err) => {
713                tracing::warn!("Failed to inspect pending HNSW work on drop: {}", err);
714                0
715            }
716        };
717
718        if pending_ops > 0 {
719            if let Err(err) =
720                hnsw_ops::recover_hnsw_sidecar_sync(&self.pool, &self.paths, &self.config.hnsw)
721            {
722                tracing::error!("Failed to recover and flush HNSW on drop: {}", err);
723            }
724            return;
725        }
726
727        let hnsw_guard = match self.hnsw_index.read() {
728            Ok(g) => g,
729            Err(_) => {
730                tracing::warn!("HNSW RwLock poisoned on drop — skipping save");
731                return;
732            }
733        };
734
735        if let Err(err) = hnsw_ops::save_hnsw_sidecar(
736            &hnsw_guard,
737            &self.paths.hnsw_dir,
738            &self.paths.hnsw_basename,
739        ) {
740            tracing::error!("Failed to save HNSW index on drop: {}", err);
741        }
742
743        // Flush key mappings to SQLite
744        if let Err(e) = self
745            .pool
746            .with_write_conn(|conn| hnsw_guard.flush_keymap(conn))
747        {
748            tracing::error!("Failed to flush HNSW keymap on drop: {}", e);
749        }
750    }
751}
752
753fn nonzero_cache_capacity(value: usize) -> std::num::NonZeroUsize {
754    match std::num::NonZeroUsize::new(value) {
755        Some(value) => value,
756        None => std::num::NonZeroUsize::MIN,
757    }
758}
759
760impl MemoryStore {
761    /// Return the capability-gated, append-only authority mutation surface.
762    pub fn authority(&self) -> MemoryAuthority {
763        MemoryAuthority::new(self.clone())
764    }
765
766    /// Run read-only work on a pooled reader connection on a blocking thread.
767    ///
768    /// This prevents SQLite I/O from stalling the tokio executor while allowing
769    /// multiple concurrent readers under WAL mode.
770    async fn with_read_conn<F, T>(&self, f: F) -> Result<T, MemoryError>
771    where
772        F: FnOnce(&rusqlite::Connection) -> Result<T, MemoryError> + Send + 'static,
773        T: Send + 'static,
774    {
775        let inner = self.inner.clone();
776        tokio::task::spawn_blocking(move || -> Result<T, MemoryError> {
777            inner.pool.with_read_conn(f)
778        })
779        .await
780        .map_err(|e| MemoryError::Other(format!("Blocking task panicked: {}", e)))?
781    }
782
783    /// Run write-capable work on the single writer connection on a blocking thread.
784    async fn with_write_conn<F, T>(&self, f: F) -> Result<T, MemoryError>
785    where
786        F: FnOnce(&rusqlite::Connection) -> Result<T, MemoryError> + Send + 'static,
787        T: Send + 'static,
788    {
789        let inner = self.inner.clone();
790        tokio::task::spawn_blocking(move || -> Result<T, MemoryError> {
791            inner.pool.with_write_conn(f)
792        })
793        .await
794        .map_err(|e| MemoryError::Other(format!("Blocking task panicked: {}", e)))?
795    }
796
797    pub(crate) fn clear_search_cache(&self) {
798        match self.inner.search_cache.lock() {
799            Ok(mut cache) => cache.clear(),
800            Err(err) => tracing::warn!(error = %err, "search cache lock poisoned; clear skipped"),
801        }
802    }
803
804    pub(crate) fn clear_search_cache_strict(&self) -> Result<(), MemoryError> {
805        let mut cache = self.inner.search_cache.lock().map_err(|_| {
806            MemoryError::ForgettingClosureIncomplete {
807                detail: "search cache lock is poisoned".into(),
808            }
809        })?;
810        cache.clear();
811        Ok(())
812    }
813
814    async fn persist_search_receipt(
815        &self,
816        receipt: &VectorSearchReceiptV1,
817        query: &str,
818        namespaces: Option<&[&str]>,
819        source_types: Option<&[SearchSourceType]>,
820        replay_mode: ReplayMode,
821    ) -> Result<(), MemoryError> {
822        let receipt = receipt.clone();
823        let query = query.to_string();
824        let namespaces = to_owned_string_vec(namespaces);
825        let source_types = source_types.map(|values| values.to_vec());
826        self.with_write_conn(move |conn| {
827            db::store_search_receipt(conn, &receipt)?;
828            if replay_mode == ReplayMode::StoreInputs {
829                let namespace_refs = as_str_slice(&namespaces);
830                db::store_replay_inputs(
831                    conn,
832                    &receipt.receipt_id,
833                    &query,
834                    namespace_refs.as_deref(),
835                    source_types.as_deref(),
836                )?;
837            }
838            Ok(())
839        })
840        .await
841    }
842
843    /// Run HNSW search on a blocking thread to avoid holding std::sync::RwLock
844    /// across await points (CONC-001).
845    #[cfg(feature = "hnsw")]
846    async fn hnsw_search_blocking(
847        &self,
848        query_embedding: Vec<f32>,
849        candidates: usize,
850    ) -> Vec<HnswHit> {
851        let inner = self.inner.clone();
852        tokio::task::spawn_blocking(move || {
853            let guard = inner.hnsw_index.read().unwrap_or_else(|e| e.into_inner());
854            match guard.search(&query_embedding, candidates) {
855                Ok(hits) => hits,
856                Err(e) => {
857                    tracing::error!(
858                        "HNSW search failed, falling back to brute-force vector search: {}",
859                        e
860                    );
861                    Vec::new()
862                }
863            }
864        })
865        .await
866        .unwrap_or_else(|e| {
867            tracing::error!("HNSW search blocking task panicked: {}", e);
868            Vec::new()
869        })
870    }
871
872    #[cfg(feature = "hnsw")]
873    fn sync_pending_hnsw_ops_blocking(&self) -> Result<usize, MemoryError> {
874        hnsw_ops::sync_pending_hnsw_sidecar(&self.inner)
875    }
876
877    #[cfg(feature = "hnsw")]
878    async fn sync_pending_hnsw_ops(&self) -> Result<usize, MemoryError> {
879        let inner = self.inner.clone();
880        tokio::task::spawn_blocking(move || hnsw_ops::sync_pending_hnsw_sidecar(&inner))
881            .await
882            .map_err(|e| MemoryError::Other(format!("Blocking task panicked: {}", e)))?
883    }
884
885    #[cfg(feature = "hnsw")]
886    async fn sync_pending_hnsw_ops_best_effort(&self, operation: &'static str) {
887        if let Err(err) = self.sync_pending_hnsw_ops().await {
888            tracing::warn!(
889                operation,
890                error = %err,
891                "SQLite write committed but HNSW sidecar sync is still pending"
892            );
893        } else {
894            self.maybe_flush_hnsw();
895        }
896    }
897
898    /// Open or create a memory store at the configured base directory.
899    ///
900    /// Creates the directory if it doesn't exist, opens/creates SQLite,
901    /// runs migrations, and initializes the HNSW index.
902    ///
903    /// When the `candle-embedder` feature is enabled, this defaults to
904    /// [`CandleEmbedder`] (in-process, pure-Rust, no Ollama required).
905    /// Otherwise it defaults to [`OllamaEmbedder`].
906    pub fn open(config: MemoryConfig) -> Result<Self, MemoryError> {
907        let config = config.normalize_and_validate()?;
908        #[cfg(feature = "candle-embedder")]
909        let embedder: Box<dyn Embedder> = Box::new(CandleEmbedder::try_new(&config.embedding)?);
910        #[cfg(not(feature = "candle-embedder"))]
911        let embedder: Box<dyn Embedder> = Box::new(OllamaEmbedder::try_new(&config.embedding)?);
912        Self::open_with_embedder(config, embedder)
913    }
914
915    /// Open with a custom embedder (for testing or non-Ollama providers).
916    #[allow(unused_mut)] // `config` is mutated only when the `hnsw` feature is enabled
917    pub fn open_with_embedder(
918        mut config: MemoryConfig,
919        embedder: Box<dyn Embedder>,
920    ) -> Result<Self, MemoryError> {
921        config = config.normalize_and_validate()?;
922        if embedder.dimensions() != config.embedding.dimensions {
923            return Err(MemoryError::DimensionMismatch {
924                expected: config.embedding.dimensions,
925                actual: embedder.dimensions(),
926            });
927        }
928        config.embedding.model = embedder.model_name().to_string();
929
930        let paths = StoragePaths::new(&config.base_dir);
931
932        // Create directory if needed
933        std::fs::create_dir_all(&paths.base_dir).map_err(|e| {
934            MemoryError::StorageError(format!(
935                "Failed to create directory {}: {}",
936                paths.base_dir.display(),
937                e
938            ))
939        })?;
940
941        let pool = pool::SqlitePool::open(&paths.sqlite_path, &config.pool, &config.limits)?;
942        pool.with_write_conn(|conn| db::check_embedding_metadata(conn, &config.embedding))?;
943
944        // Ensure HNSW dimensions match the embedding config
945        #[cfg(feature = "hnsw")]
946        {
947            config.hnsw.dimensions = config.embedding.dimensions;
948        }
949
950        let token_counter = config
951            .token_counter
952            .clone()
953            .unwrap_or_else(tokenizer::default_token_counter);
954
955        #[cfg(feature = "hnsw")]
956        let hnsw_index = {
957            let hnsw_config = config.hnsw.clone();
958
959            let embeddings_dirty = pool.with_read_conn(db::is_embeddings_dirty)?;
960            let pending_index_ops = pool.with_read_conn(db::pending_index_op_count)?;
961
962            if embeddings_dirty {
963                // Embedding model changed — old HNSW index is useless.
964                // Create a fresh index; reembed_all() will rebuild it.
965                tracing::warn!(
966                    "Embedding model changed — creating fresh HNSW index (old index is stale)"
967                );
968                pool.with_write_conn(|conn| {
969                    db::clear_all_pending_index_ops(conn)?;
970                    db::set_sidecar_dirty(conn, false)?;
971                    Ok(())
972                })?;
973                HnswIndex::new(hnsw_config)?
974            } else if pending_index_ops > 0 || pool.with_read_conn(db::is_sidecar_dirty)? {
975                tracing::warn!(
976                    pending_index_ops,
977                    "Recovering HNSW sidecar from SQLite because durable sidecar work exists"
978                );
979                hnsw_ops::recover_hnsw_sidecar_sync(&pool, &paths, &hnsw_config)?
980            } else if paths.hnsw_files_exist() {
981                tracing::info!("Loading HNSW index from {:?}", paths.hnsw_dir);
982                match HnswIndex::load(&paths.hnsw_dir, &paths.hnsw_basename, hnsw_config.clone()) {
983                    Ok(index) => {
984                        // Load key mappings from SQLite
985                        if let Err(e) = pool.with_write_conn(|conn| index.load_keymap(conn)) {
986                            tracing::warn!("Failed to load HNSW key mappings: {}. Mappings will be empty until rebuild.", e);
987                        }
988
989                        // Stale index detection: compare HNSW entry count vs SQLite
990                        // embedding count. A mismatch means the app crashed before
991                        // flushing HNSW, or keys were lost.
992                        let hnsw_count = index.len();
993                        let sqlite_count: i64 = pool.with_read_conn(|conn| {
994                            Ok(conn.query_row(
995                                    "SELECT (SELECT COUNT(*) FROM facts WHERE embedding IS NOT NULL) +
996                                        (SELECT COUNT(*) FROM chunks WHERE embedding IS NOT NULL) +
997                                        (SELECT COUNT(*) FROM messages WHERE embedding IS NOT NULL) +
998                                        (SELECT COUNT(*) FROM episodes WHERE embedding IS NOT NULL)",
999                                    [],
1000                                    |row| row.get(0),
1001                                )?)
1002                        })?;
1003
1004                        let drift = (sqlite_count - hnsw_count as i64).abs();
1005                        if drift > 0 {
1006                            tracing::warn!(
1007                                hnsw_count,
1008                                sqlite_count,
1009                                drift,
1010                                "HNSW index is stale — {} entries differ from SQLite. \
1011                                 Likely caused by unclean shutdown. Triggering inline rebuild.",
1012                                drift
1013                            );
1014                            // Discard the stale index and rebuild from SQLite
1015                            let rebuilt =
1016                                hnsw_ops::recover_hnsw_sidecar_sync(&pool, &paths, &hnsw_config)?;
1017                            tracing::info!(
1018                                active = rebuilt.len(),
1019                                "HNSW index rebuilt after stale detection"
1020                            );
1021                            rebuilt
1022                        } else {
1023                            tracing::info!(
1024                                "HNSW index loaded ({} active keys, in sync with SQLite)",
1025                                hnsw_count
1026                            );
1027                            index
1028                        }
1029                    }
1030                    Err(e) => {
1031                        tracing::warn!(
1032                            "Failed to load HNSW index: {}. Rebuilding sidecar from authoritative SQLite rows.",
1033                            e
1034                        );
1035                        hnsw_ops::recover_hnsw_sidecar_sync(&pool, &paths, &hnsw_config)?
1036                    }
1037                }
1038            } else {
1039                // Check if SQLite has embeddings that should be in the index.
1040                // This happens when: sidecar files were deleted, data dir was
1041                // partially copied, app crashed before first flush, or HNSW was
1042                // added after data already existed.
1043                let orphan_count: i64 = pool.with_read_conn(|conn| {
1044                    Ok(conn.query_row(
1045                        "SELECT (SELECT COUNT(*) FROM facts WHERE embedding IS NOT NULL) +
1046                                (SELECT COUNT(*) FROM chunks WHERE embedding IS NOT NULL) +
1047                                (SELECT COUNT(*) FROM messages WHERE embedding IS NOT NULL) +
1048                                (SELECT COUNT(*) FROM episodes WHERE embedding IS NOT NULL)",
1049                        [],
1050                        |row| row.get(0),
1051                    )?)
1052                })?;
1053
1054                if orphan_count > 0 {
1055                    tracing::warn!(
1056                        orphan_count,
1057                        "HNSW sidecar files missing but {} embeddings exist in SQLite — \
1058                         rebuilding index inline",
1059                        orphan_count
1060                    );
1061                    let new_index =
1062                        hnsw_ops::recover_hnsw_sidecar_sync(&pool, &paths, &hnsw_config)?;
1063                    tracing::info!(
1064                        active = new_index.len(),
1065                        "HNSW index rebuilt from SQLite embeddings"
1066                    );
1067                    new_index
1068                } else {
1069                    tracing::info!("Creating new empty HNSW index (no embeddings in SQLite)");
1070                    HnswIndex::new(hnsw_config)?
1071                }
1072            }
1073        };
1074
1075        let store = Self {
1076            inner: Arc::new(MemoryStoreInner {
1077                pool,
1078                embedder,
1079                embedding_permits: Arc::new(tokio::sync::Semaphore::new(
1080                    config.limits.max_embedding_concurrency,
1081                )),
1082                config,
1083                paths,
1084                token_counter,
1085                embedding_cache: std::sync::Mutex::new(lru::LruCache::new(nonzero_cache_capacity(
1086                    256,
1087                ))),
1088                search_cache: std::sync::Mutex::new(lru::LruCache::new(nonzero_cache_capacity(64))),
1089                authority_fault: Arc::new(std::sync::Mutex::new(None)),
1090                #[cfg(feature = "hnsw")]
1091                hnsw_index: std::sync::RwLock::new(hnsw_index),
1092            }),
1093        };
1094
1095        #[cfg(feature = "hnsw")]
1096        if let Err(err) = store.sync_pending_hnsw_ops_blocking() {
1097            tracing::warn!(
1098                error = %err,
1099                "Failed to reconcile pending HNSW sidecar ops during open; sidecar replay remains pending"
1100            );
1101        }
1102
1103        Ok(store)
1104    }
1105
1106    async fn with_embedding_permit(
1107        &self,
1108    ) -> Result<tokio::sync::OwnedSemaphorePermit, MemoryError> {
1109        self.inner
1110            .embedding_permits
1111            .clone()
1112            .acquire_owned()
1113            .await
1114            .map_err(|e| MemoryError::Other(format!("embedding semaphore closed: {e}")))
1115    }
1116
1117    async fn embed_text_internal(&self, text: &str) -> Result<Vec<f32>, MemoryError> {
1118        // Check embedding cache first -- skip the compute for repeated queries
1119        let cache_key = text.to_string();
1120        {
1121            match self.inner.embedding_cache.lock() {
1122                Ok(mut cache) => {
1123                    if let Some(cached) = cache.get(&cache_key).cloned() {
1124                        return Ok(cached);
1125                    }
1126                }
1127                Err(err) => {
1128                    tracing::warn!(error = %err, "embedding cache lock poisoned; lookup skipped")
1129                }
1130            }
1131        }
1132
1133        let _permit = self.with_embedding_permit().await?;
1134        // nomic-embed-text-v1.5 uses asymmetric prefixes:
1135        // "search_query:" for queries (search-time)
1136        // "search_document:" for documents (ingestion-time)
1137        // The prefix is added here so ALL embedder backends (Candle, Ollama)
1138        // get the same prefix without each backend needing to handle it.
1139        let prefixed = format!("search_query: {text}");
1140        let embedding = self.inner.embedder.embed(&prefixed).await?;
1141        db::validate_embedding(&embedding, self.inner.config.embedding.dimensions)?;
1142
1143        // Store in cache (keyed by original text, not prefixed)
1144        {
1145            match self.inner.embedding_cache.lock() {
1146                Ok(mut cache) => {
1147                    cache.put(cache_key, embedding.clone());
1148                }
1149                Err(err) => {
1150                    tracing::warn!(error = %err, "embedding cache lock poisoned; insert skipped")
1151                }
1152            }
1153        }
1154
1155        Ok(embedding)
1156    }
1157
1158    /// Embed text while retaining an embedder-provided sparse representation.
1159    /// Dense-only derivation is possible only through the explicit search config.
1160    async fn embed_text_with_sparse_internal(
1161        &self,
1162        text: &str,
1163    ) -> Result<(Vec<f32>, Option<SparseWeights>, Option<String>), MemoryError> {
1164        let _permit = self.with_embedding_permit().await?;
1165        // Keep the established prefix used by embed_text_internal so enabling
1166        // sparse persistence does not silently change dense embedding semantics.
1167        let prefixed = format!("search_query: {text}");
1168        if let Some(multi) = self.inner.embedder.embed_multi_optional(&prefixed).await? {
1169            db::validate_embedding(&multi.dense, self.inner.config.embedding.dimensions)?;
1170            if multi
1171                .sparse
1172                .entries
1173                .iter()
1174                .any(|(_, weight)| !weight.is_finite())
1175            {
1176                return Err(MemoryError::Other(
1177                    "embedder returned non-finite sparse weights".to_string(),
1178                ));
1179            }
1180            return Ok((
1181                multi.dense,
1182                Some(multi.sparse),
1183                Some(if self.inner.embedder.model_name().contains("bge-m3") {
1184                    "bge_m3_generated_sparse".to_string()
1185                } else {
1186                    "native_sparse".to_string()
1187                }),
1188            ));
1189        }
1190
1191        let dense = self.inner.embedder.embed(&prefixed).await?;
1192        db::validate_embedding(&dense, self.inner.config.embedding.dimensions)?;
1193        if self.inner.config.search.derive_sparse_from_dense {
1194            let sparse = SparseWeights::from_dense(
1195                &dense,
1196                self.inner.config.search.sparse_derive_top_k,
1197                self.inner.config.search.sparse_derive_min_weight,
1198            );
1199            Ok((
1200                dense,
1201                Some(sparse),
1202                Some("generic_dense_derived_sparse".to_string()),
1203            ))
1204        } else {
1205            Ok((dense, None, None))
1206        }
1207    }
1208
1209    async fn embed_batch_with_sparse_internal(
1210        &self,
1211        texts: Vec<String>,
1212    ) -> Result<Vec<(Vec<f32>, Option<SparseWeights>, Option<String>)>, MemoryError> {
1213        let requested = texts.len();
1214        let _permit = self.with_embedding_permit().await?;
1215        let prefixed: Vec<String> = texts
1216            .iter()
1217            .map(|text| format!("search_document: {text}"))
1218            .collect();
1219        if let Some(multi) = self
1220            .inner
1221            .embedder
1222            .embed_batch_multi_optional(prefixed.clone())
1223            .await?
1224        {
1225            if multi.len() != requested {
1226                return Err(MemoryError::EmbeddingBatchCountMismatch {
1227                    requested,
1228                    returned: multi.len(),
1229                });
1230            }
1231            let representation = if self.inner.embedder.model_name().contains("bge-m3") {
1232                "bge_m3_generated_sparse"
1233            } else {
1234                "native_sparse"
1235            };
1236            let mut output = Vec::with_capacity(requested);
1237            for value in multi {
1238                db::validate_embedding(&value.dense, self.inner.config.embedding.dimensions)?;
1239                if value
1240                    .sparse
1241                    .entries
1242                    .iter()
1243                    .any(|(_, weight)| !weight.is_finite())
1244                {
1245                    return Err(MemoryError::Other(
1246                        "embedder returned non-finite sparse weights".to_string(),
1247                    ));
1248                }
1249                output.push((
1250                    value.dense,
1251                    Some(value.sparse),
1252                    Some(representation.to_string()),
1253                ));
1254            }
1255            return Ok(output);
1256        }
1257
1258        let dense = self.inner.embedder.embed_batch(prefixed).await?;
1259        db::validate_embedding_batch(&dense, requested, self.inner.config.embedding.dimensions)?;
1260        Ok(dense
1261            .into_iter()
1262            .map(|dense| {
1263                if self.inner.config.search.derive_sparse_from_dense {
1264                    let sparse = SparseWeights::from_dense(
1265                        &dense,
1266                        self.inner.config.search.sparse_derive_top_k,
1267                        self.inner.config.search.sparse_derive_min_weight,
1268                    );
1269                    (
1270                        dense,
1271                        Some(sparse),
1272                        Some("generic_dense_derived_sparse".to_string()),
1273                    )
1274                } else {
1275                    (dense, None, None)
1276                }
1277            })
1278            .collect())
1279    }
1280
1281    async fn embed_batch_internal(&self, texts: Vec<String>) -> Result<Vec<Vec<f32>>, MemoryError> {
1282        let requested = texts.len();
1283
1284        // Check cache for each text
1285        let mut results: Vec<Option<Vec<f32>>> = Vec::with_capacity(requested);
1286        let mut misses: Vec<String> = Vec::new();
1287        let mut miss_indices: Vec<usize> = Vec::new();
1288
1289        for (i, text) in texts.iter().enumerate() {
1290            match self.inner.embedding_cache.lock() {
1291                Ok(mut cache) => {
1292                    if let Some(cached) = cache.get(text).cloned() {
1293                        results.push(Some(cached));
1294                    } else {
1295                        results.push(None);
1296                        miss_indices.push(i);
1297                        misses.push(text.clone());
1298                    }
1299                }
1300                Err(err) => {
1301                    tracing::warn!(error = %err, "embedding cache lock poisoned; lookup skipped");
1302                    results.push(None);
1303                    miss_indices.push(i);
1304                    misses.push(text.clone());
1305                }
1306            }
1307        }
1308
1309        let _permit = self.with_embedding_permit().await?;
1310
1311        // Add search_document: prefix for all documents (ingestion path)
1312        let prefixed_misses: Vec<String> = misses
1313            .iter()
1314            .map(|t| format!("search_document: {t}"))
1315            .collect();
1316
1317        let miss_embeddings = if prefixed_misses.is_empty() {
1318            Vec::new()
1319        } else {
1320            let embeddings = self.inner.embedder.embed_batch(prefixed_misses).await?;
1321            // Validate batch count before caching or assembling
1322            if embeddings.len() != misses.len() {
1323                return Err(MemoryError::EmbeddingBatchCountMismatch {
1324                    requested: misses.len(),
1325                    returned: embeddings.len(),
1326                });
1327            }
1328            // Cache the new embeddings (keyed by original text, not prefixed)
1329            match self.inner.embedding_cache.lock() {
1330                Ok(mut cache) => {
1331                    for (text, emb) in misses.iter().zip(embeddings.iter()) {
1332                        cache.put(text.clone(), emb.clone());
1333                    }
1334                }
1335                Err(err) => {
1336                    tracing::warn!(error = %err, "embedding cache lock poisoned; batch insert skipped")
1337                }
1338            }
1339            embeddings
1340        };
1341
1342        // Assemble results in order (all slots guaranteed to have data)
1343        let mut final_results = Vec::with_capacity(requested);
1344        let mut miss_idx = 0;
1345        for i in 0..requested {
1346            if let Some(emb) = &results[i] {
1347                final_results.push(emb.clone());
1348            } else {
1349                final_results.push(miss_embeddings[miss_idx].clone());
1350                miss_idx += 1;
1351            }
1352        }
1353
1354        db::validate_embedding_batch(
1355            &final_results,
1356            requested,
1357            self.inner.config.embedding.dimensions,
1358        )?;
1359        Ok(final_results)
1360    }
1361
1362    fn validate_embedding_dimensions(&self, embedding: &[f32]) -> Result<(), MemoryError> {
1363        db::validate_embedding(embedding, self.inner.config.embedding.dimensions)
1364    }
1365
1366    fn validate_content(&self, field: &'static str, content: &str) -> Result<(), MemoryError> {
1367        if content.is_empty() {
1368            return Err(MemoryError::InvalidConfig {
1369                field,
1370                reason: "content must not be empty".to_string(),
1371            });
1372        }
1373
1374        let limit = self.inner.config.limits.max_content_bytes;
1375        if content.len() > limit {
1376            return Err(MemoryError::ContentTooLarge {
1377                size: content.len(),
1378                limit,
1379            });
1380        }
1381
1382        Ok(())
1383    }
1384
1385    fn validate_confidence(confidence: f32) -> Result<(), MemoryError> {
1386        if !confidence.is_finite() || !(0.0..=1.0).contains(&confidence) {
1387            return Err(MemoryError::InvalidConfig {
1388                field: "episodes.confidence",
1389                reason: "confidence must be finite and within [0.0, 1.0]".to_string(),
1390            });
1391        }
1392        Ok(())
1393    }
1394
1395    // ─── HNSW Management ───────────────────────────────────────
1396
1397    /// Rebuild feature-gated TurboQuant artifacts from authoritative SQLite f32 embeddings.
1398    #[cfg(feature = "turbo-quant-codec")]
1399    pub async fn rebuild_vector_artifacts(
1400        &self,
1401    ) -> Result<VectorArtifactBuildReceiptV1, MemoryError> {
1402        let dim = self.inner.config.embedding.dimensions;
1403        let search = self.inner.config.search.clone();
1404        self.with_write_conn(move |conn| {
1405            db::rebuild_turbo_quant_artifacts(
1406                conn,
1407                dim,
1408                search.turbo_quant_bits,
1409                search.turbo_quant_projections,
1410                search.turbo_quant_seed,
1411            )
1412        })
1413        .await
1414    }
1415
1416    /// Rebuild the HNSW index from SQLite f32 embeddings.
1417    ///
1418    /// Call this if sidecar files are missing, corrupted, or after `reembed_all()`.
1419    #[cfg(feature = "hnsw")]
1420    pub async fn rebuild_hnsw_index(
1421        &self,
1422    ) -> Result<crate::types::VectorArtifactBuildReceiptV1, MemoryError> {
1423        tracing::info!("Rebuilding HNSW index from SQLite embeddings...");
1424        let hnsw_config = self.inner.config.hnsw.clone();
1425        let (new_index, build_receipt) = self
1426            .with_read_conn(move |conn| hnsw_ops::rebuild_hnsw_from_sqlite(conn, &hnsw_config))
1427            .await?;
1428
1429        {
1430            let mut guard = self
1431                .inner
1432                .hnsw_index
1433                .write()
1434                .unwrap_or_else(|e| e.into_inner());
1435            *guard = new_index.clone();
1436        }
1437
1438        hnsw_ops::save_hnsw_sidecar(
1439            &new_index,
1440            &self.inner.paths.hnsw_dir,
1441            &self.inner.paths.hnsw_basename,
1442        )?;
1443        self.inner.pool.with_write_conn(|conn| {
1444            new_index.flush_keymap(conn)?;
1445            db::clear_all_pending_index_ops(conn)?;
1446            db::set_sidecar_dirty(conn, false)?;
1447            Ok(())
1448        })?;
1449
1450        tracing::info!(active = new_index.len(), receipt_generation_id = ?build_receipt.generation_id, "HNSW index rebuilt");
1451
1452        Ok(build_receipt)
1453    }
1454
1455    /// Opportunistically flush HNSW if the configured interval has elapsed.
1456    ///
1457    /// Cheap no-op when `flush_interval_secs` is None or the interval hasn't
1458    /// elapsed yet (just an atomic load + epoch comparison).
1459    #[cfg(feature = "hnsw")]
1460    fn maybe_flush_hnsw(&self) {
1461        if let Some(interval) = self.inner.config.hnsw.flush_interval_secs {
1462            let guard = self
1463                .inner
1464                .hnsw_index
1465                .read()
1466                .unwrap_or_else(|e| e.into_inner());
1467            if guard.should_flush(interval) {
1468                drop(guard); // release read lock before flushing
1469                if let Err(e) = self.flush_hnsw() {
1470                    tracing::warn!("Opportunistic HNSW flush failed: {}", e);
1471                } else {
1472                    let guard = self
1473                        .inner
1474                        .hnsw_index
1475                        .read()
1476                        .unwrap_or_else(|e| e.into_inner());
1477                    guard.update_last_flush_epoch();
1478                    tracing::info!("Opportunistic HNSW flush completed");
1479                }
1480            }
1481        }
1482    }
1483
1484    /// Persist the HNSW graph, vector data, and key mappings to disk.
1485    ///
1486    /// Called automatically on drop, but can be called explicitly for durability.
1487    #[cfg(feature = "hnsw")]
1488    pub fn flush_hnsw(&self) -> Result<(), MemoryError> {
1489        let pending_ops = self.inner.pool.with_read_conn(db::pending_index_op_count)?;
1490        if pending_ops > 0 {
1491            tracing::info!(
1492                pending_ops,
1493                "Flushing HNSW via authoritative SQLite rebuild because pending durable sidecar work exists"
1494            );
1495            let rebuilt = hnsw_ops::recover_hnsw_sidecar_sync(
1496                &self.inner.pool,
1497                &self.inner.paths,
1498                &self.inner.config.hnsw,
1499            )?;
1500            let mut guard = self
1501                .inner
1502                .hnsw_index
1503                .write()
1504                .unwrap_or_else(|e| e.into_inner());
1505            *guard = rebuilt;
1506            return Ok(());
1507        }
1508
1509        let index = self
1510            .inner
1511            .hnsw_index
1512            .write()
1513            .unwrap_or_else(|e| e.into_inner());
1514        hnsw_ops::save_hnsw_sidecar(
1515            &index,
1516            &self.inner.paths.hnsw_dir,
1517            &self.inner.paths.hnsw_basename,
1518        )?;
1519
1520        // Flush key mappings to SQLite
1521        self.inner.pool.with_write_conn(|conn| {
1522            index.flush_keymap(conn)?;
1523            db::clear_all_pending_index_ops(conn)?;
1524            db::set_sidecar_dirty(conn, false)?;
1525            Ok(())
1526        })?;
1527        Ok(())
1528    }
1529
1530    /// Compact the HNSW index by rebuilding without tombstones.
1531    ///
1532    /// Only rebuilds if the deleted ratio exceeds the compaction threshold.
1533    #[cfg(feature = "hnsw")]
1534    pub async fn compact_hnsw(&self) -> Result<(), MemoryError> {
1535        if !self
1536            .inner
1537            .hnsw_index
1538            .read()
1539            .unwrap_or_else(|e| e.into_inner())
1540            .needs_compaction()
1541        {
1542            tracing::info!("HNSW compaction not needed (deleted ratio below threshold)");
1543            return Ok(());
1544        }
1545        let _receipt = self.rebuild_hnsw_index().await?;
1546        Ok(())
1547    }
1548
1549    // ─── Integrity & Diagnostics ────────────────────────────────
1550
1551    /// Verify database integrity.
1552    ///
1553    /// In `Quick` mode, checks table existence and row counts.
1554    /// In `Full` mode, also verifies FTS consistency and runs SQLite integrity_check.
1555    pub async fn verify_integrity(
1556        &self,
1557        mode: db::VerifyMode,
1558    ) -> Result<db::IntegrityReport, MemoryError> {
1559        let use_writer = mode == db::VerifyMode::Full;
1560        let mut report = if use_writer {
1561            self.with_write_conn(move |conn| db::verify_integrity_sync(conn, mode))
1562                .await?
1563        } else {
1564            self.with_read_conn(move |conn| db::verify_integrity_sync(conn, mode))
1565                .await?
1566        };
1567
1568        #[cfg(feature = "hnsw")]
1569        {
1570            let hnsw_vectors = self
1571                .inner
1572                .hnsw_index
1573                .read()
1574                .unwrap_or_else(|e| e.into_inner())
1575                .vector_snapshot();
1576            let hnsw_dims = self.inner.config.embedding.dimensions;
1577            let hnsw_files_exist = self.inner.paths.hnsw_files_exist();
1578
1579            let hnsw_issues = if use_writer {
1580                let hnsw_vectors = hnsw_vectors.clone();
1581                self.with_write_conn(move |conn| {
1582                    verify_hnsw_key_level_integrity(
1583                        conn,
1584                        hnsw_dims,
1585                        &hnsw_vectors,
1586                        hnsw_files_exist,
1587                    )
1588                })
1589                .await?
1590            } else {
1591                let hnsw_vectors = hnsw_vectors.clone();
1592                self.with_read_conn(move |conn| {
1593                    verify_hnsw_key_level_integrity(
1594                        conn,
1595                        hnsw_dims,
1596                        &hnsw_vectors,
1597                        hnsw_files_exist,
1598                    )
1599                })
1600                .await?
1601            };
1602            report.issues.extend(hnsw_issues);
1603        }
1604
1605        report.ok = report.issues.is_empty();
1606        Ok(report)
1607    }
1608
1609    /// Reconcile detected integrity issues.
1610    ///
1611    /// - `ReportOnly`: no-op, just returns the integrity report.
1612    /// - `RebuildFts`: rebuilds all FTS indexes from source data.
1613    /// - `ReEmbed`: re-embeds authoritative rows and then verifies integrity.
1614    pub async fn reconcile(
1615        &self,
1616        action: db::ReconcileAction,
1617    ) -> Result<db::IntegrityReport, MemoryError> {
1618        match action {
1619            db::ReconcileAction::ReportOnly => self.verify_integrity(db::VerifyMode::Full).await,
1620            db::ReconcileAction::RebuildFts => {
1621                self.with_write_conn(db::reconcile_fts).await?;
1622                #[cfg(feature = "hnsw")]
1623                self.sync_pending_hnsw_ops_best_effort("reconcile_rebuild_fts")
1624                    .await;
1625                self.verify_integrity(db::VerifyMode::Full).await
1626            }
1627            db::ReconcileAction::ReEmbed => {
1628                self.reembed_all().await?;
1629                self.verify_integrity(db::VerifyMode::Full).await
1630            }
1631        }
1632    }
1633
1634    /// Get the current configuration.
1635    pub fn config(&self) -> &MemoryConfig {
1636        &self.inner.config
1637    }
1638
1639    /// View the store as a derived graph over documents, chunks, facts, sessions, messages,
1640    /// episodes, namespaces, semantic similarity edges, and first-class stored graph edges.
1641    pub fn graph_view(&self) -> Arc<dyn GraphView> {
1642        graph::graph_view(self.inner.clone())
1643    }
1644
1645    // ─── First-class stored graph edges ──────────────────────────
1646
1647    /// Add a durable, typed graph edge between two nodes.
1648    ///
1649    /// Nodes are identified by prefixed IDs (e.g. `fact:<uuid>`,
1650    /// `namespace:<name>`, `document:<id>`). The edge type must be one of
1651    /// `GraphEdgeType::Semantic`, `Temporal`, `Causal`, or `Entity`.
1652    ///
1653    /// Insertion is idempotent on content digest — inserting the same edge
1654    /// twice returns the existing edge without creating a duplicate.
1655    ///
1656    /// Returns the stored edge including its assigned ID and recorded_at timestamp.
1657    pub async fn add_graph_edge(
1658        &self,
1659        source: &str,
1660        target: &str,
1661        edge_type: GraphEdgeType,
1662        weight: f64,
1663        metadata: Option<serde_json::Value>,
1664    ) -> Result<graph_edges::StoredGraphEdge, MemoryError> {
1665        let params = graph_edges::AddGraphEdgeParams {
1666            source: source.to_string(),
1667            target: target.to_string(),
1668            edge_type,
1669            weight,
1670            metadata,
1671            valid_time: None,
1672            recorded_time: None,
1673        };
1674        let edge = self
1675            .with_write_conn(move |conn| graph_edges::insert_graph_edge(conn, &params))
1676            .await?;
1677        self.clear_search_cache();
1678        Ok(edge)
1679    }
1680
1681    /// Add a durable graph edge with explicit bitemporal timestamps.
1682    ///
1683    /// Use this when importing or correcting historical relationships where
1684    /// domain validity and system record time differ from the current wall clock.
1685    pub async fn add_graph_edge_at(
1686        &self,
1687        source: &str,
1688        target: &str,
1689        edge_type: GraphEdgeType,
1690        weight: f64,
1691        metadata: Option<serde_json::Value>,
1692        valid_time: &str,
1693        recorded_time: &str,
1694    ) -> Result<graph_edges::StoredGraphEdge, MemoryError> {
1695        let params = graph_edges::AddGraphEdgeParams {
1696            source: source.to_string(),
1697            target: target.to_string(),
1698            edge_type,
1699            weight,
1700            metadata,
1701            valid_time: Some(valid_time.to_string()),
1702            recorded_time: Some(recorded_time.to_string()),
1703        };
1704        let edge = self
1705            .with_write_conn(move |conn| graph_edges::insert_graph_edge(conn, &params))
1706            .await?;
1707        self.clear_search_cache();
1708        Ok(edge)
1709    }
1710
1711    /// Atomically consolidate two facts into one.
1712    ///
1713    /// Updates the kept fact with merged content and adds a supersession edge
1714    /// from the kept fact to the superseded fact, all in one SQLite transaction.
1715    /// No duplicate fact is created.
1716    pub async fn consolidate_facts(
1717        &self,
1718        keep_id: &str,
1719        supersede_id: &str,
1720        merged_content: &str,
1721    ) -> Result<(), MemoryError> {
1722        let keep_id = keep_id.to_string();
1723        let supersede_id = supersede_id.to_string();
1724        let merged_content = merged_content.to_string();
1725        self.with_write_conn(move |conn| {
1726            use rusqlite::params;
1727
1728            // 1. Update the kept fact's content
1729            let (fts_rowid, old_content): (i64, String) = conn
1730                .query_row(
1731                    "SELECT fm.rowid, f.content
1732                     FROM facts f
1733                     JOIN facts_rowid_map fm ON fm.fact_id = f.id
1734                     WHERE f.id = ?1",
1735                    params![&keep_id],
1736                    |row| Ok((row.get(0)?, row.get(1)?)),
1737                )
1738                .map_err(|e| MemoryError::FactNotFound(format!("{}: {e}", keep_id)))?;
1739
1740            conn.execute(
1741                "INSERT INTO facts_fts(facts_fts, rowid, content) VALUES('delete', ?1, ?2)",
1742                params![fts_rowid, old_content],
1743            )?;
1744
1745            conn.execute(
1746                "UPDATE facts SET content = ?1, updated_at = datetime('now') WHERE id = ?2",
1747                params![&merged_content, &keep_id],
1748            )?;
1749
1750            conn.execute(
1751                "INSERT INTO facts_fts(rowid, content) VALUES (?1, ?2)",
1752                params![fts_rowid, &merged_content],
1753            )?;
1754
1755            // 2. Add supersession edge from kept to superseded
1756            let edge_type_json = r#"{"Entity":{"relation":"supersedes"}}"#;
1757            let source = format!("fact:{}", keep_id);
1758            let target = format!("fact:{}", supersede_id);
1759            conn.execute(
1760                "INSERT INTO graph_edges (source, target, edge_type, weight, recorded_at, is_invalidated)
1761                 VALUES (?1, ?2, ?3, 1.0, datetime('now'), 0)",
1762                params![&source, &target, edge_type_json],
1763            )?;
1764
1765            Ok(())
1766        })
1767        .await?;
1768        self.clear_search_cache();
1769        Ok(())
1770    }
1771
1772    /// List all stored graph edges involving a given node (as source or target),
1773    /// excluding invalidated edges.
1774    pub async fn list_graph_edges_for_node(
1775        &self,
1776        node_id: &str,
1777    ) -> Result<Vec<graph_edges::StoredGraphEdge>, MemoryError> {
1778        let node_id = node_id.to_string();
1779        self.with_read_conn(move |conn| graph_edges::list_graph_edges_for_node(conn, &node_id))
1780            .await
1781    }
1782
1783    /// List graph edges involving a node as of explicit bitemporal cutoffs.
1784    ///
1785    /// `as_of_valid_time` is domain/business time; `as_of_recorded_time` is
1786    /// system knowledge time. This is the graph analogue of bitemporal as-of
1787    /// fact queries: it can reconstruct what the relationship graph knew at a
1788    /// prior recorded time, including edges invalidated later.
1789    pub async fn list_graph_edges_for_node_as_of(
1790        &self,
1791        node_id: &str,
1792        as_of_valid_time: &str,
1793        as_of_recorded_time: &str,
1794    ) -> Result<Vec<graph_edges::StoredGraphEdge>, MemoryError> {
1795        let node_id = node_id.to_string();
1796        let as_of_valid_time = as_of_valid_time.to_string();
1797        let as_of_recorded_time = as_of_recorded_time.to_string();
1798        self.with_read_conn(move |conn| {
1799            graph_edges::list_graph_edges_for_node_as_of(
1800                conn,
1801                &node_id,
1802                &as_of_valid_time,
1803                &as_of_recorded_time,
1804            )
1805        })
1806        .await
1807    }
1808
1809    /// List ALL stored graph edges, excluding invalidated ones.
1810    pub async fn list_all_graph_edges(
1811        &self,
1812    ) -> Result<Vec<graph_edges::StoredGraphEdge>, MemoryError> {
1813        self.with_read_conn(graph_edges::list_all_graph_edges).await
1814    }
1815
1816    /// List graph edges within N hops of the given seed node IDs.
1817    ///
1818    /// Performs a BFS expansion from the seeds, loading only edges in
1819    /// the local neighborhood. Much faster than `list_all_graph_edges`
1820    /// when you only need the subgraph around search results.
1821    ///
1822    /// - `seed_ids`: starting node IDs (typically search result IDs)
1823    /// - `max_hops`: BFS depth (1 = direct neighbors, 2 = neighbors of neighbors)
1824    /// - `max_nodes`: cap on total nodes visited (prevents hub explosion)
1825    pub async fn list_graph_edges_for_neighborhood(
1826        &self,
1827        seed_ids: Vec<String>,
1828        max_hops: usize,
1829        max_nodes: usize,
1830    ) -> Result<Vec<graph_edges::StoredGraphEdge>, MemoryError> {
1831        self.with_read_conn(move |conn| {
1832            graph_edges::list_graph_edges_for_neighborhood(conn, &seed_ids, max_hops, max_nodes)
1833        })
1834        .await
1835    }
1836
1837    /// Invalidate a stored graph edge by ID. Append-only — the row is never deleted.
1838    pub async fn invalidate_graph_edge(
1839        &self,
1840        edge_id: &str,
1841        reason: &str,
1842    ) -> Result<(), MemoryError> {
1843        let edge_id = edge_id.to_string();
1844        let reason = reason.to_string();
1845        self.with_write_conn(move |conn| {
1846            graph_edges::invalidate_graph_edge(conn, &edge_id, &reason)
1847        })
1848        .await
1849    }
1850
1851    /// Count non-invalidated stored graph edges.
1852    pub async fn count_graph_edges(&self) -> Result<usize, MemoryError> {
1853        self.with_read_conn(graph_edges::count_graph_edges).await
1854    }
1855
1856    // ─── Search ─────────────────────────────────────────────────
1857
1858    /// Hybrid search across facts, document chunks, and searchable episodes.
1859    pub async fn search(
1860        &self,
1861        query: &str,
1862        top_k: Option<usize>,
1863        namespaces: Option<&[&str]>,
1864        source_types: Option<&[SearchSourceType]>,
1865    ) -> Result<Vec<SearchResult>, MemoryError> {
1866        let compress = self.inner.config.search.compress_results;
1867        let results = self
1868            .search_with_context(
1869                query,
1870                top_k,
1871                namespaces,
1872                source_types,
1873                SearchContext::default_now(),
1874            )
1875            .await?
1876            .results;
1877        if compress {
1878            Ok(compress_search_results(results))
1879        } else {
1880            Ok(results)
1881        }
1882    }
1883
1884    /// Hybrid search with an explicit deterministic context and optional receipt.
1885    pub async fn search_with_context(
1886        &self,
1887        query: &str,
1888        top_k: Option<usize>,
1889        namespaces: Option<&[&str]>,
1890        source_types: Option<&[SearchSourceType]>,
1891        context: SearchContext,
1892    ) -> Result<SearchResponse, MemoryError> {
1893        self.search_with_context_for_view(
1894            query,
1895            top_k,
1896            namespaces,
1897            source_types,
1898            context,
1899            StateView::Current,
1900        )
1901        .await
1902    }
1903
1904    /// Hybrid fact search under an explicit authority-state view.
1905    pub async fn search_with_view(
1906        &self,
1907        query: &str,
1908        top_k: Option<usize>,
1909        namespaces: Option<&[&str]>,
1910        source_types: Option<&[SearchSourceType]>,
1911        view: StateView,
1912    ) -> Result<Vec<SearchResult>, MemoryError> {
1913        Ok(self
1914            .search_with_context_for_view(
1915                query,
1916                top_k,
1917                namespaces,
1918                source_types,
1919                SearchContext::default_now(),
1920                view,
1921            )
1922            .await?
1923            .results)
1924    }
1925
1926    async fn search_with_context_for_view(
1927        &self,
1928        query: &str,
1929        top_k: Option<usize>,
1930        namespaces: Option<&[&str]>,
1931        source_types: Option<&[SearchSourceType]>,
1932        context: SearchContext,
1933        view: StateView,
1934    ) -> Result<SearchResponse, MemoryError> {
1935        let k = top_k
1936            .unwrap_or(self.inner.config.search.default_top_k)
1937            .min(MAX_TOP_K);
1938
1939        // Check search result cache for simple unfiltered queries.
1940        // Cache is keyed by (query, k) and only used when no namespace/source_type
1941        // filters are applied AND receipt mode is not requested. Cleared on any
1942        // mutating operation (update/delete).
1943        let cache_key = if matches!(view, StateView::Current)
1944            && namespaces.is_none()
1945            && source_types.is_none()
1946            && context.receipt_mode != ReceiptMode::ReturnReceipt
1947        {
1948            Some(format!("{query}:{k}"))
1949        } else {
1950            None
1951        };
1952        let cache_epoch = if cache_key.is_some() {
1953            Some(self.authority().current_retrieval_epoch().await?)
1954        } else {
1955            None
1956        };
1957        if let Some(ref key) = cache_key {
1958            match self.inner.search_cache.lock() {
1959                Ok(mut cache) => {
1960                    if let Some(cached) = cache.get(key) {
1961                        if let Some(retrieval_epoch) = &cache_epoch {
1962                            if *retrieval_epoch == cached.retrieval_epoch {
1963                                return Ok(SearchResponse {
1964                                    results: cached.results.clone(),
1965                                    receipt: None,
1966                                });
1967                            }
1968                        } else {
1969                            return Ok(SearchResponse {
1970                                results: cached.results.clone(),
1971                                receipt: None,
1972                            });
1973                        }
1974                        cache.pop(key);
1975                    }
1976                }
1977                Err(err) => {
1978                    tracing::warn!(error = %err, "search cache lock poisoned; lookup skipped")
1979                }
1980            }
1981        }
1982
1983        let (query_embedding, query_sparse) = if self.inner.config.search.sparse_weight > 0.0 {
1984            let (dense, sparse, _) = self.embed_text_with_sparse_internal(query).await?;
1985            (dense, sparse)
1986        } else {
1987            (self.embed_text_internal(query).await?, None)
1988        };
1989
1990        #[cfg(feature = "hnsw")]
1991        let hnsw_hits = if context.exactness_profile == ExactnessProfile::PreferExact
1992            || self.inner.config.search.uses_turbo_quant_backend()
1993        {
1994            Vec::new()
1995        } else {
1996            let candidates = self
1997                .inner
1998                .config
1999                .search
2000                .candidate_pool_size
2001                .max(k.saturating_mul(3))
2002                .min(MAX_HNSW_CANDIDATES);
2003            self.hnsw_search_blocking(query_embedding.clone(), candidates)
2004                .await
2005        };
2006
2007        let q = query.to_string();
2008        let config = self.inner.config.search.clone();
2009        let ns_owned = to_owned_string_vec(namespaces);
2010        let st_owned: Option<Vec<SearchSourceType>> = source_types.map(|s| s.to_vec());
2011        let context_owned = context.clone();
2012
2013        #[cfg(feature = "hnsw")]
2014        let hnsw_hits_owned = hnsw_hits;
2015
2016        let mut response = self
2017            .with_read_conn(move |conn| {
2018                if db::is_embeddings_dirty(conn)? {
2019                    tracing::warn!(
2020                        "Embeddings are stale after model change — search quality is degraded. \
2021                     Call reembed_all() to regenerate embeddings."
2022                    );
2023                }
2024                let ns_refs = as_str_slice(&ns_owned);
2025                let ns_slice: Option<&[&str]> = ns_refs.as_deref();
2026                let st_slice: Option<&[SearchSourceType]> = st_owned.as_deref();
2027
2028                #[cfg(feature = "hnsw")]
2029                {
2030                    let mut execution = if hnsw_hits_owned.is_empty() {
2031                        search::hybrid_search_detailed_with_context(
2032                            conn,
2033                            &q,
2034                            &query_embedding,
2035                            query_sparse.as_ref(),
2036                            &config,
2037                            &context_owned,
2038                            k,
2039                            ns_slice,
2040                            st_slice,
2041                            None,
2042                        )
2043                    } else {
2044                        search::hybrid_search_with_hnsw_detailed_with_context(
2045                            conn,
2046                            &q,
2047                            &query_embedding,
2048                            query_sparse.as_ref(),
2049                            &config,
2050                            &context_owned,
2051                            k,
2052                            ns_slice,
2053                            st_slice,
2054                            None,
2055                            &hnsw_hits_owned,
2056                        )
2057                    }?;
2058                    if context_owned.receipts_enabled()
2059                        && context_owned.exactness_profile == ExactnessProfile::PreferExact
2060                    {
2061                        if let Some(receipt) = execution.receipt.as_mut() {
2062                            receipt.search_profile = "hybrid_prefer_exact".to_string();
2063                        }
2064                    }
2065                    Ok(SearchResponse {
2066                        results: dedup_by_content(
2067                            execution
2068                                .results
2069                                .into_iter()
2070                                .map(|result| result.result)
2071                                .collect(),
2072                        ),
2073                        receipt: execution.receipt,
2074                    })
2075                }
2076                #[cfg(not(feature = "hnsw"))]
2077                {
2078                    let execution = search::hybrid_search_detailed_with_context(
2079                        conn,
2080                        &q,
2081                        &query_embedding,
2082                        query_sparse.as_ref(),
2083                        &config,
2084                        &context_owned,
2085                        k,
2086                        ns_slice,
2087                        st_slice,
2088                        None,
2089                    )?;
2090                    Ok(SearchResponse {
2091                        results: dedup_by_content(
2092                            execution
2093                                .results
2094                                .into_iter()
2095                                .map(|result| result.result)
2096                                .collect(),
2097                        ),
2098                        receipt: execution.receipt,
2099                    })
2100                }
2101            })
2102            .await?;
2103        let raw_results = std::mem::take(&mut response.results);
2104        response.results = self
2105            .filter_search_results(raw_results, view.clone())
2106            .await?;
2107        response.results.truncate(k);
2108        if let Some(receipt) = &response.receipt {
2109            self.persist_search_receipt(
2110                receipt,
2111                query,
2112                namespaces,
2113                source_types,
2114                context.replay_mode,
2115            )
2116            .await?;
2117        }
2118        if let (Some(ref key), Some(retrieval_epoch)) = (cache_key.as_ref(), cache_epoch) {
2119            match self.inner.search_cache.lock() {
2120                Ok(mut cache) => {
2121                    cache.put(
2122                        key.to_string(),
2123                        CachedSearchResult {
2124                            results: response.results.clone(),
2125                            retrieval_epoch,
2126                        },
2127                    );
2128                }
2129                Err(err) => {
2130                    tracing::warn!(error = %err, "search cache lock poisoned; insert skipped")
2131                }
2132            }
2133        }
2134        Ok(response)
2135    }
2136
2137    async fn filter_search_results(
2138        &self,
2139        results: Vec<SearchResult>,
2140        view: StateView,
2141    ) -> Result<Vec<SearchResult>, MemoryError> {
2142        self.with_read_conn(move |conn| {
2143            results
2144                .into_iter()
2145                .filter_map(|result| match &result.source {
2146                    SearchSource::Fact { fact_id, .. } => {
2147                        match knowledge::fact_is_visible_with_view(conn, fact_id, &view) {
2148                            Ok(true) => Some(Ok(result)),
2149                            Ok(false) => None,
2150                            Err(error) => Some(Err(error)),
2151                        }
2152                    }
2153                    SearchSource::Episode { episode_id, .. } => {
2154                        let invalidated = conn.query_row(
2155                            "SELECT EXISTS(SELECT 1 FROM forgetting_artifact_invalidations
2156                             WHERE surface_kind = 'episode' AND artifact_id = ?1)",
2157                            rusqlite::params![episode_id],
2158                            |row| row.get::<_, bool>(0),
2159                        );
2160                        match invalidated {
2161                            Ok(false) => Some(Ok(result)),
2162                            Ok(true) => None,
2163                            Err(error) => Some(Err(MemoryError::from(error))),
2164                        }
2165                    }
2166                    SearchSource::Projection { projection_id, .. } => {
2167                        let invalidated = conn.query_row(
2168                            "SELECT EXISTS(SELECT 1 FROM forgetting_artifact_invalidations
2169                             WHERE surface_kind = 'projection' AND artifact_id = ?1)",
2170                            rusqlite::params![projection_id],
2171                            |row| row.get::<_, bool>(0),
2172                        );
2173                        match invalidated {
2174                            Ok(false) => Some(Ok(result)),
2175                            Ok(true) => None,
2176                            Err(error) => Some(Err(MemoryError::from(error))),
2177                        }
2178                    }
2179                    _ => Some(Ok(result)),
2180                })
2181                .collect()
2182        })
2183        .await
2184    }
2185
2186    /// Full-text search only (no embeddings needed).
2187    pub async fn search_fts_only(
2188        &self,
2189        query: &str,
2190        top_k: Option<usize>,
2191        namespaces: Option<&[&str]>,
2192        source_types: Option<&[SearchSourceType]>,
2193    ) -> Result<Vec<SearchResult>, MemoryError> {
2194        let k = top_k
2195            .unwrap_or(self.inner.config.search.default_top_k)
2196            .min(MAX_TOP_K);
2197        let q = query.to_string();
2198        let config = self.inner.config.search.clone();
2199        let ns_owned = to_owned_string_vec(namespaces);
2200        let st_owned: Option<Vec<SearchSourceType>> = source_types.map(|s| s.to_vec());
2201        let results = self
2202            .with_read_conn(move |conn| {
2203                let ns_refs = as_str_slice(&ns_owned);
2204                let ns_slice: Option<&[&str]> = ns_refs.as_deref();
2205                let st_slice: Option<&[SearchSourceType]> = st_owned.as_deref();
2206                search::fts_only_search(conn, &q, &config, k, ns_slice, st_slice, None)
2207            })
2208            .await?;
2209        self.filter_search_results(results, StateView::Current)
2210            .await
2211    }
2212
2213    /// Full-text-only search with an explicit deterministic context and optional receipt.
2214    pub async fn search_fts_only_with_context(
2215        &self,
2216        query: &str,
2217        top_k: Option<usize>,
2218        namespaces: Option<&[&str]>,
2219        source_types: Option<&[SearchSourceType]>,
2220        context: SearchContext,
2221    ) -> Result<SearchResponse, MemoryError> {
2222        let k = top_k
2223            .unwrap_or(self.inner.config.search.default_top_k)
2224            .min(MAX_TOP_K);
2225        let q = query.to_string();
2226        let config = self.inner.config.search.clone();
2227        let ns_owned = to_owned_string_vec(namespaces);
2228        let st_owned: Option<Vec<SearchSourceType>> = source_types.map(|s| s.to_vec());
2229        let context_owned = context.clone();
2230        let mut response = self
2231            .with_read_conn(move |conn| {
2232                let ns_refs = as_str_slice(&ns_owned);
2233                let execution = search::fts_only_search_detailed_with_context(
2234                    conn,
2235                    &q,
2236                    &config,
2237                    &context_owned,
2238                    k,
2239                    ns_refs.as_deref(),
2240                    st_owned.as_deref(),
2241                    None,
2242                )?;
2243                Ok(SearchResponse {
2244                    results: execution
2245                        .results
2246                        .into_iter()
2247                        .map(|result| result.result)
2248                        .collect(),
2249                    receipt: execution.receipt,
2250                })
2251            })
2252            .await?;
2253        response.results = self
2254            .filter_search_results(response.results, StateView::Current)
2255            .await?;
2256        if let Some(receipt) = &response.receipt {
2257            self.persist_search_receipt(
2258                receipt,
2259                query,
2260                namespaces,
2261                source_types,
2262                context.replay_mode,
2263            )
2264            .await?;
2265        }
2266        Ok(response)
2267    }
2268
2269    /// Vector similarity search only (no FTS).
2270    pub async fn search_vector_only(
2271        &self,
2272        query: &str,
2273        top_k: Option<usize>,
2274        namespaces: Option<&[&str]>,
2275        source_types: Option<&[SearchSourceType]>,
2276    ) -> Result<Vec<SearchResult>, MemoryError> {
2277        Ok(self
2278            .search_vector_only_with_context(
2279                query,
2280                top_k,
2281                namespaces,
2282                source_types,
2283                SearchContext::default_now(),
2284            )
2285            .await?
2286            .results)
2287    }
2288
2289    /// Vector similarity search with an explicit deterministic context and optional receipt.
2290    pub async fn search_vector_only_with_context(
2291        &self,
2292        query: &str,
2293        top_k: Option<usize>,
2294        namespaces: Option<&[&str]>,
2295        source_types: Option<&[SearchSourceType]>,
2296        context: SearchContext,
2297    ) -> Result<SearchResponse, MemoryError> {
2298        let k = top_k
2299            .unwrap_or(self.inner.config.search.default_top_k)
2300            .min(MAX_TOP_K);
2301        let query_embedding = self.embed_text_internal(query).await?;
2302
2303        #[cfg(feature = "hnsw")]
2304        let hnsw_hits = if context.exactness_profile == ExactnessProfile::PreferExact
2305            || self.inner.config.search.uses_turbo_quant_backend()
2306        {
2307            Vec::new()
2308        } else {
2309            let candidates = self
2310                .inner
2311                .config
2312                .search
2313                .candidate_pool_size
2314                .max(k.saturating_mul(3))
2315                .min(MAX_HNSW_CANDIDATES);
2316            self.hnsw_search_blocking(query_embedding.clone(), candidates)
2317                .await
2318        };
2319
2320        let config = self.inner.config.search.clone();
2321        let ns_owned = to_owned_string_vec(namespaces);
2322        let st_owned: Option<Vec<SearchSourceType>> = source_types.map(|s| s.to_vec());
2323        let context_owned = context.clone();
2324
2325        #[cfg(feature = "hnsw")]
2326        let hnsw_hits_owned = hnsw_hits;
2327
2328        let mut response = self
2329            .with_read_conn(move |conn| {
2330                if db::is_embeddings_dirty(conn)? {
2331                    tracing::warn!(
2332                        "Embeddings are stale after model change — search quality is degraded. \
2333                     Call reembed_all() to regenerate embeddings."
2334                    );
2335                }
2336                let ns_refs = as_str_slice(&ns_owned);
2337                let ns_slice: Option<&[&str]> = ns_refs.as_deref();
2338                let st_slice: Option<&[SearchSourceType]> = st_owned.as_deref();
2339
2340                #[cfg(feature = "hnsw")]
2341                {
2342                    let mut execution = if hnsw_hits_owned.is_empty() {
2343                        search::vector_only_search_detailed_with_context(
2344                            conn,
2345                            &query_embedding,
2346                            &config,
2347                            &context_owned,
2348                            k,
2349                            ns_slice,
2350                            st_slice,
2351                            None,
2352                        )
2353                    } else {
2354                        search::vector_only_search_with_hnsw_detailed_with_context(
2355                            conn,
2356                            &query_embedding,
2357                            &config,
2358                            &context_owned,
2359                            k,
2360                            ns_slice,
2361                            st_slice,
2362                            None,
2363                            &hnsw_hits_owned,
2364                        )
2365                    }?;
2366                    if context_owned.receipts_enabled()
2367                        && context_owned.exactness_profile == ExactnessProfile::PreferExact
2368                    {
2369                        if let Some(receipt) = execution.receipt.as_mut() {
2370                            receipt.search_profile = "vector_only_prefer_exact".to_string();
2371                        }
2372                    }
2373                    Ok(SearchResponse {
2374                        results: execution
2375                            .results
2376                            .into_iter()
2377                            .map(|result| result.result)
2378                            .collect(),
2379                        receipt: execution.receipt,
2380                    })
2381                }
2382                #[cfg(not(feature = "hnsw"))]
2383                {
2384                    let execution = search::vector_only_search_detailed_with_context(
2385                        conn,
2386                        &query_embedding,
2387                        &config,
2388                        &context_owned,
2389                        k,
2390                        ns_slice,
2391                        st_slice,
2392                        None,
2393                    )?;
2394                    Ok(SearchResponse {
2395                        results: execution
2396                            .results
2397                            .into_iter()
2398                            .map(|result| result.result)
2399                            .collect(),
2400                        receipt: execution.receipt,
2401                    })
2402                }
2403            })
2404            .await?;
2405        response.results = self
2406            .filter_search_results(response.results, StateView::Current)
2407            .await?;
2408        if let Some(receipt) = &response.receipt {
2409            self.persist_search_receipt(
2410                receipt,
2411                query,
2412                namespaces,
2413                source_types,
2414                context.replay_mode,
2415            )
2416            .await?;
2417        }
2418        Ok(response)
2419    }
2420
2421    // ─── Explainable Search ───────────────────────────────────
2422
2423    /// Search with full score breakdown for each result.
2424    pub async fn search_explained(
2425        &self,
2426        query: &str,
2427        top_k: Option<usize>,
2428        namespaces: Option<&[&str]>,
2429        source_types: Option<&[SearchSourceType]>,
2430    ) -> Result<Vec<types::ExplainedResult>, MemoryError> {
2431        Ok(self
2432            .search_explained_with_context(
2433                query,
2434                top_k,
2435                namespaces,
2436                source_types,
2437                SearchContext::default_now(),
2438            )
2439            .await?
2440            .results)
2441    }
2442
2443    /// Search with full score breakdown under an explicit deterministic context.
2444    pub async fn search_explained_with_context(
2445        &self,
2446        query: &str,
2447        top_k: Option<usize>,
2448        namespaces: Option<&[&str]>,
2449        source_types: Option<&[SearchSourceType]>,
2450        context: SearchContext,
2451    ) -> Result<types::ExplainedSearchResponse, MemoryError> {
2452        let k = top_k
2453            .unwrap_or(self.inner.config.search.default_top_k)
2454            .min(MAX_TOP_K);
2455        let (query_embedding, query_sparse) = if self.inner.config.search.sparse_weight > 0.0 {
2456            let (dense, sparse, _) = self.embed_text_with_sparse_internal(query).await?;
2457            (dense, sparse)
2458        } else {
2459            (self.embed_text_internal(query).await?, None)
2460        };
2461
2462        #[cfg(feature = "hnsw")]
2463        let hnsw_hits = if context.exactness_profile == ExactnessProfile::PreferExact {
2464            Vec::new()
2465        } else {
2466            let candidates = self
2467                .inner
2468                .config
2469                .search
2470                .candidate_pool_size
2471                .max(k.saturating_mul(3))
2472                .min(MAX_HNSW_CANDIDATES);
2473            self.hnsw_search_blocking(query_embedding.clone(), candidates)
2474                .await
2475        };
2476
2477        let q = query.to_string();
2478        let config = self.inner.config.search.clone();
2479        let ns_owned = to_owned_string_vec(namespaces);
2480        let st_owned: Option<Vec<SearchSourceType>> = source_types.map(|value| value.to_vec());
2481        let context_owned = context.clone();
2482
2483        #[cfg(feature = "hnsw")]
2484        let hnsw_hits_owned = hnsw_hits;
2485
2486        let response = self
2487            .with_read_conn(move |conn| {
2488                let ns_refs = as_str_slice(&ns_owned);
2489                let ns_slice: Option<&[&str]> = ns_refs.as_deref();
2490                let st_slice: Option<&[SearchSourceType]> = st_owned.as_deref();
2491
2492                #[cfg(feature = "hnsw")]
2493                {
2494                    let mut execution = if hnsw_hits_owned.is_empty() {
2495                        search::hybrid_search_detailed_with_context(
2496                            conn,
2497                            &q,
2498                            &query_embedding,
2499                            query_sparse.as_ref(),
2500                            &config,
2501                            &context_owned,
2502                            k,
2503                            ns_slice,
2504                            st_slice,
2505                            None,
2506                        )
2507                    } else {
2508                        search::hybrid_search_with_hnsw_detailed_with_context(
2509                            conn,
2510                            &q,
2511                            &query_embedding,
2512                            query_sparse.as_ref(),
2513                            &config,
2514                            &context_owned,
2515                            k,
2516                            ns_slice,
2517                            st_slice,
2518                            None,
2519                            &hnsw_hits_owned,
2520                        )
2521                    }?;
2522                    if context_owned.receipts_enabled()
2523                        && context_owned.exactness_profile == ExactnessProfile::PreferExact
2524                    {
2525                        if let Some(receipt) = execution.receipt.as_mut() {
2526                            receipt.search_profile = "hybrid_prefer_exact".to_string();
2527                        }
2528                    }
2529                    Ok(types::ExplainedSearchResponse {
2530                        results: execution.results,
2531                        receipt: execution.receipt,
2532                    })
2533                }
2534                #[cfg(not(feature = "hnsw"))]
2535                {
2536                    let execution = search::hybrid_search_detailed_with_context(
2537                        conn,
2538                        &q,
2539                        &query_embedding,
2540                        query_sparse.as_ref(),
2541                        &config,
2542                        &context_owned,
2543                        k,
2544                        ns_slice,
2545                        st_slice,
2546                        None,
2547                    )?;
2548                    Ok(types::ExplainedSearchResponse {
2549                        results: execution.results,
2550                        receipt: execution.receipt,
2551                    })
2552                }
2553            })
2554            .await?;
2555        if let Some(receipt) = &response.receipt {
2556            self.persist_search_receipt(
2557                receipt,
2558                query,
2559                namespaces,
2560                source_types,
2561                context.replay_mode,
2562            )
2563            .await?;
2564        }
2565        Ok(response)
2566    }
2567
2568    /// Load a durable search receipt by receipt/request ID.
2569    pub async fn get_search_receipt(
2570        &self,
2571        receipt_id: &str,
2572    ) -> Result<Option<VectorSearchReceiptV1>, MemoryError> {
2573        let receipt_id = receipt_id.to_string();
2574        self.with_read_conn(move |conn| db::get_search_receipt(conn, &receipt_id))
2575            .await
2576    }
2577
2578    /// Return whether a durable receipt has opt-in inputs for complete replay.
2579    pub async fn search_replay_inputs_available(
2580        &self,
2581        receipt_id: &str,
2582    ) -> Result<bool, MemoryError> {
2583        let receipt_id = receipt_id.to_string();
2584        self.with_read_conn(move |conn| Ok(db::get_replay_inputs(conn, &receipt_id)?.is_some()))
2585            .await
2586    }
2587
2588    /// Replay a durable receipt using its opt-in stored query and filters.
2589    pub async fn replay_search_from_stored_inputs(
2590        &self,
2591        receipt_id: &str,
2592    ) -> Result<SearchReplayReportV1, MemoryError> {
2593        self.get_search_receipt(receipt_id).await?.ok_or_else(|| {
2594            MemoryError::SearchReceiptNotFound {
2595                receipt_id: receipt_id.to_string(),
2596            }
2597        })?;
2598        let replay_receipt_id = receipt_id.to_string();
2599        let inputs = self
2600            .with_read_conn(move |conn| db::get_replay_inputs(conn, &replay_receipt_id))
2601            .await?
2602            .ok_or_else(|| {
2603                MemoryError::Other(format!(
2604                    "search receipt '{receipt_id}' has no stored replay inputs"
2605                ))
2606            })?;
2607        let namespace_refs: Option<Vec<&str>> = inputs
2608            .namespaces
2609            .as_ref()
2610            .map(|values| values.iter().map(String::as_str).collect());
2611        self.replay_search_receipt(
2612            receipt_id,
2613            &inputs.query_text,
2614            None,
2615            namespace_refs.as_deref(),
2616            inputs.source_types.as_deref(),
2617        )
2618        .await
2619    }
2620
2621    /// Replay a durable search receipt with caller-supplied query text and filters.
2622    ///
2623    /// Receipts intentionally do not store query text or filter values. The
2624    /// caller supplies those inputs, and the stored receipt supplies the
2625    /// deterministic evaluation time and retrieval family for comparison.
2626    pub async fn replay_search_receipt(
2627        &self,
2628        receipt_id: &str,
2629        query: &str,
2630        top_k: Option<usize>,
2631        namespaces: Option<&[&str]>,
2632        source_types: Option<&[SearchSourceType]>,
2633    ) -> Result<SearchReplayReportV1, MemoryError> {
2634        let invalidation_id = receipt_id.to_string();
2635        let invalidated = self
2636            .with_read_conn(move |conn| {
2637                conn.query_row(
2638                    "SELECT EXISTS(
2639                         SELECT 1 FROM forgetting_artifact_invalidations
2640                         WHERE surface_kind = 'search_receipt' AND artifact_id = ?1
2641                     )",
2642                    rusqlite::params![invalidation_id],
2643                    |row| row.get::<_, bool>(0),
2644                )
2645                .map_err(MemoryError::from)
2646            })
2647            .await?;
2648        if invalidated {
2649            return Err(MemoryError::ForgettingClosureIncomplete {
2650                detail: format!(
2651                    "search receipt '{receipt_id}' was invalidated by selective forgetting"
2652                ),
2653            });
2654        }
2655        let original_receipt = self.get_search_receipt(receipt_id).await?.ok_or_else(|| {
2656            MemoryError::SearchReceiptNotFound {
2657                receipt_id: receipt_id.to_string(),
2658            }
2659        })?;
2660
2661        let vector_only = original_receipt.search_profile.starts_with("vector_only");
2662        let fts_only = original_receipt.search_profile.starts_with("fts_only");
2663        let replay_top_k = top_k.or_else(|| Some(original_receipt.result_ids.len().max(1)));
2664        let replay_receipt_id = format!("{receipt_id}:replay:{}", uuid::Uuid::new_v4());
2665        let mut context = SearchContext::at(original_receipt.evaluation_time);
2666        context.receipt_mode = ReceiptMode::ReturnReceipt;
2667        context.request_id = Some(replay_receipt_id.clone());
2668        context.trace_id = original_receipt.trace_id.clone();
2669        context.attempt_family_id = original_receipt
2670            .attempt_family_id
2671            .clone()
2672            .or_else(|| Some(original_receipt.receipt_id.clone()));
2673        context.attempt_id = Some(replay_receipt_id.clone());
2674        context.replay_of = Some(original_receipt.receipt_id.clone());
2675        context.query_text_digest = original_receipt.query_text_digest.clone();
2676        context.query_input_digest = original_receipt.query_input_digest.clone();
2677        context.filter_digest = original_receipt.filter_digest.clone();
2678        context.redaction_state = original_receipt.redaction_state.clone();
2679        context.budget_id = original_receipt.budget_id.clone();
2680        context.exactness_profile = if original_receipt.approximate {
2681            ExactnessProfile::AllowApproximate
2682        } else {
2683            ExactnessProfile::PreferExact
2684        };
2685
2686        let replay_response = if vector_only {
2687            self.search_vector_only_with_context(
2688                query,
2689                replay_top_k,
2690                namespaces,
2691                source_types,
2692                context,
2693            )
2694            .await?
2695        } else if fts_only {
2696            self.search_fts_only_with_context(
2697                query,
2698                replay_top_k,
2699                namespaces,
2700                source_types,
2701                context,
2702            )
2703            .await?
2704        } else {
2705            self.search_with_context(query, replay_top_k, namespaces, source_types, context)
2706                .await?
2707        };
2708        let replay_receipt = replay_response
2709            .receipt
2710            .ok_or_else(|| MemoryError::Other("replay did not produce a receipt".to_string()))?;
2711
2712        let query_embedding_digest_matches =
2713            original_receipt.query_embedding_digest == replay_receipt.query_embedding_digest;
2714        let result_ids_match = original_receipt.result_ids == replay_receipt.result_ids;
2715        let missing_result_ids = original_receipt
2716            .result_ids
2717            .iter()
2718            .filter(|id| !replay_receipt.result_ids.contains(*id))
2719            .cloned()
2720            .collect();
2721        let added_result_ids = replay_receipt
2722            .result_ids
2723            .iter()
2724            .filter(|id| !original_receipt.result_ids.contains(*id))
2725            .cloned()
2726            .collect();
2727
2728        Ok(SearchReplayReportV1 {
2729            receipt_id: original_receipt.receipt_id.clone(),
2730            replay_receipt_id,
2731            original_receipt,
2732            replay_receipt,
2733            query_embedding_digest_matches,
2734            result_ids_match,
2735            missing_result_ids,
2736            added_result_ids,
2737            vector_only,
2738        })
2739    }
2740
2741    // ─── Embedding Displacement ───────────────────────────────
2742
2743    /// Compute embedding displacement between two texts.
2744    pub async fn embedding_displacement(
2745        &self,
2746        text_a: &str,
2747        text_b: &str,
2748    ) -> Result<types::EmbeddingDisplacement, MemoryError> {
2749        let emb_a = self.embed_text_internal(text_a).await?;
2750        let emb_b = self.embed_text_internal(text_b).await?;
2751        Self::embedding_displacement_from_vecs(&emb_a, &emb_b)
2752    }
2753
2754    /// Compute embedding displacement from pre-computed vectors.
2755    pub fn embedding_displacement_from_vecs(
2756        a: &[f32],
2757        b: &[f32],
2758    ) -> Result<types::EmbeddingDisplacement, MemoryError> {
2759        if a.len() != b.len() {
2760            return Err(MemoryError::DimensionMismatch {
2761                expected: a.len(),
2762                actual: b.len(),
2763            });
2764        }
2765        let cosine_sim = search::cosine_similarity(a, b)?;
2766
2767        let euclidean_dist: f32 = a
2768            .iter()
2769            .zip(b.iter())
2770            .map(|(x, y)| (x - y) * (x - y))
2771            .sum::<f32>()
2772            .sqrt();
2773
2774        let mag_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
2775        let mag_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
2776
2777        Ok(types::EmbeddingDisplacement {
2778            cosine_similarity: cosine_sim,
2779            euclidean_distance: euclidean_dist,
2780            magnitude_a: mag_a,
2781            magnitude_b: mag_b,
2782        })
2783    }
2784
2785    // ─── Utility ────────────────────────────────────────────────
2786
2787    /// Chunk text using the configured strategy and token counter.
2788    pub fn chunk_text(&self, text: &str) -> Vec<TextChunk> {
2789        chunker::chunk_text(
2790            text,
2791            &self.inner.config.chunking,
2792            self.inner.token_counter.as_ref(),
2793        )
2794    }
2795
2796    /// Embed a single text via the configured provider.
2797    pub async fn embed(&self, text: &str) -> Result<Vec<f32>, MemoryError> {
2798        self.embed_text_internal(text).await
2799    }
2800
2801    /// Embed multiple texts in a batch.
2802    pub async fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, MemoryError> {
2803        let owned: Vec<String> = texts.iter().map(|s| s.to_string()).collect();
2804        self.embed_batch_internal(owned).await
2805    }
2806
2807    /// Get database statistics.
2808    pub async fn stats(&self) -> Result<MemoryStats, MemoryError> {
2809        let db_path = self.inner.paths.sqlite_path.clone();
2810        self.with_read_conn(move |conn| {
2811            let total_facts: u64 =
2812                conn.query_row("SELECT COUNT(*) FROM facts", [], |r| r.get(0))?;
2813            let total_documents: u64 =
2814                conn.query_row("SELECT COUNT(*) FROM documents", [], |r| r.get(0))?;
2815            let total_chunks: u64 =
2816                conn.query_row("SELECT COUNT(*) FROM chunks", [], |r| r.get(0))?;
2817            let total_sessions: u64 =
2818                conn.query_row("SELECT COUNT(*) FROM sessions", [], |r| r.get(0))?;
2819            let total_messages: u64 =
2820                conn.query_row("SELECT COUNT(*) FROM messages", [], |r| r.get(0))?;
2821
2822            let db_size = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0);
2823
2824            let (model, dims): (Option<String>, Option<usize>) = conn
2825                .query_row(
2826                    "SELECT model_name, dimensions FROM embedding_metadata WHERE id = 1",
2827                    [],
2828                    |r| Ok((Some(r.get(0)?), Some(r.get(1)?))),
2829                )
2830                .unwrap_or((None, None));
2831
2832            Ok(MemoryStats {
2833                total_facts,
2834                total_documents,
2835                total_chunks,
2836                total_sessions,
2837                total_messages,
2838                database_size_bytes: db_size,
2839                embedding_model: model,
2840                embedding_dimensions: dims,
2841            })
2842        })
2843        .await
2844    }
2845
2846    /// Return distinct scope_domain values stored in document metadata.
2847    ///
2848    /// Queries `json_extract(metadata, '$.scope_domain')` across all documents
2849    /// and returns the unique non-null values. Used by the Recall app to populate
2850    /// the scope picker dynamically instead of relying on a hardcoded list.
2851    pub async fn list_scope_domains(&self) -> Result<Vec<String>, MemoryError> {
2852        self.with_read_conn(|conn| {
2853            let mut stmt = conn.prepare(
2854                "SELECT DISTINCT json_extract(metadata, '$.scope_domain') \
2855                 FROM documents \
2856                 WHERE json_extract(metadata, '$.scope_domain') IS NOT NULL",
2857            )?;
2858            let domains: Vec<String> = stmt
2859                .query_map([], |row| row.get::<_, String>(0))?
2860                .filter_map(|r| r.ok())
2861                .collect();
2862            Ok(domains)
2863        })
2864        .await
2865    }
2866
2867    /// Check if embeddings need re-generation after a model change.
2868    pub async fn embeddings_are_dirty(&self) -> Result<bool, MemoryError> {
2869        self.with_read_conn(db::is_embeddings_dirty).await
2870    }
2871
2872    /// Re-embed all facts, chunks, messages, and episodes. Call after changing embedding models.
2873    pub async fn reembed_all(&self) -> Result<usize, MemoryError> {
2874        let mut count = 0usize;
2875        let batch_size = self.inner.config.embedding.batch_size;
2876        let dims = self.inner.config.embedding.dimensions;
2877
2878        // ─── Facts ──────────────────────────────────────────────────
2879        let fact_contents: Vec<(String, String)> = self
2880            .with_read_conn(|conn| {
2881                let mut stmt = conn.prepare("SELECT id, content FROM facts")?;
2882                let result = stmt
2883                    .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
2884                    .collect::<Result<Vec<_>, _>>()?;
2885                Ok(result)
2886            })
2887            .await?;
2888
2889        let mut fact_count = 0usize;
2890        for batch in fact_contents.chunks(batch_size) {
2891            let texts: Vec<String> = batch.iter().map(|(_, c)| c.clone()).collect();
2892            let embeddings = self.embed_batch_with_sparse_internal(texts).await?;
2893
2894            let quantizer = Quantizer::new(dims);
2895            let updates: Vec<_> = batch
2896                .iter()
2897                .zip(embeddings.iter())
2898                .map(|((id, _), (emb, sparse, representation))| {
2899                    // INTENTIONAL: q8 quantization is an optional search optimization; missing q8 is non-fatal
2900                    let q8 = quantizer
2901                        .quantize(emb)
2902                        .map(|qv| quantize::pack_quantized(&qv))
2903                        .ok();
2904                    (
2905                        id.clone(),
2906                        db::embedding_to_bytes(emb),
2907                        q8,
2908                        sparse.clone(),
2909                        representation.clone(),
2910                    )
2911                })
2912                .collect();
2913
2914            self.with_write_conn(move |conn| {
2915                db::with_transaction(conn, |tx| {
2916                    for (fid, bytes, q8, sparse, representation) in &updates {
2917                        tx.execute(
2918                            "UPDATE facts SET embedding = ?1, embedding_q8 = ?2, updated_at = datetime('now') WHERE id = ?3",
2919                            rusqlite::params![bytes, q8.as_deref(), fid],
2920                        )?;
2921                        #[cfg(feature = "hnsw")]
2922                        db::queue_pending_index_op(
2923                            tx,
2924                            &format!("fact:{fid}"),
2925                            "fact",
2926                            db::IndexOpKind::Upsert,
2927                        )?;
2928                        db::invalidate_derived_vector_artifact(tx, &format!("fact:{fid}"))?;
2929                        if let Some((weights, representation)) =
2930                            sparse.as_ref().zip(representation.as_deref())
2931                        {
2932                            db::store_sparse_vector(
2933                                tx,
2934                                &format!("fact:{fid}"),
2935                                weights,
2936                                representation,
2937                            )?;
2938                        } else {
2939                            db::delete_sparse_vector(tx, &format!("fact:{fid}"))?;
2940                        }
2941                    }
2942                    Ok(())
2943                })
2944            })
2945            .await?;
2946
2947            fact_count += batch.len();
2948            count += batch.len();
2949            if fact_count % 100 == 0 || fact_count == count {
2950                tracing::info!(fact_count, "Re-embedded {} facts so far", fact_count);
2951            }
2952        }
2953
2954        // ─── Chunks ─────────────────────────────────────────────────
2955        let chunk_data: Vec<(String, String)> = self
2956            .with_read_conn(|conn| {
2957                let mut stmt = conn.prepare("SELECT id, content FROM chunks")?;
2958                let result = stmt
2959                    .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
2960                    .collect::<Result<Vec<_>, _>>()?;
2961                Ok(result)
2962            })
2963            .await?;
2964
2965        let mut chunk_count = 0usize;
2966        for batch in chunk_data.chunks(batch_size) {
2967            let texts: Vec<String> = batch.iter().map(|(_, c)| c.clone()).collect();
2968            let embeddings = self.embed_batch_with_sparse_internal(texts).await?;
2969
2970            let quantizer = Quantizer::new(dims);
2971            let updates: Vec<_> = batch
2972                .iter()
2973                .zip(embeddings.iter())
2974                .map(|((id, _), (emb, sparse, representation))| {
2975                    // INTENTIONAL: q8 quantization is an optional search optimization; missing q8 is non-fatal
2976                    let q8 = quantizer
2977                        .quantize(emb)
2978                        .map(|qv| quantize::pack_quantized(&qv))
2979                        .ok();
2980                    (
2981                        id.clone(),
2982                        db::embedding_to_bytes(emb),
2983                        q8,
2984                        sparse.clone(),
2985                        representation.clone(),
2986                    )
2987                })
2988                .collect();
2989
2990            self.with_write_conn(move |conn| {
2991                db::with_transaction(conn, |tx| {
2992                    for (cid, bytes, q8, sparse, representation) in &updates {
2993                        tx.execute(
2994                            "UPDATE chunks SET embedding = ?1, embedding_q8 = ?2 WHERE id = ?3",
2995                            rusqlite::params![bytes, q8.as_deref(), cid],
2996                        )?;
2997                        #[cfg(feature = "hnsw")]
2998                        db::queue_pending_index_op(
2999                            tx,
3000                            &format!("chunk:{cid}"),
3001                            "chunk",
3002                            db::IndexOpKind::Upsert,
3003                        )?;
3004                        db::invalidate_derived_vector_artifact(tx, &format!("chunk:{cid}"))?;
3005                        if let Some((weights, representation)) =
3006                            sparse.as_ref().zip(representation.as_deref())
3007                        {
3008                            db::store_sparse_vector(
3009                                tx,
3010                                &format!("chunk:{cid}"),
3011                                weights,
3012                                representation,
3013                            )?;
3014                        } else {
3015                            db::delete_sparse_vector(tx, &format!("chunk:{cid}"))?;
3016                        }
3017                    }
3018                    Ok(())
3019                })
3020            })
3021            .await?;
3022
3023            chunk_count += batch.len();
3024            count += batch.len();
3025            if chunk_count % 100 == 0 {
3026                tracing::info!(chunk_count, "Re-embedded {} chunks so far", chunk_count);
3027            }
3028        }
3029
3030        // ─── Messages ───────────────────────────────────────────────
3031        let message_data: Vec<(i64, String)> = self
3032            .with_read_conn(|conn| {
3033                let mut stmt = conn.prepare("SELECT id, content FROM messages")?;
3034                let result = stmt
3035                    .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
3036                    .collect::<Result<Vec<_>, _>>()?;
3037                Ok(result)
3038            })
3039            .await?;
3040
3041        let mut msg_count = 0usize;
3042        for batch in message_data.chunks(batch_size) {
3043            let texts: Vec<String> = batch.iter().map(|(_, c)| c.clone()).collect();
3044            let embeddings = self.embed_batch_with_sparse_internal(texts).await?;
3045
3046            let quantizer = Quantizer::new(dims);
3047            let updates: Vec<_> = batch
3048                .iter()
3049                .zip(embeddings.iter())
3050                .map(|((id, _), (emb, sparse, representation))| {
3051                    // INTENTIONAL: q8 quantization is an optional search optimization; missing q8 is non-fatal
3052                    let q8 = quantizer
3053                        .quantize(emb)
3054                        .map(|qv| quantize::pack_quantized(&qv))
3055                        .ok();
3056                    (
3057                        *id,
3058                        db::embedding_to_bytes(emb),
3059                        q8,
3060                        sparse.clone(),
3061                        representation.clone(),
3062                    )
3063                })
3064                .collect();
3065
3066            self.with_write_conn(move |conn| {
3067                db::with_transaction(conn, |tx| {
3068                    for (mid, bytes, q8, sparse, representation) in &updates {
3069                        tx.execute(
3070                            "UPDATE messages SET embedding = ?1, embedding_q8 = ?2 WHERE id = ?3",
3071                            rusqlite::params![bytes, q8.as_deref(), mid],
3072                        )?;
3073                        #[cfg(feature = "hnsw")]
3074                        db::queue_pending_index_op(
3075                            tx,
3076                            &format!("msg:{mid}"),
3077                            "message",
3078                            db::IndexOpKind::Upsert,
3079                        )?;
3080                        db::invalidate_derived_vector_artifact(tx, &format!("msg:{mid}"))?;
3081                        if let Some((weights, representation)) =
3082                            sparse.as_ref().zip(representation.as_deref())
3083                        {
3084                            db::store_sparse_vector(
3085                                tx,
3086                                &format!("msg:{mid}"),
3087                                weights,
3088                                representation,
3089                            )?;
3090                        } else {
3091                            db::delete_sparse_vector(tx, &format!("msg:{mid}"))?;
3092                        }
3093                    }
3094                    Ok(())
3095                })
3096            })
3097            .await?;
3098
3099            msg_count += batch.len();
3100            count += batch.len();
3101            if msg_count % 100 == 0 {
3102                tracing::info!(msg_count, "Re-embedded {} messages so far", msg_count);
3103            }
3104        }
3105
3106        // ─── Episodes ───────────────────────────────────────────────
3107        let episode_data: Vec<(String, String)> = self
3108            .with_read_conn(|conn| {
3109                let mut stmt = conn.prepare("SELECT episode_id, search_text FROM episodes")?;
3110                let result = stmt
3111                    .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
3112                    .collect::<Result<Vec<_>, _>>()?;
3113                Ok(result)
3114            })
3115            .await?;
3116
3117        let mut episode_count = 0usize;
3118        for batch in episode_data.chunks(batch_size) {
3119            let texts: Vec<String> = batch.iter().map(|(_, text)| text.clone()).collect();
3120            let embeddings = self.embed_batch_with_sparse_internal(texts).await?;
3121
3122            let quantizer = Quantizer::new(dims);
3123            let updates: Vec<_> = batch
3124                .iter()
3125                .zip(embeddings.iter())
3126                .map(|((episode_id, _), (embedding, sparse, representation))| {
3127                    // INTENTIONAL: q8 quantization is an optional search optimization; missing q8 is non-fatal
3128                    let q8 = quantizer
3129                        .quantize(embedding)
3130                        .map(|vector| quantize::pack_quantized(&vector))
3131                        .ok();
3132                    (
3133                        episode_id.clone(),
3134                        db::embedding_to_bytes(embedding),
3135                        q8,
3136                        sparse.clone(),
3137                        representation.clone(),
3138                    )
3139                })
3140                .collect();
3141
3142            self.with_write_conn(move |conn| {
3143                db::with_transaction(conn, |tx| {
3144                    for (episode_id, bytes, q8, sparse, representation) in &updates {
3145                        tx.execute(
3146                            "UPDATE episodes
3147                             SET embedding = ?1,
3148                                 embedding_q8 = ?2,
3149                                 updated_at = datetime('now')
3150                             WHERE episode_id = ?3",
3151                            rusqlite::params![bytes, q8.as_deref(), episode_id],
3152                        )?;
3153                        #[cfg(feature = "hnsw")]
3154                        db::queue_pending_index_op(
3155                            tx,
3156                            &episodes::episode_item_key(episode_id),
3157                            "episode",
3158                            db::IndexOpKind::Upsert,
3159                        )?;
3160                        db::invalidate_derived_vector_artifact(
3161                            tx,
3162                            &episodes::episode_item_key(episode_id),
3163                        )?;
3164                        let item_key = episodes::episode_item_key(episode_id);
3165                        if let Some((weights, representation)) =
3166                            sparse.as_ref().zip(representation.as_deref())
3167                        {
3168                            db::store_sparse_vector(tx, &item_key, weights, representation)?;
3169                        } else {
3170                            db::delete_sparse_vector(tx, &item_key)?;
3171                        }
3172                    }
3173                    Ok(())
3174                })
3175            })
3176            .await?;
3177
3178            episode_count += batch.len();
3179            count += batch.len();
3180            if episode_count % 100 == 0 {
3181                tracing::info!(
3182                    episode_count,
3183                    "Re-embedded {} episodes so far",
3184                    episode_count
3185                );
3186            }
3187        }
3188
3189        // Clear the dirty flag
3190        self.with_write_conn(db::clear_embeddings_dirty).await?;
3191
3192        tracing::info!(
3193            facts = fact_count,
3194            chunks = chunk_count,
3195            messages = msg_count,
3196            episodes = episode_count,
3197            total = count,
3198            "Re-embedding complete"
3199        );
3200
3201        // Rebuild HNSW after re-embedding
3202        #[cfg(feature = "hnsw")]
3203        {
3204            tracing::info!("Rebuilding HNSW index after re-embedding...");
3205            let _receipt = self.rebuild_hnsw_index().await?;
3206        }
3207
3208        Ok(count)
3209    }
3210
3211    /// Vacuum the database (reclaim space after deletions).
3212    pub async fn vacuum(&self) -> Result<(), MemoryError> {
3213        self.with_write_conn(|conn| {
3214            conn.execute_batch("VACUUM")?;
3215            Ok(())
3216        })
3217        .await
3218    }
3219
3220    // ─── Routing policy persistence ──────────────────────────────
3221
3222    /// Save a routing policy to the database as JSON.
3223    ///
3224    /// Creates the `routing_policy` table if it doesn't exist and upserts
3225    /// the serialized policy into the single-row table (id=1).
3226    #[cfg(feature = "rl-routing")]
3227    pub async fn save_routing_policy(
3228        &self,
3229        policy: &rl_routing::RoutingPolicy,
3230    ) -> Result<(), MemoryError> {
3231        let json = serde_json::to_string(policy)
3232            .map_err(|e| MemoryError::Other(format!("Failed to serialize routing policy: {e}")))?;
3233        let updated_at = chrono::Utc::now().to_rfc3339();
3234        self.with_write_conn(move |conn| {
3235            conn.execute_batch(
3236                "CREATE TABLE IF NOT EXISTS routing_policy (\
3237                 id INTEGER PRIMARY KEY, policy_json TEXT NOT NULL, updated_at TEXT NOT NULL)",
3238            )?;
3239            conn.execute(
3240                "INSERT INTO routing_policy (id, policy_json, updated_at) VALUES (1, ?1, ?2) \
3241                 ON CONFLICT(id) DO UPDATE SET policy_json = ?1, updated_at = ?2",
3242                rusqlite::params![json, updated_at],
3243            )?;
3244            Ok(())
3245        })
3246        .await
3247    }
3248
3249    /// Load the persisted routing policy from the database.
3250    ///
3251    /// Returns `Ok(None)` if no policy has been saved yet.
3252    #[cfg(feature = "rl-routing")]
3253    pub async fn load_routing_policy(
3254        &self,
3255    ) -> Result<Option<rl_routing::RoutingPolicy>, MemoryError> {
3256        self.with_read_conn(move |conn| {
3257            // Check if table exists
3258            let table_exists: bool = conn
3259                .query_row(
3260                    "SELECT EXISTS (SELECT 1 FROM sqlite_master WHERE type='table' AND name='routing_policy')",
3261                    [],
3262                    |row| row.get(0),
3263                )
3264                .unwrap_or(false);
3265            if !table_exists {
3266                return Ok(None);
3267            }
3268            let json: Option<String> = conn
3269                .query_row(
3270                    "SELECT policy_json FROM routing_policy WHERE id = 1",
3271                    [],
3272                    |row| row.get(0),
3273                )
3274                .ok();
3275            match json {
3276                Some(j) => {
3277                    let policy = serde_json::from_str(&j).map_err(|e| {
3278                        MemoryError::Other(format!("Failed to deserialize routing policy: {e}"))
3279                    })?;
3280                    Ok(Some(policy))
3281                }
3282                None => Ok(None),
3283            }
3284        })
3285        .await
3286    }
3287
3288    // ─── Projection Import ─────────────────────────────────────
3289
3290    /// Import a projection envelope atomically (V10 legacy path).
3291    ///
3292    /// ## Phase status: compatibility / migration-only
3293    ///
3294    /// This method is the V10 legacy import path. New integrations should use
3295    /// [`import_projection_batch()`](Self::import_projection_batch) instead,
3296    /// which accepts the canonical `ProjectionImportBatchV3` format from
3297    /// `forge-memory-bridge`.
3298    ///
3299    /// **Removal condition**: removed when all callers migrate to the bridge pipeline.
3300    ///
3301    /// **Idempotent**: re-importing the same envelope (same `envelope_id` +
3302    /// `schema_version` + `content_digest`) returns a receipt with
3303    /// `was_duplicate = true` and does not modify data.
3304    ///
3305    /// **Atomic**: all records are committed in a single transaction. On any
3306    /// failure the entire import is rolled back — no partial visibility.
3307    ///
3308    /// **Provenance**: each imported record's metadata is tagged with the
3309    /// envelope_id and source_authority for traceability.
3310    #[deprecated(
3311        since = "0.5.0",
3312        note = "Legacy V10 import envelope path is compatibility-only. Use `import_projection_batch()` and `ProjectionImportBatchV3` on the canonical lane."
3313    )]
3314    #[doc(hidden)]
3315    #[allow(deprecated)]
3316    pub async fn import_envelope(
3317        &self,
3318        envelope: &projection_import::ImportEnvelope,
3319    ) -> Result<projection_import::ImportReceipt, MemoryError> {
3320        projection_legacy_compat::import_envelope(self, envelope).await
3321    }
3322
3323    /// Check whether an envelope has already been imported.
3324    #[deprecated(
3325        since = "0.5.0",
3326        note = "Legacy V10 import envelope status reads are compatibility-only. Prefer the projection import log."
3327    )]
3328    #[doc(hidden)]
3329    #[allow(deprecated)]
3330    pub async fn import_status(
3331        &self,
3332        envelope_id: &projection_import::EnvelopeId,
3333    ) -> Result<Vec<projection_import::ImportReceipt>, MemoryError> {
3334        projection_legacy_compat::import_status(self, envelope_id).await
3335    }
3336
3337    /// List recent imports, optionally filtered by namespace.
3338    #[deprecated(
3339        since = "0.5.0",
3340        note = "Legacy V10 import log access is compatibility-only. Prefer new projection-import metadata."
3341    )]
3342    #[doc(hidden)]
3343    #[allow(deprecated)]
3344    pub async fn list_imports(
3345        &self,
3346        namespace: Option<&str>,
3347        limit: usize,
3348    ) -> Result<Vec<projection_import::ImportReceipt>, MemoryError> {
3349        projection_legacy_compat::list_imports(self, namespace, limit).await
3350    }
3351
3352    /// Get the most recent successful import timestamp for a namespace.
3353    #[allow(deprecated)]
3354    pub async fn last_import_at(&self, namespace: &str) -> Result<Option<String>, MemoryError> {
3355        projection_legacy_compat::last_import_at(self, namespace).await
3356    }
3357
3358    /// Query imported claim projection rows through the supported public read surface.
3359    pub async fn query_claim_versions(
3360        &self,
3361        query: ProjectionQuery,
3362    ) -> Result<Vec<ProjectionClaimVersion>, MemoryError> {
3363        self.with_read_conn(move |conn| projection_storage::query_claim_versions(conn, &query))
3364            .await
3365    }
3366
3367    /// Query imported relation projection rows through the supported public read surface.
3368    pub async fn query_relation_versions(
3369        &self,
3370        query: ProjectionQuery,
3371    ) -> Result<Vec<ProjectionRelationVersion>, MemoryError> {
3372        self.with_read_conn(move |conn| projection_storage::query_relation_versions(conn, &query))
3373            .await
3374    }
3375
3376    /// Query imported episode projection rows through the supported public read surface.
3377    pub async fn query_episodes(
3378        &self,
3379        query: ProjectionQuery,
3380    ) -> Result<Vec<ProjectionEpisode>, MemoryError> {
3381        self.with_read_conn(move |conn| projection_storage::query_episode_rows(conn, &query))
3382            .await
3383    }
3384
3385    /// Query imported entity-alias rows through the supported public read surface.
3386    pub async fn query_entity_aliases(
3387        &self,
3388        query: ProjectionQuery,
3389    ) -> Result<Vec<ProjectionEntityAlias>, MemoryError> {
3390        self.with_read_conn(move |conn| projection_storage::query_entity_aliases(conn, &query))
3391            .await
3392    }
3393
3394    /// Query imported evidence-reference rows through the supported public read surface.
3395    pub async fn query_evidence_refs(
3396        &self,
3397        query: ProjectionQuery,
3398    ) -> Result<Vec<ProjectionEvidenceRef>, MemoryError> {
3399        self.with_read_conn(move |conn| projection_storage::query_evidence_refs(conn, &query))
3400            .await
3401    }
3402
3403    /// Governed projection reads fail closed until imported rows have durable origin labels.
3404    /// The ungoverned projection methods above remain the explicit storage compatibility surface;
3405    /// no governed method delegates to them after authorization.
3406    pub async fn query_claim_versions_governed(
3407        &self,
3408        query: ProjectionQuery,
3409        request: GovernedAccessRequestV1,
3410    ) -> Result<GovernedProjectionResponseV1<ProjectionClaimVersion>, MemoryError> {
3411        let query_namespace = query.scope.namespace.clone();
3412        let rows = if query_namespace == request.scope.namespace {
3413            self.with_read_conn(move |conn| projection_storage::query_claim_versions(conn, &query))
3414                .await?
3415        } else {
3416            Vec::new()
3417        };
3418        let mut decisions = Vec::new();
3419        for row in &rows {
3420            decisions.push(origin_authority::evaluate_governed_access_v1(
3421                row.claim_version_id.as_str(),
3422                Some(&row.scope_key.namespace),
3423                None,
3424                None,
3425                &request,
3426            ));
3427        }
3428        if query_namespace != request.scope.namespace {
3429            decisions.push(origin_authority::evaluate_governed_access_v1(
3430                "projection:query",
3431                Some(&query_namespace),
3432                None,
3433                None,
3434                &request,
3435            ));
3436        }
3437        Ok(GovernedProjectionResponseV1 {
3438            items: Vec::new(),
3439            decisions,
3440        })
3441    }
3442
3443    pub async fn query_relation_versions_governed(
3444        &self,
3445        query: ProjectionQuery,
3446        request: GovernedAccessRequestV1,
3447    ) -> Result<GovernedProjectionResponseV1<ProjectionRelationVersion>, MemoryError> {
3448        let query_namespace = query.scope.namespace.clone();
3449        let rows = if query_namespace == request.scope.namespace {
3450            self.with_read_conn(move |conn| {
3451                projection_storage::query_relation_versions(conn, &query)
3452            })
3453            .await?
3454        } else {
3455            Vec::new()
3456        };
3457        let mut decisions = Vec::new();
3458        for row in &rows {
3459            decisions.push(origin_authority::evaluate_governed_access_v1(
3460                row.relation_version_id.as_str(),
3461                Some(&row.scope_key.namespace),
3462                None,
3463                None,
3464                &request,
3465            ));
3466        }
3467        if query_namespace != request.scope.namespace {
3468            decisions.push(origin_authority::evaluate_governed_access_v1(
3469                "projection:query",
3470                Some(&query_namespace),
3471                None,
3472                None,
3473                &request,
3474            ));
3475        }
3476        Ok(GovernedProjectionResponseV1 {
3477            items: Vec::new(),
3478            decisions,
3479        })
3480    }
3481
3482    pub async fn query_episodes_governed(
3483        &self,
3484        query: ProjectionQuery,
3485        request: GovernedAccessRequestV1,
3486    ) -> Result<GovernedProjectionResponseV1<ProjectionEpisode>, MemoryError> {
3487        let query_namespace = query.scope.namespace.clone();
3488        let rows = if query_namespace == request.scope.namespace {
3489            self.with_read_conn(move |conn| projection_storage::query_episode_rows(conn, &query))
3490                .await?
3491        } else {
3492            Vec::new()
3493        };
3494        let mut decisions = Vec::new();
3495        for row in &rows {
3496            decisions.push(origin_authority::evaluate_governed_access_v1(
3497                row.episode_id.as_str(),
3498                Some(&row.scope_key.namespace),
3499                None,
3500                None,
3501                &request,
3502            ));
3503        }
3504        if query_namespace != request.scope.namespace {
3505            decisions.push(origin_authority::evaluate_governed_access_v1(
3506                "projection:query",
3507                Some(&query_namespace),
3508                None,
3509                None,
3510                &request,
3511            ));
3512        }
3513        Ok(GovernedProjectionResponseV1 {
3514            items: Vec::new(),
3515            decisions,
3516        })
3517    }
3518
3519    pub async fn query_entity_aliases_governed(
3520        &self,
3521        query: ProjectionQuery,
3522        request: GovernedAccessRequestV1,
3523    ) -> Result<GovernedProjectionResponseV1<ProjectionEntityAlias>, MemoryError> {
3524        let query_namespace = query.scope.namespace.clone();
3525        let rows = if query_namespace == request.scope.namespace {
3526            self.with_read_conn(move |conn| projection_storage::query_entity_aliases(conn, &query))
3527                .await?
3528        } else {
3529            Vec::new()
3530        };
3531        let mut decisions = Vec::new();
3532        for row in &rows {
3533            decisions.push(origin_authority::evaluate_governed_access_v1(
3534                &format!(
3535                    "entity_alias:{}:{}",
3536                    row.canonical_entity_id.as_str(),
3537                    row.alias_text
3538                ),
3539                Some(&row.scope_key.namespace),
3540                None,
3541                None,
3542                &request,
3543            ));
3544        }
3545        if query_namespace != request.scope.namespace {
3546            decisions.push(origin_authority::evaluate_governed_access_v1(
3547                "projection:query",
3548                Some(&query_namespace),
3549                None,
3550                None,
3551                &request,
3552            ));
3553        }
3554        Ok(GovernedProjectionResponseV1 {
3555            items: Vec::new(),
3556            decisions,
3557        })
3558    }
3559
3560    pub async fn query_evidence_refs_governed(
3561        &self,
3562        query: ProjectionQuery,
3563        request: GovernedAccessRequestV1,
3564    ) -> Result<GovernedProjectionResponseV1<ProjectionEvidenceRef>, MemoryError> {
3565        let query_namespace = query.scope.namespace.clone();
3566        let rows = if query_namespace == request.scope.namespace {
3567            self.with_read_conn(move |conn| projection_storage::query_evidence_refs(conn, &query))
3568                .await?
3569        } else {
3570            Vec::new()
3571        };
3572        let mut decisions = Vec::new();
3573        for row in &rows {
3574            decisions.push(origin_authority::evaluate_governed_access_v1(
3575                &format!(
3576                    "evidence_ref:{}:{}",
3577                    row.claim_id.as_str(),
3578                    row.fetch_handle
3579                ),
3580                Some(&row.scope_key.namespace),
3581                None,
3582                None,
3583                &request,
3584            ));
3585        }
3586        if query_namespace != request.scope.namespace {
3587            decisions.push(origin_authority::evaluate_governed_access_v1(
3588                "projection:query",
3589                Some(&query_namespace),
3590                None,
3591                None,
3592                &request,
3593            ));
3594        }
3595        Ok(GovernedProjectionResponseV1 {
3596            items: Vec::new(),
3597            decisions,
3598        })
3599    }
3600
3601    /// Execute raw SQL. For testing only — not part of the stable public API.
3602    #[cfg(any(test, feature = "testing"))]
3603    pub async fn raw_execute(&self, sql: &str, params: Vec<String>) -> Result<usize, MemoryError> {
3604        let sql = sql.to_string();
3605        self.with_write_conn(move |conn| {
3606            let param_refs: Vec<&dyn rusqlite::types::ToSql> = params
3607                .iter()
3608                .map(|s| s as &dyn rusqlite::types::ToSql)
3609                .collect();
3610            Ok(conn.execute(&sql, &*param_refs)?)
3611        })
3612        .await
3613    }
3614}
3615
3616#[cfg(test)]
3617mod tests {
3618    use super::*;
3619    use crate::types::{SearchResult, SearchSource};
3620
3621    fn make_result(content: &str) -> SearchResult {
3622        SearchResult {
3623            content: content.to_string(),
3624            source: SearchSource::Fact {
3625                fact_id: "test".to_string(),
3626                namespace: "test".to_string(),
3627            },
3628            score: 1.0,
3629            bm25_rank: Some(1),
3630            vector_rank: Some(1),
3631            cosine_similarity: Some(0.9),
3632        }
3633    }
3634
3635    #[test]
3636    fn compress_search_results_shortens_long_content() {
3637        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.";
3638        let results = vec![make_result(long)];
3639        let compressed = compress_search_results(results);
3640        assert!(
3641            compressed[0].content.len() <= 152, // 150 + ellipsis char
3642            "compressed content should be at most ~150 chars, got {}",
3643            compressed[0].content.len()
3644        );
3645        assert!(
3646            compressed[0].content.ends_with('…') || compressed[0].content.ends_with('.'),
3647            "compressed content should end with ellipsis or sentence punctuation"
3648        );
3649    }
3650
3651    #[test]
3652    fn compress_search_results_preserves_short_content() {
3653        let short = "Short sentence.";
3654        let results = vec![make_result(short)];
3655        let compressed = compress_search_results(results);
3656        assert_eq!(compressed[0].content, "Short sentence.");
3657    }
3658
3659    #[test]
3660    fn compress_search_results_preserves_first_sentence() {
3661        let content = "First sentence. Second sentence that is longer.";
3662        let results = vec![make_result(content)];
3663        let compressed = compress_search_results(results);
3664        assert_eq!(compressed[0].content, "First sentence.");
3665    }
3666
3667    #[test]
3668    fn compress_search_results_empty_content() {
3669        let results = vec![make_result("")];
3670        let compressed = compress_search_results(results);
3671        assert_eq!(compressed[0].content, "");
3672    }
3673}