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
701pub fn open_database_connection(
703 path: &Path,
704 pool: &PoolConfig,
705 limits: &MemoryLimits,
706) -> Result<Connection, MemoryError> {
707 open_database_internal(path, pool, limits.max_db_size_bytes, false)
708}
709
710pub(crate) fn open_database_internal(
711 path: &Path,
712 pool: &PoolConfig,
713 max_db_size_bytes: u64,
714 run_schema_migrations: bool,
715) -> Result<Connection, MemoryError> {
716 create_parent_dirs(path)?;
717 let conn = Connection::open(path)?;
718 configure_connection(&conn, path, pool, max_db_size_bytes, false)?;
719 if run_schema_migrations {
720 run_migrations(&conn)?;
721 }
722 Ok(conn)
723}
724
725pub(crate) fn open_pool_member_connection(
726 path: &Path,
727 pool: &PoolConfig,
728 limits: &MemoryLimits,
729 query_only: bool,
730) -> Result<Connection, MemoryError> {
731 create_parent_dirs(path)?;
732 let flags = OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE;
733 let conn = Connection::open_with_flags(path, flags)?;
734 configure_connection(&conn, path, pool, limits.max_db_size_bytes, query_only)?;
735 Ok(conn)
736}
737
738fn create_parent_dirs(path: &Path) -> Result<(), MemoryError> {
739 if let Some(parent) = path.parent() {
740 if !parent.as_os_str().is_empty() {
741 std::fs::create_dir_all(parent).map_err(|e| {
742 MemoryError::StorageError(format!(
743 "failed to create database directory {}: {}",
744 parent.display(),
745 e
746 ))
747 })?;
748 }
749 }
750 Ok(())
751}
752
753fn configure_connection(
754 conn: &Connection,
755 path: &Path,
756 pool: &PoolConfig,
757 max_db_size_bytes: u64,
758 query_only: bool,
759) -> Result<(), MemoryError> {
760 let journal_mode = if pool.enable_wal { "WAL" } else { "DELETE" };
761 conn.execute_batch(&format!(
762 "PRAGMA journal_mode = {};
763 PRAGMA foreign_keys = ON;
764 PRAGMA busy_timeout = {};
765 PRAGMA synchronous = NORMAL;
766 PRAGMA temp_store = MEMORY;
767 PRAGMA wal_autocheckpoint = {};",
768 journal_mode, pool.busy_timeout_ms, pool.wal_autocheckpoint,
769 ))?;
770
771 if query_only {
772 conn.execute_batch("PRAGMA query_only = ON;")?;
773 }
774
775 let actual_journal_mode: String =
776 conn.query_row("PRAGMA journal_mode", [], |row| row.get(0))?;
777 let expected_journal_mode = if pool.enable_wal { "wal" } else { "delete" };
778 if actual_journal_mode.to_lowercase() != expected_journal_mode {
779 return Err(MemoryError::StorageError(format!(
780 "SQLite journal mode mismatch for {}: requested {}, got {}",
781 path.display(),
782 expected_journal_mode,
783 actual_journal_mode
784 )));
785 }
786
787 if max_db_size_bytes > 0 {
788 let page_size: u64 = conn.query_row("PRAGMA page_size", [], |row| row.get(0))?;
789 let max_page_count = max_db_size_bytes.div_ceil(page_size);
790
791 const MAX_SQLITE_PAGE_COUNT: u64 = 1_073_741_823; const MIN_SQLITE_PAGE_COUNT: u64 = 1;
794 if !(MIN_SQLITE_PAGE_COUNT..=MAX_SQLITE_PAGE_COUNT).contains(&max_page_count) {
795 return Err(MemoryError::StorageError(format!(
796 "Invalid max_page_count {}: must be between {} and {}",
797 max_page_count, MIN_SQLITE_PAGE_COUNT, MAX_SQLITE_PAGE_COUNT
798 )));
799 }
800
801 let actual_max_page_count: u64 = conn.query_row(
802 &format!("PRAGMA max_page_count = {}", max_page_count),
803 [],
804 |row| row.get(0),
805 )?;
806 let page_count: u64 = conn.query_row("PRAGMA page_count", [], |row| row.get(0))?;
807
808 if page_count > actual_max_page_count {
809 return Err(MemoryError::DatabaseSizeLimitExceeded {
810 current: page_count.saturating_mul(page_size),
811 limit: max_db_size_bytes,
812 });
813 }
814 }
815
816 let foreign_keys_enabled: bool = conn.query_row("PRAGMA foreign_keys", [], |row| row.get(0))?;
818 if !foreign_keys_enabled {
819 return Err(MemoryError::StorageError(
820 "PRAGMA foreign_keys failed to enable after configuration".to_string(),
821 ));
822 }
823
824 Ok(())
825}
826
827pub fn run_migrations(conn: &Connection) -> Result<(), MemoryError> {
829 let user_version: u32 = conn
830 .query_row("PRAGMA user_version", [], |row| row.get(0))
831 .map_err(|e| MemoryError::MigrationFailed {
832 version: 0,
833 reason: format!("failed to read PRAGMA user_version: {e}"),
834 })?;
835
836 if user_version > MAX_SCHEMA_VERSION {
837 return Err(MemoryError::SchemaAhead {
838 found: user_version,
839 supported: MAX_SCHEMA_VERSION,
840 });
841 }
842
843 conn.execute_batch(
844 "CREATE TABLE IF NOT EXISTS _schema_version (
845 version INTEGER PRIMARY KEY,
846 applied_at TEXT NOT NULL DEFAULT (datetime('now'))
847 );",
848 )?;
849
850 for &(version, sql) in MIGRATIONS {
851 let current_version: u32 = conn
852 .query_row(
853 "SELECT COALESCE(MAX(version), 0) FROM _schema_version",
854 [],
855 |row| row.get(0),
856 )
857 .unwrap_or(0);
858
859 if current_version >= version {
860 continue;
861 }
862
863 with_transaction(conn, |tx| {
864 match version {
865 9 => run_migration_v9(tx).map_err(|e| MemoryError::MigrationFailed {
866 version,
867 reason: e.to_string(),
868 })?,
869 16 => run_migration_v16(tx).map_err(|e| MemoryError::MigrationFailed {
870 version,
871 reason: e.to_string(),
872 })?,
873 17 => run_migration_v17(tx).map_err(|e| MemoryError::MigrationFailed {
874 version,
875 reason: e.to_string(),
876 })?,
877 20 => run_migration_v20(tx).map_err(|e| MemoryError::MigrationFailed {
878 version,
879 reason: e.to_string(),
880 })?,
881 21 => run_migration_v21(tx).map_err(|e| MemoryError::MigrationFailed {
882 version,
883 reason: e.to_string(),
884 })?,
885 26 => run_migration_v26(tx).map_err(|e| MemoryError::MigrationFailed {
886 version,
887 reason: e.to_string(),
888 })?,
889 _ => tx
890 .execute_batch(sql)
891 .map_err(|e| MemoryError::MigrationFailed {
892 version,
893 reason: e.to_string(),
894 })?,
895 }
896 tx.execute(
897 "INSERT INTO _schema_version (version) VALUES (?1)",
898 params![version],
899 )
900 .map_err(|e| MemoryError::MigrationFailed {
901 version,
902 reason: e.to_string(),
903 })?;
904 Ok(())
905 })?;
906
907 tracing::info!("Applied migration V{}", version);
908 }
909
910 let final_version: u32 = conn
911 .query_row(
912 "SELECT COALESCE(MAX(version), 0) FROM _schema_version",
913 [],
914 |row| row.get(0),
915 )
916 .unwrap_or(0);
917 conn.execute_batch(&format!("PRAGMA user_version = {};", final_version))?;
918
919 Ok(())
920}
921
922fn run_migration_v16(conn: &Connection) -> Result<(), rusqlite::Error> {
923 add_column_if_missing(conn, "projection_import_log", "kernel_payload_json", "TEXT")?;
924 add_column_if_missing(
925 conn,
926 "projection_import_failures",
927 "kernel_payload_json",
928 "TEXT",
929 )?;
930 Ok(())
931}
932
933fn run_migration_v17(conn: &Connection) -> Result<(), rusqlite::Error> {
934 add_column_if_missing(conn, "projection_import_log", "episode_bundle_id", "TEXT")?;
935 add_column_if_missing(conn, "projection_import_log", "episode_bundle_json", "TEXT")?;
936 add_column_if_missing(
937 conn,
938 "projection_import_log",
939 "execution_context_json",
940 "TEXT",
941 )?;
942 add_column_if_missing(
943 conn,
944 "projection_import_failures",
945 "episode_bundle_id",
946 "TEXT",
947 )?;
948 add_column_if_missing(
949 conn,
950 "projection_import_failures",
951 "episode_bundle_json",
952 "TEXT",
953 )?;
954 add_column_if_missing(
955 conn,
956 "projection_import_failures",
957 "execution_context_json",
958 "TEXT",
959 )?;
960 Ok(())
961}
962
963fn run_migration_v20(conn: &Connection) -> Result<(), rusqlite::Error> {
964 add_column_if_missing(conn, "derived_vector_artifacts", "encoded_digest", "TEXT")?;
965 conn.execute(
966 "UPDATE derived_vector_artifacts
967 SET encoded_digest = artifact_digest
968 WHERE encoded_digest IS NULL OR encoded_digest = ''",
969 [],
970 )?;
971 add_column_if_missing(
972 conn,
973 "derived_vector_artifacts",
974 "encoding",
975 "TEXT NOT NULL DEFAULT 'turbo_code_wire_v1'",
976 )?;
977 add_column_if_missing(
978 conn,
979 "derived_vector_artifacts",
980 "dim",
981 "INTEGER NOT NULL DEFAULT 0",
982 )?;
983 add_column_if_missing(
984 conn,
985 "derived_vector_artifacts",
986 "status",
987 "TEXT NOT NULL DEFAULT 'active'",
988 )?;
989 conn.execute_batch(
990 "CREATE INDEX IF NOT EXISTS idx_derived_vector_artifacts_profile
991 ON derived_vector_artifacts(codec_family, codec_profile_digest, status);
992 CREATE INDEX IF NOT EXISTS idx_derived_vector_artifacts_source_digest
993 ON derived_vector_artifacts(source_embedding_digest);",
994 )?;
995 Ok(())
996}
997
998fn run_migration_v21(conn: &Connection) -> Result<(), rusqlite::Error> {
999 conn.execute_batch(MIGRATION_V21)?;
1000 add_column_if_missing(conn, "derived_vector_artifacts", "generation_id", "TEXT")?;
1001 conn.execute_batch(
1002 "CREATE INDEX IF NOT EXISTS idx_derived_vector_artifacts_generation
1003 ON derived_vector_artifacts(generation_id, status);",
1004 )?;
1005 Ok(())
1006}
1007
1008const SEARCH_RECEIPT_SCHEMA_VERSION: &str = "vector_search_receipt_v1";
1009
1010#[derive(Debug, Serialize, Deserialize)]
1011struct StoredVectorSearchReceiptV1 {
1012 #[serde(default = "default_search_receipt_schema_version")]
1013 schema_version: String,
1014 receipt_id: String,
1015 evaluation_time: DateTime<Utc>,
1016 #[serde(default)]
1017 receipt_digest: Option<String>,
1018 #[serde(default)]
1019 trace_id: Option<String>,
1020 #[serde(default)]
1021 attempt_family_id: Option<String>,
1022 #[serde(default)]
1023 attempt_id: Option<String>,
1024 #[serde(default)]
1025 replay_of: Option<String>,
1026 query_embedding_digest: Option<String>,
1027 #[serde(default)]
1028 query_text_digest: Option<String>,
1029 #[serde(default)]
1030 query_input_digest: Option<String>,
1031 #[serde(default)]
1032 filter_digest: Option<String>,
1033 #[serde(default)]
1034 redaction_state: Option<String>,
1035 #[serde(default)]
1036 budget_id: Option<String>,
1037 #[serde(default)]
1038 deadline_at: Option<DateTime<Utc>>,
1039 search_profile: String,
1040 candidate_backend: String,
1041 codec_family: Option<String>,
1042 codec_profile_digest: Option<String>,
1043 #[serde(default)]
1044 artifact_profile_digest: Option<String>,
1045 #[serde(default)]
1046 artifact_count: Option<u64>,
1047 #[serde(default)]
1048 artifact_corruption_count: Option<u64>,
1049 #[serde(default)]
1050 artifact_missing_count: Option<u64>,
1051 #[serde(default)]
1052 vector_artifact_manifest_digest: Option<String>,
1053 #[serde(default)]
1054 artifact_generation_id: Option<String>,
1055 #[serde(default)]
1056 approximate_scanned_count: Option<u64>,
1057 #[serde(default)]
1058 approximate_returned_count: Option<u64>,
1059 #[serde(default)]
1060 raw_rows_loaded_count: Option<u64>,
1061 #[serde(default)]
1062 filter_strategy: Option<String>,
1063 #[serde(default)]
1064 vector_artifact_count: Option<u64>,
1065 #[serde(default)]
1066 vector_artifact_missing_count: Option<u64>,
1067 #[serde(default)]
1068 vector_artifact_stale_count: Option<u64>,
1069 #[serde(default)]
1070 exact_rerank_count: Option<u64>,
1071 #[serde(default)]
1072 approximate_candidate_count: Option<u64>,
1073 #[serde(default)]
1074 fallback_reason: Option<String>,
1075 approximate: bool,
1076 requested_candidates: u64,
1077 returned_candidates: u64,
1078 post_filter_candidates: u64,
1079 fallback: Option<String>,
1080 exact_rerank: bool,
1081 result_ids: Vec<String>,
1082 degradations: Vec<String>,
1083}
1084
1085fn default_search_receipt_schema_version() -> String {
1086 SEARCH_RECEIPT_SCHEMA_VERSION.to_string()
1087}
1088
1089fn b3_digest(bytes: &[u8]) -> String {
1090 format!("blake3:{}", ContentDigest::compute(bytes).hex())
1091}
1092
1093#[cfg(feature = "turbo-quant-codec")]
1095#[derive(Debug, Clone)]
1096pub(crate) struct DerivedVectorArtifactRow {
1097 pub item_key: String,
1098 pub generation_id: Option<String>,
1099 pub codec_family: String,
1100 pub codec_profile_digest: String,
1101 pub source_embedding_digest: String,
1102 pub encoded_digest: String,
1103 pub encoding: String,
1104 pub dim: usize,
1105 pub status: String,
1106 pub encoded: Vec<u8>,
1107 pub codec_governance_receipt_id: Option<String>,
1109 pub codec_profile: Option<String>,
1110 pub degradation_budget: Option<f64>,
1111 pub raw_source_artifact_id: Option<String>,
1112}
1113
1114#[cfg(feature = "turbo-quant-codec")]
1116#[derive(Debug, Clone)]
1117#[allow(dead_code)]
1118pub(crate) struct DerivedVectorArtifactGenerationRow {
1119 pub generation_id: String,
1120 pub codec_family: String,
1121 pub codec_profile_digest: String,
1122 pub source_snapshot_digest: String,
1123 pub source_row_count: usize,
1124 pub artifact_count: usize,
1125 pub dim: usize,
1126 pub encoding: String,
1127 pub artifact_manifest_digest: String,
1128 pub status: String,
1129}
1130
1131#[cfg(feature = "turbo-quant-codec")]
1133pub(crate) fn source_embedding_digest(
1134 blob: &[u8],
1135 expected_dim: usize,
1136) -> Result<String, MemoryError> {
1137 validate_vector_blob_len(blob, expected_dim)?;
1138 let mut builder = DigestBuilder::new();
1139 builder
1140 .update_str("semantic-memory.source_embedding.v1")
1141 .separator()
1142 .update(&(expected_dim as u64).to_le_bytes())
1143 .separator()
1144 .update(blob);
1145 Ok(format!("blake3:{}", builder.finalize().hex()))
1146}
1147
1148#[cfg(feature = "turbo-quant-codec")]
1149fn source_snapshot_digest(rows: &[DerivedVectorArtifactRow], dim: usize) -> String {
1150 let mut entries = rows
1151 .iter()
1152 .map(|row| (row.item_key.as_str(), row.source_embedding_digest.as_str()))
1153 .collect::<Vec<_>>();
1154 entries.sort_unstable();
1155
1156 let mut builder = DigestBuilder::new();
1157 builder
1158 .update_str("semantic-memory.vector_source_snapshot.v1")
1159 .separator()
1160 .update(&(dim as u64).to_le_bytes())
1161 .separator();
1162 for (item_key, source_embedding_digest) in entries {
1163 builder
1164 .update_str(item_key)
1165 .separator()
1166 .update_str(source_embedding_digest)
1167 .separator();
1168 }
1169 format!("blake3:{}", builder.finalize().hex())
1170}
1171
1172#[cfg(feature = "turbo-quant-codec")]
1173pub(crate) fn current_source_snapshot_digest(
1174 conn: &Connection,
1175 dim: usize,
1176) -> Result<(String, usize), MemoryError> {
1177 let mut stmt = conn.prepare(
1178 "SELECT 'fact:' || id AS item_key, embedding FROM facts WHERE embedding IS NOT NULL
1179 UNION ALL
1180 SELECT 'chunk:' || id AS item_key, embedding FROM chunks WHERE embedding IS NOT NULL
1181 UNION ALL
1182 SELECT 'msg:' || id AS item_key, embedding FROM messages WHERE embedding IS NOT NULL
1183 UNION ALL
1184 SELECT 'episode:' || episode_id AS item_key, embedding FROM episodes WHERE embedding IS NOT NULL",
1185 )?;
1186 let rows = stmt.query_map([], |row| {
1187 Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?))
1188 })?;
1189
1190 let mut entries = Vec::new();
1191 for row in rows {
1192 let (item_key, blob) = row?;
1193 entries.push((item_key, source_embedding_digest(&blob, dim)?));
1194 }
1195 entries.sort_unstable();
1196
1197 let mut builder = DigestBuilder::new();
1198 builder
1199 .update_str("semantic-memory.vector_source_snapshot.v1")
1200 .separator()
1201 .update(&(dim as u64).to_le_bytes())
1202 .separator();
1203 for (item_key, source_embedding_digest) in &entries {
1204 builder
1205 .update_str(item_key)
1206 .separator()
1207 .update_str(source_embedding_digest)
1208 .separator();
1209 }
1210 Ok((
1211 format!("blake3:{}", builder.finalize().hex()),
1212 entries.len(),
1213 ))
1214}
1215
1216#[cfg(feature = "turbo-quant-codec")]
1217fn derived_artifact_manifest_digest(rows: &[DerivedVectorArtifactRow]) -> String {
1218 let mut entries = rows
1219 .iter()
1220 .map(|row| {
1221 (
1222 row.item_key.as_str(),
1223 row.source_embedding_digest.as_str(),
1224 row.encoded_digest.as_str(),
1225 )
1226 })
1227 .collect::<Vec<_>>();
1228 entries.sort_unstable();
1229
1230 let mut builder = DigestBuilder::new();
1231 builder
1232 .update_str("semantic-memory.vector_artifact_manifest.v1")
1233 .separator();
1234 for (item_key, source_embedding_digest, encoded_digest) in entries {
1235 builder
1236 .update_str(item_key)
1237 .separator()
1238 .update_str(source_embedding_digest)
1239 .separator()
1240 .update_str(encoded_digest)
1241 .separator();
1242 }
1243 format!("blake3:{}", builder.finalize().hex())
1244}
1245
1246#[cfg(feature = "turbo-quant-codec")]
1247pub(crate) fn upsert_derived_vector_artifact(
1248 conn: &Connection,
1249 row: &DerivedVectorArtifactRow,
1250) -> Result<(), MemoryError> {
1251 conn.execute(
1252 "INSERT OR REPLACE INTO derived_vector_artifacts
1253 (item_key, generation_id, codec_family, codec_profile_digest, source_embedding_digest,
1254 encoded_digest, artifact_digest, encoding, dim, encoded, created_at, status,
1255 codec_governance_receipt_id, codec_profile, degradation_budget, raw_source_artifact_id)
1256 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6, ?7, ?8, ?9, datetime('now'), ?10, ?11, ?12, ?13, ?14)",
1257 params![
1258 row.item_key,
1259 row.generation_id.as_deref(),
1260 row.codec_family,
1261 row.codec_profile_digest,
1262 row.source_embedding_digest,
1263 row.encoded_digest,
1264 row.encoding,
1265 i64::try_from(row.dim)
1266 .map_err(|err| MemoryError::Other(format!("artifact dim overflow: {err}")))?,
1267 row.encoded,
1268 row.status,
1269 row.codec_governance_receipt_id.as_deref(),
1270 row.codec_profile.as_deref(),
1271 row.degradation_budget,
1272 row.raw_source_artifact_id.as_deref(),
1273 ],
1274 )?;
1275 Ok(())
1276}
1277
1278pub fn delete_derived_vector_artifact(
1279 conn: &Connection,
1280 item_key: &str,
1281) -> Result<(), MemoryError> {
1282 conn.execute(
1283 "DELETE FROM derived_vector_artifacts WHERE item_key = ?1",
1284 params![item_key],
1285 )?;
1286 Ok(())
1287}
1288
1289pub fn invalidate_derived_vector_artifact(
1290 conn: &Connection,
1291 item_key: &str,
1292) -> Result<(), MemoryError> {
1293 conn.execute(
1294 "UPDATE derived_vector_artifacts
1295 SET status = 'invalidated'
1296 WHERE item_key = ?1 AND status = 'active'",
1297 params![item_key],
1298 )?;
1299 conn.execute(
1300 "UPDATE derived_vector_artifact_generations
1301 SET status = 'invalidated'
1302 WHERE status = 'active'",
1303 [],
1304 )?;
1305 Ok(())
1306}
1307
1308#[cfg(feature = "turbo-quant-codec")]
1309#[allow(dead_code)]
1310pub(crate) fn load_derived_vector_artifacts_by_profile(
1311 conn: &Connection,
1312 codec_family: &str,
1313 codec_profile_digest: &str,
1314) -> Result<Vec<DerivedVectorArtifactRow>, MemoryError> {
1315 let mut stmt = conn.prepare(
1316 "SELECT item_key, generation_id, codec_family, codec_profile_digest, source_embedding_digest,
1317 encoded_digest, encoding, dim, status, encoded,
1318 codec_governance_receipt_id, codec_profile, degradation_budget, raw_source_artifact_id
1319 FROM derived_vector_artifacts
1320 WHERE codec_family = ?1 AND codec_profile_digest = ?2 AND status = 'active'",
1321 )?;
1322 let rows = stmt.query_map(params![codec_family, codec_profile_digest], |row| {
1323 let dim_i64: i64 = row.get(7)?;
1324 Ok(DerivedVectorArtifactRow {
1325 item_key: row.get(0)?,
1326 generation_id: row.get(1)?,
1327 codec_family: row.get(2)?,
1328 codec_profile_digest: row.get(3)?,
1329 source_embedding_digest: row.get(4)?,
1330 encoded_digest: row.get(5)?,
1331 encoding: row.get(6)?,
1332 dim: usize::try_from(dim_i64).map_err(|err| {
1333 rusqlite::Error::FromSqlConversionFailure(
1334 7,
1335 rusqlite::types::Type::Integer,
1336 Box::new(err),
1337 )
1338 })?,
1339 status: row.get(8)?,
1340 encoded: row.get(9)?,
1341 codec_governance_receipt_id: row.get(10)?,
1342 codec_profile: row.get(11)?,
1343 degradation_budget: row.get(12)?,
1344 raw_source_artifact_id: row.get(13)?,
1345 })
1346 })?;
1347
1348 let mut artifacts = Vec::new();
1349 for row in rows {
1350 artifacts.push(row?);
1351 }
1352 Ok(artifacts)
1353}
1354
1355#[cfg(feature = "turbo-quant-codec")]
1356pub(crate) fn load_derived_vector_artifacts_by_generation(
1357 conn: &Connection,
1358 generation_id: &str,
1359) -> Result<Vec<DerivedVectorArtifactRow>, MemoryError> {
1360 let mut stmt = conn.prepare(
1361 "SELECT item_key, generation_id, codec_family, codec_profile_digest, source_embedding_digest,
1362 encoded_digest, encoding, dim, status, encoded,
1363 codec_governance_receipt_id, codec_profile, degradation_budget, raw_source_artifact_id
1364 FROM derived_vector_artifacts
1365 WHERE generation_id = ?1 AND status = 'active'",
1366 )?;
1367 let rows = stmt.query_map(params![generation_id], |row| {
1368 let dim_i64: i64 = row.get(7)?;
1369 Ok(DerivedVectorArtifactRow {
1370 item_key: row.get(0)?,
1371 generation_id: row.get(1)?,
1372 codec_family: row.get(2)?,
1373 codec_profile_digest: row.get(3)?,
1374 source_embedding_digest: row.get(4)?,
1375 encoded_digest: row.get(5)?,
1376 encoding: row.get(6)?,
1377 dim: usize::try_from(dim_i64).map_err(|err| {
1378 rusqlite::Error::FromSqlConversionFailure(
1379 7,
1380 rusqlite::types::Type::Integer,
1381 Box::new(err),
1382 )
1383 })?,
1384 status: row.get(8)?,
1385 encoded: row.get(9)?,
1386 codec_governance_receipt_id: row.get(10)?,
1387 codec_profile: row.get(11)?,
1388 degradation_budget: row.get(12)?,
1389 raw_source_artifact_id: row.get(13)?,
1390 })
1391 })?;
1392
1393 let mut artifacts = Vec::new();
1394 for row in rows {
1395 artifacts.push(row?);
1396 }
1397 Ok(artifacts)
1398}
1399
1400#[cfg(feature = "turbo-quant-codec")]
1401pub(crate) fn current_derived_vector_generation(
1402 conn: &Connection,
1403 codec_family: &str,
1404 codec_profile_digest: &str,
1405) -> Result<Option<DerivedVectorArtifactGenerationRow>, MemoryError> {
1406 conn.query_row(
1407 "SELECT generation_id, codec_family, codec_profile_digest, source_snapshot_digest,
1408 source_row_count, artifact_count, dim, encoding, artifact_manifest_digest, status
1409 FROM derived_vector_artifact_generations
1410 WHERE codec_family = ?1 AND codec_profile_digest = ?2 AND status = 'active'
1411 ORDER BY created_at DESC
1412 LIMIT 1",
1413 params![codec_family, codec_profile_digest],
1414 |row| {
1415 let source_row_count: i64 = row.get(4)?;
1416 let artifact_count: i64 = row.get(5)?;
1417 let dim: i64 = row.get(6)?;
1418 Ok(DerivedVectorArtifactGenerationRow {
1419 generation_id: row.get(0)?,
1420 codec_family: row.get(1)?,
1421 codec_profile_digest: row.get(2)?,
1422 source_snapshot_digest: row.get(3)?,
1423 source_row_count: usize::try_from(source_row_count).map_err(|err| {
1424 rusqlite::Error::FromSqlConversionFailure(
1425 4,
1426 rusqlite::types::Type::Integer,
1427 Box::new(err),
1428 )
1429 })?,
1430 artifact_count: usize::try_from(artifact_count).map_err(|err| {
1431 rusqlite::Error::FromSqlConversionFailure(
1432 5,
1433 rusqlite::types::Type::Integer,
1434 Box::new(err),
1435 )
1436 })?,
1437 dim: usize::try_from(dim).map_err(|err| {
1438 rusqlite::Error::FromSqlConversionFailure(
1439 6,
1440 rusqlite::types::Type::Integer,
1441 Box::new(err),
1442 )
1443 })?,
1444 encoding: row.get(7)?,
1445 artifact_manifest_digest: row.get(8)?,
1446 status: row.get(9)?,
1447 })
1448 },
1449 )
1450 .optional()
1451 .map_err(MemoryError::from)
1452}
1453
1454pub fn count_derived_vector_artifacts(
1455 conn: &Connection,
1456 codec_family: &str,
1457 codec_profile_digest: &str,
1458) -> Result<usize, MemoryError> {
1459 let count: i64 = conn.query_row(
1460 "SELECT COUNT(*) FROM derived_vector_artifacts
1461 WHERE codec_family = ?1 AND codec_profile_digest = ?2 AND status = 'active'",
1462 params![codec_family, codec_profile_digest],
1463 |row| row.get(0),
1464 )?;
1465 usize::try_from(count)
1466 .map_err(|err| MemoryError::Other(format!("derived artifact count overflow: {err}")))
1467}
1468
1469#[cfg(feature = "turbo-quant-codec")]
1470pub(crate) fn rebuild_turbo_quant_artifacts(
1471 conn: &Connection,
1472 dim: usize,
1473 bits: u8,
1474 projections: usize,
1475 seed: u64,
1476) -> Result<VectorArtifactBuildReceiptV1, MemoryError> {
1477 use crate::vector_codec::{TurboQuantCodec, VectorCodec};
1478
1479 let started = std::time::Instant::now();
1480 let codec = TurboQuantCodec::new(dim, bits, projections, seed)?;
1481 let codec_profile_digest = codec.profile().digest();
1482 let generation_id = uuid::Uuid::new_v4().to_string();
1483 let mut source_row_count = 0usize;
1484 let mut artifact_count = 0usize;
1485 let mut skipped_row_count = 0usize;
1486 let mut degradations = Vec::new();
1487
1488 let mut stmt = conn.prepare(
1489 "SELECT 'fact:' || id AS item_key, embedding FROM facts WHERE embedding IS NOT NULL
1490 UNION ALL
1491 SELECT 'chunk:' || id AS item_key, embedding FROM chunks WHERE embedding IS NOT NULL
1492 UNION ALL
1493 SELECT 'msg:' || id AS item_key, embedding FROM messages WHERE embedding IS NOT NULL
1494 UNION ALL
1495 SELECT 'episode:' || episode_id AS item_key, embedding FROM episodes WHERE embedding IS NOT NULL",
1496 )?;
1497 let rows = stmt.query_map([], |row| {
1498 Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?))
1499 })?;
1500
1501 let mut pending = Vec::new();
1502 for row in rows {
1503 let (item_key, blob) = row?;
1504 source_row_count += 1;
1505 let embedding = match decode_f32_le(&blob, dim) {
1506 Ok(embedding) => embedding,
1507 Err(err) => {
1508 skipped_row_count += 1;
1509 degradations.push(format!(
1510 "skipped {item_key}: invalid authoritative embedding: {err}"
1511 ));
1512 continue;
1513 }
1514 };
1515 let artifact = match codec.encode(&embedding) {
1516 Ok(artifact) => artifact,
1517 Err(err) => {
1518 skipped_row_count += 1;
1519 degradations.push(format!("skipped {item_key}: encode failed: {err}"));
1520 continue;
1521 }
1522 };
1523 pending.push(DerivedVectorArtifactRow {
1524 item_key,
1525 generation_id: Some(generation_id.clone()),
1526 codec_family: "turbo_quant".to_string(),
1527 codec_profile_digest: codec_profile_digest.clone(),
1528 source_embedding_digest: source_embedding_digest(&blob, dim)?,
1529 encoded_digest: artifact.artifact_digest,
1530 encoding: "turbo_code_wire_v1".to_string(),
1531 dim,
1532 status: "active".to_string(),
1533 encoded: artifact.encoded,
1534 codec_governance_receipt_id: None,
1537 codec_profile: None,
1538 degradation_budget: None,
1539 raw_source_artifact_id: None,
1540 });
1541 }
1542 drop(stmt);
1543
1544 let build_receipt_id = uuid::Uuid::new_v4().to_string();
1545 let source_snapshot_digest = source_snapshot_digest(&pending, dim);
1546 let artifact_manifest_digest = derived_artifact_manifest_digest(&pending);
1547 let source_tables = vec![
1548 "facts".to_string(),
1549 "chunks".to_string(),
1550 "messages".to_string(),
1551 "episodes".to_string(),
1552 ];
1553 let generation_manifest = DerivedVectorArtifactGenerationV1 {
1554 schema_version: "derived_vector_artifact_generation_v1".to_string(),
1555 generation_id: generation_id.clone(),
1556 codec_family: "turbo_quant".to_string(),
1557 codec_profile_digest: codec_profile_digest.clone(),
1558 source_snapshot_digest: source_snapshot_digest.clone(),
1559 source_row_count,
1560 artifact_count: pending.len(),
1561 source_tables,
1562 dim,
1563 encoding: "turbo_code_wire_v1".to_string(),
1564 created_at: Utc::now(),
1565 build_receipt_id: Some(build_receipt_id.clone()),
1566 artifact_manifest_digest: artifact_manifest_digest.clone(),
1567 status: if skipped_row_count == 0 {
1568 "active".to_string()
1569 } else {
1570 "failed".to_string()
1571 },
1572 degradations: degradations.clone(),
1573 };
1574
1575 with_transaction(conn, |tx| {
1576 tx.execute(
1577 "UPDATE derived_vector_artifact_generations
1578 SET status = 'superseded'
1579 WHERE codec_family = ?1 AND codec_profile_digest = ?2 AND status = 'active'",
1580 params!["turbo_quant", &codec_profile_digest],
1581 )?;
1582 tx.execute(
1583 "DELETE FROM derived_vector_artifacts
1584 WHERE codec_family = ?1 AND codec_profile_digest = ?2",
1585 params!["turbo_quant", &codec_profile_digest],
1586 )?;
1587 tx.execute(
1588 "INSERT INTO derived_vector_artifact_generations
1589 (generation_id, schema_version, codec_family, codec_profile_digest,
1590 source_snapshot_digest, source_row_count, artifact_count, source_tables_json,
1591 dim, encoding, created_at, build_receipt_id, artifact_manifest_digest,
1592 status, degradations_json)
1593 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
1594 params![
1595 generation_manifest.generation_id,
1596 generation_manifest.schema_version,
1597 generation_manifest.codec_family,
1598 generation_manifest.codec_profile_digest,
1599 generation_manifest.source_snapshot_digest,
1600 i64::try_from(generation_manifest.source_row_count).map_err(|err| {
1601 MemoryError::Other(format!("source row count overflow: {err}"))
1602 })?,
1603 i64::try_from(generation_manifest.artifact_count).map_err(|err| {
1604 MemoryError::Other(format!("artifact count overflow: {err}"))
1605 })?,
1606 serde_json::to_string(&generation_manifest.source_tables)
1607 .map_err(|err| MemoryError::Other(err.to_string()))?,
1608 i64::try_from(generation_manifest.dim)
1609 .map_err(|err| MemoryError::Other(format!("artifact dim overflow: {err}")))?,
1610 generation_manifest.encoding,
1611 generation_manifest.created_at.to_rfc3339(),
1612 generation_manifest.build_receipt_id,
1613 generation_manifest.artifact_manifest_digest,
1614 generation_manifest.status,
1615 serde_json::to_string(&generation_manifest.degradations)
1616 .map_err(|err| MemoryError::Other(err.to_string()))?,
1617 ],
1618 )?;
1619 for row in &pending {
1620 upsert_derived_vector_artifact(tx, row)?;
1621 artifact_count += 1;
1622 }
1623 Ok(())
1624 })?;
1625
1626 Ok(VectorArtifactBuildReceiptV1 {
1627 schema_version: "vector_artifact_build_receipt_v1".to_string(),
1628 codec_family: "turbo_quant".to_string(),
1629 codec_profile_digest,
1630 source_row_count,
1631 artifact_count,
1632 generation_id: Some(generation_id),
1633 source_snapshot_digest: Some(source_snapshot_digest),
1634 artifact_manifest_digest: Some(artifact_manifest_digest),
1635 build_receipt_id: Some(build_receipt_id),
1636 skipped_row_count,
1637 elapsed_ms: started.elapsed().as_millis(),
1638 created_at: Utc::now(),
1639 degradations,
1640 })
1641}
1642
1643fn receipt_count_to_u64(value: usize, field: &'static str) -> Result<u64, MemoryError> {
1644 u64::try_from(value).map_err(|err| MemoryError::Other(format!("{field} is too large: {err}")))
1645}
1646
1647fn receipt_count_to_i64(value: u64, field: &'static str) -> Result<i64, MemoryError> {
1648 i64::try_from(value).map_err(|err| MemoryError::Other(format!("{field} is too large: {err}")))
1649}
1650
1651fn receipt_count_to_usize(
1652 value: u64,
1653 receipt_id: &str,
1654 field: &'static str,
1655) -> Result<usize, MemoryError> {
1656 usize::try_from(value).map_err(|err| MemoryError::CorruptData {
1657 table: "search_receipts",
1658 row_id: receipt_id.to_string(),
1659 detail: format!("{field} does not fit this platform: {err}"),
1660 })
1661}
1662
1663fn stored_search_receipt(
1664 receipt: &VectorSearchReceiptV1,
1665) -> Result<StoredVectorSearchReceiptV1, MemoryError> {
1666 Ok(StoredVectorSearchReceiptV1 {
1667 schema_version: SEARCH_RECEIPT_SCHEMA_VERSION.to_string(),
1668 receipt_id: receipt.receipt_id.clone(),
1669 evaluation_time: receipt.evaluation_time,
1670 receipt_digest: receipt.receipt_digest.clone(),
1671 trace_id: receipt.trace_id.clone(),
1672 attempt_family_id: receipt.attempt_family_id.clone(),
1673 attempt_id: receipt.attempt_id.clone(),
1674 replay_of: receipt.replay_of.clone(),
1675 query_embedding_digest: receipt.query_embedding_digest.clone(),
1676 query_text_digest: receipt.query_text_digest.clone(),
1677 query_input_digest: receipt.query_input_digest.clone(),
1678 filter_digest: receipt.filter_digest.clone(),
1679 redaction_state: receipt.redaction_state.clone(),
1680 budget_id: receipt.budget_id.clone(),
1681 deadline_at: receipt.deadline_at,
1682 search_profile: receipt.search_profile.clone(),
1683 candidate_backend: receipt.candidate_backend.clone(),
1684 codec_family: receipt.codec_family.clone(),
1685 codec_profile_digest: receipt.codec_profile_digest.clone(),
1686 artifact_profile_digest: receipt.artifact_profile_digest.clone(),
1687 artifact_count: receipt
1688 .artifact_count
1689 .map(|value| receipt_count_to_u64(value, "artifact_count"))
1690 .transpose()?,
1691 artifact_corruption_count: receipt
1692 .artifact_corruption_count
1693 .map(|value| receipt_count_to_u64(value, "artifact_corruption_count"))
1694 .transpose()?,
1695 artifact_missing_count: receipt
1696 .artifact_missing_count
1697 .map(|value| receipt_count_to_u64(value, "artifact_missing_count"))
1698 .transpose()?,
1699 vector_artifact_manifest_digest: receipt.vector_artifact_manifest_digest.clone(),
1700 artifact_generation_id: receipt.artifact_generation_id.clone(),
1701 approximate_scanned_count: receipt
1702 .approximate_scanned_count
1703 .map(|value| receipt_count_to_u64(value, "approximate_scanned_count"))
1704 .transpose()?,
1705 approximate_returned_count: receipt
1706 .approximate_returned_count
1707 .map(|value| receipt_count_to_u64(value, "approximate_returned_count"))
1708 .transpose()?,
1709 raw_rows_loaded_count: receipt
1710 .raw_rows_loaded_count
1711 .map(|value| receipt_count_to_u64(value, "raw_rows_loaded_count"))
1712 .transpose()?,
1713 filter_strategy: receipt.filter_strategy.clone(),
1714 vector_artifact_count: receipt
1715 .vector_artifact_count
1716 .map(|value| receipt_count_to_u64(value, "vector_artifact_count"))
1717 .transpose()?,
1718 vector_artifact_missing_count: receipt
1719 .vector_artifact_missing_count
1720 .map(|value| receipt_count_to_u64(value, "vector_artifact_missing_count"))
1721 .transpose()?,
1722 vector_artifact_stale_count: receipt
1723 .vector_artifact_stale_count
1724 .map(|value| receipt_count_to_u64(value, "vector_artifact_stale_count"))
1725 .transpose()?,
1726 exact_rerank_count: receipt
1727 .exact_rerank_count
1728 .map(|value| receipt_count_to_u64(value, "exact_rerank_count"))
1729 .transpose()?,
1730 approximate_candidate_count: receipt
1731 .approximate_candidate_count
1732 .map(|value| receipt_count_to_u64(value, "approximate_candidate_count"))
1733 .transpose()?,
1734 fallback_reason: receipt.fallback_reason.clone(),
1735 approximate: receipt.approximate,
1736 requested_candidates: receipt_count_to_u64(
1737 receipt.requested_candidates,
1738 "requested_candidates",
1739 )?,
1740 returned_candidates: receipt_count_to_u64(
1741 receipt.returned_candidates,
1742 "returned_candidates",
1743 )?,
1744 post_filter_candidates: receipt_count_to_u64(
1745 receipt.post_filter_candidates,
1746 "post_filter_candidates",
1747 )?,
1748 fallback: receipt.fallback.clone(),
1749 exact_rerank: receipt.exact_rerank,
1750 result_ids: receipt.result_ids.clone(),
1751 degradations: receipt.degradations.clone(),
1752 })
1753}
1754
1755fn search_receipt_from_stored(
1756 stored: StoredVectorSearchReceiptV1,
1757) -> Result<VectorSearchReceiptV1, MemoryError> {
1758 if stored.schema_version != SEARCH_RECEIPT_SCHEMA_VERSION {
1759 return Err(MemoryError::CorruptData {
1760 table: "search_receipts",
1761 row_id: stored.receipt_id,
1762 detail: format!(
1763 "unsupported receipt schema version '{}'",
1764 stored.schema_version
1765 ),
1766 });
1767 }
1768
1769 Ok(VectorSearchReceiptV1 {
1770 schema_version: stored.schema_version.clone(),
1771 receipt_digest: stored.receipt_digest,
1772 receipt_id: stored.receipt_id.clone(),
1773 evaluation_time: stored.evaluation_time,
1774 trace_id: stored.trace_id,
1775 attempt_family_id: stored.attempt_family_id,
1776 attempt_id: stored.attempt_id,
1777 replay_of: stored.replay_of,
1778 query_embedding_digest: stored.query_embedding_digest,
1779 query_text_digest: stored.query_text_digest,
1780 query_input_digest: stored.query_input_digest,
1781 filter_digest: stored.filter_digest,
1782 redaction_state: stored.redaction_state,
1783 budget_id: stored.budget_id,
1784 deadline_at: stored.deadline_at,
1785 search_profile: stored.search_profile,
1786 candidate_backend: stored.candidate_backend,
1787 codec_family: stored.codec_family,
1788 codec_profile_digest: stored.codec_profile_digest,
1789 artifact_profile_digest: stored.artifact_profile_digest,
1790 artifact_count: stored
1791 .artifact_count
1792 .map(|value| receipt_count_to_usize(value, &stored.receipt_id, "artifact_count"))
1793 .transpose()?,
1794 artifact_corruption_count: stored
1795 .artifact_corruption_count
1796 .map(|value| {
1797 receipt_count_to_usize(value, &stored.receipt_id, "artifact_corruption_count")
1798 })
1799 .transpose()?,
1800 artifact_missing_count: stored
1801 .artifact_missing_count
1802 .map(|value| {
1803 receipt_count_to_usize(value, &stored.receipt_id, "artifact_missing_count")
1804 })
1805 .transpose()?,
1806 vector_artifact_manifest_digest: stored.vector_artifact_manifest_digest,
1807 artifact_generation_id: stored.artifact_generation_id,
1808 approximate_scanned_count: stored
1809 .approximate_scanned_count
1810 .map(|value| {
1811 receipt_count_to_usize(value, &stored.receipt_id, "approximate_scanned_count")
1812 })
1813 .transpose()?,
1814 approximate_returned_count: stored
1815 .approximate_returned_count
1816 .map(|value| {
1817 receipt_count_to_usize(value, &stored.receipt_id, "approximate_returned_count")
1818 })
1819 .transpose()?,
1820 raw_rows_loaded_count: stored
1821 .raw_rows_loaded_count
1822 .map(|value| receipt_count_to_usize(value, &stored.receipt_id, "raw_rows_loaded_count"))
1823 .transpose()?,
1824 filter_strategy: stored.filter_strategy,
1825 vector_artifact_count: stored
1826 .vector_artifact_count
1827 .map(|value| receipt_count_to_usize(value, &stored.receipt_id, "vector_artifact_count"))
1828 .transpose()?,
1829 vector_artifact_missing_count: stored
1830 .vector_artifact_missing_count
1831 .map(|value| {
1832 receipt_count_to_usize(value, &stored.receipt_id, "vector_artifact_missing_count")
1833 })
1834 .transpose()?,
1835 vector_artifact_stale_count: stored
1836 .vector_artifact_stale_count
1837 .map(|value| {
1838 receipt_count_to_usize(value, &stored.receipt_id, "vector_artifact_stale_count")
1839 })
1840 .transpose()?,
1841 exact_rerank_count: stored
1842 .exact_rerank_count
1843 .map(|value| receipt_count_to_usize(value, &stored.receipt_id, "exact_rerank_count"))
1844 .transpose()?,
1845 approximate_candidate_count: stored
1846 .approximate_candidate_count
1847 .map(|value| {
1848 receipt_count_to_usize(value, &stored.receipt_id, "approximate_candidate_count")
1849 })
1850 .transpose()?,
1851 fallback_reason: stored.fallback_reason,
1852 derived_candidate: None,
1853 approximate: stored.approximate,
1854 requested_candidates: receipt_count_to_usize(
1855 stored.requested_candidates,
1856 &stored.receipt_id,
1857 "requested_candidates",
1858 )?,
1859 returned_candidates: receipt_count_to_usize(
1860 stored.returned_candidates,
1861 &stored.receipt_id,
1862 "returned_candidates",
1863 )?,
1864 post_filter_candidates: receipt_count_to_usize(
1865 stored.post_filter_candidates,
1866 &stored.receipt_id,
1867 "post_filter_candidates",
1868 )?,
1869 fallback: stored.fallback,
1870 exact_rerank: stored.exact_rerank,
1871 result_ids: stored.result_ids,
1872 degradations: stored.degradations,
1873 })
1874}
1875
1876pub fn store_search_receipt(
1881 conn: &Connection,
1882 receipt: &VectorSearchReceiptV1,
1883) -> Result<(), MemoryError> {
1884 let stored = stored_search_receipt(receipt)?;
1885 let receipt_json = serde_json::to_string(&stored)
1886 .map_err(|err| MemoryError::Other(format!("failed to serialize search receipt: {err}")))?;
1887 let receipt_digest = b3_digest(receipt_json.as_bytes());
1888
1889 let existing_digest: Option<String> = conn
1890 .query_row(
1891 "SELECT receipt_digest FROM search_receipts WHERE receipt_id = ?1",
1892 params![&stored.receipt_id],
1893 |row| row.get(0),
1894 )
1895 .optional()?;
1896 if let Some(existing_digest) = existing_digest {
1897 if existing_digest == receipt_digest {
1898 return Ok(());
1899 }
1900 return Err(MemoryError::SearchReceiptConflict {
1901 receipt_id: stored.receipt_id,
1902 });
1903 }
1904
1905 let result_ids_json = serde_json::to_string(&stored.result_ids).map_err(|err| {
1906 MemoryError::Other(format!(
1907 "failed to serialize search receipt result IDs: {err}"
1908 ))
1909 })?;
1910 conn.execute(
1911 "INSERT INTO search_receipts (
1912 receipt_id,
1913 schema_version,
1914 evaluation_time,
1915 search_profile,
1916 candidate_backend,
1917 approximate,
1918 exact_rerank,
1919 fallback,
1920 requested_candidates,
1921 returned_candidates,
1922 post_filter_candidates,
1923 result_ids_json,
1924 receipt_json,
1925 receipt_digest
1926 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
1927 params![
1928 &stored.receipt_id,
1929 SEARCH_RECEIPT_SCHEMA_VERSION,
1930 stored.evaluation_time.to_rfc3339(),
1931 &stored.search_profile,
1932 &stored.candidate_backend,
1933 if stored.approximate { 1_i64 } else { 0_i64 },
1934 if stored.exact_rerank { 1_i64 } else { 0_i64 },
1935 &stored.fallback,
1936 receipt_count_to_i64(stored.requested_candidates, "requested_candidates")?,
1937 receipt_count_to_i64(stored.returned_candidates, "returned_candidates")?,
1938 receipt_count_to_i64(stored.post_filter_candidates, "post_filter_candidates")?,
1939 &result_ids_json,
1940 &receipt_json,
1941 &receipt_digest,
1942 ],
1943 )?;
1944 Ok(())
1945}
1946
1947pub fn get_search_receipt(
1949 conn: &Connection,
1950 receipt_id: &str,
1951) -> Result<Option<VectorSearchReceiptV1>, MemoryError> {
1952 let row: Option<(String, String, String)> = conn
1953 .query_row(
1954 "SELECT schema_version, receipt_json, receipt_digest
1955 FROM search_receipts
1956 WHERE receipt_id = ?1",
1957 params![receipt_id],
1958 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
1959 )
1960 .optional()?;
1961
1962 let Some((schema_version, receipt_json, receipt_digest)) = row else {
1963 return Ok(None);
1964 };
1965 if schema_version != SEARCH_RECEIPT_SCHEMA_VERSION {
1966 return Err(MemoryError::CorruptData {
1967 table: "search_receipts",
1968 row_id: receipt_id.to_string(),
1969 detail: format!("unsupported receipt schema version '{schema_version}'"),
1970 });
1971 }
1972
1973 let stored: StoredVectorSearchReceiptV1 =
1974 serde_json::from_str(&receipt_json).map_err(|err| MemoryError::CorruptData {
1975 table: "search_receipts",
1976 row_id: receipt_id.to_string(),
1977 detail: format!("invalid receipt JSON: {err}"),
1978 })?;
1979 let mut receipt = search_receipt_from_stored(stored)?;
1980 receipt.receipt_digest = Some(receipt_digest);
1981 Ok(Some(receipt))
1982}
1983
1984fn add_column_if_missing(
1985 conn: &Connection,
1986 table: &str,
1987 column: &str,
1988 column_sql: &str,
1989) -> Result<(), rusqlite::Error> {
1990 let pragma = format!("PRAGMA table_info({table})");
1991 let mut stmt = conn.prepare(&pragma)?;
1992 let exists = stmt
1993 .query_map([], |row| row.get::<_, String>(1))?
1994 .collect::<Result<Vec<_>, _>>()?
1995 .into_iter()
1996 .any(|name| name == column);
1997
1998 if !exists {
1999 conn.execute(
2000 &format!("ALTER TABLE {table} ADD COLUMN {column} {column_sql}"),
2001 [],
2002 )?;
2003 }
2004
2005 Ok(())
2006}
2007
2008pub fn check_embedding_metadata(
2010 conn: &Connection,
2011 config: &EmbeddingConfig,
2012) -> Result<(), MemoryError> {
2013 let existing: Option<(String, usize)> = conn
2015 .query_row(
2016 "SELECT model_name, dimensions FROM embedding_metadata WHERE id = 1",
2017 [],
2018 |row| Ok((row.get(0)?, row.get(1)?)),
2019 )
2020 .ok();
2021
2022 match existing {
2023 Some((model, dims)) => {
2024 if model != config.model || dims != config.dimensions {
2025 tracing::warn!(
2026 stored_model = %model,
2027 stored_dims = dims,
2028 configured_model = %config.model,
2029 configured_dims = config.dimensions,
2030 "Embedding model changed. Existing embeddings are stale."
2031 );
2032 conn.execute(
2033 "UPDATE embedding_metadata
2034 SET model_name = ?1,
2035 dimensions = ?2,
2036 embeddings_dirty = 1,
2037 updated_at = datetime('now')
2038 WHERE id = 1",
2039 params![config.model, config.dimensions],
2040 )?;
2041 }
2042 }
2043 None => {
2044 conn.execute(
2045 "INSERT INTO embedding_metadata (id, model_name, dimensions) VALUES (1, ?1, ?2)",
2046 params![config.model, config.dimensions],
2047 )?;
2048 }
2049 }
2050
2051 Ok(())
2052}
2053
2054pub fn embedding_to_bytes(embedding: &[f32]) -> Vec<u8> {
2056 encode_f32_le(embedding)
2057}
2058
2059pub fn encode_f32_le(values: &[f32]) -> Vec<u8> {
2061 let mut bytes = Vec::with_capacity(values.len() * 4);
2062 for value in values {
2063 bytes.extend_from_slice(&value.to_le_bytes());
2064 }
2065 bytes
2066}
2067
2068pub(crate) fn validate_embedding(values: &[f32], expected_dim: usize) -> Result<(), MemoryError> {
2070 if values.len() != expected_dim {
2071 return Err(MemoryError::EmbeddingDimensionMismatch {
2072 expected: expected_dim,
2073 actual: values.len(),
2074 });
2075 }
2076 if let Some((index, _)) = values
2077 .iter()
2078 .enumerate()
2079 .find(|(_, value)| !value.is_finite())
2080 {
2081 return Err(MemoryError::NonFiniteEmbeddingValue { index });
2082 }
2083 Ok(())
2084}
2085
2086pub(crate) fn validate_embedding_batch(
2088 values: &[Vec<f32>],
2089 requested: usize,
2090 expected_dim: usize,
2091) -> Result<(), MemoryError> {
2092 if values.len() != requested {
2093 return Err(MemoryError::EmbeddingBatchCountMismatch {
2094 requested,
2095 returned: values.len(),
2096 });
2097 }
2098 for embedding in values {
2099 validate_embedding(embedding, expected_dim)?;
2100 }
2101 Ok(())
2102}
2103
2104pub(crate) fn validate_vector_blob_len(
2106 bytes: &[u8],
2107 expected_dim: usize,
2108) -> Result<(), MemoryError> {
2109 let expected_bytes = expected_dim
2110 .checked_mul(4)
2111 .ok_or_else(|| MemoryError::InvalidConfig {
2112 field: "embedding.dimensions",
2113 reason: "dimension byte length overflow".to_string(),
2114 })?;
2115 if bytes.len() != expected_bytes {
2116 return Err(MemoryError::VectorBlobLengthMismatch {
2117 expected_bytes,
2118 actual_bytes: bytes.len(),
2119 });
2120 }
2121 Ok(())
2122}
2123
2124#[allow(clippy::manual_is_multiple_of)]
2126pub fn decode_f32_le(bytes: &[u8], expected_dim: usize) -> Result<Vec<f32>, MemoryError> {
2127 validate_vector_blob_len(bytes, expected_dim)?;
2128 decode_f32_le_unchecked_dim(bytes)
2129}
2130
2131#[allow(clippy::manual_is_multiple_of)]
2133pub fn bytes_to_embedding(bytes: &[u8]) -> Result<Vec<f32>, MemoryError> {
2134 if bytes.len() % 4 != 0 {
2135 return Err(MemoryError::InvalidEmbedding {
2136 expected_bytes: bytes.len() - (bytes.len() % 4),
2137 actual_bytes: bytes.len(),
2138 });
2139 }
2140
2141 decode_f32_le_unchecked_dim(bytes)
2142}
2143
2144fn decode_f32_le_unchecked_dim(bytes: &[u8]) -> Result<Vec<f32>, MemoryError> {
2145 let mut embedding = Vec::with_capacity(bytes.len() / 4);
2146 for (index, chunk) in bytes.chunks_exact(4).enumerate() {
2147 let value = f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
2148 if !value.is_finite() {
2149 return Err(MemoryError::NonFiniteEmbeddingValue { index });
2150 }
2151 embedding.push(value);
2152 }
2153 Ok(embedding)
2154}
2155
2156pub fn is_embeddings_dirty(conn: &Connection) -> Result<bool, MemoryError> {
2157 let dirty: i32 = conn
2158 .query_row(
2159 "SELECT COALESCE(embeddings_dirty, 0) FROM embedding_metadata WHERE id = 1",
2160 [],
2161 |row| row.get(0),
2162 )
2163 .unwrap_or(0);
2164 Ok(dirty != 0)
2165}
2166
2167pub fn clear_embeddings_dirty(conn: &Connection) -> Result<(), MemoryError> {
2168 conn.execute(
2169 "UPDATE embedding_metadata SET embeddings_dirty = 0 WHERE id = 1",
2170 [],
2171 )?;
2172 Ok(())
2173}
2174
2175#[cfg(feature = "hnsw")]
2176pub(crate) fn queue_pending_index_op(
2177 tx: &rusqlite::Transaction<'_>,
2178 item_key: &str,
2179 entity_type: &str,
2180 op_kind: IndexOpKind,
2181) -> Result<(), MemoryError> {
2182 tx.execute(
2183 "INSERT INTO pending_index_ops (item_key, entity_type, op_kind, attempt_count, last_error, updated_at)
2184 VALUES (?1, ?2, ?3, 0, NULL, datetime('now'))
2185 ON CONFLICT(item_key) DO UPDATE SET
2186 entity_type = excluded.entity_type,
2187 op_kind = excluded.op_kind,
2188 attempt_count = 0,
2189 last_error = NULL,
2190 updated_at = datetime('now')",
2191 params![item_key, entity_type, op_kind.as_str()],
2192 )?;
2193 mark_sidecar_dirty(tx)?;
2194 Ok(())
2195}
2196
2197#[cfg(feature = "hnsw")]
2198pub(crate) use IndexOpKind as PendingIndexOpKind;
2199
2200#[cfg(feature = "hnsw")]
2201pub(crate) fn enqueue_pending_index_op(
2202 tx: &rusqlite::Transaction<'_>,
2203 item_key: &str,
2204 entity_type: &str,
2205 op_kind: PendingIndexOpKind,
2206) -> Result<(), MemoryError> {
2207 queue_pending_index_op(tx, item_key, entity_type, op_kind)
2208}
2209
2210pub(crate) fn list_pending_index_ops(
2211 conn: &Connection,
2212) -> Result<Vec<PendingIndexOp>, MemoryError> {
2213 let table_exists: bool = conn
2214 .query_row(
2215 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='pending_index_ops'",
2216 [],
2217 |row| row.get(0),
2218 )
2219 .unwrap_or(false);
2220 if !table_exists {
2221 return Ok(Vec::new());
2222 }
2223
2224 let mut stmt = conn.prepare(
2225 "SELECT item_key, entity_type, op_kind, attempt_count, last_error
2226 FROM pending_index_ops
2227 ORDER BY updated_at ASC, item_key ASC",
2228 )?;
2229 let rows = stmt
2230 .query_map([], |row| {
2231 let item_key: String = row.get(0)?;
2232 let op_kind: String = row.get(2)?;
2233 Ok(PendingIndexOp {
2234 item_key: item_key.clone(),
2235 entity_type: row.get(1)?,
2236 op_kind: IndexOpKind::parse(&op_kind, &item_key).map_err(|e| {
2237 rusqlite::Error::FromSqlConversionFailure(
2238 2,
2239 rusqlite::types::Type::Text,
2240 Box::new(e),
2241 )
2242 })?,
2243 attempt_count: row.get::<_, i64>(3)? as u32,
2244 last_error: row.get(4)?,
2245 })
2246 })?
2247 .collect::<Result<Vec<_>, _>>()?;
2248 Ok(rows)
2249}
2250
2251#[cfg(feature = "hnsw")]
2252pub(crate) fn pending_index_op_count(conn: &Connection) -> Result<usize, MemoryError> {
2253 let table_exists: bool = conn
2254 .query_row(
2255 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='pending_index_ops'",
2256 [],
2257 |row| row.get(0),
2258 )
2259 .unwrap_or(false);
2260 if !table_exists {
2261 return Ok(0);
2262 }
2263
2264 let count: i64 = conn.query_row("SELECT COUNT(*) FROM pending_index_ops", [], |row| {
2265 row.get(0)
2266 })?;
2267 Ok(count as usize)
2268}
2269
2270#[cfg(feature = "hnsw")]
2271pub(crate) fn mark_pending_index_ops_failed(
2272 conn: &Connection,
2273 item_keys: &[String],
2274 error: &str,
2275) -> Result<(), MemoryError> {
2276 with_transaction(conn, |tx| {
2277 for item_key in item_keys {
2278 tx.execute(
2279 "UPDATE pending_index_ops
2280 SET attempt_count = attempt_count + 1,
2281 last_error = ?1,
2282 updated_at = datetime('now')
2283 WHERE item_key = ?2",
2284 params![error, item_key],
2285 )?;
2286 }
2287 Ok(())
2288 })
2289}
2290
2291#[cfg(feature = "hnsw")]
2292pub(crate) fn clear_pending_index_ops(
2293 conn: &Connection,
2294 item_keys: &[String],
2295) -> Result<(), MemoryError> {
2296 with_transaction(conn, |tx| {
2297 for item_key in item_keys {
2298 tx.execute(
2299 "DELETE FROM pending_index_ops WHERE item_key = ?1",
2300 params![item_key],
2301 )?;
2302 }
2303 Ok(())
2304 })
2305}
2306
2307#[cfg(feature = "hnsw")]
2308pub(crate) fn clear_all_pending_index_ops(conn: &Connection) -> Result<(), MemoryError> {
2309 conn.execute("DELETE FROM pending_index_ops", [])?;
2310 Ok(())
2311}
2312
2313#[cfg(feature = "hnsw")]
2314pub(crate) fn load_embedding_for_index_key(
2315 conn: &Connection,
2316 item_key: &str,
2317) -> Result<Option<Vec<f32>>, MemoryError> {
2318 let Some((domain, raw_id)) = item_key.split_once(':') else {
2319 return Err(MemoryError::InvalidKey(item_key.to_string()));
2320 };
2321
2322 let blob_result: Result<Option<Vec<u8>>, rusqlite::Error> = match domain {
2323 "fact" => conn.query_row(
2324 "SELECT embedding FROM facts WHERE id = ?1",
2325 params![raw_id],
2326 |row| row.get(0),
2327 ),
2328 "chunk" => conn.query_row(
2329 "SELECT embedding FROM chunks WHERE id = ?1",
2330 params![raw_id],
2331 |row| row.get(0),
2332 ),
2333 "msg" => {
2334 let message_id = raw_id
2335 .parse::<i64>()
2336 .map_err(|e| MemoryError::InvalidKey(format!("{}: {e}", item_key)))?;
2337 conn.query_row(
2338 "SELECT embedding FROM messages WHERE id = ?1",
2339 params![message_id],
2340 |row| row.get(0),
2341 )
2342 }
2343 "episode" => conn.query_row(
2344 "SELECT embedding FROM episodes WHERE episode_id = ?1",
2345 params![raw_id],
2346 |row| row.get(0),
2347 ),
2348 _ => return Err(MemoryError::InvalidKey(item_key.to_string())),
2349 };
2350
2351 let blob = match blob_result {
2352 Ok(blob) => blob,
2353 Err(rusqlite::Error::QueryReturnedNoRows) => None,
2354 Err(err) => return Err(err.into()),
2355 };
2356
2357 blob.map(|bytes| bytes_to_embedding(&bytes)).transpose()
2358}
2359
2360#[cfg(feature = "hnsw")]
2361fn mark_sidecar_dirty(tx: &rusqlite::Transaction<'_>) -> Result<(), MemoryError> {
2362 tx.execute(
2363 "INSERT INTO hnsw_metadata (key, value) VALUES ('sidecar_dirty', '1')
2364 ON CONFLICT(key) DO UPDATE SET value = '1'",
2365 [],
2366 )?;
2367 Ok(())
2368}
2369
2370#[cfg(feature = "hnsw")]
2371pub(crate) fn is_sidecar_dirty(conn: &Connection) -> Result<bool, MemoryError> {
2372 let dirty: Option<String> = conn
2374 .query_row(
2375 "SELECT value FROM hnsw_metadata WHERE key = 'sidecar_dirty'",
2376 [],
2377 |row| row.get(0),
2378 )
2379 .ok();
2380 Ok(matches!(dirty.as_deref(), Some("1")))
2381}
2382
2383#[cfg(feature = "hnsw")]
2384pub(crate) fn set_sidecar_dirty(conn: &Connection, dirty: bool) -> Result<(), MemoryError> {
2385 conn.execute(
2386 "INSERT INTO hnsw_metadata (key, value) VALUES ('sidecar_dirty', ?1)
2387 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
2388 params![if dirty { "1" } else { "0" }],
2389 )?;
2390 Ok(())
2391}
2392
2393pub(crate) fn parse_optional_json(
2394 table: &'static str,
2395 row_id: &str,
2396 field: &'static str,
2397 raw: Option<&str>,
2398) -> Result<Option<serde_json::Value>, MemoryError> {
2399 match raw {
2400 Some(raw) => serde_json::from_str(raw)
2401 .map(Some)
2402 .map_err(|e| MemoryError::CorruptData {
2403 table,
2404 row_id: row_id.to_string(),
2405 detail: format!("invalid {field}: {e}"),
2406 }),
2407 None => Ok(None),
2408 }
2409}
2410
2411pub(crate) fn parse_string_list_json(
2412 table: &'static str,
2413 row_id: &str,
2414 field: &'static str,
2415 raw: &str,
2416) -> Result<Vec<String>, MemoryError> {
2417 serde_json::from_str(raw).map_err(|e| MemoryError::CorruptData {
2418 table,
2419 row_id: row_id.to_string(),
2420 detail: format!("invalid {field}: {e}"),
2421 })
2422}
2423
2424pub(crate) fn parse_role(
2425 table: &'static str,
2426 row_id: &str,
2427 raw: &str,
2428) -> Result<Role, MemoryError> {
2429 Role::from_str_value(raw).ok_or_else(|| MemoryError::CorruptData {
2430 table,
2431 row_id: row_id.to_string(),
2432 detail: format!("invalid role '{raw}'"),
2433 })
2434}
2435
2436pub(crate) fn parse_episode_outcome(
2437 row_id: &str,
2438 raw: &str,
2439) -> Result<EpisodeOutcome, MemoryError> {
2440 EpisodeOutcome::from_str_value(raw).ok_or_else(|| MemoryError::CorruptData {
2441 table: "episodes",
2442 row_id: row_id.to_string(),
2443 detail: format!("invalid outcome '{raw}'"),
2444 })
2445}
2446
2447pub(crate) fn parse_verification_status(
2448 row_id: &str,
2449 raw: &str,
2450) -> Result<VerificationStatus, MemoryError> {
2451 serde_json::from_str(raw).map_err(|e| MemoryError::CorruptData {
2452 table: "episodes",
2453 row_id: row_id.to_string(),
2454 detail: format!("invalid verification_status: {e}"),
2455 })
2456}
2457
2458pub fn verify_integrity_sync(
2460 conn: &Connection,
2461 mode: VerifyMode,
2462) -> Result<IntegrityReport, MemoryError> {
2463 let mut issues = Vec::new();
2464
2465 let schema_version: u32 = conn
2466 .query_row("PRAGMA user_version", [], |row| row.get(0))
2467 .unwrap_or_else(|e| {
2468 issues.push(format!("failed to read schema version: {e}"));
2469 0
2470 });
2471 if schema_version > MAX_SCHEMA_VERSION {
2472 issues.push(format!(
2473 "schema version {} is ahead of supported {}",
2474 schema_version, MAX_SCHEMA_VERSION
2475 ));
2476 }
2477
2478 let fact_count: usize = conn
2479 .query_row("SELECT COUNT(*) FROM facts", [], |row| row.get(0))
2480 .unwrap_or_else(|e| {
2481 issues.push(format!("failed to count facts: {e}"));
2482 0
2483 });
2484 let chunk_count: usize = conn
2485 .query_row("SELECT COUNT(*) FROM chunks", [], |row| row.get(0))
2486 .unwrap_or_else(|e| {
2487 issues.push(format!("failed to count chunks: {e}"));
2488 0
2489 });
2490 let message_count: usize = conn
2491 .query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))
2492 .unwrap_or_else(|e| {
2493 issues.push(format!("failed to count messages: {e}"));
2494 0
2495 });
2496 let episode_count: usize = conn
2497 .query_row("SELECT COUNT(*) FROM episodes", [], |row| row.get(0))
2498 .unwrap_or_else(|e| {
2499 issues.push(format!("failed to count episodes: {e}"));
2500 0
2501 });
2502
2503 let facts_missing_embeddings: usize = conn
2504 .query_row(
2505 "SELECT COUNT(*) FROM facts WHERE embedding IS NULL",
2506 [],
2507 |row| row.get(0),
2508 )
2509 .unwrap_or_else(|e| {
2510 issues.push(format!("failed to count facts missing embeddings: {e}"));
2511 0
2512 });
2513 let chunks_missing_embeddings: usize = conn
2514 .query_row(
2515 "SELECT COUNT(*) FROM chunks WHERE embedding IS NULL",
2516 [],
2517 |row| row.get(0),
2518 )
2519 .unwrap_or_else(|e| {
2520 issues.push(format!("failed to count chunks missing embeddings: {e}"));
2521 0
2522 });
2523 let episodes_missing_embeddings: usize = conn
2524 .query_row(
2525 "SELECT COUNT(*) FROM episodes WHERE embedding IS NULL",
2526 [],
2527 |row| row.get(0),
2528 )
2529 .unwrap_or_else(|e| {
2530 issues.push(format!("failed to count episodes missing embeddings: {e}"));
2531 0
2532 });
2533
2534 if facts_missing_embeddings > 0 {
2535 issues.push(format!(
2536 "{} facts missing embeddings",
2537 facts_missing_embeddings
2538 ));
2539 }
2540 if chunks_missing_embeddings > 0 {
2541 issues.push(format!(
2542 "{} chunks missing embeddings",
2543 chunks_missing_embeddings
2544 ));
2545 }
2546 if episodes_missing_embeddings > 0 {
2547 issues.push(format!(
2548 "{} episodes missing embeddings",
2549 episodes_missing_embeddings
2550 ));
2551 }
2552
2553 let pending_ops = list_pending_index_ops(conn).unwrap_or_default();
2554 if !pending_ops.is_empty() {
2555 issues.push(format!(
2556 "{} pending HNSW sidecar ops queued in SQLite",
2557 pending_ops.len()
2558 ));
2559 for op in pending_ops.iter().take(5) {
2560 let op_kind = op.op_kind.as_str();
2561 let detail = match &op.last_error {
2562 Some(last_error) => format!(
2563 "{} {} {} (attempts: {}, last_error: {})",
2564 op.entity_type,
2565 op.op_kind.as_str(),
2566 op.item_key,
2567 op.attempt_count,
2568 last_error
2569 ),
2570 None => format!(
2571 "{} {} {} (attempts: {})",
2572 op.entity_type, op_kind, op.item_key, op.attempt_count
2573 ),
2574 };
2575 issues.push(format!("pending sidecar op: {detail}"));
2576 }
2577 }
2578
2579 if mode == VerifyMode::Full {
2580 let dims: usize = conn
2581 .query_row(
2582 "SELECT dimensions FROM embedding_metadata WHERE id = 1",
2583 [],
2584 |row| row.get(0),
2585 )
2586 .unwrap_or_else(|e| {
2587 issues.push(format!("failed to read embedding dimensions: {e}"));
2588 0
2589 });
2590
2591 verify_fts_drift(conn, "facts", "facts_rowid_map", fact_count, &mut issues);
2592 verify_fts_drift(conn, "chunks", "chunks_rowid_map", chunk_count, &mut issues);
2593 verify_fts_drift(
2594 conn,
2595 "messages",
2596 "messages_rowid_map",
2597 message_count,
2598 &mut issues,
2599 );
2600 verify_fts_drift(
2601 conn,
2602 "episodes",
2603 "episodes_rowid_map",
2604 episode_count,
2605 &mut issues,
2606 );
2607
2608 verify_blob_table(conn, "facts", "id", "embedding", dims, &mut issues)?;
2609 verify_blob_table(conn, "chunks", "id", "embedding", dims, &mut issues)?;
2610 verify_blob_table(conn, "messages", "id", "embedding", dims, &mut issues)?;
2611 verify_blob_table(
2612 conn,
2613 "episodes",
2614 "episode_id",
2615 "embedding",
2616 dims,
2617 &mut issues,
2618 )?;
2619
2620 verify_quantized_table(conn, "facts", "id", dims, &mut issues)?;
2621 verify_quantized_table(conn, "chunks", "id", dims, &mut issues)?;
2622 verify_quantized_table(conn, "messages", "id", dims, &mut issues)?;
2623 verify_quantized_table(conn, "episodes", "episode_id", dims, &mut issues)?;
2624
2625 verify_session_rows(conn, &mut issues)?;
2626 verify_message_rows(conn, &mut issues)?;
2627 verify_fact_rows(conn, &mut issues)?;
2628 verify_document_rows(conn, &mut issues)?;
2629 verify_episode_rows(conn, &mut issues)?;
2630
2631 let integrity_check: String = conn
2632 .query_row("PRAGMA integrity_check", [], |row| row.get(0))
2633 .unwrap_or_else(|_| "error".to_string());
2634 if integrity_check != "ok" {
2635 issues.push(format!("SQLite integrity_check: {}", integrity_check));
2636 }
2637 }
2638
2639 Ok(IntegrityReport {
2640 ok: issues.is_empty(),
2641 schema_version,
2642 fact_count,
2643 chunk_count,
2644 message_count,
2645 facts_missing_embeddings,
2646 chunks_missing_embeddings,
2647 issues,
2648 })
2649}
2650
2651pub fn reconcile_fts(conn: &Connection) -> Result<(), MemoryError> {
2653 with_transaction(conn, |tx| {
2654 tx.execute_batch("DROP TABLE IF EXISTS facts_fts")?;
2655 tx.execute_batch("DELETE FROM facts_rowid_map")?;
2656 tx.execute_batch(
2657 "CREATE VIRTUAL TABLE facts_fts USING fts5(
2658 content,
2659 content='',
2660 content_rowid='rowid',
2661 tokenize='porter unicode61'
2662 )",
2663 )?;
2664 tx.execute_batch("INSERT INTO facts_rowid_map (fact_id) SELECT id FROM facts")?;
2665 tx.execute_batch(
2666 "INSERT INTO facts_fts (rowid, content)
2667 SELECT rm.rowid, f.content
2668 FROM facts_rowid_map rm
2669 JOIN facts f ON f.id = rm.fact_id",
2670 )?;
2671
2672 tx.execute_batch("DROP TABLE IF EXISTS chunks_fts")?;
2673 tx.execute_batch("DELETE FROM chunks_rowid_map")?;
2674 tx.execute_batch(
2675 "CREATE VIRTUAL TABLE chunks_fts USING fts5(
2676 content,
2677 content='',
2678 content_rowid='rowid',
2679 tokenize='porter unicode61'
2680 )",
2681 )?;
2682 tx.execute_batch("INSERT INTO chunks_rowid_map (chunk_id) SELECT id FROM chunks")?;
2683 tx.execute_batch(
2684 "INSERT INTO chunks_fts (rowid, content)
2685 SELECT rm.rowid, c.content
2686 FROM chunks_rowid_map rm
2687 JOIN chunks c ON c.id = rm.chunk_id",
2688 )?;
2689
2690 tx.execute_batch("DROP TABLE IF EXISTS messages_fts")?;
2691 tx.execute_batch("DELETE FROM messages_rowid_map")?;
2692 tx.execute_batch(
2693 "CREATE VIRTUAL TABLE messages_fts USING fts5(
2694 content,
2695 content='',
2696 content_rowid='rowid',
2697 tokenize='porter unicode61'
2698 )",
2699 )?;
2700 tx.execute_batch("INSERT INTO messages_rowid_map (message_id) SELECT id FROM messages")?;
2701 tx.execute_batch(
2702 "INSERT INTO messages_fts (rowid, content)
2703 SELECT rm.rowid, m.content
2704 FROM messages_rowid_map rm
2705 JOIN messages m ON m.id = rm.message_id",
2706 )?;
2707
2708 tx.execute_batch("DROP TABLE IF EXISTS episodes_fts")?;
2709 tx.execute_batch("DELETE FROM episodes_rowid_map")?;
2710 tx.execute_batch(
2711 "CREATE VIRTUAL TABLE episodes_fts USING fts5(
2712 content,
2713 content='',
2714 content_rowid='rowid',
2715 tokenize='porter unicode61'
2716 )",
2717 )?;
2718 tx.execute_batch(
2719 "INSERT INTO episodes_rowid_map (episode_id, document_id) SELECT episode_id, document_id FROM episodes",
2720 )?;
2721 tx.execute_batch(
2722 "INSERT INTO episodes_fts (rowid, content)
2723 SELECT rm.rowid, e.search_text
2724 FROM episodes_rowid_map rm
2725 JOIN episodes e ON e.episode_id = rm.episode_id",
2726 )?;
2727
2728 Ok(())
2729 })?;
2730
2731 tracing::info!("FTS indexes reconciled");
2732 Ok(())
2733}
2734
2735fn verify_fts_drift(
2736 conn: &Connection,
2737 label: &str,
2738 map_table: &str,
2739 source_count: usize,
2740 issues: &mut Vec<String>,
2741) {
2742 let table_exists: bool = conn
2743 .query_row(
2744 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name = ?1",
2745 params![map_table],
2746 |row| row.get(0),
2747 )
2748 .unwrap_or(false);
2749 if !table_exists {
2750 if source_count > 0 {
2751 issues.push(format!("{} rows exist but {} is missing", label, map_table));
2752 }
2753 return;
2754 }
2755
2756 let sql = format!("SELECT COUNT(*) FROM {}", map_table);
2757 let indexed_count: usize = conn.query_row(&sql, [], |row| row.get(0)).unwrap_or(0);
2758 if indexed_count != source_count {
2759 issues.push(format!(
2760 "FTS {} index drift: {} rows in map vs {} source rows",
2761 label, indexed_count, source_count
2762 ));
2763 }
2764}
2765
2766fn verify_blob_table(
2767 conn: &Connection,
2768 table: &'static str,
2769 id_column: &'static str,
2770 blob_column: &'static str,
2771 expected_dims: usize,
2772 issues: &mut Vec<String>,
2773) -> Result<(), MemoryError> {
2774 if expected_dims == 0 {
2775 return Ok(());
2776 }
2777
2778 let sql = format!(
2779 "SELECT CAST({id_column} AS TEXT), {blob_column} FROM {table} WHERE {blob_column} IS NOT NULL"
2780 );
2781 let mut stmt = conn.prepare(&sql)?;
2782 let rows = stmt.query_map([], |row| {
2783 Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?))
2784 })?;
2785
2786 for row in rows {
2787 let (row_id, blob) = row?;
2788 match bytes_to_embedding(&blob) {
2789 Ok(embedding) if embedding.len() != expected_dims => issues.push(format!(
2790 "{}({}) has embedding dimension {} but expected {}",
2791 table,
2792 row_id,
2793 embedding.len(),
2794 expected_dims
2795 )),
2796 Ok(_) => {}
2797 Err(err) => issues.push(format!(
2798 "{}({}) invalid embedding blob: {}",
2799 table, row_id, err
2800 )),
2801 }
2802 }
2803
2804 Ok(())
2805}
2806
2807fn verify_quantized_table(
2808 conn: &Connection,
2809 table: &'static str,
2810 id_column: &'static str,
2811 expected_dims: usize,
2812 issues: &mut Vec<String>,
2813) -> Result<(), MemoryError> {
2814 if expected_dims == 0 {
2815 return Ok(());
2816 }
2817
2818 let sql = format!(
2819 "SELECT CAST({id_column} AS TEXT), embedding_q8 FROM {table} WHERE embedding IS NOT NULL"
2820 );
2821 let mut stmt = conn.prepare(&sql)?;
2822 let rows = stmt.query_map([], |row| {
2823 Ok((row.get::<_, String>(0)?, row.get::<_, Option<Vec<u8>>>(1)?))
2824 })?;
2825
2826 for row in rows {
2827 let (row_id, blob) = row?;
2828 match blob {
2829 Some(blob) => {
2830 if let Err(err) = unpack_quantized(&blob, expected_dims) {
2831 issues.push(format!(
2832 "{}({}) invalid quantized embedding: {}",
2833 table, row_id, err
2834 ));
2835 }
2836 }
2837 None => issues.push(format!("{}({}) missing quantized embedding", table, row_id)),
2838 }
2839 }
2840
2841 Ok(())
2842}
2843
2844fn verify_session_rows(conn: &Connection, issues: &mut Vec<String>) -> Result<(), MemoryError> {
2845 let mut stmt = conn.prepare("SELECT id, metadata FROM sessions WHERE metadata IS NOT NULL")?;
2846 let rows = stmt.query_map([], |row| {
2847 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2848 })?;
2849 for row in rows {
2850 let (id, metadata) = row?;
2851 if let Err(err) = parse_optional_json("sessions", &id, "metadata", Some(&metadata)) {
2852 issues.push(err.to_string());
2853 }
2854 }
2855 Ok(())
2856}
2857
2858fn verify_message_rows(conn: &Connection, issues: &mut Vec<String>) -> Result<(), MemoryError> {
2859 let mut stmt = conn.prepare("SELECT id, role, metadata FROM messages")?;
2860 let rows = stmt.query_map([], |row| {
2861 Ok((
2862 row.get::<_, i64>(0)?,
2863 row.get::<_, String>(1)?,
2864 row.get::<_, Option<String>>(2)?,
2865 ))
2866 })?;
2867 for row in rows {
2868 let (id, role, metadata) = row?;
2869 let row_id = id.to_string();
2870 if let Err(err) = parse_role("messages", &row_id, &role) {
2871 issues.push(err.to_string());
2872 }
2873 if let Err(err) = parse_optional_json("messages", &row_id, "metadata", metadata.as_deref())
2874 {
2875 issues.push(err.to_string());
2876 }
2877 }
2878 Ok(())
2879}
2880
2881fn verify_fact_rows(conn: &Connection, issues: &mut Vec<String>) -> Result<(), MemoryError> {
2882 let mut stmt = conn.prepare("SELECT id, metadata FROM facts WHERE metadata IS NOT NULL")?;
2883 let rows = stmt.query_map([], |row| {
2884 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2885 })?;
2886 for row in rows {
2887 let (id, metadata) = row?;
2888 if let Err(err) = parse_optional_json("facts", &id, "metadata", Some(&metadata)) {
2889 issues.push(err.to_string());
2890 }
2891 }
2892 Ok(())
2893}
2894
2895fn verify_document_rows(conn: &Connection, issues: &mut Vec<String>) -> Result<(), MemoryError> {
2896 let mut stmt = conn.prepare("SELECT id, metadata FROM documents WHERE metadata IS NOT NULL")?;
2897 let rows = stmt.query_map([], |row| {
2898 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2899 })?;
2900 for row in rows {
2901 let (id, metadata) = row?;
2902 if let Err(err) = parse_optional_json("documents", &id, "metadata", Some(&metadata)) {
2903 issues.push(err.to_string());
2904 }
2905 }
2906 Ok(())
2907}
2908
2909fn verify_episode_rows(conn: &Connection, issues: &mut Vec<String>) -> Result<(), MemoryError> {
2910 let mut stmt = conn.prepare(
2911 "SELECT episode_id, cause_ids, outcome, verification_status
2912 FROM episodes",
2913 )?;
2914 let rows = stmt.query_map([], |row| {
2915 Ok((
2916 row.get::<_, String>(0)?,
2917 row.get::<_, String>(1)?,
2918 row.get::<_, String>(2)?,
2919 row.get::<_, String>(3)?,
2920 ))
2921 })?;
2922 for row in rows {
2923 let (episode_id, cause_ids, outcome, verification_status) = row?;
2924 if let Err(err) = parse_string_list_json("episodes", &episode_id, "cause_ids", &cause_ids) {
2925 issues.push(err.to_string());
2926 }
2927 if let Err(err) = parse_episode_outcome(&episode_id, &outcome) {
2928 issues.push(err.to_string());
2929 }
2930 if let Err(err) = parse_verification_status(&episode_id, &verification_status) {
2931 issues.push(err.to_string());
2932 }
2933 }
2934 Ok(())
2935}
2936
2937#[derive(Debug, Clone)]
2938pub(crate) struct ProveKvPoolGenerationRow {
2939 pub generation: ProveKvPoolGenerationV1,
2940}
2941
2942fn parse_provekv_status(value: &str) -> ProveKvPoolGenerationStatus {
2943 match value {
2944 "disabled" => ProveKvPoolGenerationStatus::Disabled,
2945 "missing" => ProveKvPoolGenerationStatus::Missing,
2946 "building" => ProveKvPoolGenerationStatus::Building,
2947 "ready" => ProveKvPoolGenerationStatus::Ready,
2948 "stale" => ProveKvPoolGenerationStatus::Stale,
2949 "failed" => ProveKvPoolGenerationStatus::Failed,
2950 _ => ProveKvPoolGenerationStatus::Failed,
2951 }
2952}
2953
2954fn provekv_generation_from_row(
2955 row: &rusqlite::Row<'_>,
2956) -> rusqlite::Result<ProveKvPoolGenerationRow> {
2957 let created_at: String = row.get(9)?;
2958 Ok(ProveKvPoolGenerationRow {
2959 generation: ProveKvPoolGenerationV1 {
2960 schema_version: "semantic_memory_provekv_pool_generation_v1".to_string(),
2961 generation_id: row.get(0)?,
2962 embedding_snapshot_digest: row.get(1)?,
2963 source_digest: row.get(2)?,
2964 pool_manifest_digest: row.get(3)?,
2965 codec_family: row.get(4)?,
2966 codec_profile: row.get(5)?,
2967 vector_dim: row.get::<_, i64>(6)? as usize,
2968 item_count: row.get::<_, i64>(7)? as usize,
2969 payload_bytes: row.get::<_, i64>(8)? as u64,
2970 created_at: DateTime::parse_from_rfc3339(&created_at)
2971 .map(|dt| dt.with_timezone(&Utc))
2972 .map_err(|err| {
2973 rusqlite::Error::FromSqlConversionFailure(
2974 9,
2975 rusqlite::types::Type::Text,
2976 Box::new(err),
2977 )
2978 })?,
2979 },
2980 })
2981}
2982
2983#[allow(dead_code)]
2984pub(crate) fn insert_provekv_pool_generation(
2985 conn: &Connection,
2986 generation: &ProveKvPoolGenerationV1,
2987 payload: &[u8],
2988 item_map: &[ProveKvPoolItemMapEntryV1],
2989) -> Result<(), MemoryError> {
2990 let tx = conn.unchecked_transaction()?;
2991 tx.execute(
2992 "INSERT OR REPLACE INTO provekv_pool_generations
2993 (generation_id, embedding_snapshot_digest, source_digest, pool_manifest_digest,
2994 codec_family, codec_profile, vector_dim, item_count, payload_bytes, payload,
2995 status, failure_reason, created_at)
2996 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 'ready', NULL, ?11)",
2997 params![
2998 generation.generation_id,
2999 generation.embedding_snapshot_digest,
3000 generation.source_digest,
3001 generation.pool_manifest_digest,
3002 generation.codec_family,
3003 generation.codec_profile,
3004 generation.vector_dim as i64,
3005 generation.item_count as i64,
3006 generation.payload_bytes as i64,
3007 payload,
3008 generation.created_at.to_rfc3339(),
3009 ],
3010 )?;
3011 tx.execute(
3012 "DELETE FROM provekv_pool_item_map WHERE generation_id = ?1",
3013 params![generation.generation_id],
3014 )?;
3015 for entry in item_map {
3016 tx.execute(
3017 "INSERT INTO provekv_pool_item_map
3018 (generation_id, item_id, source_type, pool_index, embedding_digest)
3019 VALUES (?1, ?2, ?3, ?4, ?5)",
3020 params![
3021 entry.generation_id,
3022 entry.item_id,
3023 entry.source_type,
3024 entry.pool_index as i64,
3025 entry.embedding_digest,
3026 ],
3027 )?;
3028 }
3029 tx.commit()?;
3030 Ok(())
3031}
3032
3033pub(crate) fn latest_ready_provekv_pool_generation(
3034 conn: &Connection,
3035) -> Result<Option<ProveKvPoolGenerationRow>, MemoryError> {
3036 conn.query_row(
3037 "SELECT generation_id, embedding_snapshot_digest, source_digest, pool_manifest_digest,
3038 codec_family, codec_profile, vector_dim, item_count, payload_bytes, created_at
3039 FROM provekv_pool_generations
3040 WHERE status = 'ready'
3041 ORDER BY created_at DESC
3042 LIMIT 1",
3043 [],
3044 provekv_generation_from_row,
3045 )
3046 .optional()
3047 .map_err(MemoryError::from)
3048}
3049
3050pub(crate) fn load_provekv_pool_payload(
3051 conn: &Connection,
3052 generation_id: &str,
3053) -> Result<Vec<u8>, MemoryError> {
3054 conn.query_row(
3055 "SELECT payload FROM provekv_pool_generations WHERE generation_id = ?1",
3056 params![generation_id],
3057 |row| row.get(0),
3058 )
3059 .map_err(MemoryError::from)
3060}
3061
3062pub(crate) fn load_provekv_pool_item_map(
3063 conn: &Connection,
3064 generation_id: &str,
3065) -> Result<Vec<ProveKvPoolItemMapEntryV1>, MemoryError> {
3066 let mut stmt = conn.prepare(
3067 "SELECT generation_id, item_id, source_type, pool_index, embedding_digest
3068 FROM provekv_pool_item_map
3069 WHERE generation_id = ?1
3070 ORDER BY pool_index ASC",
3071 )?;
3072 let rows = stmt.query_map(params![generation_id], |row| {
3073 Ok(ProveKvPoolItemMapEntryV1 {
3074 generation_id: row.get(0)?,
3075 item_id: row.get(1)?,
3076 source_type: row.get(2)?,
3077 pool_index: row.get::<_, i64>(3)? as usize,
3078 embedding_digest: row.get(4)?,
3079 })
3080 })?;
3081 let mut entries = Vec::new();
3082 for row in rows {
3083 entries.push(row?);
3084 }
3085 Ok(entries)
3086}
3087
3088#[allow(dead_code)]
3089pub(crate) fn mark_provekv_pool_generation_failed(
3090 conn: &Connection,
3091 generation_id: &str,
3092 reason: &str,
3093) -> Result<(), MemoryError> {
3094 conn.execute(
3095 "UPDATE provekv_pool_generations SET status = 'failed', failure_reason = ?2 WHERE generation_id = ?1",
3096 params![generation_id, reason],
3097 )?;
3098 Ok(())
3099}
3100
3101pub(crate) fn provekv_pool_artifact_status(
3102 conn: &Connection,
3103) -> Result<ProveKvPoolArtifactStatusV1, MemoryError> {
3104 let row = conn
3105 .query_row(
3106 "SELECT generation_id, embedding_snapshot_digest, pool_manifest_digest,
3107 item_count, payload_bytes, status, failure_reason
3108 FROM provekv_pool_generations
3109 ORDER BY created_at DESC
3110 LIMIT 1",
3111 [],
3112 |row| {
3113 Ok((
3114 row.get::<_, String>(0)?,
3115 row.get::<_, String>(1)?,
3116 row.get::<_, String>(2)?,
3117 row.get::<_, i64>(3)?,
3118 row.get::<_, i64>(4)?,
3119 row.get::<_, String>(5)?,
3120 row.get::<_, Option<String>>(6)?,
3121 ))
3122 },
3123 )
3124 .optional()?;
3125 Ok(match row {
3126 Some((
3127 generation_id,
3128 snapshot_digest,
3129 manifest_digest,
3130 item_count,
3131 payload_bytes,
3132 status,
3133 reason,
3134 )) => ProveKvPoolArtifactStatusV1 {
3135 status: parse_provekv_status(&status),
3136 generation_id: Some(generation_id),
3137 embedding_snapshot_digest: Some(snapshot_digest),
3138 pool_manifest_digest: Some(manifest_digest),
3139 item_count: item_count as usize,
3140 payload_bytes: payload_bytes as u64,
3141 reason,
3142 },
3143 None => ProveKvPoolArtifactStatusV1 {
3144 status: ProveKvPoolGenerationStatus::Missing,
3145 generation_id: None,
3146 embedding_snapshot_digest: None,
3147 pool_manifest_digest: None,
3148 item_count: 0,
3149 payload_bytes: 0,
3150 reason: Some("provekv_pool_generation_not_materialized".into()),
3151 },
3152 })
3153}
3154
3155#[cfg(test)]
3156mod provekv_pool_generation_db_tests {
3157 use super::*;
3158
3159 fn test_conn() -> Connection {
3160 let conn = Connection::open_in_memory().expect("in-memory db opens");
3161 conn.execute_batch(MIGRATION_V24)
3162 .expect("proveKV schema migration applies");
3163 conn
3164 }
3165
3166 fn generation(id: &str) -> ProveKvPoolGenerationV1 {
3167 ProveKvPoolGenerationV1 {
3168 schema_version: "semantic_memory_provekv_pool_generation_v1".to_string(),
3169 generation_id: id.to_string(),
3170 embedding_snapshot_digest: "blake3:snapshot".to_string(),
3171 source_digest: "blake3:source".to_string(),
3172 pool_manifest_digest: "blake3:manifest".to_string(),
3173 codec_family: "provekv_pool".to_string(),
3174 codec_profile: "semantic-memory-f32-derived-candidate-v1".to_string(),
3175 vector_dim: 4,
3176 item_count: 2,
3177 payload_bytes: 3,
3178 created_at: Utc::now(),
3179 }
3180 }
3181
3182 #[test]
3183 fn provekv_pool_generation_roundtrips_and_cascades_item_map() {
3184 let conn = test_conn();
3185 let gen = generation("gen-1");
3186 let item_map = vec![
3187 ProveKvPoolItemMapEntryV1 {
3188 generation_id: gen.generation_id.clone(),
3189 item_id: "fact-1".to_string(),
3190 source_type: "fact".to_string(),
3191 pool_index: 0,
3192 embedding_digest: "blake3:item-1".to_string(),
3193 },
3194 ProveKvPoolItemMapEntryV1 {
3195 generation_id: gen.generation_id.clone(),
3196 item_id: "fact-2".to_string(),
3197 source_type: "fact".to_string(),
3198 pool_index: 1,
3199 embedding_digest: "blake3:item-2".to_string(),
3200 },
3201 ];
3202 insert_provekv_pool_generation(&conn, &gen, &[1, 2, 3], &item_map).unwrap();
3203
3204 let latest = latest_ready_provekv_pool_generation(&conn)
3205 .unwrap()
3206 .expect("latest ready generation");
3207 assert_eq!(latest.generation.generation_id, gen.generation_id);
3208 assert_eq!(
3209 load_provekv_pool_payload(&conn, "gen-1").unwrap(),
3210 vec![1, 2, 3]
3211 );
3212 assert_eq!(
3213 load_provekv_pool_item_map(&conn, "gen-1").unwrap(),
3214 item_map
3215 );
3216
3217 mark_provekv_pool_generation_failed(&conn, "gen-1", "boom").unwrap();
3218 let status = provekv_pool_artifact_status(&conn).unwrap();
3219 assert_eq!(status.status, ProveKvPoolGenerationStatus::Failed);
3220 assert_eq!(status.reason.as_deref(), Some("boom"));
3221
3222 conn.execute(
3223 "DELETE FROM provekv_pool_generations WHERE generation_id = 'gen-1'",
3224 [],
3225 )
3226 .unwrap();
3227 let count: i64 = conn
3228 .query_row("SELECT COUNT(*) FROM provekv_pool_item_map", [], |row| {
3229 row.get(0)
3230 })
3231 .unwrap();
3232 assert_eq!(count, 0);
3233 }
3234}