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