Skip to main content

semantic_memory/
lib.rs

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