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