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