1use crate::config::{EmbeddingConfig, MemoryLimits, PoolConfig};
4use crate::embedder::SparseWeights;
5use crate::error::MemoryError;
6use crate::quantize::unpack_quantized;
7#[cfg(feature = "turbo-quant-codec")]
8use crate::types::{DerivedVectorArtifactGenerationV1, VectorArtifactBuildReceiptV1};
9use crate::types::{
10 EpisodeOutcome, ProveKvPoolArtifactStatusV1, ProveKvPoolGenerationStatus,
11 ProveKvPoolGenerationV1, ProveKvPoolItemMapEntryV1, Role, SearchSourceType,
12 SparseRankReceiptV1, VectorSearchReceiptV1, VerificationStatus,
13};
14use chrono::{DateTime, Utc};
15use rusqlite::{params, Connection, OpenFlags, OptionalExtension};
16use serde::{Deserialize, Serialize};
17use stack_ids::ContentDigest;
18#[cfg(feature = "turbo-quant-codec")]
19use stack_ids::DigestBuilder;
20use std::path::Path;
21
22const MIGRATION_V1: &str = r#"
24-- CONVERSATIONS
25CREATE TABLE sessions (
26 id TEXT PRIMARY KEY,
27 channel TEXT NOT NULL DEFAULT 'repl',
28 created_at TEXT NOT NULL DEFAULT (datetime('now')),
29 updated_at TEXT NOT NULL DEFAULT (datetime('now')),
30 metadata TEXT
31);
32
33CREATE INDEX idx_sessions_updated ON sessions(updated_at DESC);
34
35CREATE TABLE messages (
36 id INTEGER PRIMARY KEY AUTOINCREMENT,
37 session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
38 role TEXT NOT NULL CHECK (role IN ('system', 'user', 'assistant', 'tool')),
39 content TEXT NOT NULL,
40 token_count INTEGER,
41 created_at TEXT NOT NULL DEFAULT (datetime('now')),
42 metadata TEXT
43);
44
45CREATE INDEX idx_messages_session ON messages(session_id, created_at ASC);
46CREATE INDEX idx_messages_created ON messages(created_at DESC);
47
48-- KNOWLEDGE (Facts)
49CREATE TABLE facts (
50 id TEXT PRIMARY KEY,
51 namespace TEXT NOT NULL DEFAULT 'general',
52 content TEXT NOT NULL,
53 source TEXT,
54 embedding BLOB,
55 created_at TEXT NOT NULL DEFAULT (datetime('now')),
56 updated_at TEXT NOT NULL DEFAULT (datetime('now')),
57 metadata TEXT
58);
59
60CREATE INDEX idx_facts_namespace ON facts(namespace);
61CREATE INDEX idx_facts_updated ON facts(updated_at DESC);
62
63CREATE TABLE facts_rowid_map (
64 rowid INTEGER PRIMARY KEY AUTOINCREMENT,
65 fact_id TEXT NOT NULL UNIQUE REFERENCES facts(id) ON DELETE CASCADE
66);
67
68CREATE VIRTUAL TABLE facts_fts USING fts5(
69 content,
70 content='',
71 content_rowid='rowid',
72 tokenize='porter unicode61'
73);
74
75-- DOCUMENTS (Chunked content)
76CREATE TABLE documents (
77 id TEXT PRIMARY KEY,
78 title TEXT NOT NULL,
79 source_path TEXT,
80 namespace TEXT NOT NULL DEFAULT 'general',
81 created_at TEXT NOT NULL DEFAULT (datetime('now')),
82 metadata TEXT
83);
84
85CREATE TABLE chunks (
86 id TEXT PRIMARY KEY,
87 document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
88 chunk_index INTEGER NOT NULL,
89 content TEXT NOT NULL,
90 token_count INTEGER,
91 embedding BLOB,
92 created_at TEXT NOT NULL DEFAULT (datetime('now'))
93);
94
95CREATE INDEX idx_chunks_document ON chunks(document_id, chunk_index ASC);
96
97CREATE TABLE chunks_rowid_map (
98 rowid INTEGER PRIMARY KEY AUTOINCREMENT,
99 chunk_id TEXT NOT NULL UNIQUE REFERENCES chunks(id) ON DELETE CASCADE
100);
101
102CREATE VIRTUAL TABLE chunks_fts USING fts5(
103 content,
104 content='',
105 content_rowid='rowid',
106 tokenize='porter unicode61'
107);
108
109-- EMBEDDING METADATA
110CREATE TABLE embedding_metadata (
111 id INTEGER PRIMARY KEY CHECK (id = 1),
112 model_name TEXT NOT NULL,
113 dimensions INTEGER NOT NULL,
114 updated_at TEXT NOT NULL DEFAULT (datetime('now'))
115);
116"#;
117
118const MIGRATION_V2: &str = r#"
120ALTER TABLE messages ADD COLUMN embedding BLOB;
121
122CREATE TABLE messages_rowid_map (
123 rowid INTEGER PRIMARY KEY AUTOINCREMENT,
124 message_id INTEGER NOT NULL UNIQUE REFERENCES messages(id) ON DELETE CASCADE
125);
126
127CREATE VIRTUAL TABLE messages_fts USING fts5(
128 content,
129 content='',
130 content_rowid='rowid',
131 tokenize='porter unicode61'
132);
133"#;
134
135const MIGRATION_V3: &str = r#"
137ALTER TABLE embedding_metadata ADD COLUMN embeddings_dirty INTEGER NOT NULL DEFAULT 0;
138"#;
139
140const MIGRATION_V4: &str = r#"
142CREATE TABLE IF NOT EXISTS hnsw_metadata (
143 key TEXT PRIMARY KEY,
144 value TEXT NOT NULL
145);
146"#;
147
148const MIGRATION_V5: &str = r#"
150ALTER TABLE facts ADD COLUMN embedding_q8 BLOB;
151ALTER TABLE chunks ADD COLUMN embedding_q8 BLOB;
152ALTER TABLE messages ADD COLUMN embedding_q8 BLOB;
153
154CREATE TABLE IF NOT EXISTS hnsw_keymap (
155 node_id INTEGER PRIMARY KEY,
156 item_key TEXT NOT NULL UNIQUE,
157 deleted INTEGER NOT NULL DEFAULT 0
158);
159
160CREATE INDEX idx_hnsw_keymap_key ON hnsw_keymap(item_key);
161"#;
162
163const MIGRATION_V6: &str = r#"
165CREATE TABLE IF NOT EXISTS episodes (
166 document_id TEXT PRIMARY KEY REFERENCES documents(id) ON DELETE CASCADE,
167 cause_ids TEXT NOT NULL,
168 effect_type TEXT NOT NULL,
169 outcome TEXT NOT NULL DEFAULT 'pending',
170 confidence REAL NOT NULL DEFAULT 0.0,
171 verification_status TEXT NOT NULL DEFAULT '{"status":"unverified"}',
172 experiment_id TEXT,
173 created_at TEXT NOT NULL DEFAULT (datetime('now'))
174);
175
176CREATE INDEX IF NOT EXISTS idx_episodes_effect_type ON episodes(effect_type);
177CREATE INDEX IF NOT EXISTS idx_episodes_outcome ON episodes(outcome);
178CREATE INDEX IF NOT EXISTS idx_episodes_experiment_id ON episodes(experiment_id);
179"#;
180
181const MIGRATION_V7: &str = r#"
183ALTER TABLE episodes ADD COLUMN updated_at TEXT NOT NULL DEFAULT (datetime('now'));
184ALTER TABLE episodes ADD COLUMN search_text TEXT NOT NULL DEFAULT '';
185ALTER TABLE episodes ADD COLUMN embedding BLOB;
186ALTER TABLE episodes ADD COLUMN embedding_q8 BLOB;
187
188CREATE TABLE IF NOT EXISTS episodes_rowid_map (
189 rowid INTEGER PRIMARY KEY AUTOINCREMENT,
190 document_id TEXT NOT NULL UNIQUE REFERENCES episodes(document_id) ON DELETE CASCADE
191);
192
193CREATE VIRTUAL TABLE episodes_fts USING fts5(
194 content,
195 content='',
196 content_rowid='rowid',
197 tokenize='porter unicode61'
198);
199
200CREATE TABLE IF NOT EXISTS pending_index_ops (
201 item_key TEXT PRIMARY KEY,
202 entity_type TEXT NOT NULL,
203 op_kind TEXT NOT NULL CHECK (op_kind IN ('upsert', 'delete')),
204 attempt_count INTEGER NOT NULL DEFAULT 0,
205 last_error TEXT,
206 updated_at TEXT NOT NULL DEFAULT (datetime('now'))
207);
208
209INSERT OR IGNORE INTO hnsw_metadata (key, value) VALUES ('sidecar_dirty', '0');
210
211UPDATE episodes
212SET search_text = TRIM(
213 COALESCE(effect_type, '') || ' ' ||
214 COALESCE(outcome, '') || ' ' ||
215 COALESCE(experiment_id, '') || ' ' ||
216 COALESCE(cause_ids, '')
217)
218WHERE search_text = '';
219
220INSERT OR IGNORE INTO episodes_rowid_map (document_id)
221SELECT document_id FROM episodes;
222
223INSERT INTO episodes_fts (rowid, content)
224SELECT rm.rowid, e.search_text
225FROM episodes_rowid_map rm
226JOIN episodes e ON e.document_id = rm.document_id;
227"#;
228
229const MIGRATION_V8: &str = r#"
231ALTER TABLE episodes ADD COLUMN trace_id TEXT;
232"#;
233
234const MIGRATION_V9: &str = "";
242
243const MIGRATION_V18: &str = r#"
245CREATE TABLE IF NOT EXISTS search_receipts (
246 receipt_id TEXT PRIMARY KEY,
247 schema_version TEXT NOT NULL,
248 evaluation_time TEXT NOT NULL,
249 search_profile TEXT NOT NULL,
250 candidate_backend TEXT NOT NULL,
251 approximate INTEGER NOT NULL CHECK (approximate IN (0, 1)),
252 exact_rerank INTEGER NOT NULL CHECK (exact_rerank IN (0, 1)),
253 fallback TEXT,
254 requested_candidates INTEGER NOT NULL CHECK (requested_candidates >= 0),
255 returned_candidates INTEGER NOT NULL CHECK (returned_candidates >= 0),
256 post_filter_candidates INTEGER NOT NULL CHECK (post_filter_candidates >= 0),
257 result_ids_json TEXT NOT NULL,
258 receipt_json TEXT NOT NULL,
259 receipt_digest TEXT NOT NULL,
260 created_at TEXT NOT NULL DEFAULT (datetime('now'))
261);
262
263CREATE INDEX IF NOT EXISTS idx_search_receipts_created
264ON search_receipts(created_at DESC);
265
266CREATE INDEX IF NOT EXISTS idx_search_receipts_backend
267ON search_receipts(candidate_backend);
268"#;
269
270const MIGRATION_V19: &str = r#"
272CREATE TABLE IF NOT EXISTS derived_vector_artifacts (
273 item_key TEXT NOT NULL,
274 codec_family TEXT NOT NULL,
275 codec_profile_digest TEXT NOT NULL,
276 source_embedding_digest TEXT NOT NULL,
277 encoded_digest TEXT NOT NULL,
278 artifact_digest TEXT NOT NULL,
279 encoding TEXT NOT NULL,
280 dim INTEGER NOT NULL,
281 encoded BLOB NOT NULL,
282 created_at TEXT NOT NULL DEFAULT (datetime('now')),
283 status TEXT NOT NULL DEFAULT 'active',
284 PRIMARY KEY (item_key, codec_family, codec_profile_digest)
285);
286
287CREATE INDEX IF NOT EXISTS idx_derived_vector_artifacts_profile
288ON derived_vector_artifacts(codec_family, codec_profile_digest, status);
289
290CREATE INDEX IF NOT EXISTS idx_derived_vector_artifacts_source_digest
291ON derived_vector_artifacts(source_embedding_digest);
292"#;
293
294const MIGRATION_V20: &str = r#"
296-- Procedural migration; see run_migration_v20.
297"#;
298
299const MIGRATION_V21: &str = r#"
301CREATE TABLE IF NOT EXISTS derived_vector_artifact_generations (
302 generation_id TEXT PRIMARY KEY,
303 schema_version TEXT NOT NULL,
304 codec_family TEXT NOT NULL,
305 codec_profile_digest TEXT NOT NULL,
306 source_snapshot_digest TEXT NOT NULL,
307 source_row_count INTEGER NOT NULL,
308 artifact_count INTEGER NOT NULL,
309 source_tables_json TEXT NOT NULL,
310 dim INTEGER NOT NULL,
311 encoding TEXT NOT NULL,
312 created_at TEXT NOT NULL,
313 build_receipt_id TEXT,
314 artifact_manifest_digest TEXT NOT NULL,
315 status TEXT NOT NULL CHECK (status IN ('active', 'superseded', 'invalidated', 'failed')),
316 degradations_json TEXT NOT NULL DEFAULT '[]'
317);
318
319CREATE INDEX IF NOT EXISTS idx_derived_vector_generations_profile
320ON derived_vector_artifact_generations(codec_family, codec_profile_digest, status, created_at DESC);
321"#;
322
323const MIGRATION_V23: &str = r#"
326ALTER TABLE derived_vector_artifacts ADD COLUMN codec_governance_receipt_id TEXT;
327ALTER TABLE derived_vector_artifacts ADD COLUMN codec_profile TEXT;
328ALTER TABLE derived_vector_artifacts ADD COLUMN degradation_budget REAL;
329ALTER TABLE derived_vector_artifacts ADD COLUMN raw_source_artifact_id TEXT;
330"#;
331
332const MIGRATION_V22: &str = r#"
335ALTER TABLE episodes ADD COLUMN valid_time TEXT;
336ALTER TABLE episodes ADD COLUMN recorded_time TEXT NOT NULL DEFAULT (datetime('now'));
337ALTER TABLE episodes ADD COLUMN superseded_by TEXT;
338ALTER TABLE episodes ADD COLUMN fact_digest TEXT;
339CREATE INDEX IF NOT EXISTS idx_episodes_recorded ON episodes(recorded_time ASC);
340CREATE INDEX IF NOT EXISTS idx_episodes_valid ON episodes(valid_time);
341CREATE INDEX IF NOT EXISTS idx_episodes_superseded ON episodes(superseded_by) WHERE superseded_by IS NOT NULL;
342UPDATE episodes SET recorded_time = updated_at WHERE recorded_time IS NULL OR recorded_time = '';
343"#;
344
345const MIGRATION_V24: &str = r#"
347CREATE TABLE IF NOT EXISTS provekv_pool_generations (
348 generation_id TEXT PRIMARY KEY,
349 embedding_snapshot_digest TEXT NOT NULL,
350 source_digest TEXT NOT NULL,
351 pool_manifest_digest TEXT NOT NULL,
352 codec_family TEXT NOT NULL,
353 codec_profile TEXT NOT NULL,
354 vector_dim INTEGER NOT NULL,
355 item_count INTEGER NOT NULL,
356 payload_bytes INTEGER NOT NULL,
357 payload BLOB NOT NULL,
358 status TEXT NOT NULL,
359 failure_reason TEXT,
360 created_at TEXT NOT NULL
361);
362
363CREATE TABLE IF NOT EXISTS provekv_pool_item_map (
364 generation_id TEXT NOT NULL,
365 item_id TEXT NOT NULL,
366 source_type TEXT NOT NULL,
367 pool_index INTEGER NOT NULL,
368 embedding_digest TEXT NOT NULL,
369 PRIMARY KEY (generation_id, item_id),
370 FOREIGN KEY (generation_id) REFERENCES provekv_pool_generations(generation_id) ON DELETE CASCADE
371);
372
373CREATE INDEX IF NOT EXISTS idx_provekv_pool_item_map_generation_index
374 ON provekv_pool_item_map(generation_id, pool_index);
375
376CREATE INDEX IF NOT EXISTS idx_provekv_pool_generations_status_created
377 ON provekv_pool_generations(status, created_at DESC);
378"#;
379
380const MIGRATION_V25: &str = r#"
387CREATE TABLE IF NOT EXISTS provenance (
388 id TEXT PRIMARY KEY,
389 item_type TEXT NOT NULL,
390 item_id TEXT NOT NULL,
391 semiring_type TEXT NOT NULL,
392 semiring_value TEXT NOT NULL,
393 support_chain_json TEXT NOT NULL DEFAULT '[]',
394 recorded_at TEXT NOT NULL DEFAULT (datetime('now')),
395 episode_id TEXT
396);
397
398CREATE INDEX IF NOT EXISTS idx_provenance_item
399 ON provenance(item_type, item_id);
400
401CREATE INDEX IF NOT EXISTS idx_provenance_episode
402 ON provenance(episode_id);
403"#;
404
405const MIGRATION_V26: &str = "";
412
413fn run_migration_v26(conn: &Connection) -> Result<(), rusqlite::Error> {
415 add_column_if_missing(
416 conn,
417 "facts",
418 "temporal_weight",
419 "REAL NOT NULL DEFAULT 1.0",
420 )?;
421 add_column_if_missing(
422 conn,
423 "chunks",
424 "temporal_weight",
425 "REAL NOT NULL DEFAULT 1.0",
426 )?;
427 add_column_if_missing(
428 conn,
429 "messages",
430 "temporal_weight",
431 "REAL NOT NULL DEFAULT 1.0",
432 )?;
433 conn.execute_batch(
434 "CREATE INDEX IF NOT EXISTS idx_facts_temporal ON facts(temporal_weight);
435 CREATE INDEX IF NOT EXISTS idx_chunks_temporal ON chunks(temporal_weight);",
436 )?;
437 Ok(())
438}
439
440const MIGRATION_V27: &str = r#"
446CREATE TABLE IF NOT EXISTS graph_edges (
447 id TEXT PRIMARY KEY,
448 source TEXT NOT NULL,
449 target TEXT NOT NULL,
450 edge_type TEXT NOT NULL,
451 weight REAL NOT NULL,
452 metadata TEXT,
453 content_digest TEXT NOT NULL,
454 recorded_at TEXT NOT NULL,
455 is_invalidated INTEGER NOT NULL DEFAULT 0,
456 invalidated_at TEXT,
457 invalidation_reason TEXT
458);
459
460CREATE INDEX IF NOT EXISTS idx_graph_edges_source
461 ON graph_edges(source) WHERE is_invalidated = 0;
462
463CREATE INDEX IF NOT EXISTS idx_graph_edges_target
464 ON graph_edges(target) WHERE is_invalidated = 0;
465
466CREATE INDEX IF NOT EXISTS idx_graph_edges_digest
467 ON graph_edges(content_digest) WHERE is_invalidated = 0;
468"#;
469
470const MIGRATION_V28: &str = "";
476
477fn run_migration_v28(conn: &Connection) -> Result<(), rusqlite::Error> {
478 add_column_if_missing(conn, "graph_edges", "valid_time", "TEXT")?;
479 add_column_if_missing(conn, "graph_edges", "recorded_time", "TEXT")?;
480 conn.execute(
482 "UPDATE graph_edges
483 SET valid_time = COALESCE(valid_time, recorded_at),
484 recorded_time = COALESCE(recorded_time, recorded_at)",
485 [],
486 )?;
487 conn.execute(
491 "UPDATE graph_edges
492 SET valid_time = format('%Y-%m-%d %H:%M:%f', valid_time),
493 recorded_time = format('%Y-%m-%d %H:%M:%f', recorded_time),
494 invalidated_at = CASE
495 WHEN invalidated_at IS NULL THEN NULL
496 ELSE format('%Y-%m-%d %H:%M:%f', invalidated_at)
497 END",
498 [],
499 )?;
500 conn.execute_batch(
501 "CREATE INDEX IF NOT EXISTS idx_graph_edges_bitemporal
502 ON graph_edges(valid_time, recorded_time);
503 CREATE INDEX IF NOT EXISTS idx_graph_edges_recorded_time
504 ON graph_edges(recorded_time);",
505 )?;
506 Ok(())
507}
508
509const MIGRATION_V29: &str = r#"
511CREATE TABLE IF NOT EXISTS authority_state (
512 id INTEGER PRIMARY KEY CHECK (id = 1),
513 retrieval_epoch INTEGER NOT NULL CHECK (retrieval_epoch >= 0)
514);
515INSERT OR IGNORE INTO authority_state (id, retrieval_epoch) VALUES (1, 0);
516
517CREATE TABLE IF NOT EXISTS authority_lineages (
518 lineage_id TEXT PRIMARY KEY,
519 active_head_id TEXT NOT NULL REFERENCES facts(id),
520 updated_epoch INTEGER NOT NULL CHECK (updated_epoch >= 0)
521);
522
523CREATE TABLE IF NOT EXISTS authority_versions (
524 fact_id TEXT PRIMARY KEY REFERENCES facts(id) ON DELETE CASCADE,
525 lineage_id TEXT NOT NULL REFERENCES authority_lineages(lineage_id),
526 version INTEGER NOT NULL CHECK (version > 0),
527 operation_kind TEXT NOT NULL CHECK (operation_kind IN ('append', 'supersede', 'redact')),
528 is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)),
529 is_redacted INTEGER NOT NULL DEFAULT 0 CHECK (is_redacted IN (0, 1)),
530 content_digest TEXT NOT NULL,
531 UNIQUE (lineage_id, version)
532);
533CREATE UNIQUE INDEX IF NOT EXISTS idx_authority_one_active_head
534 ON authority_versions(lineage_id) WHERE is_active = 1;
535CREATE INDEX IF NOT EXISTS idx_authority_versions_lineage
536 ON authority_versions(lineage_id, version);
537
538CREATE TABLE IF NOT EXISTS operation_journal (
539 operation_id TEXT PRIMARY KEY,
540 caller_idempotency_key TEXT NOT NULL UNIQUE,
541 operation_kind TEXT NOT NULL,
542 payload_digest TEXT NOT NULL,
543 principal TEXT NOT NULL,
544 caller_id TEXT NOT NULL,
545 before_epoch INTEGER NOT NULL,
546 after_epoch INTEGER NOT NULL,
547 affected_ids_json TEXT NOT NULL,
548 content_digest TEXT NOT NULL,
549 committed_at TEXT NOT NULL
550);
551CREATE INDEX IF NOT EXISTS idx_operation_journal_idempotency
552 ON operation_journal(caller_idempotency_key);
553
554CREATE TABLE IF NOT EXISTS authority_receipts (
555 receipt_id TEXT PRIMARY KEY,
556 operation_id TEXT NOT NULL UNIQUE REFERENCES operation_journal(operation_id),
557 caller_idempotency_key TEXT NOT NULL UNIQUE,
558 receipt_json TEXT NOT NULL,
559 receipt_digest TEXT NOT NULL,
560 created_at TEXT NOT NULL
561);
562CREATE INDEX IF NOT EXISTS idx_authority_receipts_operation
563 ON authority_receipts(operation_id);
564CREATE INDEX IF NOT EXISTS idx_authority_receipts_idempotency
565 ON authority_receipts(caller_idempotency_key);
566"#;
567
568const MIGRATION_V30: &str = r#"
570CREATE TABLE IF NOT EXISTS memory_transition_records (
571 record_id TEXT PRIMARY KEY,
572 caller_idempotency_key TEXT NOT NULL UNIQUE,
573 principal TEXT NOT NULL,
574 caller_id TEXT NOT NULL,
575 candidate_digest TEXT NOT NULL,
576 candidate_json TEXT NOT NULL,
577 verification_json TEXT NOT NULL,
578 disposition TEXT NOT NULL CHECK (disposition IN ('commit', 'quarantine')),
579 authority_receipt_id TEXT REFERENCES authority_receipts(receipt_id),
580 created_at TEXT NOT NULL
581);
582CREATE INDEX IF NOT EXISTS idx_memory_transition_disposition
583 ON memory_transition_records(disposition, created_at);
584CREATE TRIGGER IF NOT EXISTS memory_transition_records_no_update
585BEFORE UPDATE ON memory_transition_records
586BEGIN
587 SELECT RAISE(ABORT, 'memory transition records are immutable');
588END;
589CREATE TRIGGER IF NOT EXISTS memory_transition_records_no_delete
590BEFORE DELETE ON memory_transition_records
591BEGIN
592 SELECT RAISE(ABORT, 'memory transition records are immutable');
593END;
594"#;
595
596const MIGRATION_V31: &str = r#"
598CREATE TABLE IF NOT EXISTS origin_authority_labels (
599 fact_id TEXT PRIMARY KEY REFERENCES facts(id) ON DELETE CASCADE,
600 label_json TEXT NOT NULL,
601 label_digest TEXT NOT NULL,
602 recorded_at TEXT NOT NULL
603);
604CREATE TRIGGER IF NOT EXISTS origin_authority_labels_no_update
605BEFORE UPDATE ON origin_authority_labels
606BEGIN
607 SELECT RAISE(ABORT, 'origin authority labels are immutable');
608END;
609CREATE TRIGGER IF NOT EXISTS origin_authority_labels_no_delete
610BEFORE DELETE ON origin_authority_labels
611BEGIN
612 SELECT RAISE(ABORT, 'origin authority labels are immutable');
613END;
614
615CREATE TABLE IF NOT EXISTS origin_authority_revocations (
616 revocation_id TEXT PRIMARY KEY,
617 fact_id TEXT NOT NULL REFERENCES facts(id),
618 caller_idempotency_key TEXT NOT NULL UNIQUE,
619 principal TEXT NOT NULL,
620 revocation_reference TEXT NOT NULL,
621 revoked_at TEXT NOT NULL
622);
623CREATE INDEX IF NOT EXISTS idx_origin_authority_revocations_fact
624 ON origin_authority_revocations(fact_id, revoked_at);
625CREATE TRIGGER IF NOT EXISTS origin_authority_revocations_no_update
626BEFORE UPDATE ON origin_authority_revocations
627BEGIN
628 SELECT RAISE(ABORT, 'origin authority revocations are append-only');
629END;
630CREATE TRIGGER IF NOT EXISTS origin_authority_revocations_no_delete
631BEFORE DELETE ON origin_authority_revocations
632BEGIN
633 SELECT RAISE(ABORT, 'origin authority revocations are append-only');
634END;
635"#;
636
637const MIGRATION_V32: &str = r#"
639ALTER TABLE authority_state ADD COLUMN projection_epoch INTEGER NOT NULL DEFAULT 0;
640ALTER TABLE authority_state ADD COLUMN cache_epoch INTEGER NOT NULL DEFAULT 0;
641ALTER TABLE authority_state ADD COLUMN export_epoch INTEGER NOT NULL DEFAULT 0;
642ALTER TABLE authority_state ADD COLUMN replay_epoch INTEGER NOT NULL DEFAULT 0;
643
644CREATE TABLE IF NOT EXISTS forgotten_facts (
645 fact_id TEXT PRIMARY KEY REFERENCES facts(id),
646 receipt_id TEXT NOT NULL,
647 namespace TEXT NOT NULL,
648 content_digest TEXT NOT NULL,
649 forgotten_at TEXT NOT NULL
650);
651CREATE INDEX IF NOT EXISTS idx_forgotten_facts_namespace
652 ON forgotten_facts(namespace, forgotten_at);
653CREATE TRIGGER IF NOT EXISTS forgotten_facts_no_update
654BEFORE UPDATE ON forgotten_facts BEGIN
655 SELECT RAISE(ABORT, 'forgotten fact tombstones are append-only');
656END;
657CREATE TRIGGER IF NOT EXISTS forgotten_facts_no_delete
658BEFORE DELETE ON forgotten_facts BEGIN
659 SELECT RAISE(ABORT, 'forgotten fact tombstones are append-only');
660END;
661
662CREATE TABLE IF NOT EXISTS forgetting_artifact_invalidations (
663 surface_kind TEXT NOT NULL,
664 artifact_id TEXT NOT NULL,
665 receipt_id TEXT NOT NULL,
666 invalidated_at TEXT NOT NULL,
667 PRIMARY KEY(surface_kind, artifact_id)
668);
669CREATE INDEX IF NOT EXISTS idx_forgetting_invalidations_receipt
670 ON forgetting_artifact_invalidations(receipt_id);
671CREATE TRIGGER IF NOT EXISTS forgetting_invalidations_no_update
672BEFORE UPDATE ON forgetting_artifact_invalidations BEGIN
673 SELECT RAISE(ABORT, 'forgetting invalidations are append-only');
674END;
675CREATE TRIGGER IF NOT EXISTS forgetting_invalidations_no_delete
676BEFORE DELETE ON forgetting_artifact_invalidations BEGIN
677 SELECT RAISE(ABORT, 'forgetting invalidations are append-only');
678END;
679
680CREATE TABLE IF NOT EXISTS forgetting_closure_receipts (
681 receipt_id TEXT PRIMARY KEY,
682 caller_idempotency_key TEXT NOT NULL UNIQUE,
683 payload_digest TEXT NOT NULL,
684 receipt_json TEXT NOT NULL,
685 receipt_digest TEXT NOT NULL,
686 created_at TEXT NOT NULL
687);
688CREATE TRIGGER IF NOT EXISTS forgetting_receipts_no_update
689BEFORE UPDATE ON forgetting_closure_receipts BEGIN
690 SELECT RAISE(ABORT, 'forgetting receipts are immutable');
691END;
692CREATE TRIGGER IF NOT EXISTS forgetting_receipts_no_delete
693BEFORE DELETE ON forgetting_closure_receipts BEGIN
694 SELECT RAISE(ABORT, 'forgetting receipts are immutable');
695END;
696"#;
697
698const MIGRATION_V33: &str = r#"
700CREATE TABLE IF NOT EXISTS shadow_policy_proposals (
701 proposal_id TEXT PRIMARY KEY,
702 idempotency_key TEXT NOT NULL UNIQUE,
703 principal TEXT NOT NULL,
704 policy_kind TEXT NOT NULL,
705 proposal_digest TEXT NOT NULL,
706 proposal_json TEXT NOT NULL,
707 status TEXT NOT NULL,
708 created_at TEXT NOT NULL
709);
710CREATE INDEX IF NOT EXISTS idx_shadow_policy_proposals_principal
711 ON shadow_policy_proposals(principal, policy_kind, created_at);
712CREATE TRIGGER IF NOT EXISTS shadow_policy_proposals_no_update
713BEFORE UPDATE ON shadow_policy_proposals BEGIN
714 SELECT RAISE(ABORT, 'shadow policy proposals are append-only');
715END;
716CREATE TRIGGER IF NOT EXISTS shadow_policy_proposals_no_delete
717BEFORE DELETE ON shadow_policy_proposals BEGIN
718 SELECT RAISE(ABORT, 'shadow policy proposals are append-only');
719END;
720
721CREATE TABLE IF NOT EXISTS shadow_policy_versions (
722 principal TEXT NOT NULL,
723 policy_kind TEXT NOT NULL,
724 version INTEGER NOT NULL CHECK (version > 0),
725 policy_json TEXT NOT NULL,
726 policy_digest TEXT NOT NULL,
727 proposal_id TEXT NOT NULL REFERENCES shadow_policy_proposals(proposal_id),
728 activated_by TEXT NOT NULL,
729 activated_at TEXT NOT NULL,
730 PRIMARY KEY (principal, policy_kind, version)
731);
732CREATE TRIGGER IF NOT EXISTS shadow_policy_versions_no_update
733BEFORE UPDATE ON shadow_policy_versions BEGIN
734 SELECT RAISE(ABORT, 'shadow policy versions are append-only');
735END;
736CREATE TRIGGER IF NOT EXISTS shadow_policy_versions_no_delete
737BEFORE DELETE ON shadow_policy_versions BEGIN
738 SELECT RAISE(ABORT, 'shadow policy versions are append-only');
739END;
740
741CREATE TABLE IF NOT EXISTS shadow_active_policies (
742 principal TEXT NOT NULL,
743 policy_kind TEXT NOT NULL,
744 version INTEGER NOT NULL,
745 policy_json TEXT NOT NULL,
746 policy_digest TEXT NOT NULL,
747 source_proposal_id TEXT NOT NULL,
748 activated_by TEXT NOT NULL,
749 activated_at TEXT NOT NULL,
750 PRIMARY KEY (principal, policy_kind),
751 FOREIGN KEY (principal, policy_kind, version)
752 REFERENCES shadow_policy_versions(principal, policy_kind, version)
753);
754
755CREATE TABLE IF NOT EXISTS shadow_policy_receipts (
756 receipt_id TEXT PRIMARY KEY,
757 caller_idempotency_key TEXT NOT NULL UNIQUE,
758 proposal_id TEXT NOT NULL,
759 principal TEXT NOT NULL,
760 policy_kind TEXT NOT NULL,
761 evidence_digest TEXT NOT NULL,
762 status TEXT NOT NULL,
763 receipt_json TEXT NOT NULL,
764 receipt_digest TEXT NOT NULL,
765 created_at TEXT NOT NULL
766);
767CREATE INDEX IF NOT EXISTS idx_shadow_policy_receipts_proposal
768 ON shadow_policy_receipts(proposal_id, created_at);
769CREATE TRIGGER IF NOT EXISTS shadow_policy_receipts_no_update
770BEFORE UPDATE ON shadow_policy_receipts BEGIN
771 SELECT RAISE(ABORT, 'shadow policy receipts are immutable');
772END;
773CREATE TRIGGER IF NOT EXISTS shadow_policy_receipts_no_delete
774BEFORE DELETE ON shadow_policy_receipts BEGIN
775 SELECT RAISE(ABORT, 'shadow policy receipts are immutable');
776END;
777"#;
778
779const MIGRATION_V34: &str = r#"
784CREATE TABLE IF NOT EXISTS procedural_memory_artifacts (
785 artifact_id TEXT PRIMARY KEY,
786 principal TEXT NOT NULL,
787 capability_domain TEXT NOT NULL,
788 capability_name TEXT NOT NULL,
789 action_kind TEXT NOT NULL,
790 version INTEGER NOT NULL CHECK (version > 0),
791 supersedes TEXT,
792 artifact_digest TEXT NOT NULL UNIQUE,
793 artifact_json TEXT NOT NULL,
794 created_at TEXT NOT NULL
795);
796CREATE INDEX IF NOT EXISTS idx_procedural_memory_lookup
797 ON procedural_memory_artifacts(principal, capability_domain, capability_name, action_kind, version DESC);
798CREATE TRIGGER IF NOT EXISTS procedural_memory_artifacts_no_update
799BEFORE UPDATE ON procedural_memory_artifacts BEGIN
800 SELECT RAISE(ABORT, 'procedural memory artifacts are immutable');
801END;
802CREATE TRIGGER IF NOT EXISTS procedural_memory_artifacts_no_delete
803BEFORE DELETE ON procedural_memory_artifacts BEGIN
804 SELECT RAISE(ABORT, 'procedural memory artifacts are immutable');
805END;
806
807CREATE TABLE IF NOT EXISTS procedural_memory_events (
808 event_id TEXT PRIMARY KEY,
809 artifact_id TEXT NOT NULL REFERENCES procedural_memory_artifacts(artifact_id),
810 disposition TEXT NOT NULL,
811 reason_digest TEXT NOT NULL,
812 test_receipt_json TEXT,
813 prior_event_digest TEXT,
814 event_digest TEXT NOT NULL UNIQUE,
815 created_at TEXT NOT NULL
816);
817CREATE INDEX IF NOT EXISTS idx_procedural_memory_events_artifact
818 ON procedural_memory_events(artifact_id, created_at, event_id);
819CREATE TRIGGER IF NOT EXISTS procedural_memory_events_no_update
820BEFORE UPDATE ON procedural_memory_events BEGIN
821 SELECT RAISE(ABORT, 'procedural memory events are append-only');
822END;
823CREATE TRIGGER IF NOT EXISTS procedural_memory_events_no_delete
824BEFORE DELETE ON procedural_memory_events BEGIN
825 SELECT RAISE(ABORT, 'procedural memory events are append-only');
826END;
827
828CREATE TABLE IF NOT EXISTS procedural_memory_receipts (
829 receipt_id TEXT PRIMARY KEY,
830 caller_idempotency_key TEXT NOT NULL UNIQUE,
831 operation TEXT NOT NULL,
832 payload_digest TEXT NOT NULL,
833 artifact_id TEXT NOT NULL,
834 receipt_json TEXT NOT NULL,
835 receipt_digest TEXT NOT NULL UNIQUE,
836 created_at TEXT NOT NULL
837);
838CREATE INDEX IF NOT EXISTS idx_procedural_memory_receipts_artifact
839 ON procedural_memory_receipts(artifact_id, created_at);
840CREATE TRIGGER IF NOT EXISTS procedural_memory_receipts_no_update
841BEFORE UPDATE ON procedural_memory_receipts BEGIN
842 SELECT RAISE(ABORT, 'procedural memory receipts are immutable');
843END;
844CREATE TRIGGER IF NOT EXISTS procedural_memory_receipts_no_delete
845BEFORE DELETE ON procedural_memory_receipts BEGIN
846 SELECT RAISE(ABORT, 'procedural memory receipts are immutable');
847END;
848"#;
849
850const MIGRATION_V35: &str = r#"
852CREATE TABLE IF NOT EXISTS replay_inputs (
853 receipt_id TEXT PRIMARY KEY,
854 query_text TEXT NOT NULL,
855 namespaces_json TEXT,
856 source_types_json TEXT,
857 stored_at TEXT NOT NULL DEFAULT (datetime('now'))
858);
859"#;
860
861const MIGRATION_V36: &str = r#"
863CREATE TABLE IF NOT EXISTS sparse_vectors (
864 item_key TEXT PRIMARY KEY,
865 entries_json TEXT NOT NULL,
866 representation TEXT NOT NULL,
867 updated_at TEXT NOT NULL DEFAULT (datetime('now'))
868);
869
870CREATE TRIGGER IF NOT EXISTS sparse_vectors_delete_fact
871AFTER DELETE ON facts BEGIN
872 DELETE FROM sparse_vectors WHERE item_key = 'fact:' || OLD.id;
873END;
874
875CREATE TRIGGER IF NOT EXISTS sparse_vectors_delete_chunk
876AFTER DELETE ON chunks BEGIN
877 DELETE FROM sparse_vectors WHERE item_key = 'chunk:' || OLD.id;
878END;
879
880CREATE TRIGGER IF NOT EXISTS sparse_vectors_delete_message
881AFTER DELETE ON messages BEGIN
882 DELETE FROM sparse_vectors WHERE item_key = 'msg:' || OLD.id;
883END;
884
885CREATE TRIGGER IF NOT EXISTS sparse_vectors_delete_episode
886AFTER DELETE ON episodes BEGIN
887 DELETE FROM sparse_vectors WHERE item_key = 'episode:' || OLD.episode_id;
888END;
889"#;
890
891#[allow(deprecated)]
893const MIGRATIONS: &[(u32, &str)] = &[
894 (1, MIGRATION_V1),
895 (2, MIGRATION_V2),
896 (3, MIGRATION_V3),
897 (4, MIGRATION_V4),
898 (5, MIGRATION_V5),
899 (6, MIGRATION_V6),
900 (7, MIGRATION_V7),
901 (8, MIGRATION_V8),
902 (9, MIGRATION_V9),
903 (10, crate::projection_import::MIGRATION_V10),
904 (11, crate::projection_storage::MIGRATION_V11),
905 (12, crate::projection_storage::MIGRATION_V12),
906 (13, crate::projection_storage::MIGRATION_V13),
907 (14, crate::projection_storage::MIGRATION_V14),
908 (15, crate::projection_storage::MIGRATION_V15),
909 (16, crate::projection_storage::MIGRATION_V16),
910 (17, crate::projection_storage::MIGRATION_V17),
911 (18, MIGRATION_V18),
912 (19, MIGRATION_V19),
913 (20, MIGRATION_V20),
914 (21, MIGRATION_V21),
915 (22, MIGRATION_V22),
916 (23, MIGRATION_V23),
917 (24, MIGRATION_V24),
918 (25, MIGRATION_V25),
919 (26, MIGRATION_V26),
920 (27, MIGRATION_V27),
921 (28, MIGRATION_V28),
922 (29, MIGRATION_V29),
923 (30, MIGRATION_V30),
924 (31, MIGRATION_V31),
925 (32, MIGRATION_V32),
926 (33, MIGRATION_V33),
927 (34, MIGRATION_V34),
928 (35, MIGRATION_V35),
929 (36, MIGRATION_V36),
930 (37, crate::journal::MIGRATION_V37),
931 (38, crate::journal::MIGRATION_V38),
932 (39, crate::journal::MIGRATION_V39),
933];
934
935pub const MAX_SCHEMA_VERSION: u32 = 39;
937
938fn run_migration_v9(conn: &Connection) -> Result<(), MemoryError> {
940 let episodes_exist: bool = conn
942 .query_row(
943 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='episodes'",
944 [],
945 |row| row.get(0),
946 )
947 .map_err(|e| MemoryError::MigrationFailed {
948 version: 9,
949 reason: format!("existence check failed: {e}"),
950 })?;
951
952 if !episodes_exist {
953 conn.execute_batch(
955 "CREATE TABLE IF NOT EXISTS episode_causes (
956 episode_id TEXT NOT NULL,
957 cause_node_id TEXT NOT NULL,
958 ordinal INTEGER NOT NULL DEFAULT 0,
959 PRIMARY KEY (episode_id, cause_node_id)
960 );
961 CREATE INDEX IF NOT EXISTS idx_episode_causes_cause ON episode_causes(cause_node_id);",
962 )?;
963 return Ok(());
964 }
965
966 conn.execute_batch("PRAGMA foreign_keys = OFF;")?;
968
969 conn.execute_batch(
970 "CREATE TABLE episodes_new (
971 episode_id TEXT PRIMARY KEY,
972 document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
973 cause_ids TEXT NOT NULL,
974 effect_type TEXT NOT NULL,
975 outcome TEXT NOT NULL DEFAULT 'pending',
976 confidence REAL NOT NULL DEFAULT 0.0,
977 verification_status TEXT NOT NULL DEFAULT '{\"status\":\"unverified\"}',
978 experiment_id TEXT,
979 created_at TEXT NOT NULL DEFAULT (datetime('now')),
980 updated_at TEXT NOT NULL DEFAULT (datetime('now')),
981 search_text TEXT NOT NULL DEFAULT '',
982 embedding BLOB,
983 embedding_q8 BLOB,
984 trace_id TEXT
985 )",
986 )?;
987
988 conn.execute_batch(
990 "INSERT INTO episodes_new
991 (episode_id, document_id, cause_ids, effect_type, outcome, confidence,
992 verification_status, experiment_id, created_at, updated_at,
993 search_text, embedding, embedding_q8, trace_id)
994 SELECT
995 document_id || '-ep0',
996 document_id, cause_ids, effect_type, outcome, confidence,
997 verification_status, experiment_id, created_at, updated_at,
998 search_text, embedding, embedding_q8, trace_id
999 FROM episodes",
1000 )?;
1001
1002 conn.execute_batch("DROP TABLE episodes")?;
1003 conn.execute_batch("ALTER TABLE episodes_new RENAME TO episodes")?;
1004
1005 conn.execute_batch(
1006 "CREATE INDEX idx_episodes_document_id ON episodes(document_id);
1007 CREATE INDEX idx_episodes_effect_type ON episodes(effect_type);
1008 CREATE INDEX idx_episodes_outcome ON episodes(outcome);
1009 CREATE INDEX idx_episodes_experiment_id ON episodes(experiment_id);",
1010 )?;
1011
1012 conn.execute_batch(
1014 "DROP TABLE IF EXISTS episodes_rowid_map;
1015 CREATE TABLE episodes_rowid_map (
1016 rowid INTEGER PRIMARY KEY AUTOINCREMENT,
1017 episode_id TEXT NOT NULL UNIQUE,
1018 document_id TEXT
1019 );
1020 INSERT INTO episodes_rowid_map (episode_id, document_id)
1021 SELECT episode_id, document_id FROM episodes;",
1022 )?;
1023
1024 conn.execute_batch(
1026 "DROP TABLE IF EXISTS episodes_fts;
1027 CREATE VIRTUAL TABLE episodes_fts USING fts5(
1028 content,
1029 content='',
1030 content_rowid='rowid',
1031 tokenize='porter unicode61'
1032 );
1033 INSERT INTO episodes_fts (rowid, content)
1034 SELECT rm.rowid, e.search_text
1035 FROM episodes_rowid_map rm
1036 JOIN episodes e ON e.episode_id = rm.episode_id;",
1037 )?;
1038
1039 conn.execute_batch(
1041 "CREATE TABLE IF NOT EXISTS episode_causes (
1042 episode_id TEXT NOT NULL,
1043 cause_node_id TEXT NOT NULL,
1044 ordinal INTEGER NOT NULL DEFAULT 0,
1045 PRIMARY KEY (episode_id, cause_node_id)
1046 );
1047 CREATE INDEX IF NOT EXISTS idx_episode_causes_cause ON episode_causes(cause_node_id);",
1048 )?;
1049
1050 conn.execute_batch(
1052 "INSERT OR IGNORE INTO episode_causes (episode_id, cause_node_id, ordinal)
1053 SELECT e.episode_id, je.value, CAST(je.key AS INTEGER)
1054 FROM episodes e, json_each(e.cause_ids) je;",
1055 )?;
1056
1057 conn.execute_batch("PRAGMA foreign_keys = ON;")?;
1058
1059 Ok(())
1060}
1061
1062#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1064pub enum VerifyMode {
1065 Quick,
1067 Full,
1069}
1070
1071#[derive(Debug, Clone)]
1073pub struct IntegrityReport {
1074 pub ok: bool,
1075 pub schema_version: u32,
1076 pub fact_count: usize,
1077 pub chunk_count: usize,
1078 pub message_count: usize,
1079 pub facts_missing_embeddings: usize,
1080 pub chunks_missing_embeddings: usize,
1081 pub issues: Vec<String>,
1082}
1083
1084#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1086pub enum ReconcileAction {
1087 ReportOnly,
1088 RebuildFts,
1089 ReEmbed,
1090}
1091
1092#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1094pub(crate) enum IndexOpKind {
1095 Upsert,
1096 Delete,
1097}
1098
1099impl IndexOpKind {
1100 pub(crate) fn as_str(self) -> &'static str {
1101 match self {
1102 Self::Upsert => "upsert",
1103 Self::Delete => "delete",
1104 }
1105 }
1106
1107 fn parse(raw: &str, item_key: &str) -> Result<Self, MemoryError> {
1108 match raw {
1109 "upsert" => Ok(Self::Upsert),
1110 "delete" => Ok(Self::Delete),
1111 other => Err(MemoryError::CorruptData {
1112 table: "pending_index_ops",
1113 row_id: item_key.to_string(),
1114 detail: format!("invalid op_kind '{other}'"),
1115 }),
1116 }
1117 }
1118}
1119
1120#[derive(Debug, Clone)]
1122pub(crate) struct PendingIndexOp {
1123 pub item_key: String,
1124 pub entity_type: String,
1125 pub op_kind: IndexOpKind,
1126 pub attempt_count: u32,
1127 pub last_error: Option<String>,
1128}
1129
1130pub fn with_transaction<F, T>(conn: &Connection, f: F) -> Result<T, MemoryError>
1132where
1133 F: FnOnce(&rusqlite::Transaction<'_>) -> Result<T, MemoryError>,
1134{
1135 let tx = conn.unchecked_transaction()?;
1136 let result = f(&tx)?;
1137 tx.commit()?;
1138 Ok(result)
1139}
1140
1141pub fn open_database(
1143 path: &Path,
1144 pool: &PoolConfig,
1145 limits: &MemoryLimits,
1146) -> Result<Connection, MemoryError> {
1147 open_database_internal(path, pool, limits.max_db_size_bytes, true)
1148}
1149
1150#[allow(dead_code)] pub fn open_database_connection(
1153 path: &Path,
1154 pool: &PoolConfig,
1155 limits: &MemoryLimits,
1156) -> Result<Connection, MemoryError> {
1157 open_database_internal(path, pool, limits.max_db_size_bytes, false)
1158}
1159
1160pub(crate) fn open_database_internal(
1161 path: &Path,
1162 pool: &PoolConfig,
1163 max_db_size_bytes: u64,
1164 run_schema_migrations: bool,
1165) -> Result<Connection, MemoryError> {
1166 create_parent_dirs(path)?;
1167 let conn = Connection::open(path)?;
1168 configure_connection(&conn, path, pool, max_db_size_bytes, false)?;
1169 if run_schema_migrations {
1170 run_migrations(&conn)?;
1171 }
1172 Ok(conn)
1173}
1174
1175pub(crate) fn open_pool_member_connection(
1176 path: &Path,
1177 pool: &PoolConfig,
1178 limits: &MemoryLimits,
1179 query_only: bool,
1180) -> Result<Connection, MemoryError> {
1181 create_parent_dirs(path)?;
1182 let flags = OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE;
1183 let conn = Connection::open_with_flags(path, flags)?;
1184 configure_connection(&conn, path, pool, limits.max_db_size_bytes, query_only)?;
1185 Ok(conn)
1186}
1187
1188fn create_parent_dirs(path: &Path) -> Result<(), MemoryError> {
1189 if let Some(parent) = path.parent() {
1190 if !parent.as_os_str().is_empty() {
1191 std::fs::create_dir_all(parent).map_err(|e| {
1192 MemoryError::StorageError(format!(
1193 "failed to create database directory {}: {}",
1194 parent.display(),
1195 e
1196 ))
1197 })?;
1198 }
1199 }
1200 Ok(())
1201}
1202
1203fn configure_connection(
1204 conn: &Connection,
1205 path: &Path,
1206 pool: &PoolConfig,
1207 max_db_size_bytes: u64,
1208 query_only: bool,
1209) -> Result<(), MemoryError> {
1210 let journal_mode = if pool.enable_wal { "WAL" } else { "DELETE" };
1211 conn.execute_batch(&format!(
1212 "PRAGMA journal_mode = {};
1213 PRAGMA foreign_keys = ON;
1214 PRAGMA busy_timeout = {};
1215 PRAGMA synchronous = NORMAL;
1216 PRAGMA temp_store = MEMORY;
1217 PRAGMA wal_autocheckpoint = {};
1218 PRAGMA cache_size = -25600;
1219 PRAGMA mmap_size = 268435456;",
1220 journal_mode, pool.busy_timeout_ms, pool.wal_autocheckpoint,
1221 ))?;
1222
1223 if query_only {
1224 conn.execute_batch("PRAGMA query_only = ON;")?;
1225 }
1226
1227 let actual_journal_mode: String =
1228 conn.query_row("PRAGMA journal_mode", [], |row| row.get(0))?;
1229 let expected_journal_mode = if pool.enable_wal { "wal" } else { "delete" };
1230 if actual_journal_mode.to_lowercase() != expected_journal_mode {
1231 return Err(MemoryError::StorageError(format!(
1232 "SQLite journal mode mismatch for {}: requested {}, got {}",
1233 path.display(),
1234 expected_journal_mode,
1235 actual_journal_mode
1236 )));
1237 }
1238
1239 if max_db_size_bytes > 0 {
1240 let page_size: u64 = conn.query_row("PRAGMA page_size", [], |row| row.get(0))?;
1241 let max_page_count = max_db_size_bytes.div_ceil(page_size);
1242
1243 const MAX_SQLITE_PAGE_COUNT: u64 = 1_073_741_823; const MIN_SQLITE_PAGE_COUNT: u64 = 1;
1246 if !(MIN_SQLITE_PAGE_COUNT..=MAX_SQLITE_PAGE_COUNT).contains(&max_page_count) {
1247 return Err(MemoryError::StorageError(format!(
1248 "Invalid max_page_count {}: must be between {} and {}",
1249 max_page_count, MIN_SQLITE_PAGE_COUNT, MAX_SQLITE_PAGE_COUNT
1250 )));
1251 }
1252
1253 let actual_max_page_count: u64 = conn.query_row(
1254 &format!("PRAGMA max_page_count = {}", max_page_count),
1255 [],
1256 |row| row.get(0),
1257 )?;
1258 let page_count: u64 = conn.query_row("PRAGMA page_count", [], |row| row.get(0))?;
1259
1260 if page_count > actual_max_page_count {
1261 return Err(MemoryError::DatabaseSizeLimitExceeded {
1262 current: page_count.saturating_mul(page_size),
1263 limit: max_db_size_bytes,
1264 });
1265 }
1266 }
1267
1268 let foreign_keys_enabled: bool = conn.query_row("PRAGMA foreign_keys", [], |row| row.get(0))?;
1270 if !foreign_keys_enabled {
1271 return Err(MemoryError::StorageError(
1272 "PRAGMA foreign_keys failed to enable after configuration".to_string(),
1273 ));
1274 }
1275
1276 Ok(())
1277}
1278
1279pub fn run_migrations(conn: &Connection) -> Result<(), MemoryError> {
1281 let user_version: u32 = conn
1282 .query_row("PRAGMA user_version", [], |row| row.get(0))
1283 .map_err(|e| MemoryError::MigrationFailed {
1284 version: 0,
1285 reason: format!("failed to read PRAGMA user_version: {e}"),
1286 })?;
1287
1288 if user_version > MAX_SCHEMA_VERSION {
1289 return Err(MemoryError::SchemaAhead {
1290 found: user_version,
1291 supported: MAX_SCHEMA_VERSION,
1292 });
1293 }
1294
1295 conn.execute_batch(
1296 "CREATE TABLE IF NOT EXISTS _schema_version (
1297 version INTEGER PRIMARY KEY,
1298 applied_at TEXT NOT NULL DEFAULT (datetime('now'))
1299 );",
1300 )?;
1301
1302 for &(version, sql) in MIGRATIONS {
1303 let current_version: u32 = conn
1304 .query_row(
1305 "SELECT COALESCE(MAX(version), 0) FROM _schema_version",
1306 [],
1307 |row| row.get(0),
1308 )
1309 .unwrap_or(0);
1310
1311 if current_version >= version {
1312 continue;
1313 }
1314
1315 with_transaction(conn, |tx| {
1316 match version {
1317 9 => run_migration_v9(tx).map_err(|e| MemoryError::MigrationFailed {
1318 version,
1319 reason: e.to_string(),
1320 })?,
1321 16 => run_migration_v16(tx).map_err(|e| MemoryError::MigrationFailed {
1322 version,
1323 reason: e.to_string(),
1324 })?,
1325 17 => run_migration_v17(tx).map_err(|e| MemoryError::MigrationFailed {
1326 version,
1327 reason: e.to_string(),
1328 })?,
1329 20 => run_migration_v20(tx).map_err(|e| MemoryError::MigrationFailed {
1330 version,
1331 reason: e.to_string(),
1332 })?,
1333 21 => run_migration_v21(tx).map_err(|e| MemoryError::MigrationFailed {
1334 version,
1335 reason: e.to_string(),
1336 })?,
1337 26 => run_migration_v26(tx).map_err(|e| MemoryError::MigrationFailed {
1338 version,
1339 reason: e.to_string(),
1340 })?,
1341 28 => run_migration_v28(tx).map_err(|e| MemoryError::MigrationFailed {
1342 version,
1343 reason: e.to_string(),
1344 })?,
1345 _ => tx
1346 .execute_batch(sql)
1347 .map_err(|e| MemoryError::MigrationFailed {
1348 version,
1349 reason: e.to_string(),
1350 })?,
1351 }
1352 tx.execute(
1353 "INSERT INTO _schema_version (version) VALUES (?1)",
1354 params![version],
1355 )
1356 .map_err(|e| MemoryError::MigrationFailed {
1357 version,
1358 reason: e.to_string(),
1359 })?;
1360 Ok(())
1361 })?;
1362
1363 tracing::info!("Applied migration V{}", version);
1364 }
1365
1366 with_transaction(conn, |tx| {
1370 tx.execute_batch(MIGRATION_V29)
1371 .map_err(MemoryError::Database)
1372 })?;
1373 with_transaction(conn, |tx| {
1374 tx.execute_batch(MIGRATION_V30)
1375 .map_err(MemoryError::Database)
1376 })?;
1377 with_transaction(conn, |tx| {
1378 tx.execute_batch(MIGRATION_V31)
1379 .map_err(MemoryError::Database)
1380 })?;
1381
1382 let final_version: u32 = conn
1383 .query_row(
1384 "SELECT COALESCE(MAX(version), 0) FROM _schema_version",
1385 [],
1386 |row| row.get(0),
1387 )
1388 .unwrap_or(0);
1389 conn.execute_batch(&format!("PRAGMA user_version = {};", final_version))?;
1390
1391 Ok(())
1392}
1393
1394fn run_migration_v16(conn: &Connection) -> Result<(), rusqlite::Error> {
1395 add_column_if_missing(conn, "projection_import_log", "kernel_payload_json", "TEXT")?;
1396 add_column_if_missing(
1397 conn,
1398 "projection_import_failures",
1399 "kernel_payload_json",
1400 "TEXT",
1401 )?;
1402 Ok(())
1403}
1404
1405fn run_migration_v17(conn: &Connection) -> Result<(), rusqlite::Error> {
1406 add_column_if_missing(conn, "projection_import_log", "episode_bundle_id", "TEXT")?;
1407 add_column_if_missing(conn, "projection_import_log", "episode_bundle_json", "TEXT")?;
1408 add_column_if_missing(
1409 conn,
1410 "projection_import_log",
1411 "execution_context_json",
1412 "TEXT",
1413 )?;
1414 add_column_if_missing(
1415 conn,
1416 "projection_import_failures",
1417 "episode_bundle_id",
1418 "TEXT",
1419 )?;
1420 add_column_if_missing(
1421 conn,
1422 "projection_import_failures",
1423 "episode_bundle_json",
1424 "TEXT",
1425 )?;
1426 add_column_if_missing(
1427 conn,
1428 "projection_import_failures",
1429 "execution_context_json",
1430 "TEXT",
1431 )?;
1432 Ok(())
1433}
1434
1435fn run_migration_v20(conn: &Connection) -> Result<(), rusqlite::Error> {
1436 add_column_if_missing(conn, "derived_vector_artifacts", "encoded_digest", "TEXT")?;
1437 conn.execute(
1438 "UPDATE derived_vector_artifacts
1439 SET encoded_digest = artifact_digest
1440 WHERE encoded_digest IS NULL OR encoded_digest = ''",
1441 [],
1442 )?;
1443 add_column_if_missing(
1444 conn,
1445 "derived_vector_artifacts",
1446 "encoding",
1447 "TEXT NOT NULL DEFAULT 'turbo_code_wire_v1'",
1448 )?;
1449 add_column_if_missing(
1450 conn,
1451 "derived_vector_artifacts",
1452 "dim",
1453 "INTEGER NOT NULL DEFAULT 0",
1454 )?;
1455 add_column_if_missing(
1456 conn,
1457 "derived_vector_artifacts",
1458 "status",
1459 "TEXT NOT NULL DEFAULT 'active'",
1460 )?;
1461 conn.execute_batch(
1462 "CREATE INDEX IF NOT EXISTS idx_derived_vector_artifacts_profile
1463 ON derived_vector_artifacts(codec_family, codec_profile_digest, status);
1464 CREATE INDEX IF NOT EXISTS idx_derived_vector_artifacts_source_digest
1465 ON derived_vector_artifacts(source_embedding_digest);",
1466 )?;
1467 Ok(())
1468}
1469
1470fn run_migration_v21(conn: &Connection) -> Result<(), rusqlite::Error> {
1471 conn.execute_batch(MIGRATION_V21)?;
1472 add_column_if_missing(conn, "derived_vector_artifacts", "generation_id", "TEXT")?;
1473 conn.execute_batch(
1474 "CREATE INDEX IF NOT EXISTS idx_derived_vector_artifacts_generation
1475 ON derived_vector_artifacts(generation_id, status);",
1476 )?;
1477 Ok(())
1478}
1479
1480const SEARCH_RECEIPT_SCHEMA_VERSION: &str = "vector_search_receipt_v1";
1481
1482#[derive(Debug, Serialize, Deserialize)]
1483struct StoredVectorSearchReceiptV1 {
1484 #[serde(default = "default_search_receipt_schema_version")]
1485 schema_version: String,
1486 receipt_id: String,
1487 evaluation_time: DateTime<Utc>,
1488 #[serde(default)]
1489 receipt_digest: Option<String>,
1490 #[serde(default)]
1491 trace_id: Option<String>,
1492 #[serde(default)]
1493 attempt_family_id: Option<String>,
1494 #[serde(default)]
1495 attempt_id: Option<String>,
1496 #[serde(default)]
1497 replay_of: Option<String>,
1498 query_embedding_digest: Option<String>,
1499 #[serde(default)]
1500 query_text_digest: Option<String>,
1501 #[serde(default)]
1502 query_input_digest: Option<String>,
1503 #[serde(default)]
1504 filter_digest: Option<String>,
1505 #[serde(default)]
1506 redaction_state: Option<String>,
1507 #[serde(default)]
1508 budget_id: Option<String>,
1509 #[serde(default)]
1510 deadline_at: Option<DateTime<Utc>>,
1511 search_profile: String,
1512 candidate_backend: String,
1513 codec_family: Option<String>,
1514 codec_profile_digest: Option<String>,
1515 #[serde(default)]
1516 artifact_profile_digest: Option<String>,
1517 #[serde(default)]
1518 artifact_count: Option<u64>,
1519 #[serde(default)]
1520 artifact_corruption_count: Option<u64>,
1521 #[serde(default)]
1522 artifact_missing_count: Option<u64>,
1523 #[serde(default)]
1524 vector_artifact_manifest_digest: Option<String>,
1525 #[serde(default)]
1526 artifact_generation_id: Option<String>,
1527 #[serde(default)]
1528 approximate_scanned_count: Option<u64>,
1529 #[serde(default)]
1530 approximate_returned_count: Option<u64>,
1531 #[serde(default)]
1532 raw_rows_loaded_count: Option<u64>,
1533 #[serde(default)]
1534 filter_strategy: Option<String>,
1535 #[serde(default)]
1536 vector_artifact_count: Option<u64>,
1537 #[serde(default)]
1538 vector_artifact_missing_count: Option<u64>,
1539 #[serde(default)]
1540 vector_artifact_stale_count: Option<u64>,
1541 #[serde(default)]
1542 exact_rerank_count: Option<u64>,
1543 #[serde(default)]
1544 approximate_candidate_count: Option<u64>,
1545 #[serde(default)]
1546 fallback_reason: Option<String>,
1547 approximate: bool,
1548 requested_candidates: u64,
1549 returned_candidates: u64,
1550 post_filter_candidates: u64,
1551 #[serde(default)]
1552 sparse_enabled: bool,
1553 #[serde(default)]
1554 sparse_weight: Option<f64>,
1555 #[serde(default)]
1556 sparse_query_nonzero_count: Option<u64>,
1557 #[serde(default)]
1558 sparse_candidate_count: Option<u64>,
1559 #[serde(default)]
1560 sparse_representations: Vec<String>,
1561 #[serde(default)]
1562 sparse_result_ranks: Vec<SparseRankReceiptV1>,
1563 fallback: Option<String>,
1564 exact_rerank: bool,
1565 result_ids: Vec<String>,
1566 degradations: Vec<String>,
1567}
1568
1569fn default_search_receipt_schema_version() -> String {
1570 SEARCH_RECEIPT_SCHEMA_VERSION.to_string()
1571}
1572
1573fn b3_digest(bytes: &[u8]) -> String {
1574 format!("blake3:{}", ContentDigest::compute(bytes).hex())
1575}
1576
1577#[cfg(feature = "turbo-quant-codec")]
1579#[derive(Debug, Clone)]
1580pub(crate) struct DerivedVectorArtifactRow {
1581 pub item_key: String,
1582 pub generation_id: Option<String>,
1583 pub codec_family: String,
1584 pub codec_profile_digest: String,
1585 pub source_embedding_digest: String,
1586 pub encoded_digest: String,
1587 pub encoding: String,
1588 pub dim: usize,
1589 pub status: String,
1590 pub encoded: Vec<u8>,
1591 pub codec_governance_receipt_id: Option<String>,
1593 pub codec_profile: Option<String>,
1594 pub degradation_budget: Option<f64>,
1595 pub raw_source_artifact_id: Option<String>,
1596}
1597
1598#[cfg(feature = "turbo-quant-codec")]
1600#[derive(Debug, Clone)]
1601#[allow(dead_code)]
1602pub(crate) struct DerivedVectorArtifactGenerationRow {
1603 pub generation_id: String,
1604 pub codec_family: String,
1605 pub codec_profile_digest: String,
1606 pub source_snapshot_digest: String,
1607 pub source_row_count: usize,
1608 pub artifact_count: usize,
1609 pub dim: usize,
1610 pub encoding: String,
1611 pub artifact_manifest_digest: String,
1612 pub status: String,
1613}
1614
1615#[cfg(feature = "turbo-quant-codec")]
1617pub(crate) fn source_embedding_digest(
1618 blob: &[u8],
1619 expected_dim: usize,
1620) -> Result<String, MemoryError> {
1621 validate_vector_blob_len(blob, expected_dim)?;
1622 let mut builder = DigestBuilder::new();
1623 builder
1624 .update_str("semantic-memory.source_embedding.v1")
1625 .separator()
1626 .update(&(expected_dim as u64).to_le_bytes())
1627 .separator()
1628 .update(blob);
1629 Ok(format!("blake3:{}", builder.finalize().hex()))
1630}
1631
1632#[cfg(feature = "turbo-quant-codec")]
1633fn source_snapshot_digest(rows: &[DerivedVectorArtifactRow], dim: usize) -> String {
1634 let mut entries = rows
1635 .iter()
1636 .map(|row| (row.item_key.as_str(), row.source_embedding_digest.as_str()))
1637 .collect::<Vec<_>>();
1638 entries.sort_unstable();
1639
1640 let mut builder = DigestBuilder::new();
1641 builder
1642 .update_str("semantic-memory.vector_source_snapshot.v1")
1643 .separator()
1644 .update(&(dim as u64).to_le_bytes())
1645 .separator();
1646 for (item_key, source_embedding_digest) in entries {
1647 builder
1648 .update_str(item_key)
1649 .separator()
1650 .update_str(source_embedding_digest)
1651 .separator();
1652 }
1653 format!("blake3:{}", builder.finalize().hex())
1654}
1655
1656#[cfg(feature = "turbo-quant-codec")]
1657pub(crate) fn current_source_snapshot_digest(
1658 conn: &Connection,
1659 dim: usize,
1660) -> Result<(String, usize), MemoryError> {
1661 let mut stmt = conn.prepare(
1662 "SELECT 'fact:' || id AS item_key, embedding FROM facts WHERE embedding IS NOT NULL
1663 UNION ALL
1664 SELECT 'chunk:' || id AS item_key, embedding FROM chunks WHERE embedding IS NOT NULL
1665 UNION ALL
1666 SELECT 'msg:' || id AS item_key, embedding FROM messages WHERE embedding IS NOT NULL
1667 UNION ALL
1668 SELECT 'episode:' || episode_id AS item_key, embedding FROM episodes WHERE embedding IS NOT NULL",
1669 )?;
1670 let rows = stmt.query_map([], |row| {
1671 Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?))
1672 })?;
1673
1674 let mut entries = Vec::new();
1675 for row in rows {
1676 let (item_key, blob) = row?;
1677 entries.push((item_key, source_embedding_digest(&blob, dim)?));
1678 }
1679 entries.sort_unstable();
1680
1681 let mut builder = DigestBuilder::new();
1682 builder
1683 .update_str("semantic-memory.vector_source_snapshot.v1")
1684 .separator()
1685 .update(&(dim as u64).to_le_bytes())
1686 .separator();
1687 for (item_key, source_embedding_digest) in &entries {
1688 builder
1689 .update_str(item_key)
1690 .separator()
1691 .update_str(source_embedding_digest)
1692 .separator();
1693 }
1694 Ok((
1695 format!("blake3:{}", builder.finalize().hex()),
1696 entries.len(),
1697 ))
1698}
1699
1700#[cfg(feature = "turbo-quant-codec")]
1701fn derived_artifact_manifest_digest(rows: &[DerivedVectorArtifactRow]) -> String {
1702 let mut entries = rows
1703 .iter()
1704 .map(|row| {
1705 (
1706 row.item_key.as_str(),
1707 row.source_embedding_digest.as_str(),
1708 row.encoded_digest.as_str(),
1709 )
1710 })
1711 .collect::<Vec<_>>();
1712 entries.sort_unstable();
1713
1714 let mut builder = DigestBuilder::new();
1715 builder
1716 .update_str("semantic-memory.vector_artifact_manifest.v1")
1717 .separator();
1718 for (item_key, source_embedding_digest, encoded_digest) in entries {
1719 builder
1720 .update_str(item_key)
1721 .separator()
1722 .update_str(source_embedding_digest)
1723 .separator()
1724 .update_str(encoded_digest)
1725 .separator();
1726 }
1727 format!("blake3:{}", builder.finalize().hex())
1728}
1729
1730#[cfg(feature = "turbo-quant-codec")]
1731pub(crate) fn upsert_derived_vector_artifact(
1732 conn: &Connection,
1733 row: &DerivedVectorArtifactRow,
1734) -> Result<(), MemoryError> {
1735 conn.execute(
1736 "INSERT OR REPLACE INTO derived_vector_artifacts
1737 (item_key, generation_id, codec_family, codec_profile_digest, source_embedding_digest,
1738 encoded_digest, artifact_digest, encoding, dim, encoded, created_at, status,
1739 codec_governance_receipt_id, codec_profile, degradation_budget, raw_source_artifact_id)
1740 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6, ?7, ?8, ?9, datetime('now'), ?10, ?11, ?12, ?13, ?14)",
1741 params![
1742 row.item_key,
1743 row.generation_id.as_deref(),
1744 row.codec_family,
1745 row.codec_profile_digest,
1746 row.source_embedding_digest,
1747 row.encoded_digest,
1748 row.encoding,
1749 i64::try_from(row.dim)
1750 .map_err(|err| MemoryError::Other(format!("artifact dim overflow: {err}")))?,
1751 row.encoded,
1752 row.status,
1753 row.codec_governance_receipt_id.as_deref(),
1754 row.codec_profile.as_deref(),
1755 row.degradation_budget,
1756 row.raw_source_artifact_id.as_deref(),
1757 ],
1758 )?;
1759 Ok(())
1760}
1761
1762#[allow(dead_code)] pub fn delete_derived_vector_artifact(
1764 conn: &Connection,
1765 item_key: &str,
1766) -> Result<(), MemoryError> {
1767 conn.execute(
1768 "DELETE FROM derived_vector_artifacts WHERE item_key = ?1",
1769 params![item_key],
1770 )?;
1771 Ok(())
1772}
1773
1774pub fn invalidate_derived_vector_artifact(
1775 conn: &Connection,
1776 item_key: &str,
1777) -> Result<(), MemoryError> {
1778 conn.execute(
1779 "UPDATE derived_vector_artifacts
1780 SET status = 'invalidated'
1781 WHERE item_key = ?1 AND status = 'active'",
1782 params![item_key],
1783 )?;
1784 conn.execute(
1785 "UPDATE derived_vector_artifact_generations
1786 SET status = 'invalidated'
1787 WHERE status = 'active'",
1788 [],
1789 )?;
1790 Ok(())
1791}
1792
1793#[cfg(feature = "turbo-quant-codec")]
1794#[allow(dead_code)]
1795pub(crate) fn load_derived_vector_artifacts_by_profile(
1796 conn: &Connection,
1797 codec_family: &str,
1798 codec_profile_digest: &str,
1799) -> Result<Vec<DerivedVectorArtifactRow>, MemoryError> {
1800 let mut stmt = conn.prepare(
1801 "SELECT item_key, generation_id, codec_family, codec_profile_digest, source_embedding_digest,
1802 encoded_digest, encoding, dim, status, encoded,
1803 codec_governance_receipt_id, codec_profile, degradation_budget, raw_source_artifact_id
1804 FROM derived_vector_artifacts
1805 WHERE codec_family = ?1 AND codec_profile_digest = ?2 AND status = 'active'",
1806 )?;
1807 let rows = stmt.query_map(params![codec_family, codec_profile_digest], |row| {
1808 let dim_i64: i64 = row.get(7)?;
1809 Ok(DerivedVectorArtifactRow {
1810 item_key: row.get(0)?,
1811 generation_id: row.get(1)?,
1812 codec_family: row.get(2)?,
1813 codec_profile_digest: row.get(3)?,
1814 source_embedding_digest: row.get(4)?,
1815 encoded_digest: row.get(5)?,
1816 encoding: row.get(6)?,
1817 dim: usize::try_from(dim_i64).map_err(|err| {
1818 rusqlite::Error::FromSqlConversionFailure(
1819 7,
1820 rusqlite::types::Type::Integer,
1821 Box::new(err),
1822 )
1823 })?,
1824 status: row.get(8)?,
1825 encoded: row.get(9)?,
1826 codec_governance_receipt_id: row.get(10)?,
1827 codec_profile: row.get(11)?,
1828 degradation_budget: row.get(12)?,
1829 raw_source_artifact_id: row.get(13)?,
1830 })
1831 })?;
1832
1833 let mut artifacts = Vec::new();
1834 for row in rows {
1835 artifacts.push(row?);
1836 }
1837 Ok(artifacts)
1838}
1839
1840#[cfg(feature = "turbo-quant-codec")]
1841pub(crate) fn load_derived_vector_artifacts_by_generation(
1842 conn: &Connection,
1843 generation_id: &str,
1844) -> Result<Vec<DerivedVectorArtifactRow>, MemoryError> {
1845 let mut stmt = conn.prepare(
1846 "SELECT item_key, generation_id, codec_family, codec_profile_digest, source_embedding_digest,
1847 encoded_digest, encoding, dim, status, encoded,
1848 codec_governance_receipt_id, codec_profile, degradation_budget, raw_source_artifact_id
1849 FROM derived_vector_artifacts
1850 WHERE generation_id = ?1 AND status = 'active'",
1851 )?;
1852 let rows = stmt.query_map(params![generation_id], |row| {
1853 let dim_i64: i64 = row.get(7)?;
1854 Ok(DerivedVectorArtifactRow {
1855 item_key: row.get(0)?,
1856 generation_id: row.get(1)?,
1857 codec_family: row.get(2)?,
1858 codec_profile_digest: row.get(3)?,
1859 source_embedding_digest: row.get(4)?,
1860 encoded_digest: row.get(5)?,
1861 encoding: row.get(6)?,
1862 dim: usize::try_from(dim_i64).map_err(|err| {
1863 rusqlite::Error::FromSqlConversionFailure(
1864 7,
1865 rusqlite::types::Type::Integer,
1866 Box::new(err),
1867 )
1868 })?,
1869 status: row.get(8)?,
1870 encoded: row.get(9)?,
1871 codec_governance_receipt_id: row.get(10)?,
1872 codec_profile: row.get(11)?,
1873 degradation_budget: row.get(12)?,
1874 raw_source_artifact_id: row.get(13)?,
1875 })
1876 })?;
1877
1878 let mut artifacts = Vec::new();
1879 for row in rows {
1880 artifacts.push(row?);
1881 }
1882 Ok(artifacts)
1883}
1884
1885#[cfg(feature = "turbo-quant-codec")]
1886pub(crate) fn current_derived_vector_generation(
1887 conn: &Connection,
1888 codec_family: &str,
1889 codec_profile_digest: &str,
1890) -> Result<Option<DerivedVectorArtifactGenerationRow>, MemoryError> {
1891 conn.query_row(
1892 "SELECT generation_id, codec_family, codec_profile_digest, source_snapshot_digest,
1893 source_row_count, artifact_count, dim, encoding, artifact_manifest_digest, status
1894 FROM derived_vector_artifact_generations
1895 WHERE codec_family = ?1 AND codec_profile_digest = ?2 AND status = 'active'
1896 ORDER BY created_at DESC
1897 LIMIT 1",
1898 params![codec_family, codec_profile_digest],
1899 |row| {
1900 let source_row_count: i64 = row.get(4)?;
1901 let artifact_count: i64 = row.get(5)?;
1902 let dim: i64 = row.get(6)?;
1903 Ok(DerivedVectorArtifactGenerationRow {
1904 generation_id: row.get(0)?,
1905 codec_family: row.get(1)?,
1906 codec_profile_digest: row.get(2)?,
1907 source_snapshot_digest: row.get(3)?,
1908 source_row_count: usize::try_from(source_row_count).map_err(|err| {
1909 rusqlite::Error::FromSqlConversionFailure(
1910 4,
1911 rusqlite::types::Type::Integer,
1912 Box::new(err),
1913 )
1914 })?,
1915 artifact_count: usize::try_from(artifact_count).map_err(|err| {
1916 rusqlite::Error::FromSqlConversionFailure(
1917 5,
1918 rusqlite::types::Type::Integer,
1919 Box::new(err),
1920 )
1921 })?,
1922 dim: usize::try_from(dim).map_err(|err| {
1923 rusqlite::Error::FromSqlConversionFailure(
1924 6,
1925 rusqlite::types::Type::Integer,
1926 Box::new(err),
1927 )
1928 })?,
1929 encoding: row.get(7)?,
1930 artifact_manifest_digest: row.get(8)?,
1931 status: row.get(9)?,
1932 })
1933 },
1934 )
1935 .optional()
1936 .map_err(MemoryError::from)
1937}
1938
1939#[allow(dead_code)] pub fn count_derived_vector_artifacts(
1941 conn: &Connection,
1942 codec_family: &str,
1943 codec_profile_digest: &str,
1944) -> Result<usize, MemoryError> {
1945 let count: i64 = conn.query_row(
1946 "SELECT COUNT(*) FROM derived_vector_artifacts
1947 WHERE codec_family = ?1 AND codec_profile_digest = ?2 AND status = 'active'",
1948 params![codec_family, codec_profile_digest],
1949 |row| row.get(0),
1950 )?;
1951 usize::try_from(count)
1952 .map_err(|err| MemoryError::Other(format!("derived artifact count overflow: {err}")))
1953}
1954
1955#[cfg(feature = "turbo-quant-codec")]
1956pub(crate) fn rebuild_turbo_quant_artifacts(
1957 conn: &Connection,
1958 dim: usize,
1959 bits: u8,
1960 projections: usize,
1961 seed: u64,
1962) -> Result<VectorArtifactBuildReceiptV1, MemoryError> {
1963 use crate::vector_codec::{TurboQuantCodec, VectorCodec};
1964
1965 let started = std::time::Instant::now();
1966 let codec = TurboQuantCodec::new(dim, bits, projections, seed)?;
1967 let codec_profile_digest = codec.profile().digest();
1968 let generation_id = uuid::Uuid::new_v4().to_string();
1969 let mut source_row_count = 0usize;
1970 let mut artifact_count = 0usize;
1971 let mut skipped_row_count = 0usize;
1972 let mut degradations = Vec::new();
1973
1974 let mut stmt = conn.prepare(
1975 "SELECT 'fact:' || id AS item_key, embedding FROM facts WHERE embedding IS NOT NULL
1976 UNION ALL
1977 SELECT 'chunk:' || id AS item_key, embedding FROM chunks WHERE embedding IS NOT NULL
1978 UNION ALL
1979 SELECT 'msg:' || id AS item_key, embedding FROM messages WHERE embedding IS NOT NULL
1980 UNION ALL
1981 SELECT 'episode:' || episode_id AS item_key, embedding FROM episodes WHERE embedding IS NOT NULL",
1982 )?;
1983 let rows = stmt.query_map([], |row| {
1984 Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?))
1985 })?;
1986
1987 let mut pending = Vec::new();
1988 for row in rows {
1989 let (item_key, blob) = row?;
1990 source_row_count += 1;
1991 let embedding = match decode_f32_le(&blob, dim) {
1992 Ok(embedding) => embedding,
1993 Err(err) => {
1994 skipped_row_count += 1;
1995 degradations.push(format!(
1996 "skipped {item_key}: invalid authoritative embedding: {err}"
1997 ));
1998 continue;
1999 }
2000 };
2001 let artifact = match codec.encode(&embedding) {
2002 Ok(artifact) => artifact,
2003 Err(err) => {
2004 skipped_row_count += 1;
2005 degradations.push(format!("skipped {item_key}: encode failed: {err}"));
2006 continue;
2007 }
2008 };
2009 pending.push(DerivedVectorArtifactRow {
2010 item_key,
2011 generation_id: Some(generation_id.clone()),
2012 codec_family: "turbo_quant".to_string(),
2013 codec_profile_digest: codec_profile_digest.clone(),
2014 source_embedding_digest: source_embedding_digest(&blob, dim)?,
2015 encoded_digest: artifact.artifact_digest,
2016 encoding: "turbo_code_wire_v1".to_string(),
2017 dim,
2018 status: "active".to_string(),
2019 encoded: artifact.encoded,
2020 codec_governance_receipt_id: None,
2023 codec_profile: None,
2024 degradation_budget: None,
2025 raw_source_artifact_id: None,
2026 });
2027 }
2028 drop(stmt);
2029
2030 let build_receipt_id = uuid::Uuid::new_v4().to_string();
2031 let source_snapshot_digest = source_snapshot_digest(&pending, dim);
2032 let artifact_manifest_digest = derived_artifact_manifest_digest(&pending);
2033 let source_tables = vec![
2034 "facts".to_string(),
2035 "chunks".to_string(),
2036 "messages".to_string(),
2037 "episodes".to_string(),
2038 ];
2039 let generation_manifest = DerivedVectorArtifactGenerationV1 {
2040 schema_version: "derived_vector_artifact_generation_v1".to_string(),
2041 generation_id: generation_id.clone(),
2042 codec_family: "turbo_quant".to_string(),
2043 codec_profile_digest: codec_profile_digest.clone(),
2044 source_snapshot_digest: source_snapshot_digest.clone(),
2045 source_row_count,
2046 artifact_count: pending.len(),
2047 source_tables,
2048 dim,
2049 encoding: "turbo_code_wire_v1".to_string(),
2050 created_at: Utc::now(),
2051 build_receipt_id: Some(build_receipt_id.clone()),
2052 artifact_manifest_digest: artifact_manifest_digest.clone(),
2053 status: if skipped_row_count == 0 {
2054 "active".to_string()
2055 } else {
2056 "failed".to_string()
2057 },
2058 degradations: degradations.clone(),
2059 };
2060
2061 with_transaction(conn, |tx| {
2062 tx.execute(
2063 "UPDATE derived_vector_artifact_generations
2064 SET status = 'superseded'
2065 WHERE codec_family = ?1 AND codec_profile_digest = ?2 AND status = 'active'",
2066 params!["turbo_quant", &codec_profile_digest],
2067 )?;
2068 tx.execute(
2069 "DELETE FROM derived_vector_artifacts
2070 WHERE codec_family = ?1 AND codec_profile_digest = ?2",
2071 params!["turbo_quant", &codec_profile_digest],
2072 )?;
2073 tx.execute(
2074 "INSERT INTO derived_vector_artifact_generations
2075 (generation_id, schema_version, codec_family, codec_profile_digest,
2076 source_snapshot_digest, source_row_count, artifact_count, source_tables_json,
2077 dim, encoding, created_at, build_receipt_id, artifact_manifest_digest,
2078 status, degradations_json)
2079 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
2080 params![
2081 generation_manifest.generation_id,
2082 generation_manifest.schema_version,
2083 generation_manifest.codec_family,
2084 generation_manifest.codec_profile_digest,
2085 generation_manifest.source_snapshot_digest,
2086 i64::try_from(generation_manifest.source_row_count).map_err(|err| {
2087 MemoryError::Other(format!("source row count overflow: {err}"))
2088 })?,
2089 i64::try_from(generation_manifest.artifact_count).map_err(|err| {
2090 MemoryError::Other(format!("artifact count overflow: {err}"))
2091 })?,
2092 serde_json::to_string(&generation_manifest.source_tables)
2093 .map_err(|err| MemoryError::Other(err.to_string()))?,
2094 i64::try_from(generation_manifest.dim)
2095 .map_err(|err| MemoryError::Other(format!("artifact dim overflow: {err}")))?,
2096 generation_manifest.encoding,
2097 generation_manifest.created_at.to_rfc3339(),
2098 generation_manifest.build_receipt_id,
2099 generation_manifest.artifact_manifest_digest,
2100 generation_manifest.status,
2101 serde_json::to_string(&generation_manifest.degradations)
2102 .map_err(|err| MemoryError::Other(err.to_string()))?,
2103 ],
2104 )?;
2105 for row in &pending {
2106 upsert_derived_vector_artifact(tx, row)?;
2107 artifact_count += 1;
2108 }
2109 Ok(())
2110 })?;
2111
2112 Ok(VectorArtifactBuildReceiptV1 {
2113 schema_version: "vector_artifact_build_receipt_v1".to_string(),
2114 codec_family: "turbo_quant".to_string(),
2115 codec_profile_digest,
2116 source_row_count,
2117 artifact_count,
2118 generation_id: Some(generation_id),
2119 source_snapshot_digest: Some(source_snapshot_digest),
2120 artifact_manifest_digest: Some(artifact_manifest_digest),
2121 build_receipt_id: Some(build_receipt_id),
2122 skipped_row_count,
2123 elapsed_ms: started.elapsed().as_millis(),
2124 created_at: Utc::now(),
2125 degradations,
2126 })
2127}
2128
2129fn receipt_count_to_u64(value: usize, field: &'static str) -> Result<u64, MemoryError> {
2130 u64::try_from(value).map_err(|err| MemoryError::Other(format!("{field} is too large: {err}")))
2131}
2132
2133fn receipt_count_to_i64(value: u64, field: &'static str) -> Result<i64, MemoryError> {
2134 i64::try_from(value).map_err(|err| MemoryError::Other(format!("{field} is too large: {err}")))
2135}
2136
2137fn receipt_count_to_usize(
2138 value: u64,
2139 receipt_id: &str,
2140 field: &'static str,
2141) -> Result<usize, MemoryError> {
2142 usize::try_from(value).map_err(|err| MemoryError::CorruptData {
2143 table: "search_receipts",
2144 row_id: receipt_id.to_string(),
2145 detail: format!("{field} does not fit this platform: {err}"),
2146 })
2147}
2148
2149fn stored_search_receipt(
2150 receipt: &VectorSearchReceiptV1,
2151) -> Result<StoredVectorSearchReceiptV1, MemoryError> {
2152 Ok(StoredVectorSearchReceiptV1 {
2153 schema_version: SEARCH_RECEIPT_SCHEMA_VERSION.to_string(),
2154 receipt_id: receipt.receipt_id.clone(),
2155 evaluation_time: receipt.evaluation_time,
2156 receipt_digest: receipt.receipt_digest.clone(),
2157 trace_id: receipt.trace_id.clone(),
2158 attempt_family_id: receipt.attempt_family_id.clone(),
2159 attempt_id: receipt.attempt_id.clone(),
2160 replay_of: receipt.replay_of.clone(),
2161 query_embedding_digest: receipt.query_embedding_digest.clone(),
2162 query_text_digest: receipt.query_text_digest.clone(),
2163 query_input_digest: receipt.query_input_digest.clone(),
2164 filter_digest: receipt.filter_digest.clone(),
2165 redaction_state: receipt.redaction_state.clone(),
2166 budget_id: receipt.budget_id.clone(),
2167 deadline_at: receipt.deadline_at,
2168 search_profile: receipt.search_profile.clone(),
2169 candidate_backend: receipt.candidate_backend.clone(),
2170 codec_family: receipt.codec_family.clone(),
2171 codec_profile_digest: receipt.codec_profile_digest.clone(),
2172 artifact_profile_digest: receipt.artifact_profile_digest.clone(),
2173 artifact_count: receipt
2174 .artifact_count
2175 .map(|value| receipt_count_to_u64(value, "artifact_count"))
2176 .transpose()?,
2177 artifact_corruption_count: receipt
2178 .artifact_corruption_count
2179 .map(|value| receipt_count_to_u64(value, "artifact_corruption_count"))
2180 .transpose()?,
2181 artifact_missing_count: receipt
2182 .artifact_missing_count
2183 .map(|value| receipt_count_to_u64(value, "artifact_missing_count"))
2184 .transpose()?,
2185 vector_artifact_manifest_digest: receipt.vector_artifact_manifest_digest.clone(),
2186 artifact_generation_id: receipt.artifact_generation_id.clone(),
2187 approximate_scanned_count: receipt
2188 .approximate_scanned_count
2189 .map(|value| receipt_count_to_u64(value, "approximate_scanned_count"))
2190 .transpose()?,
2191 approximate_returned_count: receipt
2192 .approximate_returned_count
2193 .map(|value| receipt_count_to_u64(value, "approximate_returned_count"))
2194 .transpose()?,
2195 raw_rows_loaded_count: receipt
2196 .raw_rows_loaded_count
2197 .map(|value| receipt_count_to_u64(value, "raw_rows_loaded_count"))
2198 .transpose()?,
2199 filter_strategy: receipt.filter_strategy.clone(),
2200 vector_artifact_count: receipt
2201 .vector_artifact_count
2202 .map(|value| receipt_count_to_u64(value, "vector_artifact_count"))
2203 .transpose()?,
2204 vector_artifact_missing_count: receipt
2205 .vector_artifact_missing_count
2206 .map(|value| receipt_count_to_u64(value, "vector_artifact_missing_count"))
2207 .transpose()?,
2208 vector_artifact_stale_count: receipt
2209 .vector_artifact_stale_count
2210 .map(|value| receipt_count_to_u64(value, "vector_artifact_stale_count"))
2211 .transpose()?,
2212 exact_rerank_count: receipt
2213 .exact_rerank_count
2214 .map(|value| receipt_count_to_u64(value, "exact_rerank_count"))
2215 .transpose()?,
2216 approximate_candidate_count: receipt
2217 .approximate_candidate_count
2218 .map(|value| receipt_count_to_u64(value, "approximate_candidate_count"))
2219 .transpose()?,
2220 fallback_reason: receipt.fallback_reason.clone(),
2221 approximate: receipt.approximate,
2222 requested_candidates: receipt_count_to_u64(
2223 receipt.requested_candidates,
2224 "requested_candidates",
2225 )?,
2226 returned_candidates: receipt_count_to_u64(
2227 receipt.returned_candidates,
2228 "returned_candidates",
2229 )?,
2230 post_filter_candidates: receipt_count_to_u64(
2231 receipt.post_filter_candidates,
2232 "post_filter_candidates",
2233 )?,
2234 sparse_enabled: receipt.sparse_enabled,
2235 sparse_weight: receipt.sparse_weight,
2236 sparse_query_nonzero_count: receipt
2237 .sparse_query_nonzero_count
2238 .map(|value| receipt_count_to_u64(value, "sparse_query_nonzero_count"))
2239 .transpose()?,
2240 sparse_candidate_count: receipt
2241 .sparse_candidate_count
2242 .map(|value| receipt_count_to_u64(value, "sparse_candidate_count"))
2243 .transpose()?,
2244 sparse_representations: receipt.sparse_representations.clone(),
2245 sparse_result_ranks: receipt.sparse_result_ranks.clone(),
2246 fallback: receipt.fallback.clone(),
2247 exact_rerank: receipt.exact_rerank,
2248 result_ids: receipt.result_ids.clone(),
2249 degradations: receipt.degradations.clone(),
2250 })
2251}
2252
2253fn search_receipt_from_stored(
2254 stored: StoredVectorSearchReceiptV1,
2255) -> Result<VectorSearchReceiptV1, MemoryError> {
2256 if stored.schema_version != SEARCH_RECEIPT_SCHEMA_VERSION {
2257 return Err(MemoryError::CorruptData {
2258 table: "search_receipts",
2259 row_id: stored.receipt_id,
2260 detail: format!(
2261 "unsupported receipt schema version '{}'",
2262 stored.schema_version
2263 ),
2264 });
2265 }
2266
2267 Ok(VectorSearchReceiptV1 {
2268 schema_version: stored.schema_version.clone(),
2269 receipt_digest: stored.receipt_digest,
2270 receipt_id: stored.receipt_id.clone(),
2271 evaluation_time: stored.evaluation_time,
2272 trace_id: stored.trace_id,
2273 attempt_family_id: stored.attempt_family_id,
2274 attempt_id: stored.attempt_id,
2275 replay_of: stored.replay_of,
2276 query_embedding_digest: stored.query_embedding_digest,
2277 query_text_digest: stored.query_text_digest,
2278 query_input_digest: stored.query_input_digest,
2279 filter_digest: stored.filter_digest,
2280 redaction_state: stored.redaction_state,
2281 budget_id: stored.budget_id,
2282 deadline_at: stored.deadline_at,
2283 search_profile: stored.search_profile,
2284 candidate_backend: stored.candidate_backend,
2285 codec_family: stored.codec_family,
2286 codec_profile_digest: stored.codec_profile_digest,
2287 artifact_profile_digest: stored.artifact_profile_digest,
2288 artifact_count: stored
2289 .artifact_count
2290 .map(|value| receipt_count_to_usize(value, &stored.receipt_id, "artifact_count"))
2291 .transpose()?,
2292 artifact_corruption_count: stored
2293 .artifact_corruption_count
2294 .map(|value| {
2295 receipt_count_to_usize(value, &stored.receipt_id, "artifact_corruption_count")
2296 })
2297 .transpose()?,
2298 artifact_missing_count: stored
2299 .artifact_missing_count
2300 .map(|value| {
2301 receipt_count_to_usize(value, &stored.receipt_id, "artifact_missing_count")
2302 })
2303 .transpose()?,
2304 vector_artifact_manifest_digest: stored.vector_artifact_manifest_digest,
2305 artifact_generation_id: stored.artifact_generation_id,
2306 approximate_scanned_count: stored
2307 .approximate_scanned_count
2308 .map(|value| {
2309 receipt_count_to_usize(value, &stored.receipt_id, "approximate_scanned_count")
2310 })
2311 .transpose()?,
2312 approximate_returned_count: stored
2313 .approximate_returned_count
2314 .map(|value| {
2315 receipt_count_to_usize(value, &stored.receipt_id, "approximate_returned_count")
2316 })
2317 .transpose()?,
2318 raw_rows_loaded_count: stored
2319 .raw_rows_loaded_count
2320 .map(|value| receipt_count_to_usize(value, &stored.receipt_id, "raw_rows_loaded_count"))
2321 .transpose()?,
2322 filter_strategy: stored.filter_strategy,
2323 vector_artifact_count: stored
2324 .vector_artifact_count
2325 .map(|value| receipt_count_to_usize(value, &stored.receipt_id, "vector_artifact_count"))
2326 .transpose()?,
2327 vector_artifact_missing_count: stored
2328 .vector_artifact_missing_count
2329 .map(|value| {
2330 receipt_count_to_usize(value, &stored.receipt_id, "vector_artifact_missing_count")
2331 })
2332 .transpose()?,
2333 vector_artifact_stale_count: stored
2334 .vector_artifact_stale_count
2335 .map(|value| {
2336 receipt_count_to_usize(value, &stored.receipt_id, "vector_artifact_stale_count")
2337 })
2338 .transpose()?,
2339 exact_rerank_count: stored
2340 .exact_rerank_count
2341 .map(|value| receipt_count_to_usize(value, &stored.receipt_id, "exact_rerank_count"))
2342 .transpose()?,
2343 approximate_candidate_count: stored
2344 .approximate_candidate_count
2345 .map(|value| {
2346 receipt_count_to_usize(value, &stored.receipt_id, "approximate_candidate_count")
2347 })
2348 .transpose()?,
2349 fallback_reason: stored.fallback_reason,
2350 derived_candidate: None,
2351 approximate: stored.approximate,
2352 requested_candidates: receipt_count_to_usize(
2353 stored.requested_candidates,
2354 &stored.receipt_id,
2355 "requested_candidates",
2356 )?,
2357 returned_candidates: receipt_count_to_usize(
2358 stored.returned_candidates,
2359 &stored.receipt_id,
2360 "returned_candidates",
2361 )?,
2362 post_filter_candidates: receipt_count_to_usize(
2363 stored.post_filter_candidates,
2364 &stored.receipt_id,
2365 "post_filter_candidates",
2366 )?,
2367 sparse_enabled: stored.sparse_enabled,
2368 sparse_weight: stored.sparse_weight,
2369 sparse_query_nonzero_count: stored
2370 .sparse_query_nonzero_count
2371 .map(|value| {
2372 receipt_count_to_usize(value, &stored.receipt_id, "sparse_query_nonzero_count")
2373 })
2374 .transpose()?,
2375 sparse_candidate_count: stored
2376 .sparse_candidate_count
2377 .map(|value| {
2378 receipt_count_to_usize(value, &stored.receipt_id, "sparse_candidate_count")
2379 })
2380 .transpose()?,
2381 sparse_representations: stored.sparse_representations,
2382 sparse_result_ranks: stored.sparse_result_ranks,
2383 fallback: stored.fallback,
2384 exact_rerank: stored.exact_rerank,
2385 result_ids: stored.result_ids,
2386 degradations: stored.degradations,
2387 })
2388}
2389
2390pub fn store_search_receipt(
2395 conn: &Connection,
2396 receipt: &VectorSearchReceiptV1,
2397) -> Result<(), MemoryError> {
2398 let stored = stored_search_receipt(receipt)?;
2399 let receipt_json = serde_json::to_string(&stored)
2400 .map_err(|err| MemoryError::Other(format!("failed to serialize search receipt: {err}")))?;
2401 let receipt_digest = b3_digest(receipt_json.as_bytes());
2402
2403 let existing_digest: Option<String> = conn
2404 .query_row(
2405 "SELECT receipt_digest FROM search_receipts WHERE receipt_id = ?1",
2406 params![&stored.receipt_id],
2407 |row| row.get(0),
2408 )
2409 .optional()?;
2410 if let Some(existing_digest) = existing_digest {
2411 if existing_digest == receipt_digest {
2412 return Ok(());
2413 }
2414 return Err(MemoryError::SearchReceiptConflict {
2415 receipt_id: stored.receipt_id,
2416 });
2417 }
2418
2419 let result_ids_json = serde_json::to_string(&stored.result_ids).map_err(|err| {
2420 MemoryError::Other(format!(
2421 "failed to serialize search receipt result IDs: {err}"
2422 ))
2423 })?;
2424 conn.execute(
2425 "INSERT INTO search_receipts (
2426 receipt_id,
2427 schema_version,
2428 evaluation_time,
2429 search_profile,
2430 candidate_backend,
2431 approximate,
2432 exact_rerank,
2433 fallback,
2434 requested_candidates,
2435 returned_candidates,
2436 post_filter_candidates,
2437 result_ids_json,
2438 receipt_json,
2439 receipt_digest
2440 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
2441 params![
2442 &stored.receipt_id,
2443 SEARCH_RECEIPT_SCHEMA_VERSION,
2444 stored.evaluation_time.to_rfc3339(),
2445 &stored.search_profile,
2446 &stored.candidate_backend,
2447 if stored.approximate { 1_i64 } else { 0_i64 },
2448 if stored.exact_rerank { 1_i64 } else { 0_i64 },
2449 &stored.fallback,
2450 receipt_count_to_i64(stored.requested_candidates, "requested_candidates")?,
2451 receipt_count_to_i64(stored.returned_candidates, "returned_candidates")?,
2452 receipt_count_to_i64(stored.post_filter_candidates, "post_filter_candidates")?,
2453 &result_ids_json,
2454 &receipt_json,
2455 &receipt_digest,
2456 ],
2457 )?;
2458 Ok(())
2459}
2460
2461pub fn get_search_receipt(
2463 conn: &Connection,
2464 receipt_id: &str,
2465) -> Result<Option<VectorSearchReceiptV1>, MemoryError> {
2466 let row: Option<(String, String, String)> = conn
2467 .query_row(
2468 "SELECT schema_version, receipt_json, receipt_digest
2469 FROM search_receipts
2470 WHERE receipt_id = ?1",
2471 params![receipt_id],
2472 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
2473 )
2474 .optional()?;
2475
2476 let Some((schema_version, receipt_json, receipt_digest)) = row else {
2477 return Ok(None);
2478 };
2479 if schema_version != SEARCH_RECEIPT_SCHEMA_VERSION {
2480 return Err(MemoryError::CorruptData {
2481 table: "search_receipts",
2482 row_id: receipt_id.to_string(),
2483 detail: format!("unsupported receipt schema version '{schema_version}'"),
2484 });
2485 }
2486
2487 let stored: StoredVectorSearchReceiptV1 =
2488 serde_json::from_str(&receipt_json).map_err(|err| MemoryError::CorruptData {
2489 table: "search_receipts",
2490 row_id: receipt_id.to_string(),
2491 detail: format!("invalid receipt JSON: {err}"),
2492 })?;
2493 let mut receipt = search_receipt_from_stored(stored)?;
2494 receipt.receipt_digest = Some(receipt_digest);
2495 Ok(Some(receipt))
2496}
2497
2498pub(crate) struct ReplayInputs {
2500 pub query_text: String,
2501 pub namespaces: Option<Vec<String>>,
2502 pub source_types: Option<Vec<SearchSourceType>>,
2503}
2504
2505pub(crate) fn store_replay_inputs(
2507 conn: &Connection,
2508 receipt_id: &str,
2509 query_text: &str,
2510 namespaces: Option<&[&str]>,
2511 source_types: Option<&[SearchSourceType]>,
2512) -> Result<(), MemoryError> {
2513 let namespaces_json = namespaces
2514 .map(serde_json::to_string)
2515 .transpose()
2516 .map_err(|error| {
2517 MemoryError::Other(format!("failed to serialize replay namespaces: {error}"))
2518 })?;
2519 let source_types_json = source_types
2520 .map(serde_json::to_string)
2521 .transpose()
2522 .map_err(|error| {
2523 MemoryError::Other(format!("failed to serialize replay source types: {error}"))
2524 })?;
2525
2526 let existing: Option<(String, Option<String>, Option<String>)> = conn
2527 .query_row(
2528 "SELECT query_text, namespaces_json, source_types_json
2529 FROM replay_inputs WHERE receipt_id = ?1",
2530 params![receipt_id],
2531 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
2532 )
2533 .optional()?;
2534 if let Some(existing) = existing {
2535 if existing.0 == query_text
2536 && existing.1.as_ref() == namespaces_json.as_ref()
2537 && existing.2.as_ref() == source_types_json.as_ref()
2538 {
2539 return Ok(());
2540 }
2541 return Err(MemoryError::SearchReceiptConflict {
2542 receipt_id: receipt_id.to_string(),
2543 });
2544 }
2545
2546 conn.execute(
2547 "INSERT INTO replay_inputs
2548 (receipt_id, query_text, namespaces_json, source_types_json)
2549 VALUES (?1, ?2, ?3, ?4)",
2550 params![receipt_id, query_text, namespaces_json, source_types_json],
2551 )?;
2552 Ok(())
2553}
2554
2555pub(crate) fn get_replay_inputs(
2557 conn: &Connection,
2558 receipt_id: &str,
2559) -> Result<Option<ReplayInputs>, MemoryError> {
2560 let row: Option<(String, Option<String>, Option<String>)> = conn
2561 .query_row(
2562 "SELECT query_text, namespaces_json, source_types_json
2563 FROM replay_inputs WHERE receipt_id = ?1",
2564 params![receipt_id],
2565 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
2566 )
2567 .optional()?;
2568 let Some((query_text, namespaces_json, source_types_json)) = row else {
2569 return Ok(None);
2570 };
2571 let namespaces = namespaces_json
2572 .map(|json| serde_json::from_str(&json))
2573 .transpose()
2574 .map_err(|error| MemoryError::CorruptData {
2575 table: "replay_inputs",
2576 row_id: receipt_id.to_string(),
2577 detail: format!("invalid namespaces JSON: {error}"),
2578 })?;
2579 let source_types = source_types_json
2580 .map(|json| serde_json::from_str(&json))
2581 .transpose()
2582 .map_err(|error| MemoryError::CorruptData {
2583 table: "replay_inputs",
2584 row_id: receipt_id.to_string(),
2585 detail: format!("invalid source types JSON: {error}"),
2586 })?;
2587 Ok(Some(ReplayInputs {
2588 query_text,
2589 namespaces,
2590 source_types,
2591 }))
2592}
2593
2594fn add_column_if_missing(
2595 conn: &Connection,
2596 table: &str,
2597 column: &str,
2598 column_sql: &str,
2599) -> Result<(), rusqlite::Error> {
2600 let pragma = format!("PRAGMA table_info({table})");
2601 let mut stmt = conn.prepare(&pragma)?;
2602 let exists = stmt
2603 .query_map([], |row| row.get::<_, String>(1))?
2604 .collect::<Result<Vec<_>, _>>()?
2605 .into_iter()
2606 .any(|name| name == column);
2607
2608 if !exists {
2609 conn.execute(
2610 &format!("ALTER TABLE {table} ADD COLUMN {column} {column_sql}"),
2611 [],
2612 )?;
2613 }
2614
2615 Ok(())
2616}
2617
2618pub fn check_embedding_metadata(
2620 conn: &Connection,
2621 config: &EmbeddingConfig,
2622) -> Result<(), MemoryError> {
2623 let existing: Option<(String, usize)> = conn
2625 .query_row(
2626 "SELECT model_name, dimensions FROM embedding_metadata WHERE id = 1",
2627 [],
2628 |row| Ok((row.get(0)?, row.get(1)?)),
2629 )
2630 .ok();
2631
2632 match existing {
2633 Some((model, dims)) => {
2634 if model != config.model || dims != config.dimensions {
2635 tracing::warn!(
2636 stored_model = %model,
2637 stored_dims = dims,
2638 configured_model = %config.model,
2639 configured_dims = config.dimensions,
2640 "Embedding model changed. Existing embeddings are stale."
2641 );
2642 conn.execute(
2643 "UPDATE embedding_metadata
2644 SET model_name = ?1,
2645 dimensions = ?2,
2646 embeddings_dirty = 1,
2647 updated_at = datetime('now')
2648 WHERE id = 1",
2649 params![config.model, config.dimensions],
2650 )?;
2651 }
2652 }
2653 None => {
2654 conn.execute(
2655 "INSERT INTO embedding_metadata (id, model_name, dimensions) VALUES (1, ?1, ?2)",
2656 params![config.model, config.dimensions],
2657 )?;
2658 }
2659 }
2660
2661 Ok(())
2662}
2663
2664pub fn embedding_to_bytes(embedding: &[f32]) -> Vec<u8> {
2666 encode_f32_le(embedding)
2667}
2668
2669#[derive(Debug, Clone)]
2671pub(crate) struct SparseVectorRow {
2672 pub item_key: String,
2673 pub weights: SparseWeights,
2674 pub representation: String,
2675}
2676
2677pub(crate) fn store_sparse_vector(
2679 conn: &Connection,
2680 item_key: &str,
2681 weights: &SparseWeights,
2682 representation: &str,
2683) -> Result<(), MemoryError> {
2684 if item_key.split_once(':').is_none() || representation.trim().is_empty() {
2685 return Err(MemoryError::InvalidConfig {
2686 field: "sparse_vector",
2687 reason: "item_key must be canonical and representation must not be empty".to_string(),
2688 });
2689 }
2690 if weights
2691 .entries
2692 .iter()
2693 .any(|(_, weight)| !weight.is_finite())
2694 {
2695 return Err(MemoryError::InvalidConfig {
2696 field: "sparse_vector.entries",
2697 reason: "sparse weights must be finite".to_string(),
2698 });
2699 }
2700 let entries_json = serde_json::to_string(&weights.entries)
2701 .map_err(|error| MemoryError::Other(format!("serialize sparse weights: {error}")))?;
2702 conn.execute(
2703 "INSERT INTO sparse_vectors (item_key, entries_json, representation, updated_at)
2704 VALUES (?1, ?2, ?3, datetime('now'))
2705 ON CONFLICT(item_key) DO UPDATE SET
2706 entries_json = excluded.entries_json,
2707 representation = excluded.representation,
2708 updated_at = excluded.updated_at",
2709 params![item_key, entries_json, representation],
2710 )?;
2711 Ok(())
2712}
2713
2714pub(crate) fn search_sparse_vectors(
2716 conn: &Connection,
2717 query: &SparseWeights,
2718 candidate_limit: usize,
2719 min_score: f64,
2720) -> Result<Vec<(SparseVectorRow, f64)>, MemoryError> {
2721 if query.is_empty() || candidate_limit == 0 {
2722 return Ok(Vec::new());
2723 }
2724
2725 let mut sql = String::from("WITH query_sparse(dimension, weight) AS (VALUES ");
2726 for index in 0..query.entries.len() {
2727 if index > 0 {
2728 sql.push(',');
2729 }
2730 let dimension_param = index * 2 + 1;
2731 let weight_param = dimension_param + 1;
2732 sql.push_str(&format!("(?{dimension_param}, ?{weight_param})"));
2733 }
2734 let min_score_param = query.entries.len() * 2 + 1;
2735 let limit_param = min_score_param + 1;
2736 sql.push_str(&format!(
2737 ") SELECT sv.item_key, sv.entries_json, sv.representation,
2738 SUM(CAST(json_extract(entry.value, '$[1]') AS REAL) * query_sparse.weight) AS sparse_score
2739 FROM sparse_vectors sv
2740 JOIN json_each(sv.entries_json) AS entry
2741 JOIN query_sparse
2742 ON CAST(json_extract(entry.value, '$[0]') AS INTEGER) = query_sparse.dimension
2743 GROUP BY sv.item_key
2744 HAVING sparse_score >= ?{min_score_param}
2745 ORDER BY sparse_score DESC, sv.item_key ASC
2746 LIMIT ?{limit_param}"
2747 ));
2748
2749 let mut values: Vec<rusqlite::types::Value> = Vec::with_capacity(query.entries.len() * 2 + 2);
2750 for (dimension, weight) in &query.entries {
2751 values.push((*dimension as i64).into());
2752 values.push(f64::from(*weight).into());
2753 }
2754 values.push(min_score.into());
2755 values.push((candidate_limit as i64).into());
2756
2757 let mut statement = conn.prepare(&sql)?;
2758 let rows = statement.query_map(rusqlite::params_from_iter(values), |row| {
2759 Ok((
2760 row.get::<_, String>(0)?,
2761 row.get::<_, String>(1)?,
2762 row.get::<_, String>(2)?,
2763 row.get::<_, f64>(3)?,
2764 ))
2765 })?;
2766 let mut results = Vec::new();
2767 for row in rows {
2768 let (item_key, entries_json, representation, score) = row?;
2769 let entries: Vec<(usize, f32)> =
2770 serde_json::from_str(&entries_json).map_err(|error| MemoryError::CorruptData {
2771 table: "sparse_vectors",
2772 row_id: item_key.clone(),
2773 detail: format!("invalid sparse entries JSON: {error}"),
2774 })?;
2775 results.push((
2776 SparseVectorRow {
2777 item_key,
2778 weights: SparseWeights::from_entries(entries),
2779 representation,
2780 },
2781 score,
2782 ));
2783 }
2784 Ok(results)
2785}
2786
2787pub(crate) fn delete_sparse_vector(conn: &Connection, item_key: &str) -> Result<(), MemoryError> {
2789 conn.execute(
2790 "DELETE FROM sparse_vectors WHERE item_key = ?1",
2791 params![item_key],
2792 )?;
2793 Ok(())
2794}
2795
2796pub fn encode_f32_le(values: &[f32]) -> Vec<u8> {
2798 let mut bytes = Vec::with_capacity(values.len() * 4);
2799 for value in values {
2800 bytes.extend_from_slice(&value.to_le_bytes());
2801 }
2802 bytes
2803}
2804
2805pub(crate) fn validate_embedding(values: &[f32], expected_dim: usize) -> Result<(), MemoryError> {
2807 if values.len() != expected_dim {
2808 return Err(MemoryError::EmbeddingDimensionMismatch {
2809 expected: expected_dim,
2810 actual: values.len(),
2811 });
2812 }
2813 if let Some((index, _)) = values
2814 .iter()
2815 .enumerate()
2816 .find(|(_, value)| !value.is_finite())
2817 {
2818 return Err(MemoryError::NonFiniteEmbeddingValue { index });
2819 }
2820 Ok(())
2821}
2822
2823pub(crate) fn validate_embedding_batch(
2825 values: &[Vec<f32>],
2826 requested: usize,
2827 expected_dim: usize,
2828) -> Result<(), MemoryError> {
2829 if values.len() != requested {
2830 return Err(MemoryError::EmbeddingBatchCountMismatch {
2831 requested,
2832 returned: values.len(),
2833 });
2834 }
2835 for embedding in values {
2836 validate_embedding(embedding, expected_dim)?;
2837 }
2838 Ok(())
2839}
2840
2841pub(crate) fn validate_vector_blob_len(
2843 bytes: &[u8],
2844 expected_dim: usize,
2845) -> Result<(), MemoryError> {
2846 let expected_bytes = expected_dim
2847 .checked_mul(4)
2848 .ok_or_else(|| MemoryError::InvalidConfig {
2849 field: "embedding.dimensions",
2850 reason: "dimension byte length overflow".to_string(),
2851 })?;
2852 if bytes.len() != expected_bytes {
2853 return Err(MemoryError::VectorBlobLengthMismatch {
2854 expected_bytes,
2855 actual_bytes: bytes.len(),
2856 });
2857 }
2858 Ok(())
2859}
2860
2861#[allow(clippy::manual_is_multiple_of)]
2863pub fn decode_f32_le(bytes: &[u8], expected_dim: usize) -> Result<Vec<f32>, MemoryError> {
2864 validate_vector_blob_len(bytes, expected_dim)?;
2865 decode_f32_le_unchecked_dim(bytes)
2866}
2867
2868#[allow(clippy::manual_is_multiple_of)]
2870pub fn bytes_to_embedding(bytes: &[u8]) -> Result<Vec<f32>, MemoryError> {
2871 if bytes.len() % 4 != 0 {
2872 return Err(MemoryError::InvalidEmbedding {
2873 expected_bytes: bytes.len() - (bytes.len() % 4),
2874 actual_bytes: bytes.len(),
2875 });
2876 }
2877
2878 decode_f32_le_unchecked_dim(bytes)
2879}
2880
2881fn decode_f32_le_unchecked_dim(bytes: &[u8]) -> Result<Vec<f32>, MemoryError> {
2882 let mut embedding = Vec::with_capacity(bytes.len() / 4);
2883 for (index, chunk) in bytes.chunks_exact(4).enumerate() {
2884 let value = f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
2885 if !value.is_finite() {
2886 return Err(MemoryError::NonFiniteEmbeddingValue { index });
2887 }
2888 embedding.push(value);
2889 }
2890 Ok(embedding)
2891}
2892
2893pub fn is_embeddings_dirty(conn: &Connection) -> Result<bool, MemoryError> {
2894 let dirty: i32 = conn
2895 .query_row(
2896 "SELECT COALESCE(embeddings_dirty, 0) FROM embedding_metadata WHERE id = 1",
2897 [],
2898 |row| row.get(0),
2899 )
2900 .unwrap_or(0);
2901 Ok(dirty != 0)
2902}
2903
2904pub fn clear_embeddings_dirty(conn: &Connection) -> Result<(), MemoryError> {
2905 conn.execute(
2906 "UPDATE embedding_metadata SET embeddings_dirty = 0 WHERE id = 1",
2907 [],
2908 )?;
2909 Ok(())
2910}
2911
2912#[cfg(feature = "hnsw")]
2913pub(crate) fn queue_pending_index_op(
2914 tx: &rusqlite::Transaction<'_>,
2915 item_key: &str,
2916 entity_type: &str,
2917 op_kind: IndexOpKind,
2918) -> Result<(), MemoryError> {
2919 tx.execute(
2920 "INSERT INTO pending_index_ops (item_key, entity_type, op_kind, attempt_count, last_error, updated_at)
2921 VALUES (?1, ?2, ?3, 0, NULL, datetime('now'))
2922 ON CONFLICT(item_key) DO UPDATE SET
2923 entity_type = excluded.entity_type,
2924 op_kind = excluded.op_kind,
2925 attempt_count = 0,
2926 last_error = NULL,
2927 updated_at = datetime('now')",
2928 params![item_key, entity_type, op_kind.as_str()],
2929 )?;
2930 mark_sidecar_dirty(tx)?;
2931 Ok(())
2932}
2933
2934#[cfg(feature = "hnsw")]
2935pub(crate) use IndexOpKind as PendingIndexOpKind;
2936
2937#[cfg(feature = "hnsw")]
2938pub(crate) fn enqueue_pending_index_op(
2939 tx: &rusqlite::Transaction<'_>,
2940 item_key: &str,
2941 entity_type: &str,
2942 op_kind: PendingIndexOpKind,
2943) -> Result<(), MemoryError> {
2944 queue_pending_index_op(tx, item_key, entity_type, op_kind)
2945}
2946
2947pub(crate) fn list_pending_index_ops(
2948 conn: &Connection,
2949) -> Result<Vec<PendingIndexOp>, MemoryError> {
2950 let table_exists: bool = conn
2951 .query_row(
2952 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='pending_index_ops'",
2953 [],
2954 |row| row.get(0),
2955 )
2956 .unwrap_or(false);
2957 if !table_exists {
2958 return Ok(Vec::new());
2959 }
2960
2961 let mut stmt = conn.prepare(
2962 "SELECT item_key, entity_type, op_kind, attempt_count, last_error
2963 FROM pending_index_ops
2964 ORDER BY updated_at ASC, item_key ASC",
2965 )?;
2966 let rows = stmt
2967 .query_map([], |row| {
2968 let item_key: String = row.get(0)?;
2969 let op_kind: String = row.get(2)?;
2970 Ok(PendingIndexOp {
2971 item_key: item_key.clone(),
2972 entity_type: row.get(1)?,
2973 op_kind: IndexOpKind::parse(&op_kind, &item_key).map_err(|e| {
2974 rusqlite::Error::FromSqlConversionFailure(
2975 2,
2976 rusqlite::types::Type::Text,
2977 Box::new(e),
2978 )
2979 })?,
2980 attempt_count: row.get::<_, i64>(3)? as u32,
2981 last_error: row.get(4)?,
2982 })
2983 })?
2984 .collect::<Result<Vec<_>, _>>()?;
2985 Ok(rows)
2986}
2987
2988#[cfg(feature = "hnsw")]
2989pub(crate) fn pending_index_op_count(conn: &Connection) -> Result<usize, MemoryError> {
2990 let table_exists: bool = conn
2991 .query_row(
2992 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='pending_index_ops'",
2993 [],
2994 |row| row.get(0),
2995 )
2996 .unwrap_or(false);
2997 if !table_exists {
2998 return Ok(0);
2999 }
3000
3001 let count: i64 = conn.query_row("SELECT COUNT(*) FROM pending_index_ops", [], |row| {
3002 row.get(0)
3003 })?;
3004 Ok(count as usize)
3005}
3006
3007#[cfg(feature = "hnsw")]
3008pub(crate) fn mark_pending_index_ops_failed(
3009 conn: &Connection,
3010 item_keys: &[String],
3011 error: &str,
3012) -> Result<(), MemoryError> {
3013 with_transaction(conn, |tx| {
3014 for item_key in item_keys {
3015 tx.execute(
3016 "UPDATE pending_index_ops
3017 SET attempt_count = attempt_count + 1,
3018 last_error = ?1,
3019 updated_at = datetime('now')
3020 WHERE item_key = ?2",
3021 params![error, item_key],
3022 )?;
3023 }
3024 Ok(())
3025 })
3026}
3027
3028#[cfg(feature = "hnsw")]
3029pub(crate) fn clear_pending_index_ops(
3030 conn: &Connection,
3031 item_keys: &[String],
3032) -> Result<(), MemoryError> {
3033 with_transaction(conn, |tx| {
3034 for item_key in item_keys {
3035 tx.execute(
3036 "DELETE FROM pending_index_ops WHERE item_key = ?1",
3037 params![item_key],
3038 )?;
3039 }
3040 Ok(())
3041 })
3042}
3043
3044#[cfg(feature = "hnsw")]
3045pub(crate) fn clear_all_pending_index_ops(conn: &Connection) -> Result<(), MemoryError> {
3046 conn.execute("DELETE FROM pending_index_ops", [])?;
3047 Ok(())
3048}
3049
3050#[cfg(feature = "hnsw")]
3051pub(crate) fn load_embedding_for_index_key(
3052 conn: &Connection,
3053 item_key: &str,
3054) -> Result<Option<Vec<f32>>, MemoryError> {
3055 let Some((domain, raw_id)) = item_key.split_once(':') else {
3056 return Err(MemoryError::InvalidKey(item_key.to_string()));
3057 };
3058
3059 let blob_result: Result<Option<Vec<u8>>, rusqlite::Error> = match domain {
3060 "fact" => conn.query_row(
3061 "SELECT embedding FROM facts WHERE id = ?1",
3062 params![raw_id],
3063 |row| row.get(0),
3064 ),
3065 "chunk" => conn.query_row(
3066 "SELECT embedding FROM chunks WHERE id = ?1",
3067 params![raw_id],
3068 |row| row.get(0),
3069 ),
3070 "msg" => {
3071 let message_id = raw_id
3072 .parse::<i64>()
3073 .map_err(|e| MemoryError::InvalidKey(format!("{}: {e}", item_key)))?;
3074 conn.query_row(
3075 "SELECT embedding FROM messages WHERE id = ?1",
3076 params![message_id],
3077 |row| row.get(0),
3078 )
3079 }
3080 "episode" => conn.query_row(
3081 "SELECT embedding FROM episodes WHERE episode_id = ?1",
3082 params![raw_id],
3083 |row| row.get(0),
3084 ),
3085 _ => return Err(MemoryError::InvalidKey(item_key.to_string())),
3086 };
3087
3088 let blob = match blob_result {
3089 Ok(blob) => blob,
3090 Err(rusqlite::Error::QueryReturnedNoRows) => None,
3091 Err(err) => return Err(err.into()),
3092 };
3093
3094 blob.map(|bytes| bytes_to_embedding(&bytes)).transpose()
3095}
3096
3097#[cfg(feature = "hnsw")]
3098fn mark_sidecar_dirty(tx: &rusqlite::Transaction<'_>) -> Result<(), MemoryError> {
3099 tx.execute(
3100 "INSERT INTO hnsw_metadata (key, value) VALUES ('sidecar_dirty', '1')
3101 ON CONFLICT(key) DO UPDATE SET value = '1'",
3102 [],
3103 )?;
3104 Ok(())
3105}
3106
3107#[cfg(feature = "hnsw")]
3108pub(crate) fn is_sidecar_dirty(conn: &Connection) -> Result<bool, MemoryError> {
3109 let dirty: Option<String> = conn
3111 .query_row(
3112 "SELECT value FROM hnsw_metadata WHERE key = 'sidecar_dirty'",
3113 [],
3114 |row| row.get(0),
3115 )
3116 .ok();
3117 Ok(matches!(dirty.as_deref(), Some("1")))
3118}
3119
3120#[cfg(feature = "hnsw")]
3121pub(crate) fn set_sidecar_dirty(conn: &Connection, dirty: bool) -> Result<(), MemoryError> {
3122 conn.execute(
3123 "INSERT INTO hnsw_metadata (key, value) VALUES ('sidecar_dirty', ?1)
3124 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
3125 params![if dirty { "1" } else { "0" }],
3126 )?;
3127 Ok(())
3128}
3129
3130pub(crate) fn parse_optional_json(
3131 table: &'static str,
3132 row_id: &str,
3133 field: &'static str,
3134 raw: Option<&str>,
3135) -> Result<Option<serde_json::Value>, MemoryError> {
3136 match raw {
3137 Some(raw) => serde_json::from_str(raw)
3138 .map(Some)
3139 .map_err(|e| MemoryError::CorruptData {
3140 table,
3141 row_id: row_id.to_string(),
3142 detail: format!("invalid {field}: {e}"),
3143 }),
3144 None => Ok(None),
3145 }
3146}
3147
3148pub(crate) fn parse_string_list_json(
3149 table: &'static str,
3150 row_id: &str,
3151 field: &'static str,
3152 raw: &str,
3153) -> Result<Vec<String>, MemoryError> {
3154 serde_json::from_str(raw).map_err(|e| MemoryError::CorruptData {
3155 table,
3156 row_id: row_id.to_string(),
3157 detail: format!("invalid {field}: {e}"),
3158 })
3159}
3160
3161pub(crate) fn parse_role(
3162 table: &'static str,
3163 row_id: &str,
3164 raw: &str,
3165) -> Result<Role, MemoryError> {
3166 Role::from_str_value(raw).ok_or_else(|| MemoryError::CorruptData {
3167 table,
3168 row_id: row_id.to_string(),
3169 detail: format!("invalid role '{raw}'"),
3170 })
3171}
3172
3173pub(crate) fn parse_episode_outcome(
3174 row_id: &str,
3175 raw: &str,
3176) -> Result<EpisodeOutcome, MemoryError> {
3177 EpisodeOutcome::from_str_value(raw).ok_or_else(|| MemoryError::CorruptData {
3178 table: "episodes",
3179 row_id: row_id.to_string(),
3180 detail: format!("invalid outcome '{raw}'"),
3181 })
3182}
3183
3184pub(crate) fn parse_verification_status(
3185 row_id: &str,
3186 raw: &str,
3187) -> Result<VerificationStatus, MemoryError> {
3188 serde_json::from_str(raw).map_err(|e| MemoryError::CorruptData {
3189 table: "episodes",
3190 row_id: row_id.to_string(),
3191 detail: format!("invalid verification_status: {e}"),
3192 })
3193}
3194
3195pub fn verify_integrity_sync(
3197 conn: &Connection,
3198 mode: VerifyMode,
3199) -> Result<IntegrityReport, MemoryError> {
3200 let mut issues = Vec::new();
3201
3202 let schema_version: u32 = conn
3203 .query_row("PRAGMA user_version", [], |row| row.get(0))
3204 .unwrap_or_else(|e| {
3205 issues.push(format!("failed to read schema version: {e}"));
3206 0
3207 });
3208 if schema_version > MAX_SCHEMA_VERSION {
3209 issues.push(format!(
3210 "schema version {} is ahead of supported {}",
3211 schema_version, MAX_SCHEMA_VERSION
3212 ));
3213 }
3214
3215 let fact_count: usize = conn
3216 .query_row("SELECT COUNT(*) FROM facts", [], |row| row.get(0))
3217 .unwrap_or_else(|e| {
3218 issues.push(format!("failed to count facts: {e}"));
3219 0
3220 });
3221 let chunk_count: usize = conn
3222 .query_row("SELECT COUNT(*) FROM chunks", [], |row| row.get(0))
3223 .unwrap_or_else(|e| {
3224 issues.push(format!("failed to count chunks: {e}"));
3225 0
3226 });
3227 let message_count: usize = conn
3228 .query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))
3229 .unwrap_or_else(|e| {
3230 issues.push(format!("failed to count messages: {e}"));
3231 0
3232 });
3233 let episode_count: usize = conn
3234 .query_row("SELECT COUNT(*) FROM episodes", [], |row| row.get(0))
3235 .unwrap_or_else(|e| {
3236 issues.push(format!("failed to count episodes: {e}"));
3237 0
3238 });
3239
3240 let facts_missing_embeddings: usize = conn
3241 .query_row(
3242 "SELECT COUNT(*) FROM facts WHERE embedding IS NULL",
3243 [],
3244 |row| row.get(0),
3245 )
3246 .unwrap_or_else(|e| {
3247 issues.push(format!("failed to count facts missing embeddings: {e}"));
3248 0
3249 });
3250 let chunks_missing_embeddings: usize = conn
3251 .query_row(
3252 "SELECT COUNT(*) FROM chunks WHERE embedding IS NULL",
3253 [],
3254 |row| row.get(0),
3255 )
3256 .unwrap_or_else(|e| {
3257 issues.push(format!("failed to count chunks missing embeddings: {e}"));
3258 0
3259 });
3260 let episodes_missing_embeddings: usize = conn
3261 .query_row(
3262 "SELECT COUNT(*) FROM episodes WHERE embedding IS NULL",
3263 [],
3264 |row| row.get(0),
3265 )
3266 .unwrap_or_else(|e| {
3267 issues.push(format!("failed to count episodes missing embeddings: {e}"));
3268 0
3269 });
3270
3271 if facts_missing_embeddings > 0 {
3272 issues.push(format!(
3273 "{} facts missing embeddings",
3274 facts_missing_embeddings
3275 ));
3276 }
3277 if chunks_missing_embeddings > 0 {
3278 issues.push(format!(
3279 "{} chunks missing embeddings",
3280 chunks_missing_embeddings
3281 ));
3282 }
3283 if episodes_missing_embeddings > 0 {
3284 issues.push(format!(
3285 "{} episodes missing embeddings",
3286 episodes_missing_embeddings
3287 ));
3288 }
3289
3290 let pending_ops = list_pending_index_ops(conn).unwrap_or_default();
3291 if !pending_ops.is_empty() {
3292 issues.push(format!(
3293 "{} pending HNSW sidecar ops queued in SQLite",
3294 pending_ops.len()
3295 ));
3296 for op in pending_ops.iter().take(5) {
3297 let op_kind = op.op_kind.as_str();
3298 let detail = match &op.last_error {
3299 Some(last_error) => format!(
3300 "{} {} {} (attempts: {}, last_error: {})",
3301 op.entity_type,
3302 op.op_kind.as_str(),
3303 op.item_key,
3304 op.attempt_count,
3305 last_error
3306 ),
3307 None => format!(
3308 "{} {} {} (attempts: {})",
3309 op.entity_type, op_kind, op.item_key, op.attempt_count
3310 ),
3311 };
3312 issues.push(format!("pending sidecar op: {detail}"));
3313 }
3314 }
3315
3316 if mode == VerifyMode::Full {
3317 let dims: usize = conn
3318 .query_row(
3319 "SELECT dimensions FROM embedding_metadata WHERE id = 1",
3320 [],
3321 |row| row.get(0),
3322 )
3323 .unwrap_or_else(|e| {
3324 issues.push(format!("failed to read embedding dimensions: {e}"));
3325 0
3326 });
3327
3328 verify_fts_drift(conn, "facts", "facts_rowid_map", fact_count, &mut issues);
3329 verify_fts_drift(conn, "chunks", "chunks_rowid_map", chunk_count, &mut issues);
3330 verify_fts_drift(
3331 conn,
3332 "messages",
3333 "messages_rowid_map",
3334 message_count,
3335 &mut issues,
3336 );
3337 verify_fts_drift(
3338 conn,
3339 "episodes",
3340 "episodes_rowid_map",
3341 episode_count,
3342 &mut issues,
3343 );
3344
3345 verify_blob_table(conn, "facts", "id", "embedding", dims, &mut issues)?;
3346 verify_blob_table(conn, "chunks", "id", "embedding", dims, &mut issues)?;
3347 verify_blob_table(conn, "messages", "id", "embedding", dims, &mut issues)?;
3348 verify_blob_table(
3349 conn,
3350 "episodes",
3351 "episode_id",
3352 "embedding",
3353 dims,
3354 &mut issues,
3355 )?;
3356
3357 verify_quantized_table(conn, "facts", "id", dims, &mut issues)?;
3358 verify_quantized_table(conn, "chunks", "id", dims, &mut issues)?;
3359 verify_quantized_table(conn, "messages", "id", dims, &mut issues)?;
3360 verify_quantized_table(conn, "episodes", "episode_id", dims, &mut issues)?;
3361
3362 verify_session_rows(conn, &mut issues)?;
3363 verify_message_rows(conn, &mut issues)?;
3364 verify_fact_rows(conn, &mut issues)?;
3365 verify_document_rows(conn, &mut issues)?;
3366 verify_episode_rows(conn, &mut issues)?;
3367
3368 let integrity_check: String = conn
3369 .query_row("PRAGMA integrity_check", [], |row| row.get(0))
3370 .unwrap_or_else(|_| "error".to_string());
3371 if integrity_check != "ok" {
3372 issues.push(format!("SQLite integrity_check: {}", integrity_check));
3373 }
3374 }
3375
3376 Ok(IntegrityReport {
3377 ok: issues.is_empty(),
3378 schema_version,
3379 fact_count,
3380 chunk_count,
3381 message_count,
3382 facts_missing_embeddings,
3383 chunks_missing_embeddings,
3384 issues,
3385 })
3386}
3387
3388pub fn reconcile_fts(conn: &Connection) -> Result<(), MemoryError> {
3390 with_transaction(conn, |tx| {
3391 tx.execute_batch("DROP TABLE IF EXISTS facts_fts")?;
3392 tx.execute_batch("DELETE FROM facts_rowid_map")?;
3393 tx.execute_batch(
3394 "CREATE VIRTUAL TABLE facts_fts USING fts5(
3395 content,
3396 content='',
3397 content_rowid='rowid',
3398 tokenize='porter unicode61'
3399 )",
3400 )?;
3401 tx.execute_batch("INSERT INTO facts_rowid_map (fact_id) SELECT id FROM facts")?;
3402 tx.execute_batch(
3403 "INSERT INTO facts_fts (rowid, content)
3404 SELECT rm.rowid, f.content
3405 FROM facts_rowid_map rm
3406 JOIN facts f ON f.id = rm.fact_id",
3407 )?;
3408
3409 tx.execute_batch("DROP TABLE IF EXISTS chunks_fts")?;
3410 tx.execute_batch("DELETE FROM chunks_rowid_map")?;
3411 tx.execute_batch(
3412 "CREATE VIRTUAL TABLE chunks_fts USING fts5(
3413 content,
3414 content='',
3415 content_rowid='rowid',
3416 tokenize='porter unicode61'
3417 )",
3418 )?;
3419 tx.execute_batch("INSERT INTO chunks_rowid_map (chunk_id) SELECT id FROM chunks")?;
3420 tx.execute_batch(
3421 "INSERT INTO chunks_fts (rowid, content)
3422 SELECT rm.rowid, c.content
3423 FROM chunks_rowid_map rm
3424 JOIN chunks c ON c.id = rm.chunk_id",
3425 )?;
3426
3427 tx.execute_batch("DROP TABLE IF EXISTS messages_fts")?;
3428 tx.execute_batch("DELETE FROM messages_rowid_map")?;
3429 tx.execute_batch(
3430 "CREATE VIRTUAL TABLE messages_fts USING fts5(
3431 content,
3432 content='',
3433 content_rowid='rowid',
3434 tokenize='porter unicode61'
3435 )",
3436 )?;
3437 tx.execute_batch("INSERT INTO messages_rowid_map (message_id) SELECT id FROM messages")?;
3438 tx.execute_batch(
3439 "INSERT INTO messages_fts (rowid, content)
3440 SELECT rm.rowid, m.content
3441 FROM messages_rowid_map rm
3442 JOIN messages m ON m.id = rm.message_id",
3443 )?;
3444
3445 tx.execute_batch("DROP TABLE IF EXISTS episodes_fts")?;
3446 tx.execute_batch("DELETE FROM episodes_rowid_map")?;
3447 tx.execute_batch(
3448 "CREATE VIRTUAL TABLE episodes_fts USING fts5(
3449 content,
3450 content='',
3451 content_rowid='rowid',
3452 tokenize='porter unicode61'
3453 )",
3454 )?;
3455 tx.execute_batch(
3456 "INSERT INTO episodes_rowid_map (episode_id, document_id) SELECT episode_id, document_id FROM episodes",
3457 )?;
3458 tx.execute_batch(
3459 "INSERT INTO episodes_fts (rowid, content)
3460 SELECT rm.rowid, e.search_text
3461 FROM episodes_rowid_map rm
3462 JOIN episodes e ON e.episode_id = rm.episode_id",
3463 )?;
3464
3465 Ok(())
3466 })?;
3467
3468 tracing::info!("FTS indexes reconciled");
3469 Ok(())
3470}
3471
3472fn verify_fts_drift(
3473 conn: &Connection,
3474 label: &str,
3475 map_table: &str,
3476 source_count: usize,
3477 issues: &mut Vec<String>,
3478) {
3479 let table_exists: bool = conn
3480 .query_row(
3481 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name = ?1",
3482 params![map_table],
3483 |row| row.get(0),
3484 )
3485 .unwrap_or(false);
3486 if !table_exists {
3487 if source_count > 0 {
3488 issues.push(format!("{} rows exist but {} is missing", label, map_table));
3489 }
3490 return;
3491 }
3492
3493 let sql = format!("SELECT COUNT(*) FROM {}", map_table);
3494 let indexed_count: usize = conn.query_row(&sql, [], |row| row.get(0)).unwrap_or(0);
3495 if indexed_count != source_count {
3496 issues.push(format!(
3497 "FTS {} index drift: {} rows in map vs {} source rows",
3498 label, indexed_count, source_count
3499 ));
3500 }
3501}
3502
3503fn verify_blob_table(
3504 conn: &Connection,
3505 table: &'static str,
3506 id_column: &'static str,
3507 blob_column: &'static str,
3508 expected_dims: usize,
3509 issues: &mut Vec<String>,
3510) -> Result<(), MemoryError> {
3511 if expected_dims == 0 {
3512 return Ok(());
3513 }
3514
3515 let sql = format!(
3516 "SELECT CAST({id_column} AS TEXT), {blob_column} FROM {table} WHERE {blob_column} IS NOT NULL"
3517 );
3518 let mut stmt = conn.prepare(&sql)?;
3519 let rows = stmt.query_map([], |row| {
3520 Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?))
3521 })?;
3522
3523 for row in rows {
3524 let (row_id, blob) = row?;
3525 match bytes_to_embedding(&blob) {
3526 Ok(embedding) if embedding.len() != expected_dims => issues.push(format!(
3527 "{}({}) has embedding dimension {} but expected {}",
3528 table,
3529 row_id,
3530 embedding.len(),
3531 expected_dims
3532 )),
3533 Ok(_) => {}
3534 Err(err) => issues.push(format!(
3535 "{}({}) invalid embedding blob: {}",
3536 table, row_id, err
3537 )),
3538 }
3539 }
3540
3541 Ok(())
3542}
3543
3544fn verify_quantized_table(
3545 conn: &Connection,
3546 table: &'static str,
3547 id_column: &'static str,
3548 expected_dims: usize,
3549 issues: &mut Vec<String>,
3550) -> Result<(), MemoryError> {
3551 if expected_dims == 0 {
3552 return Ok(());
3553 }
3554
3555 let sql = format!(
3556 "SELECT CAST({id_column} AS TEXT), embedding_q8 FROM {table} WHERE embedding IS NOT NULL"
3557 );
3558 let mut stmt = conn.prepare(&sql)?;
3559 let rows = stmt.query_map([], |row| {
3560 Ok((row.get::<_, String>(0)?, row.get::<_, Option<Vec<u8>>>(1)?))
3561 })?;
3562
3563 for row in rows {
3564 let (row_id, blob) = row?;
3565 match blob {
3566 Some(blob) => {
3567 if let Err(err) = unpack_quantized(&blob, expected_dims) {
3568 issues.push(format!(
3569 "{}({}) invalid quantized embedding: {}",
3570 table, row_id, err
3571 ));
3572 }
3573 }
3574 None => issues.push(format!("{}({}) missing quantized embedding", table, row_id)),
3575 }
3576 }
3577
3578 Ok(())
3579}
3580
3581fn verify_session_rows(conn: &Connection, issues: &mut Vec<String>) -> Result<(), MemoryError> {
3582 let mut stmt = conn.prepare("SELECT id, metadata FROM sessions WHERE metadata IS NOT NULL")?;
3583 let rows = stmt.query_map([], |row| {
3584 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
3585 })?;
3586 for row in rows {
3587 let (id, metadata) = row?;
3588 if let Err(err) = parse_optional_json("sessions", &id, "metadata", Some(&metadata)) {
3589 issues.push(err.to_string());
3590 }
3591 }
3592 Ok(())
3593}
3594
3595fn verify_message_rows(conn: &Connection, issues: &mut Vec<String>) -> Result<(), MemoryError> {
3596 let mut stmt = conn.prepare("SELECT id, role, metadata FROM messages")?;
3597 let rows = stmt.query_map([], |row| {
3598 Ok((
3599 row.get::<_, i64>(0)?,
3600 row.get::<_, String>(1)?,
3601 row.get::<_, Option<String>>(2)?,
3602 ))
3603 })?;
3604 for row in rows {
3605 let (id, role, metadata) = row?;
3606 let row_id = id.to_string();
3607 if let Err(err) = parse_role("messages", &row_id, &role) {
3608 issues.push(err.to_string());
3609 }
3610 if let Err(err) = parse_optional_json("messages", &row_id, "metadata", metadata.as_deref())
3611 {
3612 issues.push(err.to_string());
3613 }
3614 }
3615 Ok(())
3616}
3617
3618fn verify_fact_rows(conn: &Connection, issues: &mut Vec<String>) -> Result<(), MemoryError> {
3619 let mut stmt = conn.prepare("SELECT id, metadata FROM facts WHERE metadata IS NOT NULL")?;
3620 let rows = stmt.query_map([], |row| {
3621 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
3622 })?;
3623 for row in rows {
3624 let (id, metadata) = row?;
3625 if let Err(err) = parse_optional_json("facts", &id, "metadata", Some(&metadata)) {
3626 issues.push(err.to_string());
3627 }
3628 }
3629 Ok(())
3630}
3631
3632fn verify_document_rows(conn: &Connection, issues: &mut Vec<String>) -> Result<(), MemoryError> {
3633 let mut stmt = conn.prepare("SELECT id, metadata FROM documents WHERE metadata IS NOT NULL")?;
3634 let rows = stmt.query_map([], |row| {
3635 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
3636 })?;
3637 for row in rows {
3638 let (id, metadata) = row?;
3639 if let Err(err) = parse_optional_json("documents", &id, "metadata", Some(&metadata)) {
3640 issues.push(err.to_string());
3641 }
3642 }
3643 Ok(())
3644}
3645
3646fn verify_episode_rows(conn: &Connection, issues: &mut Vec<String>) -> Result<(), MemoryError> {
3647 let mut stmt = conn.prepare(
3648 "SELECT episode_id, cause_ids, outcome, verification_status
3649 FROM episodes",
3650 )?;
3651 let rows = stmt.query_map([], |row| {
3652 Ok((
3653 row.get::<_, String>(0)?,
3654 row.get::<_, String>(1)?,
3655 row.get::<_, String>(2)?,
3656 row.get::<_, String>(3)?,
3657 ))
3658 })?;
3659 for row in rows {
3660 let (episode_id, cause_ids, outcome, verification_status) = row?;
3661 if let Err(err) = parse_string_list_json("episodes", &episode_id, "cause_ids", &cause_ids) {
3662 issues.push(err.to_string());
3663 }
3664 if let Err(err) = parse_episode_outcome(&episode_id, &outcome) {
3665 issues.push(err.to_string());
3666 }
3667 if let Err(err) = parse_verification_status(&episode_id, &verification_status) {
3668 issues.push(err.to_string());
3669 }
3670 }
3671 Ok(())
3672}
3673
3674#[derive(Debug, Clone)]
3675pub(crate) struct ProveKvPoolGenerationRow {
3676 pub generation: ProveKvPoolGenerationV1,
3677}
3678
3679#[allow(dead_code)] fn parse_provekv_status(value: &str) -> ProveKvPoolGenerationStatus {
3681 match value {
3682 "disabled" => ProveKvPoolGenerationStatus::Disabled,
3683 "missing" => ProveKvPoolGenerationStatus::Missing,
3684 "building" => ProveKvPoolGenerationStatus::Building,
3685 "ready" => ProveKvPoolGenerationStatus::Ready,
3686 "stale" => ProveKvPoolGenerationStatus::Stale,
3687 "failed" => ProveKvPoolGenerationStatus::Failed,
3688 _ => ProveKvPoolGenerationStatus::Failed,
3689 }
3690}
3691
3692fn provekv_generation_from_row(
3693 row: &rusqlite::Row<'_>,
3694) -> rusqlite::Result<ProveKvPoolGenerationRow> {
3695 let created_at: String = row.get(9)?;
3696 Ok(ProveKvPoolGenerationRow {
3697 generation: ProveKvPoolGenerationV1 {
3698 schema_version: "semantic_memory_provekv_pool_generation_v1".to_string(),
3699 generation_id: row.get(0)?,
3700 embedding_snapshot_digest: row.get(1)?,
3701 source_digest: row.get(2)?,
3702 pool_manifest_digest: row.get(3)?,
3703 codec_family: row.get(4)?,
3704 codec_profile: row.get(5)?,
3705 vector_dim: row.get::<_, i64>(6)? as usize,
3706 item_count: row.get::<_, i64>(7)? as usize,
3707 payload_bytes: row.get::<_, i64>(8)? as u64,
3708 created_at: DateTime::parse_from_rfc3339(&created_at)
3709 .map(|dt| dt.with_timezone(&Utc))
3710 .map_err(|err| {
3711 rusqlite::Error::FromSqlConversionFailure(
3712 9,
3713 rusqlite::types::Type::Text,
3714 Box::new(err),
3715 )
3716 })?,
3717 },
3718 })
3719}
3720
3721#[allow(dead_code)]
3722pub(crate) fn insert_provekv_pool_generation(
3723 conn: &Connection,
3724 generation: &ProveKvPoolGenerationV1,
3725 payload: &[u8],
3726 item_map: &[ProveKvPoolItemMapEntryV1],
3727) -> Result<(), MemoryError> {
3728 let tx = conn.unchecked_transaction()?;
3729 tx.execute(
3730 "INSERT OR REPLACE INTO provekv_pool_generations
3731 (generation_id, embedding_snapshot_digest, source_digest, pool_manifest_digest,
3732 codec_family, codec_profile, vector_dim, item_count, payload_bytes, payload,
3733 status, failure_reason, created_at)
3734 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 'ready', NULL, ?11)",
3735 params![
3736 generation.generation_id,
3737 generation.embedding_snapshot_digest,
3738 generation.source_digest,
3739 generation.pool_manifest_digest,
3740 generation.codec_family,
3741 generation.codec_profile,
3742 generation.vector_dim as i64,
3743 generation.item_count as i64,
3744 generation.payload_bytes as i64,
3745 payload,
3746 generation.created_at.to_rfc3339(),
3747 ],
3748 )?;
3749 tx.execute(
3750 "DELETE FROM provekv_pool_item_map WHERE generation_id = ?1",
3751 params![generation.generation_id],
3752 )?;
3753 for entry in item_map {
3754 tx.execute(
3755 "INSERT INTO provekv_pool_item_map
3756 (generation_id, item_id, source_type, pool_index, embedding_digest)
3757 VALUES (?1, ?2, ?3, ?4, ?5)",
3758 params![
3759 entry.generation_id,
3760 entry.item_id,
3761 entry.source_type,
3762 entry.pool_index as i64,
3763 entry.embedding_digest,
3764 ],
3765 )?;
3766 }
3767 tx.commit()?;
3768 Ok(())
3769}
3770
3771pub(crate) fn latest_ready_provekv_pool_generation(
3772 conn: &Connection,
3773) -> Result<Option<ProveKvPoolGenerationRow>, MemoryError> {
3774 conn.query_row(
3775 "SELECT generation_id, embedding_snapshot_digest, source_digest, pool_manifest_digest,
3776 codec_family, codec_profile, vector_dim, item_count, payload_bytes, created_at
3777 FROM provekv_pool_generations
3778 WHERE status = 'ready'
3779 ORDER BY created_at DESC
3780 LIMIT 1",
3781 [],
3782 provekv_generation_from_row,
3783 )
3784 .optional()
3785 .map_err(MemoryError::from)
3786}
3787
3788pub(crate) fn load_provekv_pool_payload(
3789 conn: &Connection,
3790 generation_id: &str,
3791) -> Result<Vec<u8>, MemoryError> {
3792 conn.query_row(
3793 "SELECT payload FROM provekv_pool_generations WHERE generation_id = ?1",
3794 params![generation_id],
3795 |row| row.get(0),
3796 )
3797 .map_err(MemoryError::from)
3798}
3799
3800pub(crate) fn load_provekv_pool_item_map(
3801 conn: &Connection,
3802 generation_id: &str,
3803) -> Result<Vec<ProveKvPoolItemMapEntryV1>, MemoryError> {
3804 let mut stmt = conn.prepare(
3805 "SELECT generation_id, item_id, source_type, pool_index, embedding_digest
3806 FROM provekv_pool_item_map
3807 WHERE generation_id = ?1
3808 ORDER BY pool_index ASC",
3809 )?;
3810 let rows = stmt.query_map(params![generation_id], |row| {
3811 Ok(ProveKvPoolItemMapEntryV1 {
3812 generation_id: row.get(0)?,
3813 item_id: row.get(1)?,
3814 source_type: row.get(2)?,
3815 pool_index: row.get::<_, i64>(3)? as usize,
3816 embedding_digest: row.get(4)?,
3817 })
3818 })?;
3819 let mut entries = Vec::new();
3820 for row in rows {
3821 entries.push(row?);
3822 }
3823 Ok(entries)
3824}
3825
3826#[allow(dead_code)]
3827pub(crate) fn mark_provekv_pool_generation_failed(
3828 conn: &Connection,
3829 generation_id: &str,
3830 reason: &str,
3831) -> Result<(), MemoryError> {
3832 conn.execute(
3833 "UPDATE provekv_pool_generations SET status = 'failed', failure_reason = ?2 WHERE generation_id = ?1",
3834 params![generation_id, reason],
3835 )?;
3836 Ok(())
3837}
3838
3839#[allow(dead_code)] pub(crate) fn provekv_pool_artifact_status(
3841 conn: &Connection,
3842) -> Result<ProveKvPoolArtifactStatusV1, MemoryError> {
3843 let row = conn
3844 .query_row(
3845 "SELECT generation_id, embedding_snapshot_digest, pool_manifest_digest,
3846 item_count, payload_bytes, status, failure_reason
3847 FROM provekv_pool_generations
3848 ORDER BY created_at DESC
3849 LIMIT 1",
3850 [],
3851 |row| {
3852 Ok((
3853 row.get::<_, String>(0)?,
3854 row.get::<_, String>(1)?,
3855 row.get::<_, String>(2)?,
3856 row.get::<_, i64>(3)?,
3857 row.get::<_, i64>(4)?,
3858 row.get::<_, String>(5)?,
3859 row.get::<_, Option<String>>(6)?,
3860 ))
3861 },
3862 )
3863 .optional()?;
3864 Ok(match row {
3865 Some((
3866 generation_id,
3867 snapshot_digest,
3868 manifest_digest,
3869 item_count,
3870 payload_bytes,
3871 status,
3872 reason,
3873 )) => ProveKvPoolArtifactStatusV1 {
3874 status: parse_provekv_status(&status),
3875 generation_id: Some(generation_id),
3876 embedding_snapshot_digest: Some(snapshot_digest),
3877 pool_manifest_digest: Some(manifest_digest),
3878 item_count: item_count as usize,
3879 payload_bytes: payload_bytes as u64,
3880 reason,
3881 },
3882 None => ProveKvPoolArtifactStatusV1 {
3883 status: ProveKvPoolGenerationStatus::Missing,
3884 generation_id: None,
3885 embedding_snapshot_digest: None,
3886 pool_manifest_digest: None,
3887 item_count: 0,
3888 payload_bytes: 0,
3889 reason: Some("provekv_pool_generation_not_materialized".into()),
3890 },
3891 })
3892}
3893
3894#[cfg(test)]
3895mod provekv_pool_generation_db_tests {
3896 use super::*;
3897
3898 fn test_conn() -> Connection {
3899 let conn = Connection::open_in_memory().expect("in-memory db opens");
3900 conn.execute_batch(MIGRATION_V24)
3901 .expect("proveKV schema migration applies");
3902 conn
3903 }
3904
3905 fn generation(id: &str) -> ProveKvPoolGenerationV1 {
3906 ProveKvPoolGenerationV1 {
3907 schema_version: "semantic_memory_provekv_pool_generation_v1".to_string(),
3908 generation_id: id.to_string(),
3909 embedding_snapshot_digest: "blake3:snapshot".to_string(),
3910 source_digest: "blake3:source".to_string(),
3911 pool_manifest_digest: "blake3:manifest".to_string(),
3912 codec_family: "provekv_pool".to_string(),
3913 codec_profile: "semantic-memory-f32-derived-candidate-v1".to_string(),
3914 vector_dim: 4,
3915 item_count: 2,
3916 payload_bytes: 3,
3917 created_at: Utc::now(),
3918 }
3919 }
3920
3921 #[test]
3922 fn provekv_pool_generation_roundtrips_and_cascades_item_map() {
3923 let conn = test_conn();
3924 let gen = generation("gen-1");
3925 let item_map = vec![
3926 ProveKvPoolItemMapEntryV1 {
3927 generation_id: gen.generation_id.clone(),
3928 item_id: "fact-1".to_string(),
3929 source_type: "fact".to_string(),
3930 pool_index: 0,
3931 embedding_digest: "blake3:item-1".to_string(),
3932 },
3933 ProveKvPoolItemMapEntryV1 {
3934 generation_id: gen.generation_id.clone(),
3935 item_id: "fact-2".to_string(),
3936 source_type: "fact".to_string(),
3937 pool_index: 1,
3938 embedding_digest: "blake3:item-2".to_string(),
3939 },
3940 ];
3941 insert_provekv_pool_generation(&conn, &gen, &[1, 2, 3], &item_map).unwrap();
3942
3943 let latest = latest_ready_provekv_pool_generation(&conn)
3944 .unwrap()
3945 .expect("latest ready generation");
3946 assert_eq!(latest.generation.generation_id, gen.generation_id);
3947 assert_eq!(
3948 load_provekv_pool_payload(&conn, "gen-1").unwrap(),
3949 vec![1, 2, 3]
3950 );
3951 assert_eq!(
3952 load_provekv_pool_item_map(&conn, "gen-1").unwrap(),
3953 item_map
3954 );
3955
3956 mark_provekv_pool_generation_failed(&conn, "gen-1", "boom").unwrap();
3957 let status = provekv_pool_artifact_status(&conn).unwrap();
3958 assert_eq!(status.status, ProveKvPoolGenerationStatus::Failed);
3959 assert_eq!(status.reason.as_deref(), Some("boom"));
3960
3961 conn.execute(
3962 "DELETE FROM provekv_pool_generations WHERE generation_id = 'gen-1'",
3963 [],
3964 )
3965 .unwrap();
3966 let count: i64 = conn
3967 .query_row("SELECT COUNT(*) FROM provekv_pool_item_map", [], |row| {
3968 row.get(0)
3969 })
3970 .unwrap();
3971 assert_eq!(count, 0);
3972 }
3973}