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