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