Skip to main content

semantic_memory/
lib.rs

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