1use crate::config::{EmbeddingConfig, MemoryLimits, PoolConfig};
4use crate::error::MemoryError;
5use crate::quantize::unpack_quantized;
6#[cfg(feature = "turbo-quant-codec")]
7use crate::types::{DerivedVectorArtifactGenerationV1, VectorArtifactBuildReceiptV1};
8use crate::types::{
9 EpisodeOutcome, ProveKvPoolArtifactStatusV1, ProveKvPoolGenerationStatus,
10 ProveKvPoolGenerationV1, ProveKvPoolItemMapEntryV1, Role, VectorSearchReceiptV1,
11 VerificationStatus,
12};
13use chrono::{DateTime, Utc};
14use rusqlite::{params, Connection, OpenFlags, OptionalExtension};
15use serde::{Deserialize, Serialize};
16use stack_ids::ContentDigest;
17#[cfg(feature = "turbo-quant-codec")]
18use stack_ids::DigestBuilder;
19use std::path::Path;
20
21const MIGRATION_V1: &str = r#"
23-- CONVERSATIONS
24CREATE TABLE sessions (
25 id TEXT PRIMARY KEY,
26 channel TEXT NOT NULL DEFAULT 'repl',
27 created_at TEXT NOT NULL DEFAULT (datetime('now')),
28 updated_at TEXT NOT NULL DEFAULT (datetime('now')),
29 metadata TEXT
30);
31
32CREATE INDEX idx_sessions_updated ON sessions(updated_at DESC);
33
34CREATE TABLE messages (
35 id INTEGER PRIMARY KEY AUTOINCREMENT,
36 session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
37 role TEXT NOT NULL CHECK (role IN ('system', 'user', 'assistant', 'tool')),
38 content TEXT NOT NULL,
39 token_count INTEGER,
40 created_at TEXT NOT NULL DEFAULT (datetime('now')),
41 metadata TEXT
42);
43
44CREATE INDEX idx_messages_session ON messages(session_id, created_at ASC);
45CREATE INDEX idx_messages_created ON messages(created_at DESC);
46
47-- KNOWLEDGE (Facts)
48CREATE TABLE facts (
49 id TEXT PRIMARY KEY,
50 namespace TEXT NOT NULL DEFAULT 'general',
51 content TEXT NOT NULL,
52 source TEXT,
53 embedding BLOB,
54 created_at TEXT NOT NULL DEFAULT (datetime('now')),
55 updated_at TEXT NOT NULL DEFAULT (datetime('now')),
56 metadata TEXT
57);
58
59CREATE INDEX idx_facts_namespace ON facts(namespace);
60CREATE INDEX idx_facts_updated ON facts(updated_at DESC);
61
62CREATE TABLE facts_rowid_map (
63 rowid INTEGER PRIMARY KEY AUTOINCREMENT,
64 fact_id TEXT NOT NULL UNIQUE REFERENCES facts(id) ON DELETE CASCADE
65);
66
67CREATE VIRTUAL TABLE facts_fts USING fts5(
68 content,
69 content='',
70 content_rowid='rowid',
71 tokenize='porter unicode61'
72);
73
74-- DOCUMENTS (Chunked content)
75CREATE TABLE documents (
76 id TEXT PRIMARY KEY,
77 title TEXT NOT NULL,
78 source_path TEXT,
79 namespace TEXT NOT NULL DEFAULT 'general',
80 created_at TEXT NOT NULL DEFAULT (datetime('now')),
81 metadata TEXT
82);
83
84CREATE TABLE chunks (
85 id TEXT PRIMARY KEY,
86 document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
87 chunk_index INTEGER NOT NULL,
88 content TEXT NOT NULL,
89 token_count INTEGER,
90 embedding BLOB,
91 created_at TEXT NOT NULL DEFAULT (datetime('now'))
92);
93
94CREATE INDEX idx_chunks_document ON chunks(document_id, chunk_index ASC);
95
96CREATE TABLE chunks_rowid_map (
97 rowid INTEGER PRIMARY KEY AUTOINCREMENT,
98 chunk_id TEXT NOT NULL UNIQUE REFERENCES chunks(id) ON DELETE CASCADE
99);
100
101CREATE VIRTUAL TABLE chunks_fts USING fts5(
102 content,
103 content='',
104 content_rowid='rowid',
105 tokenize='porter unicode61'
106);
107
108-- EMBEDDING METADATA
109CREATE TABLE embedding_metadata (
110 id INTEGER PRIMARY KEY CHECK (id = 1),
111 model_name TEXT NOT NULL,
112 dimensions INTEGER NOT NULL,
113 updated_at TEXT NOT NULL DEFAULT (datetime('now'))
114);
115"#;
116
117const MIGRATION_V2: &str = r#"
119ALTER TABLE messages ADD COLUMN embedding BLOB;
120
121CREATE TABLE messages_rowid_map (
122 rowid INTEGER PRIMARY KEY AUTOINCREMENT,
123 message_id INTEGER NOT NULL UNIQUE REFERENCES messages(id) ON DELETE CASCADE
124);
125
126CREATE VIRTUAL TABLE messages_fts USING fts5(
127 content,
128 content='',
129 content_rowid='rowid',
130 tokenize='porter unicode61'
131);
132"#;
133
134const MIGRATION_V3: &str = r#"
136ALTER TABLE embedding_metadata ADD COLUMN embeddings_dirty INTEGER NOT NULL DEFAULT 0;
137"#;
138
139const MIGRATION_V4: &str = r#"
141CREATE TABLE IF NOT EXISTS hnsw_metadata (
142 key TEXT PRIMARY KEY,
143 value TEXT NOT NULL
144);
145"#;
146
147const MIGRATION_V5: &str = r#"
149ALTER TABLE facts ADD COLUMN embedding_q8 BLOB;
150ALTER TABLE chunks ADD COLUMN embedding_q8 BLOB;
151ALTER TABLE messages ADD COLUMN embedding_q8 BLOB;
152
153CREATE TABLE IF NOT EXISTS hnsw_keymap (
154 node_id INTEGER PRIMARY KEY,
155 item_key TEXT NOT NULL UNIQUE,
156 deleted INTEGER NOT NULL DEFAULT 0
157);
158
159CREATE INDEX idx_hnsw_keymap_key ON hnsw_keymap(item_key);
160"#;
161
162const MIGRATION_V6: &str = r#"
164CREATE TABLE IF NOT EXISTS episodes (
165 document_id TEXT PRIMARY KEY REFERENCES documents(id) ON DELETE CASCADE,
166 cause_ids TEXT NOT NULL,
167 effect_type TEXT NOT NULL,
168 outcome TEXT NOT NULL DEFAULT 'pending',
169 confidence REAL NOT NULL DEFAULT 0.0,
170 verification_status TEXT NOT NULL DEFAULT '{"status":"unverified"}',
171 experiment_id TEXT,
172 created_at TEXT NOT NULL DEFAULT (datetime('now'))
173);
174
175CREATE INDEX IF NOT EXISTS idx_episodes_effect_type ON episodes(effect_type);
176CREATE INDEX IF NOT EXISTS idx_episodes_outcome ON episodes(outcome);
177CREATE INDEX IF NOT EXISTS idx_episodes_experiment_id ON episodes(experiment_id);
178"#;
179
180const MIGRATION_V7: &str = r#"
182ALTER TABLE episodes ADD COLUMN updated_at TEXT NOT NULL DEFAULT (datetime('now'));
183ALTER TABLE episodes ADD COLUMN search_text TEXT NOT NULL DEFAULT '';
184ALTER TABLE episodes ADD COLUMN embedding BLOB;
185ALTER TABLE episodes ADD COLUMN embedding_q8 BLOB;
186
187CREATE TABLE IF NOT EXISTS episodes_rowid_map (
188 rowid INTEGER PRIMARY KEY AUTOINCREMENT,
189 document_id TEXT NOT NULL UNIQUE REFERENCES episodes(document_id) ON DELETE CASCADE
190);
191
192CREATE VIRTUAL TABLE episodes_fts USING fts5(
193 content,
194 content='',
195 content_rowid='rowid',
196 tokenize='porter unicode61'
197);
198
199CREATE TABLE IF NOT EXISTS pending_index_ops (
200 item_key TEXT PRIMARY KEY,
201 entity_type TEXT NOT NULL,
202 op_kind TEXT NOT NULL CHECK (op_kind IN ('upsert', 'delete')),
203 attempt_count INTEGER NOT NULL DEFAULT 0,
204 last_error TEXT,
205 updated_at TEXT NOT NULL DEFAULT (datetime('now'))
206);
207
208INSERT OR IGNORE INTO hnsw_metadata (key, value) VALUES ('sidecar_dirty', '0');
209
210UPDATE episodes
211SET search_text = TRIM(
212 COALESCE(effect_type, '') || ' ' ||
213 COALESCE(outcome, '') || ' ' ||
214 COALESCE(experiment_id, '') || ' ' ||
215 COALESCE(cause_ids, '')
216)
217WHERE search_text = '';
218
219INSERT OR IGNORE INTO episodes_rowid_map (document_id)
220SELECT document_id FROM episodes;
221
222INSERT INTO episodes_fts (rowid, content)
223SELECT rm.rowid, e.search_text
224FROM episodes_rowid_map rm
225JOIN episodes e ON e.document_id = rm.document_id;
226"#;
227
228const MIGRATION_V8: &str = r#"
230ALTER TABLE episodes ADD COLUMN trace_id TEXT;
231"#;
232
233const MIGRATION_V9: &str = "";
241
242const MIGRATION_V18: &str = r#"
244CREATE TABLE IF NOT EXISTS search_receipts (
245 receipt_id TEXT PRIMARY KEY,
246 schema_version TEXT NOT NULL,
247 evaluation_time TEXT NOT NULL,
248 search_profile TEXT NOT NULL,
249 candidate_backend TEXT NOT NULL,
250 approximate INTEGER NOT NULL CHECK (approximate IN (0, 1)),
251 exact_rerank INTEGER NOT NULL CHECK (exact_rerank IN (0, 1)),
252 fallback TEXT,
253 requested_candidates INTEGER NOT NULL CHECK (requested_candidates >= 0),
254 returned_candidates INTEGER NOT NULL CHECK (returned_candidates >= 0),
255 post_filter_candidates INTEGER NOT NULL CHECK (post_filter_candidates >= 0),
256 result_ids_json TEXT NOT NULL,
257 receipt_json TEXT NOT NULL,
258 receipt_digest TEXT NOT NULL,
259 created_at TEXT NOT NULL DEFAULT (datetime('now'))
260);
261
262CREATE INDEX IF NOT EXISTS idx_search_receipts_created
263ON search_receipts(created_at DESC);
264
265CREATE INDEX IF NOT EXISTS idx_search_receipts_backend
266ON search_receipts(candidate_backend);
267"#;
268
269const MIGRATION_V19: &str = r#"
271CREATE TABLE IF NOT EXISTS derived_vector_artifacts (
272 item_key TEXT NOT NULL,
273 codec_family TEXT NOT NULL,
274 codec_profile_digest TEXT NOT NULL,
275 source_embedding_digest TEXT NOT NULL,
276 encoded_digest TEXT NOT NULL,
277 artifact_digest TEXT NOT NULL,
278 encoding TEXT NOT NULL,
279 dim INTEGER NOT NULL,
280 encoded BLOB NOT NULL,
281 created_at TEXT NOT NULL DEFAULT (datetime('now')),
282 status TEXT NOT NULL DEFAULT 'active',
283 PRIMARY KEY (item_key, codec_family, codec_profile_digest)
284);
285
286CREATE INDEX IF NOT EXISTS idx_derived_vector_artifacts_profile
287ON derived_vector_artifacts(codec_family, codec_profile_digest, status);
288
289CREATE INDEX IF NOT EXISTS idx_derived_vector_artifacts_source_digest
290ON derived_vector_artifacts(source_embedding_digest);
291"#;
292
293const MIGRATION_V20: &str = r#"
295-- Procedural migration; see run_migration_v20.
296"#;
297
298const MIGRATION_V21: &str = r#"
300CREATE TABLE IF NOT EXISTS derived_vector_artifact_generations (
301 generation_id TEXT PRIMARY KEY,
302 schema_version TEXT NOT NULL,
303 codec_family TEXT NOT NULL,
304 codec_profile_digest TEXT NOT NULL,
305 source_snapshot_digest TEXT NOT NULL,
306 source_row_count INTEGER NOT NULL,
307 artifact_count INTEGER NOT NULL,
308 source_tables_json TEXT NOT NULL,
309 dim INTEGER NOT NULL,
310 encoding TEXT NOT NULL,
311 created_at TEXT NOT NULL,
312 build_receipt_id TEXT,
313 artifact_manifest_digest TEXT NOT NULL,
314 status TEXT NOT NULL CHECK (status IN ('active', 'superseded', 'invalidated', 'failed')),
315 degradations_json TEXT NOT NULL DEFAULT '[]'
316);
317
318CREATE INDEX IF NOT EXISTS idx_derived_vector_generations_profile
319ON derived_vector_artifact_generations(codec_family, codec_profile_digest, status, created_at DESC);
320"#;
321
322const MIGRATION_V23: &str = r#"
325ALTER TABLE derived_vector_artifacts ADD COLUMN codec_governance_receipt_id TEXT;
326ALTER TABLE derived_vector_artifacts ADD COLUMN codec_profile TEXT;
327ALTER TABLE derived_vector_artifacts ADD COLUMN degradation_budget REAL;
328ALTER TABLE derived_vector_artifacts ADD COLUMN raw_source_artifact_id TEXT;
329"#;
330
331const MIGRATION_V22: &str = r#"
334ALTER TABLE episodes ADD COLUMN valid_time TEXT;
335ALTER TABLE episodes ADD COLUMN recorded_time TEXT NOT NULL DEFAULT (datetime('now'));
336ALTER TABLE episodes ADD COLUMN superseded_by TEXT;
337ALTER TABLE episodes ADD COLUMN fact_digest TEXT;
338CREATE INDEX IF NOT EXISTS idx_episodes_recorded ON episodes(recorded_time ASC);
339CREATE INDEX IF NOT EXISTS idx_episodes_valid ON episodes(valid_time);
340CREATE INDEX IF NOT EXISTS idx_episodes_superseded ON episodes(superseded_by) WHERE superseded_by IS NOT NULL;
341UPDATE episodes SET recorded_time = updated_at WHERE recorded_time IS NULL OR recorded_time = '';
342"#;
343
344const MIGRATION_V24: &str = r#"
346CREATE TABLE IF NOT EXISTS provekv_pool_generations (
347 generation_id TEXT PRIMARY KEY,
348 embedding_snapshot_digest TEXT NOT NULL,
349 source_digest TEXT NOT NULL,
350 pool_manifest_digest TEXT NOT NULL,
351 codec_family TEXT NOT NULL,
352 codec_profile TEXT NOT NULL,
353 vector_dim INTEGER NOT NULL,
354 item_count INTEGER NOT NULL,
355 payload_bytes INTEGER NOT NULL,
356 payload BLOB NOT NULL,
357 status TEXT NOT NULL,
358 failure_reason TEXT,
359 created_at TEXT NOT NULL
360);
361
362CREATE TABLE IF NOT EXISTS provekv_pool_item_map (
363 generation_id TEXT NOT NULL,
364 item_id TEXT NOT NULL,
365 source_type TEXT NOT NULL,
366 pool_index INTEGER NOT NULL,
367 embedding_digest TEXT NOT NULL,
368 PRIMARY KEY (generation_id, item_id),
369 FOREIGN KEY (generation_id) REFERENCES provekv_pool_generations(generation_id) ON DELETE CASCADE
370);
371
372CREATE INDEX IF NOT EXISTS idx_provekv_pool_item_map_generation_index
373 ON provekv_pool_item_map(generation_id, pool_index);
374
375CREATE INDEX IF NOT EXISTS idx_provekv_pool_generations_status_created
376 ON provekv_pool_generations(status, created_at DESC);
377"#;
378
379const MIGRATION_V25: &str = r#"
386CREATE TABLE IF NOT EXISTS provenance (
387 id TEXT PRIMARY KEY,
388 item_type TEXT NOT NULL,
389 item_id TEXT NOT NULL,
390 semiring_type TEXT NOT NULL,
391 semiring_value TEXT NOT NULL,
392 support_chain_json TEXT NOT NULL DEFAULT '[]',
393 recorded_at TEXT NOT NULL DEFAULT (datetime('now')),
394 episode_id TEXT
395);
396
397CREATE INDEX IF NOT EXISTS idx_provenance_item
398 ON provenance(item_type, item_id);
399
400CREATE INDEX IF NOT EXISTS idx_provenance_episode
401 ON provenance(episode_id);
402"#;
403
404const MIGRATION_V26: &str = "";
411
412fn run_migration_v26(conn: &Connection) -> Result<(), rusqlite::Error> {
414 add_column_if_missing(conn, "facts", "temporal_weight", "REAL NOT NULL DEFAULT 1.0")?;
415 add_column_if_missing(conn, "chunks", "temporal_weight", "REAL NOT NULL DEFAULT 1.0")?;
416 add_column_if_missing(conn, "messages", "temporal_weight", "REAL NOT NULL DEFAULT 1.0")?;
417 conn.execute_batch(
418 "CREATE INDEX IF NOT EXISTS idx_facts_temporal ON facts(temporal_weight);
419 CREATE INDEX IF NOT EXISTS idx_chunks_temporal ON chunks(temporal_weight);",
420 )?;
421 Ok(())
422}
423
424const MIGRATION_V27: &str = r#"
430CREATE TABLE IF NOT EXISTS graph_edges (
431 id TEXT PRIMARY KEY,
432 source TEXT NOT NULL,
433 target TEXT NOT NULL,
434 edge_type TEXT NOT NULL,
435 weight REAL NOT NULL,
436 metadata TEXT,
437 content_digest TEXT NOT NULL,
438 recorded_at TEXT NOT NULL,
439 is_invalidated INTEGER NOT NULL DEFAULT 0,
440 invalidated_at TEXT,
441 invalidation_reason TEXT
442);
443
444CREATE INDEX IF NOT EXISTS idx_graph_edges_source
445 ON graph_edges(source) WHERE is_invalidated = 0;
446
447CREATE INDEX IF NOT EXISTS idx_graph_edges_target
448 ON graph_edges(target) WHERE is_invalidated = 0;
449
450CREATE INDEX IF NOT EXISTS idx_graph_edges_digest
451 ON graph_edges(content_digest) WHERE is_invalidated = 0;
452"#;
453
454#[allow(deprecated)]
456const MIGRATIONS: &[(u32, &str)] = &[
457 (1, MIGRATION_V1),
458 (2, MIGRATION_V2),
459 (3, MIGRATION_V3),
460 (4, MIGRATION_V4),
461 (5, MIGRATION_V5),
462 (6, MIGRATION_V6),
463 (7, MIGRATION_V7),
464 (8, MIGRATION_V8),
465 (9, MIGRATION_V9),
466 (10, crate::projection_import::MIGRATION_V10),
467 (11, crate::projection_storage::MIGRATION_V11),
468 (12, crate::projection_storage::MIGRATION_V12),
469 (13, crate::projection_storage::MIGRATION_V13),
470 (14, crate::projection_storage::MIGRATION_V14),
471 (15, crate::projection_storage::MIGRATION_V15),
472 (16, crate::projection_storage::MIGRATION_V16),
473 (17, crate::projection_storage::MIGRATION_V17),
474 (18, MIGRATION_V18),
475 (19, MIGRATION_V19),
476 (20, MIGRATION_V20),
477 (21, MIGRATION_V21),
478 (22, MIGRATION_V22),
479 (23, MIGRATION_V23),
480 (24, MIGRATION_V24),
481 (25, MIGRATION_V25),
482 (26, MIGRATION_V26),
483 (27, MIGRATION_V27),
484];
485
486pub const MAX_SCHEMA_VERSION: u32 = 27;
488
489fn run_migration_v9(conn: &Connection) -> Result<(), MemoryError> {
491 let episodes_exist: bool = conn
493 .query_row(
494 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='episodes'",
495 [],
496 |row| row.get(0),
497 )
498 .map_err(|e| MemoryError::MigrationFailed {
499 version: 9,
500 reason: format!("existence check failed: {e}"),
501 })?;
502
503 if !episodes_exist {
504 conn.execute_batch(
506 "CREATE TABLE IF NOT EXISTS episode_causes (
507 episode_id TEXT NOT NULL,
508 cause_node_id TEXT NOT NULL,
509 ordinal INTEGER NOT NULL DEFAULT 0,
510 PRIMARY KEY (episode_id, cause_node_id)
511 );
512 CREATE INDEX IF NOT EXISTS idx_episode_causes_cause ON episode_causes(cause_node_id);",
513 )?;
514 return Ok(());
515 }
516
517 conn.execute_batch("PRAGMA foreign_keys = OFF;")?;
519
520 conn.execute_batch(
521 "CREATE TABLE episodes_new (
522 episode_id TEXT PRIMARY KEY,
523 document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
524 cause_ids TEXT NOT NULL,
525 effect_type TEXT NOT NULL,
526 outcome TEXT NOT NULL DEFAULT 'pending',
527 confidence REAL NOT NULL DEFAULT 0.0,
528 verification_status TEXT NOT NULL DEFAULT '{\"status\":\"unverified\"}',
529 experiment_id TEXT,
530 created_at TEXT NOT NULL DEFAULT (datetime('now')),
531 updated_at TEXT NOT NULL DEFAULT (datetime('now')),
532 search_text TEXT NOT NULL DEFAULT '',
533 embedding BLOB,
534 embedding_q8 BLOB,
535 trace_id TEXT
536 )",
537 )?;
538
539 conn.execute_batch(
541 "INSERT INTO episodes_new
542 (episode_id, document_id, cause_ids, effect_type, outcome, confidence,
543 verification_status, experiment_id, created_at, updated_at,
544 search_text, embedding, embedding_q8, trace_id)
545 SELECT
546 document_id || '-ep0',
547 document_id, cause_ids, effect_type, outcome, confidence,
548 verification_status, experiment_id, created_at, updated_at,
549 search_text, embedding, embedding_q8, trace_id
550 FROM episodes",
551 )?;
552
553 conn.execute_batch("DROP TABLE episodes")?;
554 conn.execute_batch("ALTER TABLE episodes_new RENAME TO episodes")?;
555
556 conn.execute_batch(
557 "CREATE INDEX idx_episodes_document_id ON episodes(document_id);
558 CREATE INDEX idx_episodes_effect_type ON episodes(effect_type);
559 CREATE INDEX idx_episodes_outcome ON episodes(outcome);
560 CREATE INDEX idx_episodes_experiment_id ON episodes(experiment_id);",
561 )?;
562
563 conn.execute_batch(
565 "DROP TABLE IF EXISTS episodes_rowid_map;
566 CREATE TABLE episodes_rowid_map (
567 rowid INTEGER PRIMARY KEY AUTOINCREMENT,
568 episode_id TEXT NOT NULL UNIQUE,
569 document_id TEXT
570 );
571 INSERT INTO episodes_rowid_map (episode_id, document_id)
572 SELECT episode_id, document_id FROM episodes;",
573 )?;
574
575 conn.execute_batch(
577 "DROP TABLE IF EXISTS episodes_fts;
578 CREATE VIRTUAL TABLE episodes_fts USING fts5(
579 content,
580 content='',
581 content_rowid='rowid',
582 tokenize='porter unicode61'
583 );
584 INSERT INTO episodes_fts (rowid, content)
585 SELECT rm.rowid, e.search_text
586 FROM episodes_rowid_map rm
587 JOIN episodes e ON e.episode_id = rm.episode_id;",
588 )?;
589
590 conn.execute_batch(
592 "CREATE TABLE IF NOT EXISTS episode_causes (
593 episode_id TEXT NOT NULL,
594 cause_node_id TEXT NOT NULL,
595 ordinal INTEGER NOT NULL DEFAULT 0,
596 PRIMARY KEY (episode_id, cause_node_id)
597 );
598 CREATE INDEX IF NOT EXISTS idx_episode_causes_cause ON episode_causes(cause_node_id);",
599 )?;
600
601 conn.execute_batch(
603 "INSERT OR IGNORE INTO episode_causes (episode_id, cause_node_id, ordinal)
604 SELECT e.episode_id, je.value, CAST(je.key AS INTEGER)
605 FROM episodes e, json_each(e.cause_ids) je;",
606 )?;
607
608 conn.execute_batch("PRAGMA foreign_keys = ON;")?;
609
610 Ok(())
611}
612
613#[derive(Debug, Clone, Copy, PartialEq, Eq)]
615pub enum VerifyMode {
616 Quick,
618 Full,
620}
621
622#[derive(Debug, Clone)]
624pub struct IntegrityReport {
625 pub ok: bool,
626 pub schema_version: u32,
627 pub fact_count: usize,
628 pub chunk_count: usize,
629 pub message_count: usize,
630 pub facts_missing_embeddings: usize,
631 pub chunks_missing_embeddings: usize,
632 pub issues: Vec<String>,
633}
634
635#[derive(Debug, Clone, Copy, PartialEq, Eq)]
637pub enum ReconcileAction {
638 ReportOnly,
639 RebuildFts,
640 ReEmbed,
641}
642
643#[derive(Debug, Clone, Copy, PartialEq, Eq)]
645pub(crate) enum IndexOpKind {
646 Upsert,
647 Delete,
648}
649
650impl IndexOpKind {
651 pub(crate) fn as_str(self) -> &'static str {
652 match self {
653 Self::Upsert => "upsert",
654 Self::Delete => "delete",
655 }
656 }
657
658 fn parse(raw: &str, item_key: &str) -> Result<Self, MemoryError> {
659 match raw {
660 "upsert" => Ok(Self::Upsert),
661 "delete" => Ok(Self::Delete),
662 other => Err(MemoryError::CorruptData {
663 table: "pending_index_ops",
664 row_id: item_key.to_string(),
665 detail: format!("invalid op_kind '{other}'"),
666 }),
667 }
668 }
669}
670
671#[derive(Debug, Clone)]
673pub(crate) struct PendingIndexOp {
674 pub item_key: String,
675 pub entity_type: String,
676 pub op_kind: IndexOpKind,
677 pub attempt_count: u32,
678 pub last_error: Option<String>,
679}
680
681pub fn with_transaction<F, T>(conn: &Connection, f: F) -> Result<T, MemoryError>
683where
684 F: FnOnce(&rusqlite::Transaction<'_>) -> Result<T, MemoryError>,
685{
686 let tx = conn.unchecked_transaction()?;
687 let result = f(&tx)?;
688 tx.commit()?;
689 Ok(result)
690}
691
692pub fn open_database(
694 path: &Path,
695 pool: &PoolConfig,
696 limits: &MemoryLimits,
697) -> Result<Connection, MemoryError> {
698 open_database_internal(path, pool, limits.max_db_size_bytes, true)
699}
700
701#[allow(dead_code)] pub fn open_database_connection(
704 path: &Path,
705 pool: &PoolConfig,
706 limits: &MemoryLimits,
707) -> Result<Connection, MemoryError> {
708 open_database_internal(path, pool, limits.max_db_size_bytes, false)
709}
710
711pub(crate) fn open_database_internal(
712 path: &Path,
713 pool: &PoolConfig,
714 max_db_size_bytes: u64,
715 run_schema_migrations: bool,
716) -> Result<Connection, MemoryError> {
717 create_parent_dirs(path)?;
718 let conn = Connection::open(path)?;
719 configure_connection(&conn, path, pool, max_db_size_bytes, false)?;
720 if run_schema_migrations {
721 run_migrations(&conn)?;
722 }
723 Ok(conn)
724}
725
726pub(crate) fn open_pool_member_connection(
727 path: &Path,
728 pool: &PoolConfig,
729 limits: &MemoryLimits,
730 query_only: bool,
731) -> Result<Connection, MemoryError> {
732 create_parent_dirs(path)?;
733 let flags = OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE;
734 let conn = Connection::open_with_flags(path, flags)?;
735 configure_connection(&conn, path, pool, limits.max_db_size_bytes, query_only)?;
736 Ok(conn)
737}
738
739fn create_parent_dirs(path: &Path) -> Result<(), MemoryError> {
740 if let Some(parent) = path.parent() {
741 if !parent.as_os_str().is_empty() {
742 std::fs::create_dir_all(parent).map_err(|e| {
743 MemoryError::StorageError(format!(
744 "failed to create database directory {}: {}",
745 parent.display(),
746 e
747 ))
748 })?;
749 }
750 }
751 Ok(())
752}
753
754fn configure_connection(
755 conn: &Connection,
756 path: &Path,
757 pool: &PoolConfig,
758 max_db_size_bytes: u64,
759 query_only: bool,
760) -> Result<(), MemoryError> {
761 let journal_mode = if pool.enable_wal { "WAL" } else { "DELETE" };
762 conn.execute_batch(&format!(
763 "PRAGMA journal_mode = {};
764 PRAGMA foreign_keys = ON;
765 PRAGMA busy_timeout = {};
766 PRAGMA synchronous = NORMAL;
767 PRAGMA temp_store = MEMORY;
768 PRAGMA wal_autocheckpoint = {};
769 PRAGMA cache_size = -25600;
770 PRAGMA mmap_size = 268435456;",
771 journal_mode, pool.busy_timeout_ms, pool.wal_autocheckpoint,
772 ))?;
773
774 if query_only {
775 conn.execute_batch("PRAGMA query_only = ON;")?;
776 }
777
778 let actual_journal_mode: String =
779 conn.query_row("PRAGMA journal_mode", [], |row| row.get(0))?;
780 let expected_journal_mode = if pool.enable_wal { "wal" } else { "delete" };
781 if actual_journal_mode.to_lowercase() != expected_journal_mode {
782 return Err(MemoryError::StorageError(format!(
783 "SQLite journal mode mismatch for {}: requested {}, got {}",
784 path.display(),
785 expected_journal_mode,
786 actual_journal_mode
787 )));
788 }
789
790 if max_db_size_bytes > 0 {
791 let page_size: u64 = conn.query_row("PRAGMA page_size", [], |row| row.get(0))?;
792 let max_page_count = max_db_size_bytes.div_ceil(page_size);
793
794 const MAX_SQLITE_PAGE_COUNT: u64 = 1_073_741_823; const MIN_SQLITE_PAGE_COUNT: u64 = 1;
797 if !(MIN_SQLITE_PAGE_COUNT..=MAX_SQLITE_PAGE_COUNT).contains(&max_page_count) {
798 return Err(MemoryError::StorageError(format!(
799 "Invalid max_page_count {}: must be between {} and {}",
800 max_page_count, MIN_SQLITE_PAGE_COUNT, MAX_SQLITE_PAGE_COUNT
801 )));
802 }
803
804 let actual_max_page_count: u64 = conn.query_row(
805 &format!("PRAGMA max_page_count = {}", max_page_count),
806 [],
807 |row| row.get(0),
808 )?;
809 let page_count: u64 = conn.query_row("PRAGMA page_count", [], |row| row.get(0))?;
810
811 if page_count > actual_max_page_count {
812 return Err(MemoryError::DatabaseSizeLimitExceeded {
813 current: page_count.saturating_mul(page_size),
814 limit: max_db_size_bytes,
815 });
816 }
817 }
818
819 let foreign_keys_enabled: bool = conn.query_row("PRAGMA foreign_keys", [], |row| row.get(0))?;
821 if !foreign_keys_enabled {
822 return Err(MemoryError::StorageError(
823 "PRAGMA foreign_keys failed to enable after configuration".to_string(),
824 ));
825 }
826
827 Ok(())
828}
829
830pub fn run_migrations(conn: &Connection) -> Result<(), MemoryError> {
832 let user_version: u32 = conn
833 .query_row("PRAGMA user_version", [], |row| row.get(0))
834 .map_err(|e| MemoryError::MigrationFailed {
835 version: 0,
836 reason: format!("failed to read PRAGMA user_version: {e}"),
837 })?;
838
839 if user_version > MAX_SCHEMA_VERSION {
840 return Err(MemoryError::SchemaAhead {
841 found: user_version,
842 supported: MAX_SCHEMA_VERSION,
843 });
844 }
845
846 conn.execute_batch(
847 "CREATE TABLE IF NOT EXISTS _schema_version (
848 version INTEGER PRIMARY KEY,
849 applied_at TEXT NOT NULL DEFAULT (datetime('now'))
850 );",
851 )?;
852
853 for &(version, sql) in MIGRATIONS {
854 let current_version: u32 = conn
855 .query_row(
856 "SELECT COALESCE(MAX(version), 0) FROM _schema_version",
857 [],
858 |row| row.get(0),
859 )
860 .unwrap_or(0);
861
862 if current_version >= version {
863 continue;
864 }
865
866 with_transaction(conn, |tx| {
867 match version {
868 9 => run_migration_v9(tx).map_err(|e| MemoryError::MigrationFailed {
869 version,
870 reason: e.to_string(),
871 })?,
872 16 => run_migration_v16(tx).map_err(|e| MemoryError::MigrationFailed {
873 version,
874 reason: e.to_string(),
875 })?,
876 17 => run_migration_v17(tx).map_err(|e| MemoryError::MigrationFailed {
877 version,
878 reason: e.to_string(),
879 })?,
880 20 => run_migration_v20(tx).map_err(|e| MemoryError::MigrationFailed {
881 version,
882 reason: e.to_string(),
883 })?,
884 21 => run_migration_v21(tx).map_err(|e| MemoryError::MigrationFailed {
885 version,
886 reason: e.to_string(),
887 })?,
888 26 => run_migration_v26(tx).map_err(|e| MemoryError::MigrationFailed {
889 version,
890 reason: e.to_string(),
891 })?,
892 _ => tx
893 .execute_batch(sql)
894 .map_err(|e| MemoryError::MigrationFailed {
895 version,
896 reason: e.to_string(),
897 })?,
898 }
899 tx.execute(
900 "INSERT INTO _schema_version (version) VALUES (?1)",
901 params![version],
902 )
903 .map_err(|e| MemoryError::MigrationFailed {
904 version,
905 reason: e.to_string(),
906 })?;
907 Ok(())
908 })?;
909
910 tracing::info!("Applied migration V{}", version);
911 }
912
913 let final_version: u32 = conn
914 .query_row(
915 "SELECT COALESCE(MAX(version), 0) FROM _schema_version",
916 [],
917 |row| row.get(0),
918 )
919 .unwrap_or(0);
920 conn.execute_batch(&format!("PRAGMA user_version = {};", final_version))?;
921
922 Ok(())
923}
924
925fn run_migration_v16(conn: &Connection) -> Result<(), rusqlite::Error> {
926 add_column_if_missing(conn, "projection_import_log", "kernel_payload_json", "TEXT")?;
927 add_column_if_missing(
928 conn,
929 "projection_import_failures",
930 "kernel_payload_json",
931 "TEXT",
932 )?;
933 Ok(())
934}
935
936fn run_migration_v17(conn: &Connection) -> Result<(), rusqlite::Error> {
937 add_column_if_missing(conn, "projection_import_log", "episode_bundle_id", "TEXT")?;
938 add_column_if_missing(conn, "projection_import_log", "episode_bundle_json", "TEXT")?;
939 add_column_if_missing(
940 conn,
941 "projection_import_log",
942 "execution_context_json",
943 "TEXT",
944 )?;
945 add_column_if_missing(
946 conn,
947 "projection_import_failures",
948 "episode_bundle_id",
949 "TEXT",
950 )?;
951 add_column_if_missing(
952 conn,
953 "projection_import_failures",
954 "episode_bundle_json",
955 "TEXT",
956 )?;
957 add_column_if_missing(
958 conn,
959 "projection_import_failures",
960 "execution_context_json",
961 "TEXT",
962 )?;
963 Ok(())
964}
965
966fn run_migration_v20(conn: &Connection) -> Result<(), rusqlite::Error> {
967 add_column_if_missing(conn, "derived_vector_artifacts", "encoded_digest", "TEXT")?;
968 conn.execute(
969 "UPDATE derived_vector_artifacts
970 SET encoded_digest = artifact_digest
971 WHERE encoded_digest IS NULL OR encoded_digest = ''",
972 [],
973 )?;
974 add_column_if_missing(
975 conn,
976 "derived_vector_artifacts",
977 "encoding",
978 "TEXT NOT NULL DEFAULT 'turbo_code_wire_v1'",
979 )?;
980 add_column_if_missing(
981 conn,
982 "derived_vector_artifacts",
983 "dim",
984 "INTEGER NOT NULL DEFAULT 0",
985 )?;
986 add_column_if_missing(
987 conn,
988 "derived_vector_artifacts",
989 "status",
990 "TEXT NOT NULL DEFAULT 'active'",
991 )?;
992 conn.execute_batch(
993 "CREATE INDEX IF NOT EXISTS idx_derived_vector_artifacts_profile
994 ON derived_vector_artifacts(codec_family, codec_profile_digest, status);
995 CREATE INDEX IF NOT EXISTS idx_derived_vector_artifacts_source_digest
996 ON derived_vector_artifacts(source_embedding_digest);",
997 )?;
998 Ok(())
999}
1000
1001fn run_migration_v21(conn: &Connection) -> Result<(), rusqlite::Error> {
1002 conn.execute_batch(MIGRATION_V21)?;
1003 add_column_if_missing(conn, "derived_vector_artifacts", "generation_id", "TEXT")?;
1004 conn.execute_batch(
1005 "CREATE INDEX IF NOT EXISTS idx_derived_vector_artifacts_generation
1006 ON derived_vector_artifacts(generation_id, status);",
1007 )?;
1008 Ok(())
1009}
1010
1011const SEARCH_RECEIPT_SCHEMA_VERSION: &str = "vector_search_receipt_v1";
1012
1013#[derive(Debug, Serialize, Deserialize)]
1014struct StoredVectorSearchReceiptV1 {
1015 #[serde(default = "default_search_receipt_schema_version")]
1016 schema_version: String,
1017 receipt_id: String,
1018 evaluation_time: DateTime<Utc>,
1019 #[serde(default)]
1020 receipt_digest: Option<String>,
1021 #[serde(default)]
1022 trace_id: Option<String>,
1023 #[serde(default)]
1024 attempt_family_id: Option<String>,
1025 #[serde(default)]
1026 attempt_id: Option<String>,
1027 #[serde(default)]
1028 replay_of: Option<String>,
1029 query_embedding_digest: Option<String>,
1030 #[serde(default)]
1031 query_text_digest: Option<String>,
1032 #[serde(default)]
1033 query_input_digest: Option<String>,
1034 #[serde(default)]
1035 filter_digest: Option<String>,
1036 #[serde(default)]
1037 redaction_state: Option<String>,
1038 #[serde(default)]
1039 budget_id: Option<String>,
1040 #[serde(default)]
1041 deadline_at: Option<DateTime<Utc>>,
1042 search_profile: String,
1043 candidate_backend: String,
1044 codec_family: Option<String>,
1045 codec_profile_digest: Option<String>,
1046 #[serde(default)]
1047 artifact_profile_digest: Option<String>,
1048 #[serde(default)]
1049 artifact_count: Option<u64>,
1050 #[serde(default)]
1051 artifact_corruption_count: Option<u64>,
1052 #[serde(default)]
1053 artifact_missing_count: Option<u64>,
1054 #[serde(default)]
1055 vector_artifact_manifest_digest: Option<String>,
1056 #[serde(default)]
1057 artifact_generation_id: Option<String>,
1058 #[serde(default)]
1059 approximate_scanned_count: Option<u64>,
1060 #[serde(default)]
1061 approximate_returned_count: Option<u64>,
1062 #[serde(default)]
1063 raw_rows_loaded_count: Option<u64>,
1064 #[serde(default)]
1065 filter_strategy: Option<String>,
1066 #[serde(default)]
1067 vector_artifact_count: Option<u64>,
1068 #[serde(default)]
1069 vector_artifact_missing_count: Option<u64>,
1070 #[serde(default)]
1071 vector_artifact_stale_count: Option<u64>,
1072 #[serde(default)]
1073 exact_rerank_count: Option<u64>,
1074 #[serde(default)]
1075 approximate_candidate_count: Option<u64>,
1076 #[serde(default)]
1077 fallback_reason: Option<String>,
1078 approximate: bool,
1079 requested_candidates: u64,
1080 returned_candidates: u64,
1081 post_filter_candidates: u64,
1082 fallback: Option<String>,
1083 exact_rerank: bool,
1084 result_ids: Vec<String>,
1085 degradations: Vec<String>,
1086}
1087
1088fn default_search_receipt_schema_version() -> String {
1089 SEARCH_RECEIPT_SCHEMA_VERSION.to_string()
1090}
1091
1092fn b3_digest(bytes: &[u8]) -> String {
1093 format!("blake3:{}", ContentDigest::compute(bytes).hex())
1094}
1095
1096#[cfg(feature = "turbo-quant-codec")]
1098#[derive(Debug, Clone)]
1099pub(crate) struct DerivedVectorArtifactRow {
1100 pub item_key: String,
1101 pub generation_id: Option<String>,
1102 pub codec_family: String,
1103 pub codec_profile_digest: String,
1104 pub source_embedding_digest: String,
1105 pub encoded_digest: String,
1106 pub encoding: String,
1107 pub dim: usize,
1108 pub status: String,
1109 pub encoded: Vec<u8>,
1110 pub codec_governance_receipt_id: Option<String>,
1112 pub codec_profile: Option<String>,
1113 pub degradation_budget: Option<f64>,
1114 pub raw_source_artifact_id: Option<String>,
1115}
1116
1117#[cfg(feature = "turbo-quant-codec")]
1119#[derive(Debug, Clone)]
1120#[allow(dead_code)]
1121pub(crate) struct DerivedVectorArtifactGenerationRow {
1122 pub generation_id: String,
1123 pub codec_family: String,
1124 pub codec_profile_digest: String,
1125 pub source_snapshot_digest: String,
1126 pub source_row_count: usize,
1127 pub artifact_count: usize,
1128 pub dim: usize,
1129 pub encoding: String,
1130 pub artifact_manifest_digest: String,
1131 pub status: String,
1132}
1133
1134#[cfg(feature = "turbo-quant-codec")]
1136pub(crate) fn source_embedding_digest(
1137 blob: &[u8],
1138 expected_dim: usize,
1139) -> Result<String, MemoryError> {
1140 validate_vector_blob_len(blob, expected_dim)?;
1141 let mut builder = DigestBuilder::new();
1142 builder
1143 .update_str("semantic-memory.source_embedding.v1")
1144 .separator()
1145 .update(&(expected_dim as u64).to_le_bytes())
1146 .separator()
1147 .update(blob);
1148 Ok(format!("blake3:{}", builder.finalize().hex()))
1149}
1150
1151#[cfg(feature = "turbo-quant-codec")]
1152fn source_snapshot_digest(rows: &[DerivedVectorArtifactRow], dim: usize) -> String {
1153 let mut entries = rows
1154 .iter()
1155 .map(|row| (row.item_key.as_str(), row.source_embedding_digest.as_str()))
1156 .collect::<Vec<_>>();
1157 entries.sort_unstable();
1158
1159 let mut builder = DigestBuilder::new();
1160 builder
1161 .update_str("semantic-memory.vector_source_snapshot.v1")
1162 .separator()
1163 .update(&(dim as u64).to_le_bytes())
1164 .separator();
1165 for (item_key, source_embedding_digest) in entries {
1166 builder
1167 .update_str(item_key)
1168 .separator()
1169 .update_str(source_embedding_digest)
1170 .separator();
1171 }
1172 format!("blake3:{}", builder.finalize().hex())
1173}
1174
1175#[cfg(feature = "turbo-quant-codec")]
1176pub(crate) fn current_source_snapshot_digest(
1177 conn: &Connection,
1178 dim: usize,
1179) -> Result<(String, usize), MemoryError> {
1180 let mut stmt = conn.prepare(
1181 "SELECT 'fact:' || id AS item_key, embedding FROM facts WHERE embedding IS NOT NULL
1182 UNION ALL
1183 SELECT 'chunk:' || id AS item_key, embedding FROM chunks WHERE embedding IS NOT NULL
1184 UNION ALL
1185 SELECT 'msg:' || id AS item_key, embedding FROM messages WHERE embedding IS NOT NULL
1186 UNION ALL
1187 SELECT 'episode:' || episode_id AS item_key, embedding FROM episodes WHERE embedding IS NOT NULL",
1188 )?;
1189 let rows = stmt.query_map([], |row| {
1190 Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?))
1191 })?;
1192
1193 let mut entries = Vec::new();
1194 for row in rows {
1195 let (item_key, blob) = row?;
1196 entries.push((item_key, source_embedding_digest(&blob, dim)?));
1197 }
1198 entries.sort_unstable();
1199
1200 let mut builder = DigestBuilder::new();
1201 builder
1202 .update_str("semantic-memory.vector_source_snapshot.v1")
1203 .separator()
1204 .update(&(dim as u64).to_le_bytes())
1205 .separator();
1206 for (item_key, source_embedding_digest) in &entries {
1207 builder
1208 .update_str(item_key)
1209 .separator()
1210 .update_str(source_embedding_digest)
1211 .separator();
1212 }
1213 Ok((
1214 format!("blake3:{}", builder.finalize().hex()),
1215 entries.len(),
1216 ))
1217}
1218
1219#[cfg(feature = "turbo-quant-codec")]
1220fn derived_artifact_manifest_digest(rows: &[DerivedVectorArtifactRow]) -> String {
1221 let mut entries = rows
1222 .iter()
1223 .map(|row| {
1224 (
1225 row.item_key.as_str(),
1226 row.source_embedding_digest.as_str(),
1227 row.encoded_digest.as_str(),
1228 )
1229 })
1230 .collect::<Vec<_>>();
1231 entries.sort_unstable();
1232
1233 let mut builder = DigestBuilder::new();
1234 builder
1235 .update_str("semantic-memory.vector_artifact_manifest.v1")
1236 .separator();
1237 for (item_key, source_embedding_digest, encoded_digest) in entries {
1238 builder
1239 .update_str(item_key)
1240 .separator()
1241 .update_str(source_embedding_digest)
1242 .separator()
1243 .update_str(encoded_digest)
1244 .separator();
1245 }
1246 format!("blake3:{}", builder.finalize().hex())
1247}
1248
1249#[cfg(feature = "turbo-quant-codec")]
1250pub(crate) fn upsert_derived_vector_artifact(
1251 conn: &Connection,
1252 row: &DerivedVectorArtifactRow,
1253) -> Result<(), MemoryError> {
1254 conn.execute(
1255 "INSERT OR REPLACE INTO derived_vector_artifacts
1256 (item_key, generation_id, codec_family, codec_profile_digest, source_embedding_digest,
1257 encoded_digest, artifact_digest, encoding, dim, encoded, created_at, status,
1258 codec_governance_receipt_id, codec_profile, degradation_budget, raw_source_artifact_id)
1259 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6, ?7, ?8, ?9, datetime('now'), ?10, ?11, ?12, ?13, ?14)",
1260 params![
1261 row.item_key,
1262 row.generation_id.as_deref(),
1263 row.codec_family,
1264 row.codec_profile_digest,
1265 row.source_embedding_digest,
1266 row.encoded_digest,
1267 row.encoding,
1268 i64::try_from(row.dim)
1269 .map_err(|err| MemoryError::Other(format!("artifact dim overflow: {err}")))?,
1270 row.encoded,
1271 row.status,
1272 row.codec_governance_receipt_id.as_deref(),
1273 row.codec_profile.as_deref(),
1274 row.degradation_budget,
1275 row.raw_source_artifact_id.as_deref(),
1276 ],
1277 )?;
1278 Ok(())
1279}
1280
1281#[allow(dead_code)] pub fn delete_derived_vector_artifact(
1283 conn: &Connection,
1284 item_key: &str,
1285) -> Result<(), MemoryError> {
1286 conn.execute(
1287 "DELETE FROM derived_vector_artifacts WHERE item_key = ?1",
1288 params![item_key],
1289 )?;
1290 Ok(())
1291}
1292
1293pub fn invalidate_derived_vector_artifact(
1294 conn: &Connection,
1295 item_key: &str,
1296) -> Result<(), MemoryError> {
1297 conn.execute(
1298 "UPDATE derived_vector_artifacts
1299 SET status = 'invalidated'
1300 WHERE item_key = ?1 AND status = 'active'",
1301 params![item_key],
1302 )?;
1303 conn.execute(
1304 "UPDATE derived_vector_artifact_generations
1305 SET status = 'invalidated'
1306 WHERE status = 'active'",
1307 [],
1308 )?;
1309 Ok(())
1310}
1311
1312#[cfg(feature = "turbo-quant-codec")]
1313#[allow(dead_code)]
1314pub(crate) fn load_derived_vector_artifacts_by_profile(
1315 conn: &Connection,
1316 codec_family: &str,
1317 codec_profile_digest: &str,
1318) -> Result<Vec<DerivedVectorArtifactRow>, MemoryError> {
1319 let mut stmt = conn.prepare(
1320 "SELECT item_key, generation_id, codec_family, codec_profile_digest, source_embedding_digest,
1321 encoded_digest, encoding, dim, status, encoded,
1322 codec_governance_receipt_id, codec_profile, degradation_budget, raw_source_artifact_id
1323 FROM derived_vector_artifacts
1324 WHERE codec_family = ?1 AND codec_profile_digest = ?2 AND status = 'active'",
1325 )?;
1326 let rows = stmt.query_map(params![codec_family, codec_profile_digest], |row| {
1327 let dim_i64: i64 = row.get(7)?;
1328 Ok(DerivedVectorArtifactRow {
1329 item_key: row.get(0)?,
1330 generation_id: row.get(1)?,
1331 codec_family: row.get(2)?,
1332 codec_profile_digest: row.get(3)?,
1333 source_embedding_digest: row.get(4)?,
1334 encoded_digest: row.get(5)?,
1335 encoding: row.get(6)?,
1336 dim: usize::try_from(dim_i64).map_err(|err| {
1337 rusqlite::Error::FromSqlConversionFailure(
1338 7,
1339 rusqlite::types::Type::Integer,
1340 Box::new(err),
1341 )
1342 })?,
1343 status: row.get(8)?,
1344 encoded: row.get(9)?,
1345 codec_governance_receipt_id: row.get(10)?,
1346 codec_profile: row.get(11)?,
1347 degradation_budget: row.get(12)?,
1348 raw_source_artifact_id: row.get(13)?,
1349 })
1350 })?;
1351
1352 let mut artifacts = Vec::new();
1353 for row in rows {
1354 artifacts.push(row?);
1355 }
1356 Ok(artifacts)
1357}
1358
1359#[cfg(feature = "turbo-quant-codec")]
1360pub(crate) fn load_derived_vector_artifacts_by_generation(
1361 conn: &Connection,
1362 generation_id: &str,
1363) -> Result<Vec<DerivedVectorArtifactRow>, MemoryError> {
1364 let mut stmt = conn.prepare(
1365 "SELECT item_key, generation_id, codec_family, codec_profile_digest, source_embedding_digest,
1366 encoded_digest, encoding, dim, status, encoded,
1367 codec_governance_receipt_id, codec_profile, degradation_budget, raw_source_artifact_id
1368 FROM derived_vector_artifacts
1369 WHERE generation_id = ?1 AND status = 'active'",
1370 )?;
1371 let rows = stmt.query_map(params![generation_id], |row| {
1372 let dim_i64: i64 = row.get(7)?;
1373 Ok(DerivedVectorArtifactRow {
1374 item_key: row.get(0)?,
1375 generation_id: row.get(1)?,
1376 codec_family: row.get(2)?,
1377 codec_profile_digest: row.get(3)?,
1378 source_embedding_digest: row.get(4)?,
1379 encoded_digest: row.get(5)?,
1380 encoding: row.get(6)?,
1381 dim: usize::try_from(dim_i64).map_err(|err| {
1382 rusqlite::Error::FromSqlConversionFailure(
1383 7,
1384 rusqlite::types::Type::Integer,
1385 Box::new(err),
1386 )
1387 })?,
1388 status: row.get(8)?,
1389 encoded: row.get(9)?,
1390 codec_governance_receipt_id: row.get(10)?,
1391 codec_profile: row.get(11)?,
1392 degradation_budget: row.get(12)?,
1393 raw_source_artifact_id: row.get(13)?,
1394 })
1395 })?;
1396
1397 let mut artifacts = Vec::new();
1398 for row in rows {
1399 artifacts.push(row?);
1400 }
1401 Ok(artifacts)
1402}
1403
1404#[cfg(feature = "turbo-quant-codec")]
1405pub(crate) fn current_derived_vector_generation(
1406 conn: &Connection,
1407 codec_family: &str,
1408 codec_profile_digest: &str,
1409) -> Result<Option<DerivedVectorArtifactGenerationRow>, MemoryError> {
1410 conn.query_row(
1411 "SELECT generation_id, codec_family, codec_profile_digest, source_snapshot_digest,
1412 source_row_count, artifact_count, dim, encoding, artifact_manifest_digest, status
1413 FROM derived_vector_artifact_generations
1414 WHERE codec_family = ?1 AND codec_profile_digest = ?2 AND status = 'active'
1415 ORDER BY created_at DESC
1416 LIMIT 1",
1417 params![codec_family, codec_profile_digest],
1418 |row| {
1419 let source_row_count: i64 = row.get(4)?;
1420 let artifact_count: i64 = row.get(5)?;
1421 let dim: i64 = row.get(6)?;
1422 Ok(DerivedVectorArtifactGenerationRow {
1423 generation_id: row.get(0)?,
1424 codec_family: row.get(1)?,
1425 codec_profile_digest: row.get(2)?,
1426 source_snapshot_digest: row.get(3)?,
1427 source_row_count: usize::try_from(source_row_count).map_err(|err| {
1428 rusqlite::Error::FromSqlConversionFailure(
1429 4,
1430 rusqlite::types::Type::Integer,
1431 Box::new(err),
1432 )
1433 })?,
1434 artifact_count: usize::try_from(artifact_count).map_err(|err| {
1435 rusqlite::Error::FromSqlConversionFailure(
1436 5,
1437 rusqlite::types::Type::Integer,
1438 Box::new(err),
1439 )
1440 })?,
1441 dim: usize::try_from(dim).map_err(|err| {
1442 rusqlite::Error::FromSqlConversionFailure(
1443 6,
1444 rusqlite::types::Type::Integer,
1445 Box::new(err),
1446 )
1447 })?,
1448 encoding: row.get(7)?,
1449 artifact_manifest_digest: row.get(8)?,
1450 status: row.get(9)?,
1451 })
1452 },
1453 )
1454 .optional()
1455 .map_err(MemoryError::from)
1456}
1457
1458#[allow(dead_code)] pub fn count_derived_vector_artifacts(
1460 conn: &Connection,
1461 codec_family: &str,
1462 codec_profile_digest: &str,
1463) -> Result<usize, MemoryError> {
1464 let count: i64 = conn.query_row(
1465 "SELECT COUNT(*) FROM derived_vector_artifacts
1466 WHERE codec_family = ?1 AND codec_profile_digest = ?2 AND status = 'active'",
1467 params![codec_family, codec_profile_digest],
1468 |row| row.get(0),
1469 )?;
1470 usize::try_from(count)
1471 .map_err(|err| MemoryError::Other(format!("derived artifact count overflow: {err}")))
1472}
1473
1474#[cfg(feature = "turbo-quant-codec")]
1475pub(crate) fn rebuild_turbo_quant_artifacts(
1476 conn: &Connection,
1477 dim: usize,
1478 bits: u8,
1479 projections: usize,
1480 seed: u64,
1481) -> Result<VectorArtifactBuildReceiptV1, MemoryError> {
1482 use crate::vector_codec::{TurboQuantCodec, VectorCodec};
1483
1484 let started = std::time::Instant::now();
1485 let codec = TurboQuantCodec::new(dim, bits, projections, seed)?;
1486 let codec_profile_digest = codec.profile().digest();
1487 let generation_id = uuid::Uuid::new_v4().to_string();
1488 let mut source_row_count = 0usize;
1489 let mut artifact_count = 0usize;
1490 let mut skipped_row_count = 0usize;
1491 let mut degradations = Vec::new();
1492
1493 let mut stmt = conn.prepare(
1494 "SELECT 'fact:' || id AS item_key, embedding FROM facts WHERE embedding IS NOT NULL
1495 UNION ALL
1496 SELECT 'chunk:' || id AS item_key, embedding FROM chunks WHERE embedding IS NOT NULL
1497 UNION ALL
1498 SELECT 'msg:' || id AS item_key, embedding FROM messages WHERE embedding IS NOT NULL
1499 UNION ALL
1500 SELECT 'episode:' || episode_id AS item_key, embedding FROM episodes WHERE embedding IS NOT NULL",
1501 )?;
1502 let rows = stmt.query_map([], |row| {
1503 Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?))
1504 })?;
1505
1506 let mut pending = Vec::new();
1507 for row in rows {
1508 let (item_key, blob) = row?;
1509 source_row_count += 1;
1510 let embedding = match decode_f32_le(&blob, dim) {
1511 Ok(embedding) => embedding,
1512 Err(err) => {
1513 skipped_row_count += 1;
1514 degradations.push(format!(
1515 "skipped {item_key}: invalid authoritative embedding: {err}"
1516 ));
1517 continue;
1518 }
1519 };
1520 let artifact = match codec.encode(&embedding) {
1521 Ok(artifact) => artifact,
1522 Err(err) => {
1523 skipped_row_count += 1;
1524 degradations.push(format!("skipped {item_key}: encode failed: {err}"));
1525 continue;
1526 }
1527 };
1528 pending.push(DerivedVectorArtifactRow {
1529 item_key,
1530 generation_id: Some(generation_id.clone()),
1531 codec_family: "turbo_quant".to_string(),
1532 codec_profile_digest: codec_profile_digest.clone(),
1533 source_embedding_digest: source_embedding_digest(&blob, dim)?,
1534 encoded_digest: artifact.artifact_digest,
1535 encoding: "turbo_code_wire_v1".to_string(),
1536 dim,
1537 status: "active".to_string(),
1538 encoded: artifact.encoded,
1539 codec_governance_receipt_id: None,
1542 codec_profile: None,
1543 degradation_budget: None,
1544 raw_source_artifact_id: None,
1545 });
1546 }
1547 drop(stmt);
1548
1549 let build_receipt_id = uuid::Uuid::new_v4().to_string();
1550 let source_snapshot_digest = source_snapshot_digest(&pending, dim);
1551 let artifact_manifest_digest = derived_artifact_manifest_digest(&pending);
1552 let source_tables = vec![
1553 "facts".to_string(),
1554 "chunks".to_string(),
1555 "messages".to_string(),
1556 "episodes".to_string(),
1557 ];
1558 let generation_manifest = DerivedVectorArtifactGenerationV1 {
1559 schema_version: "derived_vector_artifact_generation_v1".to_string(),
1560 generation_id: generation_id.clone(),
1561 codec_family: "turbo_quant".to_string(),
1562 codec_profile_digest: codec_profile_digest.clone(),
1563 source_snapshot_digest: source_snapshot_digest.clone(),
1564 source_row_count,
1565 artifact_count: pending.len(),
1566 source_tables,
1567 dim,
1568 encoding: "turbo_code_wire_v1".to_string(),
1569 created_at: Utc::now(),
1570 build_receipt_id: Some(build_receipt_id.clone()),
1571 artifact_manifest_digest: artifact_manifest_digest.clone(),
1572 status: if skipped_row_count == 0 {
1573 "active".to_string()
1574 } else {
1575 "failed".to_string()
1576 },
1577 degradations: degradations.clone(),
1578 };
1579
1580 with_transaction(conn, |tx| {
1581 tx.execute(
1582 "UPDATE derived_vector_artifact_generations
1583 SET status = 'superseded'
1584 WHERE codec_family = ?1 AND codec_profile_digest = ?2 AND status = 'active'",
1585 params!["turbo_quant", &codec_profile_digest],
1586 )?;
1587 tx.execute(
1588 "DELETE FROM derived_vector_artifacts
1589 WHERE codec_family = ?1 AND codec_profile_digest = ?2",
1590 params!["turbo_quant", &codec_profile_digest],
1591 )?;
1592 tx.execute(
1593 "INSERT INTO derived_vector_artifact_generations
1594 (generation_id, schema_version, codec_family, codec_profile_digest,
1595 source_snapshot_digest, source_row_count, artifact_count, source_tables_json,
1596 dim, encoding, created_at, build_receipt_id, artifact_manifest_digest,
1597 status, degradations_json)
1598 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
1599 params![
1600 generation_manifest.generation_id,
1601 generation_manifest.schema_version,
1602 generation_manifest.codec_family,
1603 generation_manifest.codec_profile_digest,
1604 generation_manifest.source_snapshot_digest,
1605 i64::try_from(generation_manifest.source_row_count).map_err(|err| {
1606 MemoryError::Other(format!("source row count overflow: {err}"))
1607 })?,
1608 i64::try_from(generation_manifest.artifact_count).map_err(|err| {
1609 MemoryError::Other(format!("artifact count overflow: {err}"))
1610 })?,
1611 serde_json::to_string(&generation_manifest.source_tables)
1612 .map_err(|err| MemoryError::Other(err.to_string()))?,
1613 i64::try_from(generation_manifest.dim)
1614 .map_err(|err| MemoryError::Other(format!("artifact dim overflow: {err}")))?,
1615 generation_manifest.encoding,
1616 generation_manifest.created_at.to_rfc3339(),
1617 generation_manifest.build_receipt_id,
1618 generation_manifest.artifact_manifest_digest,
1619 generation_manifest.status,
1620 serde_json::to_string(&generation_manifest.degradations)
1621 .map_err(|err| MemoryError::Other(err.to_string()))?,
1622 ],
1623 )?;
1624 for row in &pending {
1625 upsert_derived_vector_artifact(tx, row)?;
1626 artifact_count += 1;
1627 }
1628 Ok(())
1629 })?;
1630
1631 Ok(VectorArtifactBuildReceiptV1 {
1632 schema_version: "vector_artifact_build_receipt_v1".to_string(),
1633 codec_family: "turbo_quant".to_string(),
1634 codec_profile_digest,
1635 source_row_count,
1636 artifact_count,
1637 generation_id: Some(generation_id),
1638 source_snapshot_digest: Some(source_snapshot_digest),
1639 artifact_manifest_digest: Some(artifact_manifest_digest),
1640 build_receipt_id: Some(build_receipt_id),
1641 skipped_row_count,
1642 elapsed_ms: started.elapsed().as_millis(),
1643 created_at: Utc::now(),
1644 degradations,
1645 })
1646}
1647
1648fn receipt_count_to_u64(value: usize, field: &'static str) -> Result<u64, MemoryError> {
1649 u64::try_from(value).map_err(|err| MemoryError::Other(format!("{field} is too large: {err}")))
1650}
1651
1652fn receipt_count_to_i64(value: u64, field: &'static str) -> Result<i64, MemoryError> {
1653 i64::try_from(value).map_err(|err| MemoryError::Other(format!("{field} is too large: {err}")))
1654}
1655
1656fn receipt_count_to_usize(
1657 value: u64,
1658 receipt_id: &str,
1659 field: &'static str,
1660) -> Result<usize, MemoryError> {
1661 usize::try_from(value).map_err(|err| MemoryError::CorruptData {
1662 table: "search_receipts",
1663 row_id: receipt_id.to_string(),
1664 detail: format!("{field} does not fit this platform: {err}"),
1665 })
1666}
1667
1668fn stored_search_receipt(
1669 receipt: &VectorSearchReceiptV1,
1670) -> Result<StoredVectorSearchReceiptV1, MemoryError> {
1671 Ok(StoredVectorSearchReceiptV1 {
1672 schema_version: SEARCH_RECEIPT_SCHEMA_VERSION.to_string(),
1673 receipt_id: receipt.receipt_id.clone(),
1674 evaluation_time: receipt.evaluation_time,
1675 receipt_digest: receipt.receipt_digest.clone(),
1676 trace_id: receipt.trace_id.clone(),
1677 attempt_family_id: receipt.attempt_family_id.clone(),
1678 attempt_id: receipt.attempt_id.clone(),
1679 replay_of: receipt.replay_of.clone(),
1680 query_embedding_digest: receipt.query_embedding_digest.clone(),
1681 query_text_digest: receipt.query_text_digest.clone(),
1682 query_input_digest: receipt.query_input_digest.clone(),
1683 filter_digest: receipt.filter_digest.clone(),
1684 redaction_state: receipt.redaction_state.clone(),
1685 budget_id: receipt.budget_id.clone(),
1686 deadline_at: receipt.deadline_at,
1687 search_profile: receipt.search_profile.clone(),
1688 candidate_backend: receipt.candidate_backend.clone(),
1689 codec_family: receipt.codec_family.clone(),
1690 codec_profile_digest: receipt.codec_profile_digest.clone(),
1691 artifact_profile_digest: receipt.artifact_profile_digest.clone(),
1692 artifact_count: receipt
1693 .artifact_count
1694 .map(|value| receipt_count_to_u64(value, "artifact_count"))
1695 .transpose()?,
1696 artifact_corruption_count: receipt
1697 .artifact_corruption_count
1698 .map(|value| receipt_count_to_u64(value, "artifact_corruption_count"))
1699 .transpose()?,
1700 artifact_missing_count: receipt
1701 .artifact_missing_count
1702 .map(|value| receipt_count_to_u64(value, "artifact_missing_count"))
1703 .transpose()?,
1704 vector_artifact_manifest_digest: receipt.vector_artifact_manifest_digest.clone(),
1705 artifact_generation_id: receipt.artifact_generation_id.clone(),
1706 approximate_scanned_count: receipt
1707 .approximate_scanned_count
1708 .map(|value| receipt_count_to_u64(value, "approximate_scanned_count"))
1709 .transpose()?,
1710 approximate_returned_count: receipt
1711 .approximate_returned_count
1712 .map(|value| receipt_count_to_u64(value, "approximate_returned_count"))
1713 .transpose()?,
1714 raw_rows_loaded_count: receipt
1715 .raw_rows_loaded_count
1716 .map(|value| receipt_count_to_u64(value, "raw_rows_loaded_count"))
1717 .transpose()?,
1718 filter_strategy: receipt.filter_strategy.clone(),
1719 vector_artifact_count: receipt
1720 .vector_artifact_count
1721 .map(|value| receipt_count_to_u64(value, "vector_artifact_count"))
1722 .transpose()?,
1723 vector_artifact_missing_count: receipt
1724 .vector_artifact_missing_count
1725 .map(|value| receipt_count_to_u64(value, "vector_artifact_missing_count"))
1726 .transpose()?,
1727 vector_artifact_stale_count: receipt
1728 .vector_artifact_stale_count
1729 .map(|value| receipt_count_to_u64(value, "vector_artifact_stale_count"))
1730 .transpose()?,
1731 exact_rerank_count: receipt
1732 .exact_rerank_count
1733 .map(|value| receipt_count_to_u64(value, "exact_rerank_count"))
1734 .transpose()?,
1735 approximate_candidate_count: receipt
1736 .approximate_candidate_count
1737 .map(|value| receipt_count_to_u64(value, "approximate_candidate_count"))
1738 .transpose()?,
1739 fallback_reason: receipt.fallback_reason.clone(),
1740 approximate: receipt.approximate,
1741 requested_candidates: receipt_count_to_u64(
1742 receipt.requested_candidates,
1743 "requested_candidates",
1744 )?,
1745 returned_candidates: receipt_count_to_u64(
1746 receipt.returned_candidates,
1747 "returned_candidates",
1748 )?,
1749 post_filter_candidates: receipt_count_to_u64(
1750 receipt.post_filter_candidates,
1751 "post_filter_candidates",
1752 )?,
1753 fallback: receipt.fallback.clone(),
1754 exact_rerank: receipt.exact_rerank,
1755 result_ids: receipt.result_ids.clone(),
1756 degradations: receipt.degradations.clone(),
1757 })
1758}
1759
1760fn search_receipt_from_stored(
1761 stored: StoredVectorSearchReceiptV1,
1762) -> Result<VectorSearchReceiptV1, MemoryError> {
1763 if stored.schema_version != SEARCH_RECEIPT_SCHEMA_VERSION {
1764 return Err(MemoryError::CorruptData {
1765 table: "search_receipts",
1766 row_id: stored.receipt_id,
1767 detail: format!(
1768 "unsupported receipt schema version '{}'",
1769 stored.schema_version
1770 ),
1771 });
1772 }
1773
1774 Ok(VectorSearchReceiptV1 {
1775 schema_version: stored.schema_version.clone(),
1776 receipt_digest: stored.receipt_digest,
1777 receipt_id: stored.receipt_id.clone(),
1778 evaluation_time: stored.evaluation_time,
1779 trace_id: stored.trace_id,
1780 attempt_family_id: stored.attempt_family_id,
1781 attempt_id: stored.attempt_id,
1782 replay_of: stored.replay_of,
1783 query_embedding_digest: stored.query_embedding_digest,
1784 query_text_digest: stored.query_text_digest,
1785 query_input_digest: stored.query_input_digest,
1786 filter_digest: stored.filter_digest,
1787 redaction_state: stored.redaction_state,
1788 budget_id: stored.budget_id,
1789 deadline_at: stored.deadline_at,
1790 search_profile: stored.search_profile,
1791 candidate_backend: stored.candidate_backend,
1792 codec_family: stored.codec_family,
1793 codec_profile_digest: stored.codec_profile_digest,
1794 artifact_profile_digest: stored.artifact_profile_digest,
1795 artifact_count: stored
1796 .artifact_count
1797 .map(|value| receipt_count_to_usize(value, &stored.receipt_id, "artifact_count"))
1798 .transpose()?,
1799 artifact_corruption_count: stored
1800 .artifact_corruption_count
1801 .map(|value| {
1802 receipt_count_to_usize(value, &stored.receipt_id, "artifact_corruption_count")
1803 })
1804 .transpose()?,
1805 artifact_missing_count: stored
1806 .artifact_missing_count
1807 .map(|value| {
1808 receipt_count_to_usize(value, &stored.receipt_id, "artifact_missing_count")
1809 })
1810 .transpose()?,
1811 vector_artifact_manifest_digest: stored.vector_artifact_manifest_digest,
1812 artifact_generation_id: stored.artifact_generation_id,
1813 approximate_scanned_count: stored
1814 .approximate_scanned_count
1815 .map(|value| {
1816 receipt_count_to_usize(value, &stored.receipt_id, "approximate_scanned_count")
1817 })
1818 .transpose()?,
1819 approximate_returned_count: stored
1820 .approximate_returned_count
1821 .map(|value| {
1822 receipt_count_to_usize(value, &stored.receipt_id, "approximate_returned_count")
1823 })
1824 .transpose()?,
1825 raw_rows_loaded_count: stored
1826 .raw_rows_loaded_count
1827 .map(|value| receipt_count_to_usize(value, &stored.receipt_id, "raw_rows_loaded_count"))
1828 .transpose()?,
1829 filter_strategy: stored.filter_strategy,
1830 vector_artifact_count: stored
1831 .vector_artifact_count
1832 .map(|value| receipt_count_to_usize(value, &stored.receipt_id, "vector_artifact_count"))
1833 .transpose()?,
1834 vector_artifact_missing_count: stored
1835 .vector_artifact_missing_count
1836 .map(|value| {
1837 receipt_count_to_usize(value, &stored.receipt_id, "vector_artifact_missing_count")
1838 })
1839 .transpose()?,
1840 vector_artifact_stale_count: stored
1841 .vector_artifact_stale_count
1842 .map(|value| {
1843 receipt_count_to_usize(value, &stored.receipt_id, "vector_artifact_stale_count")
1844 })
1845 .transpose()?,
1846 exact_rerank_count: stored
1847 .exact_rerank_count
1848 .map(|value| receipt_count_to_usize(value, &stored.receipt_id, "exact_rerank_count"))
1849 .transpose()?,
1850 approximate_candidate_count: stored
1851 .approximate_candidate_count
1852 .map(|value| {
1853 receipt_count_to_usize(value, &stored.receipt_id, "approximate_candidate_count")
1854 })
1855 .transpose()?,
1856 fallback_reason: stored.fallback_reason,
1857 derived_candidate: None,
1858 approximate: stored.approximate,
1859 requested_candidates: receipt_count_to_usize(
1860 stored.requested_candidates,
1861 &stored.receipt_id,
1862 "requested_candidates",
1863 )?,
1864 returned_candidates: receipt_count_to_usize(
1865 stored.returned_candidates,
1866 &stored.receipt_id,
1867 "returned_candidates",
1868 )?,
1869 post_filter_candidates: receipt_count_to_usize(
1870 stored.post_filter_candidates,
1871 &stored.receipt_id,
1872 "post_filter_candidates",
1873 )?,
1874 fallback: stored.fallback,
1875 exact_rerank: stored.exact_rerank,
1876 result_ids: stored.result_ids,
1877 degradations: stored.degradations,
1878 })
1879}
1880
1881pub fn store_search_receipt(
1886 conn: &Connection,
1887 receipt: &VectorSearchReceiptV1,
1888) -> Result<(), MemoryError> {
1889 let stored = stored_search_receipt(receipt)?;
1890 let receipt_json = serde_json::to_string(&stored)
1891 .map_err(|err| MemoryError::Other(format!("failed to serialize search receipt: {err}")))?;
1892 let receipt_digest = b3_digest(receipt_json.as_bytes());
1893
1894 let existing_digest: Option<String> = conn
1895 .query_row(
1896 "SELECT receipt_digest FROM search_receipts WHERE receipt_id = ?1",
1897 params![&stored.receipt_id],
1898 |row| row.get(0),
1899 )
1900 .optional()?;
1901 if let Some(existing_digest) = existing_digest {
1902 if existing_digest == receipt_digest {
1903 return Ok(());
1904 }
1905 return Err(MemoryError::SearchReceiptConflict {
1906 receipt_id: stored.receipt_id,
1907 });
1908 }
1909
1910 let result_ids_json = serde_json::to_string(&stored.result_ids).map_err(|err| {
1911 MemoryError::Other(format!(
1912 "failed to serialize search receipt result IDs: {err}"
1913 ))
1914 })?;
1915 conn.execute(
1916 "INSERT INTO search_receipts (
1917 receipt_id,
1918 schema_version,
1919 evaluation_time,
1920 search_profile,
1921 candidate_backend,
1922 approximate,
1923 exact_rerank,
1924 fallback,
1925 requested_candidates,
1926 returned_candidates,
1927 post_filter_candidates,
1928 result_ids_json,
1929 receipt_json,
1930 receipt_digest
1931 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
1932 params![
1933 &stored.receipt_id,
1934 SEARCH_RECEIPT_SCHEMA_VERSION,
1935 stored.evaluation_time.to_rfc3339(),
1936 &stored.search_profile,
1937 &stored.candidate_backend,
1938 if stored.approximate { 1_i64 } else { 0_i64 },
1939 if stored.exact_rerank { 1_i64 } else { 0_i64 },
1940 &stored.fallback,
1941 receipt_count_to_i64(stored.requested_candidates, "requested_candidates")?,
1942 receipt_count_to_i64(stored.returned_candidates, "returned_candidates")?,
1943 receipt_count_to_i64(stored.post_filter_candidates, "post_filter_candidates")?,
1944 &result_ids_json,
1945 &receipt_json,
1946 &receipt_digest,
1947 ],
1948 )?;
1949 Ok(())
1950}
1951
1952pub fn get_search_receipt(
1954 conn: &Connection,
1955 receipt_id: &str,
1956) -> Result<Option<VectorSearchReceiptV1>, MemoryError> {
1957 let row: Option<(String, String, String)> = conn
1958 .query_row(
1959 "SELECT schema_version, receipt_json, receipt_digest
1960 FROM search_receipts
1961 WHERE receipt_id = ?1",
1962 params![receipt_id],
1963 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
1964 )
1965 .optional()?;
1966
1967 let Some((schema_version, receipt_json, receipt_digest)) = row else {
1968 return Ok(None);
1969 };
1970 if schema_version != SEARCH_RECEIPT_SCHEMA_VERSION {
1971 return Err(MemoryError::CorruptData {
1972 table: "search_receipts",
1973 row_id: receipt_id.to_string(),
1974 detail: format!("unsupported receipt schema version '{schema_version}'"),
1975 });
1976 }
1977
1978 let stored: StoredVectorSearchReceiptV1 =
1979 serde_json::from_str(&receipt_json).map_err(|err| MemoryError::CorruptData {
1980 table: "search_receipts",
1981 row_id: receipt_id.to_string(),
1982 detail: format!("invalid receipt JSON: {err}"),
1983 })?;
1984 let mut receipt = search_receipt_from_stored(stored)?;
1985 receipt.receipt_digest = Some(receipt_digest);
1986 Ok(Some(receipt))
1987}
1988
1989fn add_column_if_missing(
1990 conn: &Connection,
1991 table: &str,
1992 column: &str,
1993 column_sql: &str,
1994) -> Result<(), rusqlite::Error> {
1995 let pragma = format!("PRAGMA table_info({table})");
1996 let mut stmt = conn.prepare(&pragma)?;
1997 let exists = stmt
1998 .query_map([], |row| row.get::<_, String>(1))?
1999 .collect::<Result<Vec<_>, _>>()?
2000 .into_iter()
2001 .any(|name| name == column);
2002
2003 if !exists {
2004 conn.execute(
2005 &format!("ALTER TABLE {table} ADD COLUMN {column} {column_sql}"),
2006 [],
2007 )?;
2008 }
2009
2010 Ok(())
2011}
2012
2013pub fn check_embedding_metadata(
2015 conn: &Connection,
2016 config: &EmbeddingConfig,
2017) -> Result<(), MemoryError> {
2018 let existing: Option<(String, usize)> = conn
2020 .query_row(
2021 "SELECT model_name, dimensions FROM embedding_metadata WHERE id = 1",
2022 [],
2023 |row| Ok((row.get(0)?, row.get(1)?)),
2024 )
2025 .ok();
2026
2027 match existing {
2028 Some((model, dims)) => {
2029 if model != config.model || dims != config.dimensions {
2030 tracing::warn!(
2031 stored_model = %model,
2032 stored_dims = dims,
2033 configured_model = %config.model,
2034 configured_dims = config.dimensions,
2035 "Embedding model changed. Existing embeddings are stale."
2036 );
2037 conn.execute(
2038 "UPDATE embedding_metadata
2039 SET model_name = ?1,
2040 dimensions = ?2,
2041 embeddings_dirty = 1,
2042 updated_at = datetime('now')
2043 WHERE id = 1",
2044 params![config.model, config.dimensions],
2045 )?;
2046 }
2047 }
2048 None => {
2049 conn.execute(
2050 "INSERT INTO embedding_metadata (id, model_name, dimensions) VALUES (1, ?1, ?2)",
2051 params![config.model, config.dimensions],
2052 )?;
2053 }
2054 }
2055
2056 Ok(())
2057}
2058
2059pub fn embedding_to_bytes(embedding: &[f32]) -> Vec<u8> {
2061 encode_f32_le(embedding)
2062}
2063
2064pub fn encode_f32_le(values: &[f32]) -> Vec<u8> {
2066 let mut bytes = Vec::with_capacity(values.len() * 4);
2067 for value in values {
2068 bytes.extend_from_slice(&value.to_le_bytes());
2069 }
2070 bytes
2071}
2072
2073pub(crate) fn validate_embedding(values: &[f32], expected_dim: usize) -> Result<(), MemoryError> {
2075 if values.len() != expected_dim {
2076 return Err(MemoryError::EmbeddingDimensionMismatch {
2077 expected: expected_dim,
2078 actual: values.len(),
2079 });
2080 }
2081 if let Some((index, _)) = values
2082 .iter()
2083 .enumerate()
2084 .find(|(_, value)| !value.is_finite())
2085 {
2086 return Err(MemoryError::NonFiniteEmbeddingValue { index });
2087 }
2088 Ok(())
2089}
2090
2091pub(crate) fn validate_embedding_batch(
2093 values: &[Vec<f32>],
2094 requested: usize,
2095 expected_dim: usize,
2096) -> Result<(), MemoryError> {
2097 if values.len() != requested {
2098 return Err(MemoryError::EmbeddingBatchCountMismatch {
2099 requested,
2100 returned: values.len(),
2101 });
2102 }
2103 for embedding in values {
2104 validate_embedding(embedding, expected_dim)?;
2105 }
2106 Ok(())
2107}
2108
2109pub(crate) fn validate_vector_blob_len(
2111 bytes: &[u8],
2112 expected_dim: usize,
2113) -> Result<(), MemoryError> {
2114 let expected_bytes = expected_dim
2115 .checked_mul(4)
2116 .ok_or_else(|| MemoryError::InvalidConfig {
2117 field: "embedding.dimensions",
2118 reason: "dimension byte length overflow".to_string(),
2119 })?;
2120 if bytes.len() != expected_bytes {
2121 return Err(MemoryError::VectorBlobLengthMismatch {
2122 expected_bytes,
2123 actual_bytes: bytes.len(),
2124 });
2125 }
2126 Ok(())
2127}
2128
2129#[allow(clippy::manual_is_multiple_of)]
2131pub fn decode_f32_le(bytes: &[u8], expected_dim: usize) -> Result<Vec<f32>, MemoryError> {
2132 validate_vector_blob_len(bytes, expected_dim)?;
2133 decode_f32_le_unchecked_dim(bytes)
2134}
2135
2136#[allow(clippy::manual_is_multiple_of)]
2138pub fn bytes_to_embedding(bytes: &[u8]) -> Result<Vec<f32>, MemoryError> {
2139 if bytes.len() % 4 != 0 {
2140 return Err(MemoryError::InvalidEmbedding {
2141 expected_bytes: bytes.len() - (bytes.len() % 4),
2142 actual_bytes: bytes.len(),
2143 });
2144 }
2145
2146 decode_f32_le_unchecked_dim(bytes)
2147}
2148
2149fn decode_f32_le_unchecked_dim(bytes: &[u8]) -> Result<Vec<f32>, MemoryError> {
2150 let mut embedding = Vec::with_capacity(bytes.len() / 4);
2151 for (index, chunk) in bytes.chunks_exact(4).enumerate() {
2152 let value = f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
2153 if !value.is_finite() {
2154 return Err(MemoryError::NonFiniteEmbeddingValue { index });
2155 }
2156 embedding.push(value);
2157 }
2158 Ok(embedding)
2159}
2160
2161pub fn is_embeddings_dirty(conn: &Connection) -> Result<bool, MemoryError> {
2162 let dirty: i32 = conn
2163 .query_row(
2164 "SELECT COALESCE(embeddings_dirty, 0) FROM embedding_metadata WHERE id = 1",
2165 [],
2166 |row| row.get(0),
2167 )
2168 .unwrap_or(0);
2169 Ok(dirty != 0)
2170}
2171
2172pub fn clear_embeddings_dirty(conn: &Connection) -> Result<(), MemoryError> {
2173 conn.execute(
2174 "UPDATE embedding_metadata SET embeddings_dirty = 0 WHERE id = 1",
2175 [],
2176 )?;
2177 Ok(())
2178}
2179
2180#[cfg(feature = "hnsw")]
2181pub(crate) fn queue_pending_index_op(
2182 tx: &rusqlite::Transaction<'_>,
2183 item_key: &str,
2184 entity_type: &str,
2185 op_kind: IndexOpKind,
2186) -> Result<(), MemoryError> {
2187 tx.execute(
2188 "INSERT INTO pending_index_ops (item_key, entity_type, op_kind, attempt_count, last_error, updated_at)
2189 VALUES (?1, ?2, ?3, 0, NULL, datetime('now'))
2190 ON CONFLICT(item_key) DO UPDATE SET
2191 entity_type = excluded.entity_type,
2192 op_kind = excluded.op_kind,
2193 attempt_count = 0,
2194 last_error = NULL,
2195 updated_at = datetime('now')",
2196 params![item_key, entity_type, op_kind.as_str()],
2197 )?;
2198 mark_sidecar_dirty(tx)?;
2199 Ok(())
2200}
2201
2202#[cfg(feature = "hnsw")]
2203pub(crate) use IndexOpKind as PendingIndexOpKind;
2204
2205#[cfg(feature = "hnsw")]
2206pub(crate) fn enqueue_pending_index_op(
2207 tx: &rusqlite::Transaction<'_>,
2208 item_key: &str,
2209 entity_type: &str,
2210 op_kind: PendingIndexOpKind,
2211) -> Result<(), MemoryError> {
2212 queue_pending_index_op(tx, item_key, entity_type, op_kind)
2213}
2214
2215pub(crate) fn list_pending_index_ops(
2216 conn: &Connection,
2217) -> Result<Vec<PendingIndexOp>, MemoryError> {
2218 let table_exists: bool = conn
2219 .query_row(
2220 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='pending_index_ops'",
2221 [],
2222 |row| row.get(0),
2223 )
2224 .unwrap_or(false);
2225 if !table_exists {
2226 return Ok(Vec::new());
2227 }
2228
2229 let mut stmt = conn.prepare(
2230 "SELECT item_key, entity_type, op_kind, attempt_count, last_error
2231 FROM pending_index_ops
2232 ORDER BY updated_at ASC, item_key ASC",
2233 )?;
2234 let rows = stmt
2235 .query_map([], |row| {
2236 let item_key: String = row.get(0)?;
2237 let op_kind: String = row.get(2)?;
2238 Ok(PendingIndexOp {
2239 item_key: item_key.clone(),
2240 entity_type: row.get(1)?,
2241 op_kind: IndexOpKind::parse(&op_kind, &item_key).map_err(|e| {
2242 rusqlite::Error::FromSqlConversionFailure(
2243 2,
2244 rusqlite::types::Type::Text,
2245 Box::new(e),
2246 )
2247 })?,
2248 attempt_count: row.get::<_, i64>(3)? as u32,
2249 last_error: row.get(4)?,
2250 })
2251 })?
2252 .collect::<Result<Vec<_>, _>>()?;
2253 Ok(rows)
2254}
2255
2256#[cfg(feature = "hnsw")]
2257pub(crate) fn pending_index_op_count(conn: &Connection) -> Result<usize, MemoryError> {
2258 let table_exists: bool = conn
2259 .query_row(
2260 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='pending_index_ops'",
2261 [],
2262 |row| row.get(0),
2263 )
2264 .unwrap_or(false);
2265 if !table_exists {
2266 return Ok(0);
2267 }
2268
2269 let count: i64 = conn.query_row("SELECT COUNT(*) FROM pending_index_ops", [], |row| {
2270 row.get(0)
2271 })?;
2272 Ok(count as usize)
2273}
2274
2275#[cfg(feature = "hnsw")]
2276pub(crate) fn mark_pending_index_ops_failed(
2277 conn: &Connection,
2278 item_keys: &[String],
2279 error: &str,
2280) -> Result<(), MemoryError> {
2281 with_transaction(conn, |tx| {
2282 for item_key in item_keys {
2283 tx.execute(
2284 "UPDATE pending_index_ops
2285 SET attempt_count = attempt_count + 1,
2286 last_error = ?1,
2287 updated_at = datetime('now')
2288 WHERE item_key = ?2",
2289 params![error, item_key],
2290 )?;
2291 }
2292 Ok(())
2293 })
2294}
2295
2296#[cfg(feature = "hnsw")]
2297pub(crate) fn clear_pending_index_ops(
2298 conn: &Connection,
2299 item_keys: &[String],
2300) -> Result<(), MemoryError> {
2301 with_transaction(conn, |tx| {
2302 for item_key in item_keys {
2303 tx.execute(
2304 "DELETE FROM pending_index_ops WHERE item_key = ?1",
2305 params![item_key],
2306 )?;
2307 }
2308 Ok(())
2309 })
2310}
2311
2312#[cfg(feature = "hnsw")]
2313pub(crate) fn clear_all_pending_index_ops(conn: &Connection) -> Result<(), MemoryError> {
2314 conn.execute("DELETE FROM pending_index_ops", [])?;
2315 Ok(())
2316}
2317
2318#[cfg(feature = "hnsw")]
2319pub(crate) fn load_embedding_for_index_key(
2320 conn: &Connection,
2321 item_key: &str,
2322) -> Result<Option<Vec<f32>>, MemoryError> {
2323 let Some((domain, raw_id)) = item_key.split_once(':') else {
2324 return Err(MemoryError::InvalidKey(item_key.to_string()));
2325 };
2326
2327 let blob_result: Result<Option<Vec<u8>>, rusqlite::Error> = match domain {
2328 "fact" => conn.query_row(
2329 "SELECT embedding FROM facts WHERE id = ?1",
2330 params![raw_id],
2331 |row| row.get(0),
2332 ),
2333 "chunk" => conn.query_row(
2334 "SELECT embedding FROM chunks WHERE id = ?1",
2335 params![raw_id],
2336 |row| row.get(0),
2337 ),
2338 "msg" => {
2339 let message_id = raw_id
2340 .parse::<i64>()
2341 .map_err(|e| MemoryError::InvalidKey(format!("{}: {e}", item_key)))?;
2342 conn.query_row(
2343 "SELECT embedding FROM messages WHERE id = ?1",
2344 params![message_id],
2345 |row| row.get(0),
2346 )
2347 }
2348 "episode" => conn.query_row(
2349 "SELECT embedding FROM episodes WHERE episode_id = ?1",
2350 params![raw_id],
2351 |row| row.get(0),
2352 ),
2353 _ => return Err(MemoryError::InvalidKey(item_key.to_string())),
2354 };
2355
2356 let blob = match blob_result {
2357 Ok(blob) => blob,
2358 Err(rusqlite::Error::QueryReturnedNoRows) => None,
2359 Err(err) => return Err(err.into()),
2360 };
2361
2362 blob.map(|bytes| bytes_to_embedding(&bytes)).transpose()
2363}
2364
2365#[cfg(feature = "hnsw")]
2366fn mark_sidecar_dirty(tx: &rusqlite::Transaction<'_>) -> Result<(), MemoryError> {
2367 tx.execute(
2368 "INSERT INTO hnsw_metadata (key, value) VALUES ('sidecar_dirty', '1')
2369 ON CONFLICT(key) DO UPDATE SET value = '1'",
2370 [],
2371 )?;
2372 Ok(())
2373}
2374
2375#[cfg(feature = "hnsw")]
2376pub(crate) fn is_sidecar_dirty(conn: &Connection) -> Result<bool, MemoryError> {
2377 let dirty: Option<String> = conn
2379 .query_row(
2380 "SELECT value FROM hnsw_metadata WHERE key = 'sidecar_dirty'",
2381 [],
2382 |row| row.get(0),
2383 )
2384 .ok();
2385 Ok(matches!(dirty.as_deref(), Some("1")))
2386}
2387
2388#[cfg(feature = "hnsw")]
2389pub(crate) fn set_sidecar_dirty(conn: &Connection, dirty: bool) -> Result<(), MemoryError> {
2390 conn.execute(
2391 "INSERT INTO hnsw_metadata (key, value) VALUES ('sidecar_dirty', ?1)
2392 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
2393 params![if dirty { "1" } else { "0" }],
2394 )?;
2395 Ok(())
2396}
2397
2398pub(crate) fn parse_optional_json(
2399 table: &'static str,
2400 row_id: &str,
2401 field: &'static str,
2402 raw: Option<&str>,
2403) -> Result<Option<serde_json::Value>, MemoryError> {
2404 match raw {
2405 Some(raw) => serde_json::from_str(raw)
2406 .map(Some)
2407 .map_err(|e| MemoryError::CorruptData {
2408 table,
2409 row_id: row_id.to_string(),
2410 detail: format!("invalid {field}: {e}"),
2411 }),
2412 None => Ok(None),
2413 }
2414}
2415
2416pub(crate) fn parse_string_list_json(
2417 table: &'static str,
2418 row_id: &str,
2419 field: &'static str,
2420 raw: &str,
2421) -> Result<Vec<String>, MemoryError> {
2422 serde_json::from_str(raw).map_err(|e| MemoryError::CorruptData {
2423 table,
2424 row_id: row_id.to_string(),
2425 detail: format!("invalid {field}: {e}"),
2426 })
2427}
2428
2429pub(crate) fn parse_role(
2430 table: &'static str,
2431 row_id: &str,
2432 raw: &str,
2433) -> Result<Role, MemoryError> {
2434 Role::from_str_value(raw).ok_or_else(|| MemoryError::CorruptData {
2435 table,
2436 row_id: row_id.to_string(),
2437 detail: format!("invalid role '{raw}'"),
2438 })
2439}
2440
2441pub(crate) fn parse_episode_outcome(
2442 row_id: &str,
2443 raw: &str,
2444) -> Result<EpisodeOutcome, MemoryError> {
2445 EpisodeOutcome::from_str_value(raw).ok_or_else(|| MemoryError::CorruptData {
2446 table: "episodes",
2447 row_id: row_id.to_string(),
2448 detail: format!("invalid outcome '{raw}'"),
2449 })
2450}
2451
2452pub(crate) fn parse_verification_status(
2453 row_id: &str,
2454 raw: &str,
2455) -> Result<VerificationStatus, MemoryError> {
2456 serde_json::from_str(raw).map_err(|e| MemoryError::CorruptData {
2457 table: "episodes",
2458 row_id: row_id.to_string(),
2459 detail: format!("invalid verification_status: {e}"),
2460 })
2461}
2462
2463pub fn verify_integrity_sync(
2465 conn: &Connection,
2466 mode: VerifyMode,
2467) -> Result<IntegrityReport, MemoryError> {
2468 let mut issues = Vec::new();
2469
2470 let schema_version: u32 = conn
2471 .query_row("PRAGMA user_version", [], |row| row.get(0))
2472 .unwrap_or_else(|e| {
2473 issues.push(format!("failed to read schema version: {e}"));
2474 0
2475 });
2476 if schema_version > MAX_SCHEMA_VERSION {
2477 issues.push(format!(
2478 "schema version {} is ahead of supported {}",
2479 schema_version, MAX_SCHEMA_VERSION
2480 ));
2481 }
2482
2483 let fact_count: usize = conn
2484 .query_row("SELECT COUNT(*) FROM facts", [], |row| row.get(0))
2485 .unwrap_or_else(|e| {
2486 issues.push(format!("failed to count facts: {e}"));
2487 0
2488 });
2489 let chunk_count: usize = conn
2490 .query_row("SELECT COUNT(*) FROM chunks", [], |row| row.get(0))
2491 .unwrap_or_else(|e| {
2492 issues.push(format!("failed to count chunks: {e}"));
2493 0
2494 });
2495 let message_count: usize = conn
2496 .query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))
2497 .unwrap_or_else(|e| {
2498 issues.push(format!("failed to count messages: {e}"));
2499 0
2500 });
2501 let episode_count: usize = conn
2502 .query_row("SELECT COUNT(*) FROM episodes", [], |row| row.get(0))
2503 .unwrap_or_else(|e| {
2504 issues.push(format!("failed to count episodes: {e}"));
2505 0
2506 });
2507
2508 let facts_missing_embeddings: usize = conn
2509 .query_row(
2510 "SELECT COUNT(*) FROM facts WHERE embedding IS NULL",
2511 [],
2512 |row| row.get(0),
2513 )
2514 .unwrap_or_else(|e| {
2515 issues.push(format!("failed to count facts missing embeddings: {e}"));
2516 0
2517 });
2518 let chunks_missing_embeddings: usize = conn
2519 .query_row(
2520 "SELECT COUNT(*) FROM chunks WHERE embedding IS NULL",
2521 [],
2522 |row| row.get(0),
2523 )
2524 .unwrap_or_else(|e| {
2525 issues.push(format!("failed to count chunks missing embeddings: {e}"));
2526 0
2527 });
2528 let episodes_missing_embeddings: usize = conn
2529 .query_row(
2530 "SELECT COUNT(*) FROM episodes WHERE embedding IS NULL",
2531 [],
2532 |row| row.get(0),
2533 )
2534 .unwrap_or_else(|e| {
2535 issues.push(format!("failed to count episodes missing embeddings: {e}"));
2536 0
2537 });
2538
2539 if facts_missing_embeddings > 0 {
2540 issues.push(format!(
2541 "{} facts missing embeddings",
2542 facts_missing_embeddings
2543 ));
2544 }
2545 if chunks_missing_embeddings > 0 {
2546 issues.push(format!(
2547 "{} chunks missing embeddings",
2548 chunks_missing_embeddings
2549 ));
2550 }
2551 if episodes_missing_embeddings > 0 {
2552 issues.push(format!(
2553 "{} episodes missing embeddings",
2554 episodes_missing_embeddings
2555 ));
2556 }
2557
2558 let pending_ops = list_pending_index_ops(conn).unwrap_or_default();
2559 if !pending_ops.is_empty() {
2560 issues.push(format!(
2561 "{} pending HNSW sidecar ops queued in SQLite",
2562 pending_ops.len()
2563 ));
2564 for op in pending_ops.iter().take(5) {
2565 let op_kind = op.op_kind.as_str();
2566 let detail = match &op.last_error {
2567 Some(last_error) => format!(
2568 "{} {} {} (attempts: {}, last_error: {})",
2569 op.entity_type,
2570 op.op_kind.as_str(),
2571 op.item_key,
2572 op.attempt_count,
2573 last_error
2574 ),
2575 None => format!(
2576 "{} {} {} (attempts: {})",
2577 op.entity_type, op_kind, op.item_key, op.attempt_count
2578 ),
2579 };
2580 issues.push(format!("pending sidecar op: {detail}"));
2581 }
2582 }
2583
2584 if mode == VerifyMode::Full {
2585 let dims: usize = conn
2586 .query_row(
2587 "SELECT dimensions FROM embedding_metadata WHERE id = 1",
2588 [],
2589 |row| row.get(0),
2590 )
2591 .unwrap_or_else(|e| {
2592 issues.push(format!("failed to read embedding dimensions: {e}"));
2593 0
2594 });
2595
2596 verify_fts_drift(conn, "facts", "facts_rowid_map", fact_count, &mut issues);
2597 verify_fts_drift(conn, "chunks", "chunks_rowid_map", chunk_count, &mut issues);
2598 verify_fts_drift(
2599 conn,
2600 "messages",
2601 "messages_rowid_map",
2602 message_count,
2603 &mut issues,
2604 );
2605 verify_fts_drift(
2606 conn,
2607 "episodes",
2608 "episodes_rowid_map",
2609 episode_count,
2610 &mut issues,
2611 );
2612
2613 verify_blob_table(conn, "facts", "id", "embedding", dims, &mut issues)?;
2614 verify_blob_table(conn, "chunks", "id", "embedding", dims, &mut issues)?;
2615 verify_blob_table(conn, "messages", "id", "embedding", dims, &mut issues)?;
2616 verify_blob_table(
2617 conn,
2618 "episodes",
2619 "episode_id",
2620 "embedding",
2621 dims,
2622 &mut issues,
2623 )?;
2624
2625 verify_quantized_table(conn, "facts", "id", dims, &mut issues)?;
2626 verify_quantized_table(conn, "chunks", "id", dims, &mut issues)?;
2627 verify_quantized_table(conn, "messages", "id", dims, &mut issues)?;
2628 verify_quantized_table(conn, "episodes", "episode_id", dims, &mut issues)?;
2629
2630 verify_session_rows(conn, &mut issues)?;
2631 verify_message_rows(conn, &mut issues)?;
2632 verify_fact_rows(conn, &mut issues)?;
2633 verify_document_rows(conn, &mut issues)?;
2634 verify_episode_rows(conn, &mut issues)?;
2635
2636 let integrity_check: String = conn
2637 .query_row("PRAGMA integrity_check", [], |row| row.get(0))
2638 .unwrap_or_else(|_| "error".to_string());
2639 if integrity_check != "ok" {
2640 issues.push(format!("SQLite integrity_check: {}", integrity_check));
2641 }
2642 }
2643
2644 Ok(IntegrityReport {
2645 ok: issues.is_empty(),
2646 schema_version,
2647 fact_count,
2648 chunk_count,
2649 message_count,
2650 facts_missing_embeddings,
2651 chunks_missing_embeddings,
2652 issues,
2653 })
2654}
2655
2656pub fn reconcile_fts(conn: &Connection) -> Result<(), MemoryError> {
2658 with_transaction(conn, |tx| {
2659 tx.execute_batch("DROP TABLE IF EXISTS facts_fts")?;
2660 tx.execute_batch("DELETE FROM facts_rowid_map")?;
2661 tx.execute_batch(
2662 "CREATE VIRTUAL TABLE facts_fts USING fts5(
2663 content,
2664 content='',
2665 content_rowid='rowid',
2666 tokenize='porter unicode61'
2667 )",
2668 )?;
2669 tx.execute_batch("INSERT INTO facts_rowid_map (fact_id) SELECT id FROM facts")?;
2670 tx.execute_batch(
2671 "INSERT INTO facts_fts (rowid, content)
2672 SELECT rm.rowid, f.content
2673 FROM facts_rowid_map rm
2674 JOIN facts f ON f.id = rm.fact_id",
2675 )?;
2676
2677 tx.execute_batch("DROP TABLE IF EXISTS chunks_fts")?;
2678 tx.execute_batch("DELETE FROM chunks_rowid_map")?;
2679 tx.execute_batch(
2680 "CREATE VIRTUAL TABLE chunks_fts USING fts5(
2681 content,
2682 content='',
2683 content_rowid='rowid',
2684 tokenize='porter unicode61'
2685 )",
2686 )?;
2687 tx.execute_batch("INSERT INTO chunks_rowid_map (chunk_id) SELECT id FROM chunks")?;
2688 tx.execute_batch(
2689 "INSERT INTO chunks_fts (rowid, content)
2690 SELECT rm.rowid, c.content
2691 FROM chunks_rowid_map rm
2692 JOIN chunks c ON c.id = rm.chunk_id",
2693 )?;
2694
2695 tx.execute_batch("DROP TABLE IF EXISTS messages_fts")?;
2696 tx.execute_batch("DELETE FROM messages_rowid_map")?;
2697 tx.execute_batch(
2698 "CREATE VIRTUAL TABLE messages_fts USING fts5(
2699 content,
2700 content='',
2701 content_rowid='rowid',
2702 tokenize='porter unicode61'
2703 )",
2704 )?;
2705 tx.execute_batch("INSERT INTO messages_rowid_map (message_id) SELECT id FROM messages")?;
2706 tx.execute_batch(
2707 "INSERT INTO messages_fts (rowid, content)
2708 SELECT rm.rowid, m.content
2709 FROM messages_rowid_map rm
2710 JOIN messages m ON m.id = rm.message_id",
2711 )?;
2712
2713 tx.execute_batch("DROP TABLE IF EXISTS episodes_fts")?;
2714 tx.execute_batch("DELETE FROM episodes_rowid_map")?;
2715 tx.execute_batch(
2716 "CREATE VIRTUAL TABLE episodes_fts USING fts5(
2717 content,
2718 content='',
2719 content_rowid='rowid',
2720 tokenize='porter unicode61'
2721 )",
2722 )?;
2723 tx.execute_batch(
2724 "INSERT INTO episodes_rowid_map (episode_id, document_id) SELECT episode_id, document_id FROM episodes",
2725 )?;
2726 tx.execute_batch(
2727 "INSERT INTO episodes_fts (rowid, content)
2728 SELECT rm.rowid, e.search_text
2729 FROM episodes_rowid_map rm
2730 JOIN episodes e ON e.episode_id = rm.episode_id",
2731 )?;
2732
2733 Ok(())
2734 })?;
2735
2736 tracing::info!("FTS indexes reconciled");
2737 Ok(())
2738}
2739
2740fn verify_fts_drift(
2741 conn: &Connection,
2742 label: &str,
2743 map_table: &str,
2744 source_count: usize,
2745 issues: &mut Vec<String>,
2746) {
2747 let table_exists: bool = conn
2748 .query_row(
2749 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name = ?1",
2750 params![map_table],
2751 |row| row.get(0),
2752 )
2753 .unwrap_or(false);
2754 if !table_exists {
2755 if source_count > 0 {
2756 issues.push(format!("{} rows exist but {} is missing", label, map_table));
2757 }
2758 return;
2759 }
2760
2761 let sql = format!("SELECT COUNT(*) FROM {}", map_table);
2762 let indexed_count: usize = conn.query_row(&sql, [], |row| row.get(0)).unwrap_or(0);
2763 if indexed_count != source_count {
2764 issues.push(format!(
2765 "FTS {} index drift: {} rows in map vs {} source rows",
2766 label, indexed_count, source_count
2767 ));
2768 }
2769}
2770
2771fn verify_blob_table(
2772 conn: &Connection,
2773 table: &'static str,
2774 id_column: &'static str,
2775 blob_column: &'static str,
2776 expected_dims: usize,
2777 issues: &mut Vec<String>,
2778) -> Result<(), MemoryError> {
2779 if expected_dims == 0 {
2780 return Ok(());
2781 }
2782
2783 let sql = format!(
2784 "SELECT CAST({id_column} AS TEXT), {blob_column} FROM {table} WHERE {blob_column} IS NOT NULL"
2785 );
2786 let mut stmt = conn.prepare(&sql)?;
2787 let rows = stmt.query_map([], |row| {
2788 Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?))
2789 })?;
2790
2791 for row in rows {
2792 let (row_id, blob) = row?;
2793 match bytes_to_embedding(&blob) {
2794 Ok(embedding) if embedding.len() != expected_dims => issues.push(format!(
2795 "{}({}) has embedding dimension {} but expected {}",
2796 table,
2797 row_id,
2798 embedding.len(),
2799 expected_dims
2800 )),
2801 Ok(_) => {}
2802 Err(err) => issues.push(format!(
2803 "{}({}) invalid embedding blob: {}",
2804 table, row_id, err
2805 )),
2806 }
2807 }
2808
2809 Ok(())
2810}
2811
2812fn verify_quantized_table(
2813 conn: &Connection,
2814 table: &'static str,
2815 id_column: &'static str,
2816 expected_dims: usize,
2817 issues: &mut Vec<String>,
2818) -> Result<(), MemoryError> {
2819 if expected_dims == 0 {
2820 return Ok(());
2821 }
2822
2823 let sql = format!(
2824 "SELECT CAST({id_column} AS TEXT), embedding_q8 FROM {table} WHERE embedding IS NOT NULL"
2825 );
2826 let mut stmt = conn.prepare(&sql)?;
2827 let rows = stmt.query_map([], |row| {
2828 Ok((row.get::<_, String>(0)?, row.get::<_, Option<Vec<u8>>>(1)?))
2829 })?;
2830
2831 for row in rows {
2832 let (row_id, blob) = row?;
2833 match blob {
2834 Some(blob) => {
2835 if let Err(err) = unpack_quantized(&blob, expected_dims) {
2836 issues.push(format!(
2837 "{}({}) invalid quantized embedding: {}",
2838 table, row_id, err
2839 ));
2840 }
2841 }
2842 None => issues.push(format!("{}({}) missing quantized embedding", table, row_id)),
2843 }
2844 }
2845
2846 Ok(())
2847}
2848
2849fn verify_session_rows(conn: &Connection, issues: &mut Vec<String>) -> Result<(), MemoryError> {
2850 let mut stmt = conn.prepare("SELECT id, metadata FROM sessions WHERE metadata IS NOT NULL")?;
2851 let rows = stmt.query_map([], |row| {
2852 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2853 })?;
2854 for row in rows {
2855 let (id, metadata) = row?;
2856 if let Err(err) = parse_optional_json("sessions", &id, "metadata", Some(&metadata)) {
2857 issues.push(err.to_string());
2858 }
2859 }
2860 Ok(())
2861}
2862
2863fn verify_message_rows(conn: &Connection, issues: &mut Vec<String>) -> Result<(), MemoryError> {
2864 let mut stmt = conn.prepare("SELECT id, role, metadata FROM messages")?;
2865 let rows = stmt.query_map([], |row| {
2866 Ok((
2867 row.get::<_, i64>(0)?,
2868 row.get::<_, String>(1)?,
2869 row.get::<_, Option<String>>(2)?,
2870 ))
2871 })?;
2872 for row in rows {
2873 let (id, role, metadata) = row?;
2874 let row_id = id.to_string();
2875 if let Err(err) = parse_role("messages", &row_id, &role) {
2876 issues.push(err.to_string());
2877 }
2878 if let Err(err) = parse_optional_json("messages", &row_id, "metadata", metadata.as_deref())
2879 {
2880 issues.push(err.to_string());
2881 }
2882 }
2883 Ok(())
2884}
2885
2886fn verify_fact_rows(conn: &Connection, issues: &mut Vec<String>) -> Result<(), MemoryError> {
2887 let mut stmt = conn.prepare("SELECT id, metadata FROM facts WHERE metadata IS NOT NULL")?;
2888 let rows = stmt.query_map([], |row| {
2889 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2890 })?;
2891 for row in rows {
2892 let (id, metadata) = row?;
2893 if let Err(err) = parse_optional_json("facts", &id, "metadata", Some(&metadata)) {
2894 issues.push(err.to_string());
2895 }
2896 }
2897 Ok(())
2898}
2899
2900fn verify_document_rows(conn: &Connection, issues: &mut Vec<String>) -> Result<(), MemoryError> {
2901 let mut stmt = conn.prepare("SELECT id, metadata FROM documents WHERE metadata IS NOT NULL")?;
2902 let rows = stmt.query_map([], |row| {
2903 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2904 })?;
2905 for row in rows {
2906 let (id, metadata) = row?;
2907 if let Err(err) = parse_optional_json("documents", &id, "metadata", Some(&metadata)) {
2908 issues.push(err.to_string());
2909 }
2910 }
2911 Ok(())
2912}
2913
2914fn verify_episode_rows(conn: &Connection, issues: &mut Vec<String>) -> Result<(), MemoryError> {
2915 let mut stmt = conn.prepare(
2916 "SELECT episode_id, cause_ids, outcome, verification_status
2917 FROM episodes",
2918 )?;
2919 let rows = stmt.query_map([], |row| {
2920 Ok((
2921 row.get::<_, String>(0)?,
2922 row.get::<_, String>(1)?,
2923 row.get::<_, String>(2)?,
2924 row.get::<_, String>(3)?,
2925 ))
2926 })?;
2927 for row in rows {
2928 let (episode_id, cause_ids, outcome, verification_status) = row?;
2929 if let Err(err) = parse_string_list_json("episodes", &episode_id, "cause_ids", &cause_ids) {
2930 issues.push(err.to_string());
2931 }
2932 if let Err(err) = parse_episode_outcome(&episode_id, &outcome) {
2933 issues.push(err.to_string());
2934 }
2935 if let Err(err) = parse_verification_status(&episode_id, &verification_status) {
2936 issues.push(err.to_string());
2937 }
2938 }
2939 Ok(())
2940}
2941
2942#[derive(Debug, Clone)]
2943pub(crate) struct ProveKvPoolGenerationRow {
2944 pub generation: ProveKvPoolGenerationV1,
2945}
2946
2947#[allow(dead_code)] fn parse_provekv_status(value: &str) -> ProveKvPoolGenerationStatus {
2949 match value {
2950 "disabled" => ProveKvPoolGenerationStatus::Disabled,
2951 "missing" => ProveKvPoolGenerationStatus::Missing,
2952 "building" => ProveKvPoolGenerationStatus::Building,
2953 "ready" => ProveKvPoolGenerationStatus::Ready,
2954 "stale" => ProveKvPoolGenerationStatus::Stale,
2955 "failed" => ProveKvPoolGenerationStatus::Failed,
2956 _ => ProveKvPoolGenerationStatus::Failed,
2957 }
2958}
2959
2960fn provekv_generation_from_row(
2961 row: &rusqlite::Row<'_>,
2962) -> rusqlite::Result<ProveKvPoolGenerationRow> {
2963 let created_at: String = row.get(9)?;
2964 Ok(ProveKvPoolGenerationRow {
2965 generation: ProveKvPoolGenerationV1 {
2966 schema_version: "semantic_memory_provekv_pool_generation_v1".to_string(),
2967 generation_id: row.get(0)?,
2968 embedding_snapshot_digest: row.get(1)?,
2969 source_digest: row.get(2)?,
2970 pool_manifest_digest: row.get(3)?,
2971 codec_family: row.get(4)?,
2972 codec_profile: row.get(5)?,
2973 vector_dim: row.get::<_, i64>(6)? as usize,
2974 item_count: row.get::<_, i64>(7)? as usize,
2975 payload_bytes: row.get::<_, i64>(8)? as u64,
2976 created_at: DateTime::parse_from_rfc3339(&created_at)
2977 .map(|dt| dt.with_timezone(&Utc))
2978 .map_err(|err| {
2979 rusqlite::Error::FromSqlConversionFailure(
2980 9,
2981 rusqlite::types::Type::Text,
2982 Box::new(err),
2983 )
2984 })?,
2985 },
2986 })
2987}
2988
2989#[allow(dead_code)]
2990pub(crate) fn insert_provekv_pool_generation(
2991 conn: &Connection,
2992 generation: &ProveKvPoolGenerationV1,
2993 payload: &[u8],
2994 item_map: &[ProveKvPoolItemMapEntryV1],
2995) -> Result<(), MemoryError> {
2996 let tx = conn.unchecked_transaction()?;
2997 tx.execute(
2998 "INSERT OR REPLACE INTO provekv_pool_generations
2999 (generation_id, embedding_snapshot_digest, source_digest, pool_manifest_digest,
3000 codec_family, codec_profile, vector_dim, item_count, payload_bytes, payload,
3001 status, failure_reason, created_at)
3002 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 'ready', NULL, ?11)",
3003 params![
3004 generation.generation_id,
3005 generation.embedding_snapshot_digest,
3006 generation.source_digest,
3007 generation.pool_manifest_digest,
3008 generation.codec_family,
3009 generation.codec_profile,
3010 generation.vector_dim as i64,
3011 generation.item_count as i64,
3012 generation.payload_bytes as i64,
3013 payload,
3014 generation.created_at.to_rfc3339(),
3015 ],
3016 )?;
3017 tx.execute(
3018 "DELETE FROM provekv_pool_item_map WHERE generation_id = ?1",
3019 params![generation.generation_id],
3020 )?;
3021 for entry in item_map {
3022 tx.execute(
3023 "INSERT INTO provekv_pool_item_map
3024 (generation_id, item_id, source_type, pool_index, embedding_digest)
3025 VALUES (?1, ?2, ?3, ?4, ?5)",
3026 params![
3027 entry.generation_id,
3028 entry.item_id,
3029 entry.source_type,
3030 entry.pool_index as i64,
3031 entry.embedding_digest,
3032 ],
3033 )?;
3034 }
3035 tx.commit()?;
3036 Ok(())
3037}
3038
3039pub(crate) fn latest_ready_provekv_pool_generation(
3040 conn: &Connection,
3041) -> Result<Option<ProveKvPoolGenerationRow>, MemoryError> {
3042 conn.query_row(
3043 "SELECT generation_id, embedding_snapshot_digest, source_digest, pool_manifest_digest,
3044 codec_family, codec_profile, vector_dim, item_count, payload_bytes, created_at
3045 FROM provekv_pool_generations
3046 WHERE status = 'ready'
3047 ORDER BY created_at DESC
3048 LIMIT 1",
3049 [],
3050 provekv_generation_from_row,
3051 )
3052 .optional()
3053 .map_err(MemoryError::from)
3054}
3055
3056pub(crate) fn load_provekv_pool_payload(
3057 conn: &Connection,
3058 generation_id: &str,
3059) -> Result<Vec<u8>, MemoryError> {
3060 conn.query_row(
3061 "SELECT payload FROM provekv_pool_generations WHERE generation_id = ?1",
3062 params![generation_id],
3063 |row| row.get(0),
3064 )
3065 .map_err(MemoryError::from)
3066}
3067
3068pub(crate) fn load_provekv_pool_item_map(
3069 conn: &Connection,
3070 generation_id: &str,
3071) -> Result<Vec<ProveKvPoolItemMapEntryV1>, MemoryError> {
3072 let mut stmt = conn.prepare(
3073 "SELECT generation_id, item_id, source_type, pool_index, embedding_digest
3074 FROM provekv_pool_item_map
3075 WHERE generation_id = ?1
3076 ORDER BY pool_index ASC",
3077 )?;
3078 let rows = stmt.query_map(params![generation_id], |row| {
3079 Ok(ProveKvPoolItemMapEntryV1 {
3080 generation_id: row.get(0)?,
3081 item_id: row.get(1)?,
3082 source_type: row.get(2)?,
3083 pool_index: row.get::<_, i64>(3)? as usize,
3084 embedding_digest: row.get(4)?,
3085 })
3086 })?;
3087 let mut entries = Vec::new();
3088 for row in rows {
3089 entries.push(row?);
3090 }
3091 Ok(entries)
3092}
3093
3094#[allow(dead_code)]
3095pub(crate) fn mark_provekv_pool_generation_failed(
3096 conn: &Connection,
3097 generation_id: &str,
3098 reason: &str,
3099) -> Result<(), MemoryError> {
3100 conn.execute(
3101 "UPDATE provekv_pool_generations SET status = 'failed', failure_reason = ?2 WHERE generation_id = ?1",
3102 params![generation_id, reason],
3103 )?;
3104 Ok(())
3105}
3106
3107#[allow(dead_code)] pub(crate) fn provekv_pool_artifact_status(
3109 conn: &Connection,
3110) -> Result<ProveKvPoolArtifactStatusV1, MemoryError> {
3111 let row = conn
3112 .query_row(
3113 "SELECT generation_id, embedding_snapshot_digest, pool_manifest_digest,
3114 item_count, payload_bytes, status, failure_reason
3115 FROM provekv_pool_generations
3116 ORDER BY created_at DESC
3117 LIMIT 1",
3118 [],
3119 |row| {
3120 Ok((
3121 row.get::<_, String>(0)?,
3122 row.get::<_, String>(1)?,
3123 row.get::<_, String>(2)?,
3124 row.get::<_, i64>(3)?,
3125 row.get::<_, i64>(4)?,
3126 row.get::<_, String>(5)?,
3127 row.get::<_, Option<String>>(6)?,
3128 ))
3129 },
3130 )
3131 .optional()?;
3132 Ok(match row {
3133 Some((
3134 generation_id,
3135 snapshot_digest,
3136 manifest_digest,
3137 item_count,
3138 payload_bytes,
3139 status,
3140 reason,
3141 )) => ProveKvPoolArtifactStatusV1 {
3142 status: parse_provekv_status(&status),
3143 generation_id: Some(generation_id),
3144 embedding_snapshot_digest: Some(snapshot_digest),
3145 pool_manifest_digest: Some(manifest_digest),
3146 item_count: item_count as usize,
3147 payload_bytes: payload_bytes as u64,
3148 reason,
3149 },
3150 None => ProveKvPoolArtifactStatusV1 {
3151 status: ProveKvPoolGenerationStatus::Missing,
3152 generation_id: None,
3153 embedding_snapshot_digest: None,
3154 pool_manifest_digest: None,
3155 item_count: 0,
3156 payload_bytes: 0,
3157 reason: Some("provekv_pool_generation_not_materialized".into()),
3158 },
3159 })
3160}
3161
3162#[cfg(test)]
3163mod provekv_pool_generation_db_tests {
3164 use super::*;
3165
3166 fn test_conn() -> Connection {
3167 let conn = Connection::open_in_memory().expect("in-memory db opens");
3168 conn.execute_batch(MIGRATION_V24)
3169 .expect("proveKV schema migration applies");
3170 conn
3171 }
3172
3173 fn generation(id: &str) -> ProveKvPoolGenerationV1 {
3174 ProveKvPoolGenerationV1 {
3175 schema_version: "semantic_memory_provekv_pool_generation_v1".to_string(),
3176 generation_id: id.to_string(),
3177 embedding_snapshot_digest: "blake3:snapshot".to_string(),
3178 source_digest: "blake3:source".to_string(),
3179 pool_manifest_digest: "blake3:manifest".to_string(),
3180 codec_family: "provekv_pool".to_string(),
3181 codec_profile: "semantic-memory-f32-derived-candidate-v1".to_string(),
3182 vector_dim: 4,
3183 item_count: 2,
3184 payload_bytes: 3,
3185 created_at: Utc::now(),
3186 }
3187 }
3188
3189 #[test]
3190 fn provekv_pool_generation_roundtrips_and_cascades_item_map() {
3191 let conn = test_conn();
3192 let gen = generation("gen-1");
3193 let item_map = vec![
3194 ProveKvPoolItemMapEntryV1 {
3195 generation_id: gen.generation_id.clone(),
3196 item_id: "fact-1".to_string(),
3197 source_type: "fact".to_string(),
3198 pool_index: 0,
3199 embedding_digest: "blake3:item-1".to_string(),
3200 },
3201 ProveKvPoolItemMapEntryV1 {
3202 generation_id: gen.generation_id.clone(),
3203 item_id: "fact-2".to_string(),
3204 source_type: "fact".to_string(),
3205 pool_index: 1,
3206 embedding_digest: "blake3:item-2".to_string(),
3207 },
3208 ];
3209 insert_provekv_pool_generation(&conn, &gen, &[1, 2, 3], &item_map).unwrap();
3210
3211 let latest = latest_ready_provekv_pool_generation(&conn)
3212 .unwrap()
3213 .expect("latest ready generation");
3214 assert_eq!(latest.generation.generation_id, gen.generation_id);
3215 assert_eq!(
3216 load_provekv_pool_payload(&conn, "gen-1").unwrap(),
3217 vec![1, 2, 3]
3218 );
3219 assert_eq!(
3220 load_provekv_pool_item_map(&conn, "gen-1").unwrap(),
3221 item_map
3222 );
3223
3224 mark_provekv_pool_generation_failed(&conn, "gen-1", "boom").unwrap();
3225 let status = provekv_pool_artifact_status(&conn).unwrap();
3226 assert_eq!(status.status, ProveKvPoolGenerationStatus::Failed);
3227 assert_eq!(status.reason.as_deref(), Some("boom"));
3228
3229 conn.execute(
3230 "DELETE FROM provekv_pool_generations WHERE generation_id = 'gen-1'",
3231 [],
3232 )
3233 .unwrap();
3234 let count: i64 = conn
3235 .query_row("SELECT COUNT(*) FROM provekv_pool_item_map", [], |row| {
3236 row.get(0)
3237 })
3238 .unwrap();
3239 assert_eq!(count, 0);
3240 }
3241}