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