1#![allow(deprecated)]
2
3#[cfg(not(any(feature = "hnsw", feature = "brute-force", feature = "usearch-backend")))]
58compile_error!(
59 "At least one search backend feature must be enabled: 'hnsw', 'usearch-backend', or 'brute-force'"
60);
61
62pub mod chunker;
63pub mod config;
64pub(crate) mod conversation;
65pub(crate) mod db;
66pub use db::{bytes_to_embedding, decode_f32_le, embedding_to_bytes};
67pub(crate) mod documents;
68pub mod embedder;
69pub(crate) mod episodes;
70pub mod error;
71#[cfg(feature = "discord")]
73pub mod discord;
74#[cfg(feature = "decoder")]
76pub mod decoder;
77mod graph;
78pub(crate) mod graph_edges;
80#[cfg(feature = "hnsw")]
81pub mod hnsw;
82#[cfg(feature = "hnsw")]
83mod hnsw_backend;
84#[cfg(feature = "hnsw")]
85mod hnsw_ops;
86mod json_compat_import;
87pub(crate) mod knowledge;
88mod pool;
89#[cfg(feature = "provenance")]
91pub mod provenance;
92#[cfg(feature = "temporal")]
94pub mod temporal;
95mod projection_batch;
96mod projection_derivation;
97#[deprecated(
101 since = "0.6.0",
102 note = "Legacy V10 import path is migration-only. Use `import_projection_batch()` with `ProjectionImportBatchV3` on the canonical lane."
103)]
104#[doc(hidden)]
105pub mod projection_import;
106mod projection_lane;
107mod projection_legacy_compat;
108pub(crate) mod projection_storage;
109#[cfg(feature = "multiscale")]
111pub mod pipeline;
112pub mod quantize;
113pub mod quantize_governed;
114#[cfg(feature = "subtraction")]
116pub mod subtraction;
117#[cfg(feature = "compression-governor")]
119pub mod compression_governor;
120#[cfg(feature = "routing")]
122pub mod routing;
123#[cfg(feature = "benchmark")]
125pub mod benchmark;
126#[cfg(feature = "integration")]
128pub mod integration;
129#[cfg(feature = "integration")]
133pub mod factor_graph;
134#[cfg(feature = "late-interaction")]
136pub mod late_interaction;
137#[cfg(feature = "topology")]
139pub mod topology;
140#[cfg(feature = "matryoshka")]
142pub mod matryoshka;
143#[cfg(feature = "community")]
145pub mod community;
146#[cfg(feature = "rl-routing")]
148pub mod rl_routing;
149#[cfg(feature = "subgraph-pruning")]
151pub mod subgraph_pruning;
152pub mod search;
153pub mod storage;
154mod store_support;
155pub mod tokenizer;
156pub mod types;
157#[cfg(feature = "usearch-backend")]
158mod usearch_backend;
159pub mod vector_backend;
160pub mod vector_codec;
161pub mod vector_snapshot;
162
163pub use config::{
165 ChunkingConfig, DerivedVectorBackendPolicy, EmbeddingConfig, MemoryConfig, MemoryLimits,
166 PoolConfig, SearchConfig,
167};
168pub use db::{IntegrityReport, ReconcileAction, VerifyMode};
169pub use embedder::{Embedder, MockEmbedder, OllamaEmbedder};
170#[cfg(feature = "candle-embedder")]
171pub use embedder::CandleEmbedder;
172pub use error::MemoryError;
173#[cfg(feature = "hnsw")]
174pub use hnsw::{HnswConfig, HnswHit, HnswIndex};
175pub(crate) use projection_lane::projection_import_failure_id;
178pub use projection_lane::{
179 ProjectionImportFailureReceiptEntry, ProjectionImportLogEntry, ProjectionImportResult,
180};
181pub use quantize::{pack_quantized, unpack_quantized, QuantizedVector, Quantizer};
182pub use storage::StoragePaths;
183pub use tokenizer::{EstimateTokenCounter, TokenCounter};
184pub use types::{
185 ChunkManifestChunkMapping, ChunkManifestEntry, ChunkManifestIngestOptions,
186 ChunkManifestIngestResult, DerivedCandidateReceiptV1, Document, EmbeddingDisplacement,
187 EpisodeAsOfReceiptV1, EpisodeMeta, EpisodeOutcome, ExactnessProfile, ExplainedResult,
188 ExplainedResultAnswerV1, ExplainedSearchResponse, Fact, GraphDirection, GraphEdge,
189 GraphEdgeType, GraphView, MemoryStats, Message, NamespaceDeleteReport, ProjectionClaimVersion,
190 ProjectionEntityAlias, ProjectionEpisode, ProjectionEvidenceRef, ProjectionQuery,
191 ProjectionRelationVersion, ProveKvPoolArtifactBuildReceiptV1, ProveKvPoolArtifactStatusV1,
192 ProveKvPoolGenerationStatus, ProveKvPoolGenerationV1, ProveKvPoolItemMapEntryV1, ReceiptMode,
193 Role, ScoreBreakdown, SearchContext, SearchReceiptAnswersV1, SearchReplayReportV1,
194 SearchResponse, SearchResult, SearchSource, SearchSourceType, Session, TextChunk,
195 VectorArtifactBuildReceiptV1, VectorSearchReceiptV1, VerificationStatus,
196};
197pub use graph_edges::{AddGraphEdgeParams, StoredGraphEdge};
198pub use vector_backend::{VectorBackend, VectorHit, VectorIndex, VectorIndexConfig};
199#[cfg(feature = "turbo-quant-codec")]
200pub use vector_codec::TurboQuantCodec;
201pub use vector_codec::{
202 RawF32Codec, Sq8Codec, VectorArtifactV1, VectorCodec, VectorCodecProfileV1,
203};
204pub use vector_snapshot::{build_embedding_snapshot, EmbeddingSnapshotRow, EmbeddingSnapshotV1};
205
206use std::sync::Arc;
207
208const MAX_TOP_K: usize = 1_000;
209#[cfg(feature = "hnsw")]
210const MAX_HNSW_CANDIDATES: usize = 10_000;
211
212pub(crate) use store_support::{
213 as_str_slice, build_episode_search_text, merge_trace_ctx, to_owned_string_vec,
214 verification_status_for_outcome,
215};
216
217#[cfg(feature = "hnsw")]
218fn verify_hnsw_key_level_integrity(
219 conn: &rusqlite::Connection,
220 dimensions: usize,
221 node_vectors: &std::collections::HashMap<usize, Vec<f32>>,
222 sidecar_files_exist: bool,
223) -> Result<Vec<String>, MemoryError> {
224 let mut issues = Vec::new();
225 let mut live_rows: std::collections::HashMap<String, Vec<f32>> =
226 std::collections::HashMap::new();
227
228 let mut live_stmt = conn.prepare(
229 "SELECT 'fact:' || id, embedding FROM facts WHERE embedding IS NOT NULL
230 UNION ALL
231 SELECT 'chunk:' || id, embedding FROM chunks WHERE embedding IS NOT NULL
232 UNION ALL
233 SELECT 'msg:' || id, embedding FROM messages WHERE embedding IS NOT NULL
234 UNION ALL
235 SELECT 'episode:' || episode_id, embedding FROM episodes WHERE embedding IS NOT NULL",
236 )?;
237 let live_iter = live_stmt.query_map([], |row| {
238 Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?))
239 })?;
240 for row in live_iter {
241 let (key, blob) = row?;
242 match db::decode_f32_le(&blob, dimensions) {
243 Ok(vector) => {
244 live_rows.insert(key, vector);
245 }
246 Err(err) => issues.push(format!(
247 "HNSW live embedding row {key} has invalid vector: {err}"
248 )),
249 }
250 }
251
252 if !live_rows.is_empty() && !sidecar_files_exist {
253 issues.push(format!(
254 "HNSW sidecar files are missing while {} embedded rows exist in SQLite",
255 live_rows.len()
256 ));
257 }
258
259 let keymap_exists: bool = conn
260 .query_row(
261 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='hnsw_keymap'",
262 [],
263 |row| row.get(0),
264 )
265 .unwrap_or(false);
266 if !keymap_exists {
267 if !live_rows.is_empty() {
268 issues.push("HNSW keymap table missing while embedded SQLite rows exist".to_string());
269 }
270 return Ok(issues);
271 }
272
273 let mut active_keymap: std::collections::HashMap<String, usize> =
274 std::collections::HashMap::new();
275 let mut keymap_stmt =
276 conn.prepare("SELECT node_id, item_key FROM hnsw_keymap WHERE deleted = 0")?;
277 let keymap_iter = keymap_stmt.query_map([], |row| {
278 Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
279 })?;
280 for row in keymap_iter {
281 let (node_id_raw, key) = row?;
282 let Some((domain, raw_id)) = key.split_once(':') else {
283 issues.push(format!("HNSW keymap entry has malformed key: {key}"));
284 continue;
285 };
286 if !matches!(domain, "fact" | "chunk" | "msg" | "episode") || raw_id.is_empty() {
287 issues.push(format!(
288 "HNSW keymap entry has unsupported key domain: {key}"
289 ));
290 continue;
291 }
292 if domain == "msg" && raw_id.parse::<i64>().is_err() {
293 issues.push(format!("HNSW message key has non-integer row id: {key}"));
294 continue;
295 }
296 let node_id = match usize::try_from(node_id_raw) {
297 Ok(node_id) => node_id,
298 Err(err) => {
299 issues.push(format!(
300 "HNSW keymap node_id {node_id_raw} is invalid: {err}"
301 ));
302 continue;
303 }
304 };
305 active_keymap.insert(key, node_id);
306 }
307
308 for key in live_rows.keys() {
309 if !active_keymap.contains_key(key) {
310 issues.push(format!(
311 "HNSW keymap missing live embedded SQLite row: {key}"
312 ));
313 }
314 }
315
316 for (key, node_id) in &active_keymap {
317 let Some(live_vector) = live_rows.get(key) else {
318 issues.push(format!(
319 "HNSW keymap has stale active entry without live embedded SQLite row: {key}"
320 ));
321 continue;
322 };
323 let Some(index_vector) = node_vectors.get(node_id) else {
324 issues.push(format!(
325 "HNSW keymap entry {key} points to missing in-memory node vector {node_id}"
326 ));
327 continue;
328 };
329 if index_vector.len() != live_vector.len()
330 || index_vector
331 .iter()
332 .zip(live_vector)
333 .any(|(left, right)| left.to_bits() != right.to_bits())
334 {
335 issues.push(format!(
336 "HNSW keymap entry {key} points to node {node_id} whose vector does not match the authoritative SQLite embedding"
337 ));
338 }
339 }
340
341 if active_keymap.len() != live_rows.len() {
342 issues.push(format!(
343 "HNSW keymap drift: {} active keymap rows vs {} embedded SQLite rows",
344 active_keymap.len(),
345 live_rows.len()
346 ));
347 }
348
349 Ok(issues)
350}
351
352#[doc(hidden)]
354pub mod compat {
355 #[deprecated(
356 since = "0.5.0",
357 note = "Legacy ImportEnvelope is migration-only. New integrations should use `ProjectionImportBatchV3` on the canonical lane."
358 )]
359 #[doc(hidden)]
360 #[allow(deprecated)]
361 pub mod legacy_import_envelope {
362 pub use crate::projection_import::{
363 ImportEnvelope, ImportProjectionFreshness, ImportReceipt, ImportRecord, ImportStatus,
364 };
365 pub use stack_ids::EnvelopeId;
366 }
367
368 #[deprecated(
369 since = "0.5.0",
370 note = "Legacy trace_id is migration-only. Use `stack_ids::TraceCtx`."
371 )]
372 #[doc(hidden)]
373 #[allow(deprecated)]
374 pub mod compat_trace_id {
375 pub use crate::types::TraceId;
376 }
377}
378
379#[derive(Clone)]
383pub struct MemoryStore {
384 inner: Arc<MemoryStoreInner>,
385}
386
387struct MemoryStoreInner {
388 pool: pool::SqlitePool,
389 embedder: Box<dyn Embedder>,
390 embedding_permits: Arc<tokio::sync::Semaphore>,
391 config: MemoryConfig,
392 paths: StoragePaths,
393 token_counter: Arc<dyn TokenCounter>,
394 #[cfg(feature = "hnsw")]
395 hnsw_index: std::sync::RwLock<HnswIndex>,
396}
397
398#[cfg(feature = "hnsw")]
399impl Drop for MemoryStoreInner {
400 fn drop(&mut self) {
401 if !self.paths.hnsw_dir.exists() {
402 tracing::debug!(
403 path = %self.paths.hnsw_dir.display(),
404 "Skipping HNSW drop flush because the sidecar directory no longer exists"
405 );
406 return;
407 }
408
409 let pending_ops = match self.pool.with_read_conn(db::pending_index_op_count) {
410 Ok(count) => count,
411 Err(err) => {
412 tracing::warn!("Failed to inspect pending HNSW work on drop: {}", err);
413 0
414 }
415 };
416
417 if pending_ops > 0 {
418 if let Err(err) =
419 hnsw_ops::recover_hnsw_sidecar_sync(&self.pool, &self.paths, &self.config.hnsw)
420 {
421 tracing::error!("Failed to recover and flush HNSW on drop: {}", err);
422 }
423 return;
424 }
425
426 let hnsw_guard = match self.hnsw_index.read() {
427 Ok(g) => g,
428 Err(_) => {
429 tracing::warn!("HNSW RwLock poisoned on drop — skipping save");
430 return;
431 }
432 };
433
434 if let Err(err) = hnsw_ops::save_hnsw_sidecar(
435 &hnsw_guard,
436 &self.paths.hnsw_dir,
437 &self.paths.hnsw_basename,
438 ) {
439 tracing::error!("Failed to save HNSW index on drop: {}", err);
440 }
441
442 if let Err(e) = self
444 .pool
445 .with_write_conn(|conn| hnsw_guard.flush_keymap(conn))
446 {
447 tracing::error!("Failed to flush HNSW keymap on drop: {}", e);
448 }
449 }
450}
451
452impl MemoryStore {
453 async fn with_read_conn<F, T>(&self, f: F) -> Result<T, MemoryError>
458 where
459 F: FnOnce(&rusqlite::Connection) -> Result<T, MemoryError> + Send + 'static,
460 T: Send + 'static,
461 {
462 let inner = self.inner.clone();
463 tokio::task::spawn_blocking(move || -> Result<T, MemoryError> {
464 inner.pool.with_read_conn(f)
465 })
466 .await
467 .map_err(|e| MemoryError::Other(format!("Blocking task panicked: {}", e)))?
468 }
469
470 async fn with_write_conn<F, T>(&self, f: F) -> Result<T, MemoryError>
472 where
473 F: FnOnce(&rusqlite::Connection) -> Result<T, MemoryError> + Send + 'static,
474 T: Send + 'static,
475 {
476 let inner = self.inner.clone();
477 tokio::task::spawn_blocking(move || -> Result<T, MemoryError> {
478 inner.pool.with_write_conn(f)
479 })
480 .await
481 .map_err(|e| MemoryError::Other(format!("Blocking task panicked: {}", e)))?
482 }
483
484 async fn persist_search_receipt(
485 &self,
486 receipt: &VectorSearchReceiptV1,
487 ) -> Result<(), MemoryError> {
488 let receipt = receipt.clone();
489 self.with_write_conn(move |conn| db::store_search_receipt(conn, &receipt))
490 .await
491 }
492
493 #[cfg(feature = "hnsw")]
496 async fn hnsw_search_blocking(
497 &self,
498 query_embedding: Vec<f32>,
499 candidates: usize,
500 ) -> Vec<HnswHit> {
501 let inner = self.inner.clone();
502 tokio::task::spawn_blocking(move || {
503 let guard = inner.hnsw_index.read().unwrap_or_else(|e| e.into_inner());
504 match guard.search(&query_embedding, candidates) {
505 Ok(hits) => hits,
506 Err(e) => {
507 tracing::error!(
508 "HNSW search failed, falling back to brute-force vector search: {}",
509 e
510 );
511 Vec::new()
512 }
513 }
514 })
515 .await
516 .unwrap_or_else(|e| {
517 tracing::error!("HNSW search blocking task panicked: {}", e);
518 Vec::new()
519 })
520 }
521
522 #[cfg(feature = "hnsw")]
523 fn sync_pending_hnsw_ops_blocking(&self) -> Result<usize, MemoryError> {
524 hnsw_ops::sync_pending_hnsw_sidecar(&self.inner)
525 }
526
527 #[cfg(feature = "hnsw")]
528 async fn sync_pending_hnsw_ops(&self) -> Result<usize, MemoryError> {
529 let inner = self.inner.clone();
530 tokio::task::spawn_blocking(move || hnsw_ops::sync_pending_hnsw_sidecar(&inner))
531 .await
532 .map_err(|e| MemoryError::Other(format!("Blocking task panicked: {}", e)))?
533 }
534
535 #[cfg(feature = "hnsw")]
536 async fn sync_pending_hnsw_ops_best_effort(&self, operation: &'static str) {
537 if let Err(err) = self.sync_pending_hnsw_ops().await {
538 tracing::warn!(
539 operation,
540 error = %err,
541 "SQLite write committed but HNSW sidecar sync is still pending"
542 );
543 } else {
544 self.maybe_flush_hnsw();
545 }
546 }
547
548 pub fn open(config: MemoryConfig) -> Result<Self, MemoryError> {
557 let config = config.normalize_and_validate()?;
558 #[cfg(feature = "candle-embedder")]
559 let embedder: Box<dyn Embedder> = Box::new(CandleEmbedder::try_new(&config.embedding)?);
560 #[cfg(not(feature = "candle-embedder"))]
561 let embedder: Box<dyn Embedder> = Box::new(OllamaEmbedder::try_new(&config.embedding)?);
562 Self::open_with_embedder(config, embedder)
563 }
564
565 #[allow(unused_mut)] pub fn open_with_embedder(
568 mut config: MemoryConfig,
569 embedder: Box<dyn Embedder>,
570 ) -> Result<Self, MemoryError> {
571 config = config.normalize_and_validate()?;
572 if embedder.dimensions() != config.embedding.dimensions {
573 return Err(MemoryError::DimensionMismatch {
574 expected: config.embedding.dimensions,
575 actual: embedder.dimensions(),
576 });
577 }
578 config.embedding.model = embedder.model_name().to_string();
579
580 let paths = StoragePaths::new(&config.base_dir);
581
582 std::fs::create_dir_all(&paths.base_dir).map_err(|e| {
584 MemoryError::StorageError(format!(
585 "Failed to create directory {}: {}",
586 paths.base_dir.display(),
587 e
588 ))
589 })?;
590
591 let pool = pool::SqlitePool::open(&paths.sqlite_path, &config.pool, &config.limits)?;
592 pool.with_write_conn(|conn| db::check_embedding_metadata(conn, &config.embedding))?;
593
594 #[cfg(feature = "hnsw")]
596 {
597 config.hnsw.dimensions = config.embedding.dimensions;
598 }
599
600 let token_counter = config
601 .token_counter
602 .clone()
603 .unwrap_or_else(tokenizer::default_token_counter);
604
605 #[cfg(feature = "hnsw")]
606 let hnsw_index = {
607 let hnsw_config = config.hnsw.clone();
608
609 let embeddings_dirty = pool.with_read_conn(db::is_embeddings_dirty)?;
610 let pending_index_ops = pool.with_read_conn(db::pending_index_op_count)?;
611
612 if embeddings_dirty {
613 tracing::warn!(
616 "Embedding model changed — creating fresh HNSW index (old index is stale)"
617 );
618 pool.with_write_conn(|conn| {
619 db::clear_all_pending_index_ops(conn)?;
620 db::set_sidecar_dirty(conn, false)?;
621 Ok(())
622 })?;
623 HnswIndex::new(hnsw_config)?
624 } else if pending_index_ops > 0 || pool.with_read_conn(db::is_sidecar_dirty)? {
625 tracing::warn!(
626 pending_index_ops,
627 "Recovering HNSW sidecar from SQLite because durable sidecar work exists"
628 );
629 hnsw_ops::recover_hnsw_sidecar_sync(&pool, &paths, &hnsw_config)?
630 } else if paths.hnsw_files_exist() {
631 tracing::info!("Loading HNSW index from {:?}", paths.hnsw_dir);
632 match HnswIndex::load(&paths.hnsw_dir, &paths.hnsw_basename, hnsw_config.clone()) {
633 Ok(index) => {
634 if let Err(e) = pool.with_write_conn(|conn| index.load_keymap(conn)) {
636 tracing::warn!("Failed to load HNSW key mappings: {}. Mappings will be empty until rebuild.", e);
637 }
638
639 let hnsw_count = index.len();
643 let sqlite_count: i64 = pool.with_read_conn(|conn| {
644 Ok(conn.query_row(
645 "SELECT (SELECT COUNT(*) FROM facts WHERE embedding IS NOT NULL) +
646 (SELECT COUNT(*) FROM chunks WHERE embedding IS NOT NULL) +
647 (SELECT COUNT(*) FROM messages WHERE embedding IS NOT NULL) +
648 (SELECT COUNT(*) FROM episodes WHERE embedding IS NOT NULL)",
649 [],
650 |row| row.get(0),
651 )?)
652 })?;
653
654 let drift = (sqlite_count - hnsw_count as i64).abs();
655 if drift > 0 {
656 tracing::warn!(
657 hnsw_count,
658 sqlite_count,
659 drift,
660 "HNSW index is stale — {} entries differ from SQLite. \
661 Likely caused by unclean shutdown. Triggering inline rebuild.",
662 drift
663 );
664 let rebuilt =
666 hnsw_ops::recover_hnsw_sidecar_sync(&pool, &paths, &hnsw_config)?;
667 tracing::info!(
668 active = rebuilt.len(),
669 "HNSW index rebuilt after stale detection"
670 );
671 rebuilt
672 } else {
673 tracing::info!(
674 "HNSW index loaded ({} active keys, in sync with SQLite)",
675 hnsw_count
676 );
677 index
678 }
679 }
680 Err(e) => {
681 tracing::warn!(
682 "Failed to load HNSW index: {}. Rebuilding sidecar from authoritative SQLite rows.",
683 e
684 );
685 hnsw_ops::recover_hnsw_sidecar_sync(&pool, &paths, &hnsw_config)?
686 }
687 }
688 } else {
689 let orphan_count: i64 = pool.with_read_conn(|conn| {
694 Ok(conn.query_row(
695 "SELECT (SELECT COUNT(*) FROM facts WHERE embedding IS NOT NULL) +
696 (SELECT COUNT(*) FROM chunks WHERE embedding IS NOT NULL) +
697 (SELECT COUNT(*) FROM messages WHERE embedding IS NOT NULL) +
698 (SELECT COUNT(*) FROM episodes WHERE embedding IS NOT NULL)",
699 [],
700 |row| row.get(0),
701 )?)
702 })?;
703
704 if orphan_count > 0 {
705 tracing::warn!(
706 orphan_count,
707 "HNSW sidecar files missing but {} embeddings exist in SQLite — \
708 rebuilding index inline",
709 orphan_count
710 );
711 let new_index =
712 hnsw_ops::recover_hnsw_sidecar_sync(&pool, &paths, &hnsw_config)?;
713 tracing::info!(
714 active = new_index.len(),
715 "HNSW index rebuilt from SQLite embeddings"
716 );
717 new_index
718 } else {
719 tracing::info!("Creating new empty HNSW index (no embeddings in SQLite)");
720 HnswIndex::new(hnsw_config)?
721 }
722 }
723 };
724
725 let store = Self {
726 inner: Arc::new(MemoryStoreInner {
727 pool,
728 embedder,
729 embedding_permits: Arc::new(tokio::sync::Semaphore::new(
730 config.limits.max_embedding_concurrency,
731 )),
732 config,
733 paths,
734 token_counter,
735 #[cfg(feature = "hnsw")]
736 hnsw_index: std::sync::RwLock::new(hnsw_index),
737 }),
738 };
739
740 #[cfg(feature = "hnsw")]
741 if let Err(err) = store.sync_pending_hnsw_ops_blocking() {
742 tracing::warn!(
743 error = %err,
744 "Failed to reconcile pending HNSW sidecar ops during open; sidecar replay remains pending"
745 );
746 }
747
748 Ok(store)
749 }
750
751 async fn with_embedding_permit(
752 &self,
753 ) -> Result<tokio::sync::OwnedSemaphorePermit, MemoryError> {
754 self.inner
755 .embedding_permits
756 .clone()
757 .acquire_owned()
758 .await
759 .map_err(|e| MemoryError::Other(format!("embedding semaphore closed: {e}")))
760 }
761
762 async fn embed_text_internal(&self, text: &str) -> Result<Vec<f32>, MemoryError> {
763 let _permit = self.with_embedding_permit().await?;
764 let embedding = self.inner.embedder.embed(text).await?;
765 db::validate_embedding(&embedding, self.inner.config.embedding.dimensions)?;
766 Ok(embedding)
767 }
768
769 async fn embed_batch_internal(&self, texts: Vec<String>) -> Result<Vec<Vec<f32>>, MemoryError> {
770 let requested = texts.len();
771 let _permit = self.with_embedding_permit().await?;
772 let embeddings = self.inner.embedder.embed_batch(texts).await?;
773 db::validate_embedding_batch(
774 &embeddings,
775 requested,
776 self.inner.config.embedding.dimensions,
777 )?;
778 Ok(embeddings)
779 }
780
781 fn validate_embedding_dimensions(&self, embedding: &[f32]) -> Result<(), MemoryError> {
782 db::validate_embedding(embedding, self.inner.config.embedding.dimensions)
783 }
784
785 fn validate_content(&self, field: &'static str, content: &str) -> Result<(), MemoryError> {
786 if content.is_empty() {
787 return Err(MemoryError::InvalidConfig {
788 field,
789 reason: "content must not be empty".to_string(),
790 });
791 }
792
793 let limit = self.inner.config.limits.max_content_bytes;
794 if content.len() > limit {
795 return Err(MemoryError::ContentTooLarge {
796 size: content.len(),
797 limit,
798 });
799 }
800
801 Ok(())
802 }
803
804 fn validate_confidence(confidence: f32) -> Result<(), MemoryError> {
805 if !confidence.is_finite() || !(0.0..=1.0).contains(&confidence) {
806 return Err(MemoryError::InvalidConfig {
807 field: "episodes.confidence",
808 reason: "confidence must be finite and within [0.0, 1.0]".to_string(),
809 });
810 }
811 Ok(())
812 }
813
814 #[cfg(feature = "turbo-quant-codec")]
818 pub async fn rebuild_vector_artifacts(
819 &self,
820 ) -> Result<VectorArtifactBuildReceiptV1, MemoryError> {
821 let dim = self.inner.config.embedding.dimensions;
822 let search = self.inner.config.search.clone();
823 self.with_write_conn(move |conn| {
824 db::rebuild_turbo_quant_artifacts(
825 conn,
826 dim,
827 search.turbo_quant_bits,
828 search.turbo_quant_projections,
829 search.turbo_quant_seed,
830 )
831 })
832 .await
833 }
834
835 #[cfg(feature = "hnsw")]
839 pub async fn rebuild_hnsw_index(
840 &self,
841 ) -> Result<crate::types::VectorArtifactBuildReceiptV1, MemoryError> {
842 tracing::info!("Rebuilding HNSW index from SQLite embeddings...");
843 let hnsw_config = self.inner.config.hnsw.clone();
844 let (new_index, build_receipt) = self
845 .with_read_conn(move |conn| hnsw_ops::rebuild_hnsw_from_sqlite(conn, &hnsw_config))
846 .await?;
847
848 {
849 let mut guard = self
850 .inner
851 .hnsw_index
852 .write()
853 .unwrap_or_else(|e| e.into_inner());
854 *guard = new_index.clone();
855 }
856
857 hnsw_ops::save_hnsw_sidecar(
858 &new_index,
859 &self.inner.paths.hnsw_dir,
860 &self.inner.paths.hnsw_basename,
861 )?;
862 self.inner.pool.with_write_conn(|conn| {
863 new_index.flush_keymap(conn)?;
864 db::clear_all_pending_index_ops(conn)?;
865 db::set_sidecar_dirty(conn, false)?;
866 Ok(())
867 })?;
868
869 tracing::info!(active = new_index.len(), receipt_generation_id = ?build_receipt.generation_id, "HNSW index rebuilt");
870
871 Ok(build_receipt)
872 }
873
874 #[cfg(feature = "hnsw")]
879 fn maybe_flush_hnsw(&self) {
880 if let Some(interval) = self.inner.config.hnsw.flush_interval_secs {
881 let guard = self
882 .inner
883 .hnsw_index
884 .read()
885 .unwrap_or_else(|e| e.into_inner());
886 if guard.should_flush(interval) {
887 drop(guard); if let Err(e) = self.flush_hnsw() {
889 tracing::warn!("Opportunistic HNSW flush failed: {}", e);
890 } else {
891 let guard = self
892 .inner
893 .hnsw_index
894 .read()
895 .unwrap_or_else(|e| e.into_inner());
896 guard.update_last_flush_epoch();
897 tracing::info!("Opportunistic HNSW flush completed");
898 }
899 }
900 }
901 }
902
903 #[cfg(feature = "hnsw")]
907 pub fn flush_hnsw(&self) -> Result<(), MemoryError> {
908 let pending_ops = self.inner.pool.with_read_conn(db::pending_index_op_count)?;
909 if pending_ops > 0 {
910 tracing::info!(
911 pending_ops,
912 "Flushing HNSW via authoritative SQLite rebuild because pending durable sidecar work exists"
913 );
914 let rebuilt = hnsw_ops::recover_hnsw_sidecar_sync(
915 &self.inner.pool,
916 &self.inner.paths,
917 &self.inner.config.hnsw,
918 )?;
919 let mut guard = self
920 .inner
921 .hnsw_index
922 .write()
923 .unwrap_or_else(|e| e.into_inner());
924 *guard = rebuilt;
925 return Ok(());
926 }
927
928 let index = self
929 .inner
930 .hnsw_index
931 .write()
932 .unwrap_or_else(|e| e.into_inner());
933 hnsw_ops::save_hnsw_sidecar(
934 &index,
935 &self.inner.paths.hnsw_dir,
936 &self.inner.paths.hnsw_basename,
937 )?;
938
939 self.inner.pool.with_write_conn(|conn| {
941 index.flush_keymap(conn)?;
942 db::clear_all_pending_index_ops(conn)?;
943 db::set_sidecar_dirty(conn, false)?;
944 Ok(())
945 })?;
946 Ok(())
947 }
948
949 #[cfg(feature = "hnsw")]
953 pub async fn compact_hnsw(&self) -> Result<(), MemoryError> {
954 if !self
955 .inner
956 .hnsw_index
957 .read()
958 .unwrap_or_else(|e| e.into_inner())
959 .needs_compaction()
960 {
961 tracing::info!("HNSW compaction not needed (deleted ratio below threshold)");
962 return Ok(());
963 }
964 let _receipt = self.rebuild_hnsw_index().await?;
965 Ok(())
966 }
967
968 pub async fn verify_integrity(
975 &self,
976 mode: db::VerifyMode,
977 ) -> Result<db::IntegrityReport, MemoryError> {
978 let use_writer = mode == db::VerifyMode::Full;
979 let mut report = if use_writer {
980 self.with_write_conn(move |conn| db::verify_integrity_sync(conn, mode))
981 .await?
982 } else {
983 self.with_read_conn(move |conn| db::verify_integrity_sync(conn, mode))
984 .await?
985 };
986
987 #[cfg(feature = "hnsw")]
988 {
989 let hnsw_vectors = self
990 .inner
991 .hnsw_index
992 .read()
993 .unwrap_or_else(|e| e.into_inner())
994 .vector_snapshot();
995 let hnsw_dims = self.inner.config.embedding.dimensions;
996 let hnsw_files_exist = self.inner.paths.hnsw_files_exist();
997
998 let hnsw_issues = if use_writer {
999 let hnsw_vectors = hnsw_vectors.clone();
1000 self.with_write_conn(move |conn| {
1001 verify_hnsw_key_level_integrity(
1002 conn,
1003 hnsw_dims,
1004 &hnsw_vectors,
1005 hnsw_files_exist,
1006 )
1007 })
1008 .await?
1009 } else {
1010 let hnsw_vectors = hnsw_vectors.clone();
1011 self.with_read_conn(move |conn| {
1012 verify_hnsw_key_level_integrity(
1013 conn,
1014 hnsw_dims,
1015 &hnsw_vectors,
1016 hnsw_files_exist,
1017 )
1018 })
1019 .await?
1020 };
1021 report.issues.extend(hnsw_issues);
1022 }
1023
1024 report.ok = report.issues.is_empty();
1025 Ok(report)
1026 }
1027
1028 pub async fn reconcile(
1034 &self,
1035 action: db::ReconcileAction,
1036 ) -> Result<db::IntegrityReport, MemoryError> {
1037 match action {
1038 db::ReconcileAction::ReportOnly => self.verify_integrity(db::VerifyMode::Full).await,
1039 db::ReconcileAction::RebuildFts => {
1040 self.with_write_conn(db::reconcile_fts).await?;
1041 #[cfg(feature = "hnsw")]
1042 self.sync_pending_hnsw_ops_best_effort("reconcile_rebuild_fts")
1043 .await;
1044 self.verify_integrity(db::VerifyMode::Full).await
1045 }
1046 db::ReconcileAction::ReEmbed => {
1047 self.reembed_all().await?;
1048 self.verify_integrity(db::VerifyMode::Full).await
1049 }
1050 }
1051 }
1052
1053 pub fn config(&self) -> &MemoryConfig {
1055 &self.inner.config
1056 }
1057
1058 pub fn graph_view(&self) -> Arc<dyn GraphView> {
1061 graph::graph_view(self.inner.clone())
1062 }
1063
1064 pub async fn add_graph_edge(
1077 &self,
1078 source: &str,
1079 target: &str,
1080 edge_type: GraphEdgeType,
1081 weight: f64,
1082 metadata: Option<serde_json::Value>,
1083 ) -> Result<graph_edges::StoredGraphEdge, MemoryError> {
1084 let params = graph_edges::AddGraphEdgeParams {
1085 source: source.to_string(),
1086 target: target.to_string(),
1087 edge_type,
1088 weight,
1089 metadata,
1090 };
1091 self.with_write_conn(move |conn| graph_edges::insert_graph_edge(conn, ¶ms))
1092 .await
1093 }
1094
1095 pub async fn list_graph_edges_for_node(
1098 &self,
1099 node_id: &str,
1100 ) -> Result<Vec<graph_edges::StoredGraphEdge>, MemoryError> {
1101 let node_id = node_id.to_string();
1102 self.with_read_conn(move |conn| graph_edges::list_graph_edges_for_node(conn, &node_id))
1103 .await
1104 }
1105
1106 pub async fn list_all_graph_edges(
1108 &self,
1109 ) -> Result<Vec<graph_edges::StoredGraphEdge>, MemoryError> {
1110 self.with_read_conn(graph_edges::list_all_graph_edges)
1111 .await
1112 }
1113
1114 pub async fn invalidate_graph_edge(
1116 &self,
1117 edge_id: &str,
1118 reason: &str,
1119 ) -> Result<(), MemoryError> {
1120 let edge_id = edge_id.to_string();
1121 let reason = reason.to_string();
1122 self.with_write_conn(move |conn| {
1123 graph_edges::invalidate_graph_edge(conn, &edge_id, &reason)
1124 })
1125 .await
1126 }
1127
1128 pub async fn count_graph_edges(&self) -> Result<usize, MemoryError> {
1130 self.with_read_conn(graph_edges::count_graph_edges)
1131 .await
1132 }
1133
1134 pub async fn search(
1138 &self,
1139 query: &str,
1140 top_k: Option<usize>,
1141 namespaces: Option<&[&str]>,
1142 source_types: Option<&[SearchSourceType]>,
1143 ) -> Result<Vec<SearchResult>, MemoryError> {
1144 Ok(self
1145 .search_with_context(
1146 query,
1147 top_k,
1148 namespaces,
1149 source_types,
1150 SearchContext::default_now(),
1151 )
1152 .await?
1153 .results)
1154 }
1155
1156 pub async fn search_with_context(
1158 &self,
1159 query: &str,
1160 top_k: Option<usize>,
1161 namespaces: Option<&[&str]>,
1162 source_types: Option<&[SearchSourceType]>,
1163 context: SearchContext,
1164 ) -> Result<SearchResponse, MemoryError> {
1165 let k = top_k
1166 .unwrap_or(self.inner.config.search.default_top_k)
1167 .min(MAX_TOP_K);
1168
1169 let query_embedding = self.embed_text_internal(query).await?;
1170
1171 #[cfg(feature = "hnsw")]
1172 let hnsw_hits = if context.exactness_profile == ExactnessProfile::PreferExact
1173 || self.inner.config.search.uses_turbo_quant_backend()
1174 {
1175 Vec::new()
1176 } else {
1177 let candidates = self
1178 .inner
1179 .config
1180 .search
1181 .candidate_pool_size
1182 .max(k.saturating_mul(3))
1183 .min(MAX_HNSW_CANDIDATES);
1184 self.hnsw_search_blocking(query_embedding.clone(), candidates)
1185 .await
1186 };
1187
1188 let q = query.to_string();
1189 let config = self.inner.config.search.clone();
1190 let ns_owned = to_owned_string_vec(namespaces);
1191 let st_owned: Option<Vec<SearchSourceType>> = source_types.map(|s| s.to_vec());
1192 let context_owned = context.clone();
1193
1194 #[cfg(feature = "hnsw")]
1195 let hnsw_hits_owned = hnsw_hits;
1196
1197 let response = self
1198 .with_read_conn(move |conn| {
1199 if db::is_embeddings_dirty(conn)? {
1200 tracing::warn!(
1201 "Embeddings are stale after model change — search quality is degraded. \
1202 Call reembed_all() to regenerate embeddings."
1203 );
1204 }
1205 let ns_refs = as_str_slice(&ns_owned);
1206 let ns_slice: Option<&[&str]> = ns_refs.as_deref();
1207 let st_slice: Option<&[SearchSourceType]> = st_owned.as_deref();
1208
1209 #[cfg(feature = "hnsw")]
1210 {
1211 let mut execution = if hnsw_hits_owned.is_empty() {
1212 search::hybrid_search_detailed_with_context(
1213 conn,
1214 &q,
1215 &query_embedding,
1216 &config,
1217 &context_owned,
1218 k,
1219 ns_slice,
1220 st_slice,
1221 None,
1222 )
1223 } else {
1224 search::hybrid_search_with_hnsw_detailed_with_context(
1225 conn,
1226 &q,
1227 &query_embedding,
1228 &config,
1229 &context_owned,
1230 k,
1231 ns_slice,
1232 st_slice,
1233 None,
1234 &hnsw_hits_owned,
1235 )
1236 }?;
1237 if context_owned.receipts_enabled()
1238 && context_owned.exactness_profile == ExactnessProfile::PreferExact
1239 {
1240 if let Some(receipt) = execution.receipt.as_mut() {
1241 receipt.search_profile = "hybrid_prefer_exact".to_string();
1242 }
1243 }
1244 Ok(SearchResponse {
1245 results: execution
1246 .results
1247 .into_iter()
1248 .map(|result| result.result)
1249 .collect(),
1250 receipt: execution.receipt,
1251 })
1252 }
1253 #[cfg(not(feature = "hnsw"))]
1254 {
1255 let execution = search::hybrid_search_detailed_with_context(
1256 conn,
1257 &q,
1258 &query_embedding,
1259 &config,
1260 &context_owned,
1261 k,
1262 ns_slice,
1263 st_slice,
1264 None,
1265 )?;
1266 Ok(SearchResponse {
1267 results: execution
1268 .results
1269 .into_iter()
1270 .map(|result| result.result)
1271 .collect(),
1272 receipt: execution.receipt,
1273 })
1274 }
1275 })
1276 .await?;
1277 if let Some(receipt) = &response.receipt {
1278 self.persist_search_receipt(receipt).await?;
1279 }
1280 Ok(response)
1281 }
1282
1283 pub async fn search_fts_only(
1285 &self,
1286 query: &str,
1287 top_k: Option<usize>,
1288 namespaces: Option<&[&str]>,
1289 source_types: Option<&[SearchSourceType]>,
1290 ) -> Result<Vec<SearchResult>, MemoryError> {
1291 let k = top_k
1292 .unwrap_or(self.inner.config.search.default_top_k)
1293 .min(MAX_TOP_K);
1294 let q = query.to_string();
1295 let config = self.inner.config.search.clone();
1296 let ns_owned = to_owned_string_vec(namespaces);
1297 let st_owned: Option<Vec<SearchSourceType>> = source_types.map(|s| s.to_vec());
1298 self.with_read_conn(move |conn| {
1299 let ns_refs = as_str_slice(&ns_owned);
1300 let ns_slice: Option<&[&str]> = ns_refs.as_deref();
1301 let st_slice: Option<&[SearchSourceType]> = st_owned.as_deref();
1302 search::fts_only_search(conn, &q, &config, k, ns_slice, st_slice, None)
1303 })
1304 .await
1305 }
1306
1307 pub async fn search_vector_only(
1309 &self,
1310 query: &str,
1311 top_k: Option<usize>,
1312 namespaces: Option<&[&str]>,
1313 source_types: Option<&[SearchSourceType]>,
1314 ) -> Result<Vec<SearchResult>, MemoryError> {
1315 Ok(self
1316 .search_vector_only_with_context(
1317 query,
1318 top_k,
1319 namespaces,
1320 source_types,
1321 SearchContext::default_now(),
1322 )
1323 .await?
1324 .results)
1325 }
1326
1327 pub async fn search_vector_only_with_context(
1329 &self,
1330 query: &str,
1331 top_k: Option<usize>,
1332 namespaces: Option<&[&str]>,
1333 source_types: Option<&[SearchSourceType]>,
1334 context: SearchContext,
1335 ) -> Result<SearchResponse, MemoryError> {
1336 let k = top_k
1337 .unwrap_or(self.inner.config.search.default_top_k)
1338 .min(MAX_TOP_K);
1339 let query_embedding = self.embed_text_internal(query).await?;
1340
1341 #[cfg(feature = "hnsw")]
1342 let hnsw_hits = if context.exactness_profile == ExactnessProfile::PreferExact
1343 || self.inner.config.search.uses_turbo_quant_backend()
1344 {
1345 Vec::new()
1346 } else {
1347 let candidates = self
1348 .inner
1349 .config
1350 .search
1351 .candidate_pool_size
1352 .max(k.saturating_mul(3))
1353 .min(MAX_HNSW_CANDIDATES);
1354 self.hnsw_search_blocking(query_embedding.clone(), candidates)
1355 .await
1356 };
1357
1358 let config = self.inner.config.search.clone();
1359 let ns_owned = to_owned_string_vec(namespaces);
1360 let st_owned: Option<Vec<SearchSourceType>> = source_types.map(|s| s.to_vec());
1361 let context_owned = context.clone();
1362
1363 #[cfg(feature = "hnsw")]
1364 let hnsw_hits_owned = hnsw_hits;
1365
1366 let response = self
1367 .with_read_conn(move |conn| {
1368 if db::is_embeddings_dirty(conn)? {
1369 tracing::warn!(
1370 "Embeddings are stale after model change — search quality is degraded. \
1371 Call reembed_all() to regenerate embeddings."
1372 );
1373 }
1374 let ns_refs = as_str_slice(&ns_owned);
1375 let ns_slice: Option<&[&str]> = ns_refs.as_deref();
1376 let st_slice: Option<&[SearchSourceType]> = st_owned.as_deref();
1377
1378 #[cfg(feature = "hnsw")]
1379 {
1380 let mut execution = if hnsw_hits_owned.is_empty() {
1381 search::vector_only_search_detailed_with_context(
1382 conn,
1383 &query_embedding,
1384 &config,
1385 &context_owned,
1386 k,
1387 ns_slice,
1388 st_slice,
1389 None,
1390 )
1391 } else {
1392 search::vector_only_search_with_hnsw_detailed_with_context(
1393 conn,
1394 &query_embedding,
1395 &config,
1396 &context_owned,
1397 k,
1398 ns_slice,
1399 st_slice,
1400 None,
1401 &hnsw_hits_owned,
1402 )
1403 }?;
1404 if context_owned.receipts_enabled()
1405 && context_owned.exactness_profile == ExactnessProfile::PreferExact
1406 {
1407 if let Some(receipt) = execution.receipt.as_mut() {
1408 receipt.search_profile = "vector_only_prefer_exact".to_string();
1409 }
1410 }
1411 Ok(SearchResponse {
1412 results: execution
1413 .results
1414 .into_iter()
1415 .map(|result| result.result)
1416 .collect(),
1417 receipt: execution.receipt,
1418 })
1419 }
1420 #[cfg(not(feature = "hnsw"))]
1421 {
1422 let execution = search::vector_only_search_detailed_with_context(
1423 conn,
1424 &query_embedding,
1425 &config,
1426 &context_owned,
1427 k,
1428 ns_slice,
1429 st_slice,
1430 None,
1431 )?;
1432 Ok(SearchResponse {
1433 results: execution
1434 .results
1435 .into_iter()
1436 .map(|result| result.result)
1437 .collect(),
1438 receipt: execution.receipt,
1439 })
1440 }
1441 })
1442 .await?;
1443 if let Some(receipt) = &response.receipt {
1444 self.persist_search_receipt(receipt).await?;
1445 }
1446 Ok(response)
1447 }
1448
1449 pub async fn search_explained(
1453 &self,
1454 query: &str,
1455 top_k: Option<usize>,
1456 namespaces: Option<&[&str]>,
1457 source_types: Option<&[SearchSourceType]>,
1458 ) -> Result<Vec<types::ExplainedResult>, MemoryError> {
1459 Ok(self
1460 .search_explained_with_context(
1461 query,
1462 top_k,
1463 namespaces,
1464 source_types,
1465 SearchContext::default_now(),
1466 )
1467 .await?
1468 .results)
1469 }
1470
1471 pub async fn search_explained_with_context(
1473 &self,
1474 query: &str,
1475 top_k: Option<usize>,
1476 namespaces: Option<&[&str]>,
1477 source_types: Option<&[SearchSourceType]>,
1478 context: SearchContext,
1479 ) -> Result<types::ExplainedSearchResponse, MemoryError> {
1480 let k = top_k
1481 .unwrap_or(self.inner.config.search.default_top_k)
1482 .min(MAX_TOP_K);
1483 let query_embedding = self.embed_text_internal(query).await?;
1484
1485 #[cfg(feature = "hnsw")]
1486 let hnsw_hits = if context.exactness_profile == ExactnessProfile::PreferExact {
1487 Vec::new()
1488 } else {
1489 let candidates = self
1490 .inner
1491 .config
1492 .search
1493 .candidate_pool_size
1494 .max(k.saturating_mul(3))
1495 .min(MAX_HNSW_CANDIDATES);
1496 self.hnsw_search_blocking(query_embedding.clone(), candidates)
1497 .await
1498 };
1499
1500 let q = query.to_string();
1501 let config = self.inner.config.search.clone();
1502 let ns_owned = to_owned_string_vec(namespaces);
1503 let st_owned: Option<Vec<SearchSourceType>> = source_types.map(|value| value.to_vec());
1504 let context_owned = context.clone();
1505
1506 #[cfg(feature = "hnsw")]
1507 let hnsw_hits_owned = hnsw_hits;
1508
1509 let response = self
1510 .with_read_conn(move |conn| {
1511 let ns_refs = as_str_slice(&ns_owned);
1512 let ns_slice: Option<&[&str]> = ns_refs.as_deref();
1513 let st_slice: Option<&[SearchSourceType]> = st_owned.as_deref();
1514
1515 #[cfg(feature = "hnsw")]
1516 {
1517 let mut execution = if hnsw_hits_owned.is_empty() {
1518 search::hybrid_search_detailed_with_context(
1519 conn,
1520 &q,
1521 &query_embedding,
1522 &config,
1523 &context_owned,
1524 k,
1525 ns_slice,
1526 st_slice,
1527 None,
1528 )
1529 } else {
1530 search::hybrid_search_with_hnsw_detailed_with_context(
1531 conn,
1532 &q,
1533 &query_embedding,
1534 &config,
1535 &context_owned,
1536 k,
1537 ns_slice,
1538 st_slice,
1539 None,
1540 &hnsw_hits_owned,
1541 )
1542 }?;
1543 if context_owned.receipts_enabled()
1544 && context_owned.exactness_profile == ExactnessProfile::PreferExact
1545 {
1546 if let Some(receipt) = execution.receipt.as_mut() {
1547 receipt.search_profile = "hybrid_prefer_exact".to_string();
1548 }
1549 }
1550 Ok(types::ExplainedSearchResponse {
1551 results: execution.results,
1552 receipt: execution.receipt,
1553 })
1554 }
1555 #[cfg(not(feature = "hnsw"))]
1556 {
1557 let execution = search::hybrid_search_detailed_with_context(
1558 conn,
1559 &q,
1560 &query_embedding,
1561 &config,
1562 &context_owned,
1563 k,
1564 ns_slice,
1565 st_slice,
1566 None,
1567 )?;
1568 Ok(types::ExplainedSearchResponse {
1569 results: execution.results,
1570 receipt: execution.receipt,
1571 })
1572 }
1573 })
1574 .await?;
1575 if let Some(receipt) = &response.receipt {
1576 self.persist_search_receipt(receipt).await?;
1577 }
1578 Ok(response)
1579 }
1580
1581 pub async fn get_search_receipt(
1583 &self,
1584 receipt_id: &str,
1585 ) -> Result<Option<VectorSearchReceiptV1>, MemoryError> {
1586 let receipt_id = receipt_id.to_string();
1587 self.with_read_conn(move |conn| db::get_search_receipt(conn, &receipt_id))
1588 .await
1589 }
1590
1591 pub async fn replay_search_receipt(
1597 &self,
1598 receipt_id: &str,
1599 query: &str,
1600 top_k: Option<usize>,
1601 namespaces: Option<&[&str]>,
1602 source_types: Option<&[SearchSourceType]>,
1603 ) -> Result<SearchReplayReportV1, MemoryError> {
1604 let original_receipt = self.get_search_receipt(receipt_id).await?.ok_or_else(|| {
1605 MemoryError::SearchReceiptNotFound {
1606 receipt_id: receipt_id.to_string(),
1607 }
1608 })?;
1609
1610 let vector_only = original_receipt.search_profile.starts_with("vector_only");
1611 let replay_top_k = top_k.or_else(|| Some(original_receipt.result_ids.len().max(1)));
1612 let replay_receipt_id = format!("{receipt_id}:replay:{}", uuid::Uuid::new_v4());
1613 let mut context = SearchContext::at(original_receipt.evaluation_time);
1614 context.receipt_mode = ReceiptMode::ReturnReceipt;
1615 context.request_id = Some(replay_receipt_id.clone());
1616 context.trace_id = original_receipt.trace_id.clone();
1617 context.attempt_family_id = original_receipt
1618 .attempt_family_id
1619 .clone()
1620 .or_else(|| Some(original_receipt.receipt_id.clone()));
1621 context.attempt_id = Some(replay_receipt_id.clone());
1622 context.replay_of = Some(original_receipt.receipt_id.clone());
1623 context.query_text_digest = original_receipt.query_text_digest.clone();
1624 context.query_input_digest = original_receipt.query_input_digest.clone();
1625 context.filter_digest = original_receipt.filter_digest.clone();
1626 context.redaction_state = original_receipt.redaction_state.clone();
1627 context.budget_id = original_receipt.budget_id.clone();
1628 context.exactness_profile = if original_receipt.approximate {
1629 ExactnessProfile::AllowApproximate
1630 } else {
1631 ExactnessProfile::PreferExact
1632 };
1633
1634 let replay_response = if vector_only {
1635 self.search_vector_only_with_context(
1636 query,
1637 replay_top_k,
1638 namespaces,
1639 source_types,
1640 context,
1641 )
1642 .await?
1643 } else {
1644 self.search_with_context(query, replay_top_k, namespaces, source_types, context)
1645 .await?
1646 };
1647 let replay_receipt = replay_response
1648 .receipt
1649 .ok_or_else(|| MemoryError::Other("replay did not produce a receipt".to_string()))?;
1650
1651 let query_embedding_digest_matches =
1652 original_receipt.query_embedding_digest == replay_receipt.query_embedding_digest;
1653 let result_ids_match = original_receipt.result_ids == replay_receipt.result_ids;
1654 let missing_result_ids = original_receipt
1655 .result_ids
1656 .iter()
1657 .filter(|id| !replay_receipt.result_ids.contains(*id))
1658 .cloned()
1659 .collect();
1660 let added_result_ids = replay_receipt
1661 .result_ids
1662 .iter()
1663 .filter(|id| !original_receipt.result_ids.contains(*id))
1664 .cloned()
1665 .collect();
1666
1667 Ok(SearchReplayReportV1 {
1668 receipt_id: original_receipt.receipt_id.clone(),
1669 replay_receipt_id,
1670 original_receipt,
1671 replay_receipt,
1672 query_embedding_digest_matches,
1673 result_ids_match,
1674 missing_result_ids,
1675 added_result_ids,
1676 vector_only,
1677 })
1678 }
1679
1680 pub async fn embedding_displacement(
1684 &self,
1685 text_a: &str,
1686 text_b: &str,
1687 ) -> Result<types::EmbeddingDisplacement, MemoryError> {
1688 let emb_a = self.embed_text_internal(text_a).await?;
1689 let emb_b = self.embed_text_internal(text_b).await?;
1690 Self::embedding_displacement_from_vecs(&emb_a, &emb_b)
1691 }
1692
1693 pub fn embedding_displacement_from_vecs(
1695 a: &[f32],
1696 b: &[f32],
1697 ) -> Result<types::EmbeddingDisplacement, MemoryError> {
1698 if a.len() != b.len() {
1699 return Err(MemoryError::DimensionMismatch {
1700 expected: a.len(),
1701 actual: b.len(),
1702 });
1703 }
1704 let cosine_sim = search::cosine_similarity(a, b)?;
1705
1706 let euclidean_dist: f32 = a
1707 .iter()
1708 .zip(b.iter())
1709 .map(|(x, y)| (x - y) * (x - y))
1710 .sum::<f32>()
1711 .sqrt();
1712
1713 let mag_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
1714 let mag_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
1715
1716 Ok(types::EmbeddingDisplacement {
1717 cosine_similarity: cosine_sim,
1718 euclidean_distance: euclidean_dist,
1719 magnitude_a: mag_a,
1720 magnitude_b: mag_b,
1721 })
1722 }
1723
1724 pub fn chunk_text(&self, text: &str) -> Vec<TextChunk> {
1728 chunker::chunk_text(
1729 text,
1730 &self.inner.config.chunking,
1731 self.inner.token_counter.as_ref(),
1732 )
1733 }
1734
1735 pub async fn embed(&self, text: &str) -> Result<Vec<f32>, MemoryError> {
1737 self.embed_text_internal(text).await
1738 }
1739
1740 pub async fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, MemoryError> {
1742 let owned: Vec<String> = texts.iter().map(|s| s.to_string()).collect();
1743 self.embed_batch_internal(owned).await
1744 }
1745
1746 pub async fn stats(&self) -> Result<MemoryStats, MemoryError> {
1748 let db_path = self.inner.paths.sqlite_path.clone();
1749 self.with_read_conn(move |conn| {
1750 let total_facts: u64 =
1751 conn.query_row("SELECT COUNT(*) FROM facts", [], |r| r.get(0))?;
1752 let total_documents: u64 =
1753 conn.query_row("SELECT COUNT(*) FROM documents", [], |r| r.get(0))?;
1754 let total_chunks: u64 =
1755 conn.query_row("SELECT COUNT(*) FROM chunks", [], |r| r.get(0))?;
1756 let total_sessions: u64 =
1757 conn.query_row("SELECT COUNT(*) FROM sessions", [], |r| r.get(0))?;
1758 let total_messages: u64 =
1759 conn.query_row("SELECT COUNT(*) FROM messages", [], |r| r.get(0))?;
1760
1761 let db_size = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0);
1762
1763 let (model, dims): (Option<String>, Option<usize>) = conn
1764 .query_row(
1765 "SELECT model_name, dimensions FROM embedding_metadata WHERE id = 1",
1766 [],
1767 |r| Ok((Some(r.get(0)?), Some(r.get(1)?))),
1768 )
1769 .unwrap_or((None, None));
1770
1771 Ok(MemoryStats {
1772 total_facts,
1773 total_documents,
1774 total_chunks,
1775 total_sessions,
1776 total_messages,
1777 database_size_bytes: db_size,
1778 embedding_model: model,
1779 embedding_dimensions: dims,
1780 })
1781 })
1782 .await
1783 }
1784
1785 pub async fn list_scope_domains(&self) -> Result<Vec<String>, MemoryError> {
1791 self.with_read_conn(|conn| {
1792 let mut stmt = conn.prepare(
1793 "SELECT DISTINCT json_extract(metadata, '$.scope_domain') \
1794 FROM documents \
1795 WHERE json_extract(metadata, '$.scope_domain') IS NOT NULL",
1796 )?;
1797 let domains: Vec<String> = stmt
1798 .query_map([], |row| row.get::<_, String>(0))?
1799 .filter_map(|r| r.ok())
1800 .collect();
1801 Ok(domains)
1802 })
1803 .await
1804 }
1805
1806 pub async fn embeddings_are_dirty(&self) -> Result<bool, MemoryError> {
1808 self.with_read_conn(db::is_embeddings_dirty).await
1809 }
1810
1811 pub async fn reembed_all(&self) -> Result<usize, MemoryError> {
1813 let mut count = 0usize;
1814 let batch_size = self.inner.config.embedding.batch_size;
1815 let dims = self.inner.config.embedding.dimensions;
1816
1817 let fact_contents: Vec<(String, String)> = self
1819 .with_read_conn(|conn| {
1820 let mut stmt = conn.prepare("SELECT id, content FROM facts")?;
1821 let result = stmt
1822 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
1823 .collect::<Result<Vec<_>, _>>()?;
1824 Ok(result)
1825 })
1826 .await?;
1827
1828 let mut fact_count = 0usize;
1829 for batch in fact_contents.chunks(batch_size) {
1830 let texts: Vec<String> = batch.iter().map(|(_, c)| c.clone()).collect();
1831 let embeddings = self.embed_batch_internal(texts).await?;
1832 for embedding in &embeddings {
1833 self.validate_embedding_dimensions(embedding)?;
1834 }
1835
1836 let quantizer = Quantizer::new(dims);
1837 let updates: Vec<(String, Vec<u8>, Option<Vec<u8>>)> = batch
1838 .iter()
1839 .zip(embeddings.iter())
1840 .map(|((id, _), emb)| {
1841 let q8 = quantizer
1843 .quantize(emb)
1844 .map(|qv| quantize::pack_quantized(&qv))
1845 .ok();
1846 (id.clone(), db::embedding_to_bytes(emb), q8)
1847 })
1848 .collect();
1849
1850 self.with_write_conn(move |conn| {
1851 db::with_transaction(conn, |tx| {
1852 for (fid, bytes, q8) in &updates {
1853 tx.execute(
1854 "UPDATE facts SET embedding = ?1, embedding_q8 = ?2, updated_at = datetime('now') WHERE id = ?3",
1855 rusqlite::params![bytes, q8.as_deref(), fid],
1856 )?;
1857 #[cfg(feature = "hnsw")]
1858 db::queue_pending_index_op(
1859 tx,
1860 &format!("fact:{fid}"),
1861 "fact",
1862 db::IndexOpKind::Upsert,
1863 )?;
1864 db::invalidate_derived_vector_artifact(tx, &format!("fact:{fid}"))?;
1865 }
1866 Ok(())
1867 })
1868 })
1869 .await?;
1870
1871 fact_count += batch.len();
1872 count += batch.len();
1873 if fact_count % 100 == 0 || fact_count == count {
1874 tracing::info!(fact_count, "Re-embedded {} facts so far", fact_count);
1875 }
1876 }
1877
1878 let chunk_data: Vec<(String, String)> = self
1880 .with_read_conn(|conn| {
1881 let mut stmt = conn.prepare("SELECT id, content FROM chunks")?;
1882 let result = stmt
1883 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
1884 .collect::<Result<Vec<_>, _>>()?;
1885 Ok(result)
1886 })
1887 .await?;
1888
1889 let mut chunk_count = 0usize;
1890 for batch in chunk_data.chunks(batch_size) {
1891 let texts: Vec<String> = batch.iter().map(|(_, c)| c.clone()).collect();
1892 let embeddings = self.embed_batch_internal(texts).await?;
1893 for embedding in &embeddings {
1894 self.validate_embedding_dimensions(embedding)?;
1895 }
1896
1897 let quantizer = Quantizer::new(dims);
1898 let updates: Vec<(String, Vec<u8>, Option<Vec<u8>>)> = batch
1899 .iter()
1900 .zip(embeddings.iter())
1901 .map(|((id, _), emb)| {
1902 let q8 = quantizer
1904 .quantize(emb)
1905 .map(|qv| quantize::pack_quantized(&qv))
1906 .ok();
1907 (id.clone(), db::embedding_to_bytes(emb), q8)
1908 })
1909 .collect();
1910
1911 self.with_write_conn(move |conn| {
1912 db::with_transaction(conn, |tx| {
1913 for (cid, bytes, q8) in &updates {
1914 tx.execute(
1915 "UPDATE chunks SET embedding = ?1, embedding_q8 = ?2 WHERE id = ?3",
1916 rusqlite::params![bytes, q8.as_deref(), cid],
1917 )?;
1918 #[cfg(feature = "hnsw")]
1919 db::queue_pending_index_op(
1920 tx,
1921 &format!("chunk:{cid}"),
1922 "chunk",
1923 db::IndexOpKind::Upsert,
1924 )?;
1925 db::invalidate_derived_vector_artifact(tx, &format!("chunk:{cid}"))?;
1926 }
1927 Ok(())
1928 })
1929 })
1930 .await?;
1931
1932 chunk_count += batch.len();
1933 count += batch.len();
1934 if chunk_count % 100 == 0 {
1935 tracing::info!(chunk_count, "Re-embedded {} chunks so far", chunk_count);
1936 }
1937 }
1938
1939 let message_data: Vec<(i64, String)> = self
1941 .with_read_conn(|conn| {
1942 let mut stmt = conn.prepare("SELECT id, content FROM messages")?;
1943 let result = stmt
1944 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
1945 .collect::<Result<Vec<_>, _>>()?;
1946 Ok(result)
1947 })
1948 .await?;
1949
1950 let mut msg_count = 0usize;
1951 for batch in message_data.chunks(batch_size) {
1952 let texts: Vec<String> = batch.iter().map(|(_, c)| c.clone()).collect();
1953 let embeddings = self.embed_batch_internal(texts).await?;
1954 for embedding in &embeddings {
1955 self.validate_embedding_dimensions(embedding)?;
1956 }
1957
1958 let quantizer = Quantizer::new(dims);
1959 let updates: Vec<(i64, Vec<u8>, Option<Vec<u8>>)> = batch
1960 .iter()
1961 .zip(embeddings.iter())
1962 .map(|((id, _), emb)| {
1963 let q8 = quantizer
1965 .quantize(emb)
1966 .map(|qv| quantize::pack_quantized(&qv))
1967 .ok();
1968 (*id, db::embedding_to_bytes(emb), q8)
1969 })
1970 .collect();
1971
1972 self.with_write_conn(move |conn| {
1973 db::with_transaction(conn, |tx| {
1974 for (mid, bytes, q8) in &updates {
1975 tx.execute(
1976 "UPDATE messages SET embedding = ?1, embedding_q8 = ?2 WHERE id = ?3",
1977 rusqlite::params![bytes, q8.as_deref(), mid],
1978 )?;
1979 #[cfg(feature = "hnsw")]
1980 db::queue_pending_index_op(
1981 tx,
1982 &format!("msg:{mid}"),
1983 "message",
1984 db::IndexOpKind::Upsert,
1985 )?;
1986 db::invalidate_derived_vector_artifact(tx, &format!("msg:{mid}"))?;
1987 }
1988 Ok(())
1989 })
1990 })
1991 .await?;
1992
1993 msg_count += batch.len();
1994 count += batch.len();
1995 if msg_count % 100 == 0 {
1996 tracing::info!(msg_count, "Re-embedded {} messages so far", msg_count);
1997 }
1998 }
1999
2000 let episode_data: Vec<(String, String)> = self
2002 .with_read_conn(|conn| {
2003 let mut stmt = conn.prepare("SELECT episode_id, search_text FROM episodes")?;
2004 let result = stmt
2005 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
2006 .collect::<Result<Vec<_>, _>>()?;
2007 Ok(result)
2008 })
2009 .await?;
2010
2011 let mut episode_count = 0usize;
2012 for batch in episode_data.chunks(batch_size) {
2013 let texts: Vec<String> = batch.iter().map(|(_, text)| text.clone()).collect();
2014 let embeddings = self.embed_batch_internal(texts).await?;
2015 for embedding in &embeddings {
2016 self.validate_embedding_dimensions(embedding)?;
2017 }
2018
2019 let quantizer = Quantizer::new(dims);
2020 let updates: Vec<(String, Vec<u8>, Option<Vec<u8>>)> = batch
2021 .iter()
2022 .zip(embeddings.iter())
2023 .map(|((episode_id, _), embedding)| {
2024 let q8 = quantizer
2026 .quantize(embedding)
2027 .map(|vector| quantize::pack_quantized(&vector))
2028 .ok();
2029 (episode_id.clone(), db::embedding_to_bytes(embedding), q8)
2030 })
2031 .collect();
2032
2033 self.with_write_conn(move |conn| {
2034 db::with_transaction(conn, |tx| {
2035 for (episode_id, bytes, q8) in &updates {
2036 tx.execute(
2037 "UPDATE episodes
2038 SET embedding = ?1,
2039 embedding_q8 = ?2,
2040 updated_at = datetime('now')
2041 WHERE episode_id = ?3",
2042 rusqlite::params![bytes, q8.as_deref(), episode_id],
2043 )?;
2044 #[cfg(feature = "hnsw")]
2045 db::queue_pending_index_op(
2046 tx,
2047 &episodes::episode_item_key(episode_id),
2048 "episode",
2049 db::IndexOpKind::Upsert,
2050 )?;
2051 db::invalidate_derived_vector_artifact(
2052 tx,
2053 &episodes::episode_item_key(episode_id),
2054 )?;
2055 }
2056 Ok(())
2057 })
2058 })
2059 .await?;
2060
2061 episode_count += batch.len();
2062 count += batch.len();
2063 if episode_count % 100 == 0 {
2064 tracing::info!(
2065 episode_count,
2066 "Re-embedded {} episodes so far",
2067 episode_count
2068 );
2069 }
2070 }
2071
2072 self.with_write_conn(db::clear_embeddings_dirty).await?;
2074
2075 tracing::info!(
2076 facts = fact_count,
2077 chunks = chunk_count,
2078 messages = msg_count,
2079 episodes = episode_count,
2080 total = count,
2081 "Re-embedding complete"
2082 );
2083
2084 #[cfg(feature = "hnsw")]
2086 {
2087 tracing::info!("Rebuilding HNSW index after re-embedding...");
2088 let _receipt = self.rebuild_hnsw_index().await?;
2089 }
2090
2091 Ok(count)
2092 }
2093
2094 pub async fn vacuum(&self) -> Result<(), MemoryError> {
2096 self.with_write_conn(|conn| {
2097 conn.execute_batch("VACUUM")?;
2098 Ok(())
2099 })
2100 .await
2101 }
2102
2103 #[deprecated(
2126 since = "0.5.0",
2127 note = "Legacy V10 import envelope path is compatibility-only. Use `import_projection_batch()` and `ProjectionImportBatchV3` on the canonical lane."
2128 )]
2129 #[doc(hidden)]
2130 #[allow(deprecated)]
2131 pub async fn import_envelope(
2132 &self,
2133 envelope: &projection_import::ImportEnvelope,
2134 ) -> Result<projection_import::ImportReceipt, MemoryError> {
2135 projection_legacy_compat::import_envelope(self, envelope).await
2136 }
2137
2138 #[deprecated(
2140 since = "0.5.0",
2141 note = "Legacy V10 import envelope status reads are compatibility-only. Prefer the projection import log."
2142 )]
2143 #[doc(hidden)]
2144 #[allow(deprecated)]
2145 pub async fn import_status(
2146 &self,
2147 envelope_id: &projection_import::EnvelopeId,
2148 ) -> Result<Vec<projection_import::ImportReceipt>, MemoryError> {
2149 projection_legacy_compat::import_status(self, envelope_id).await
2150 }
2151
2152 #[deprecated(
2154 since = "0.5.0",
2155 note = "Legacy V10 import log access is compatibility-only. Prefer new projection-import metadata."
2156 )]
2157 #[doc(hidden)]
2158 #[allow(deprecated)]
2159 pub async fn list_imports(
2160 &self,
2161 namespace: Option<&str>,
2162 limit: usize,
2163 ) -> Result<Vec<projection_import::ImportReceipt>, MemoryError> {
2164 projection_legacy_compat::list_imports(self, namespace, limit).await
2165 }
2166
2167 #[allow(deprecated)]
2169 pub async fn last_import_at(&self, namespace: &str) -> Result<Option<String>, MemoryError> {
2170 projection_legacy_compat::last_import_at(self, namespace).await
2171 }
2172
2173 pub async fn query_claim_versions(
2175 &self,
2176 query: ProjectionQuery,
2177 ) -> Result<Vec<ProjectionClaimVersion>, MemoryError> {
2178 self.with_read_conn(move |conn| projection_storage::query_claim_versions(conn, &query))
2179 .await
2180 }
2181
2182 pub async fn query_relation_versions(
2184 &self,
2185 query: ProjectionQuery,
2186 ) -> Result<Vec<ProjectionRelationVersion>, MemoryError> {
2187 self.with_read_conn(move |conn| projection_storage::query_relation_versions(conn, &query))
2188 .await
2189 }
2190
2191 pub async fn query_episodes(
2193 &self,
2194 query: ProjectionQuery,
2195 ) -> Result<Vec<ProjectionEpisode>, MemoryError> {
2196 self.with_read_conn(move |conn| projection_storage::query_episode_rows(conn, &query))
2197 .await
2198 }
2199
2200 pub async fn query_entity_aliases(
2202 &self,
2203 query: ProjectionQuery,
2204 ) -> Result<Vec<ProjectionEntityAlias>, MemoryError> {
2205 self.with_read_conn(move |conn| projection_storage::query_entity_aliases(conn, &query))
2206 .await
2207 }
2208
2209 pub async fn query_evidence_refs(
2211 &self,
2212 query: ProjectionQuery,
2213 ) -> Result<Vec<ProjectionEvidenceRef>, MemoryError> {
2214 self.with_read_conn(move |conn| projection_storage::query_evidence_refs(conn, &query))
2215 .await
2216 }
2217
2218 #[cfg(any(test, feature = "testing"))]
2220 pub async fn raw_execute(&self, sql: &str, params: Vec<String>) -> Result<usize, MemoryError> {
2221 let sql = sql.to_string();
2222 self.with_write_conn(move |conn| {
2223 let param_refs: Vec<&dyn rusqlite::types::ToSql> = params
2224 .iter()
2225 .map(|s| s as &dyn rusqlite::types::ToSql)
2226 .collect();
2227 Ok(conn.execute(&sql, &*param_refs)?)
2228 })
2229 .await
2230 }
2231}