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