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