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];
932
933pub const MAX_SCHEMA_VERSION: u32 = 37;
935
936fn run_migration_v9(conn: &Connection) -> Result<(), MemoryError> {
938 let episodes_exist: bool = conn
940 .query_row(
941 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='episodes'",
942 [],
943 |row| row.get(0),
944 )
945 .map_err(|e| MemoryError::MigrationFailed {
946 version: 9,
947 reason: format!("existence check failed: {e}"),
948 })?;
949
950 if !episodes_exist {
951 conn.execute_batch(
953 "CREATE TABLE IF NOT EXISTS episode_causes (
954 episode_id TEXT NOT NULL,
955 cause_node_id TEXT NOT NULL,
956 ordinal INTEGER NOT NULL DEFAULT 0,
957 PRIMARY KEY (episode_id, cause_node_id)
958 );
959 CREATE INDEX IF NOT EXISTS idx_episode_causes_cause ON episode_causes(cause_node_id);",
960 )?;
961 return Ok(());
962 }
963
964 conn.execute_batch("PRAGMA foreign_keys = OFF;")?;
966
967 conn.execute_batch(
968 "CREATE TABLE episodes_new (
969 episode_id TEXT PRIMARY KEY,
970 document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
971 cause_ids TEXT NOT NULL,
972 effect_type TEXT NOT NULL,
973 outcome TEXT NOT NULL DEFAULT 'pending',
974 confidence REAL NOT NULL DEFAULT 0.0,
975 verification_status TEXT NOT NULL DEFAULT '{\"status\":\"unverified\"}',
976 experiment_id TEXT,
977 created_at TEXT NOT NULL DEFAULT (datetime('now')),
978 updated_at TEXT NOT NULL DEFAULT (datetime('now')),
979 search_text TEXT NOT NULL DEFAULT '',
980 embedding BLOB,
981 embedding_q8 BLOB,
982 trace_id TEXT
983 )",
984 )?;
985
986 conn.execute_batch(
988 "INSERT INTO episodes_new
989 (episode_id, document_id, cause_ids, effect_type, outcome, confidence,
990 verification_status, experiment_id, created_at, updated_at,
991 search_text, embedding, embedding_q8, trace_id)
992 SELECT
993 document_id || '-ep0',
994 document_id, cause_ids, effect_type, outcome, confidence,
995 verification_status, experiment_id, created_at, updated_at,
996 search_text, embedding, embedding_q8, trace_id
997 FROM episodes",
998 )?;
999
1000 conn.execute_batch("DROP TABLE episodes")?;
1001 conn.execute_batch("ALTER TABLE episodes_new RENAME TO episodes")?;
1002
1003 conn.execute_batch(
1004 "CREATE INDEX idx_episodes_document_id ON episodes(document_id);
1005 CREATE INDEX idx_episodes_effect_type ON episodes(effect_type);
1006 CREATE INDEX idx_episodes_outcome ON episodes(outcome);
1007 CREATE INDEX idx_episodes_experiment_id ON episodes(experiment_id);",
1008 )?;
1009
1010 conn.execute_batch(
1012 "DROP TABLE IF EXISTS episodes_rowid_map;
1013 CREATE TABLE episodes_rowid_map (
1014 rowid INTEGER PRIMARY KEY AUTOINCREMENT,
1015 episode_id TEXT NOT NULL UNIQUE,
1016 document_id TEXT
1017 );
1018 INSERT INTO episodes_rowid_map (episode_id, document_id)
1019 SELECT episode_id, document_id FROM episodes;",
1020 )?;
1021
1022 conn.execute_batch(
1024 "DROP TABLE IF EXISTS episodes_fts;
1025 CREATE VIRTUAL TABLE episodes_fts USING fts5(
1026 content,
1027 content='',
1028 content_rowid='rowid',
1029 tokenize='porter unicode61'
1030 );
1031 INSERT INTO episodes_fts (rowid, content)
1032 SELECT rm.rowid, e.search_text
1033 FROM episodes_rowid_map rm
1034 JOIN episodes e ON e.episode_id = rm.episode_id;",
1035 )?;
1036
1037 conn.execute_batch(
1039 "CREATE TABLE IF NOT EXISTS episode_causes (
1040 episode_id TEXT NOT NULL,
1041 cause_node_id TEXT NOT NULL,
1042 ordinal INTEGER NOT NULL DEFAULT 0,
1043 PRIMARY KEY (episode_id, cause_node_id)
1044 );
1045 CREATE INDEX IF NOT EXISTS idx_episode_causes_cause ON episode_causes(cause_node_id);",
1046 )?;
1047
1048 conn.execute_batch(
1050 "INSERT OR IGNORE INTO episode_causes (episode_id, cause_node_id, ordinal)
1051 SELECT e.episode_id, je.value, CAST(je.key AS INTEGER)
1052 FROM episodes e, json_each(e.cause_ids) je;",
1053 )?;
1054
1055 conn.execute_batch("PRAGMA foreign_keys = ON;")?;
1056
1057 Ok(())
1058}
1059
1060#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1062pub enum VerifyMode {
1063 Quick,
1065 Full,
1067}
1068
1069#[derive(Debug, Clone)]
1071pub struct IntegrityReport {
1072 pub ok: bool,
1073 pub schema_version: u32,
1074 pub fact_count: usize,
1075 pub chunk_count: usize,
1076 pub message_count: usize,
1077 pub facts_missing_embeddings: usize,
1078 pub chunks_missing_embeddings: usize,
1079 pub issues: Vec<String>,
1080}
1081
1082#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1084pub enum ReconcileAction {
1085 ReportOnly,
1086 RebuildFts,
1087 ReEmbed,
1088}
1089
1090#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1092pub(crate) enum IndexOpKind {
1093 Upsert,
1094 Delete,
1095}
1096
1097impl IndexOpKind {
1098 pub(crate) fn as_str(self) -> &'static str {
1099 match self {
1100 Self::Upsert => "upsert",
1101 Self::Delete => "delete",
1102 }
1103 }
1104
1105 fn parse(raw: &str, item_key: &str) -> Result<Self, MemoryError> {
1106 match raw {
1107 "upsert" => Ok(Self::Upsert),
1108 "delete" => Ok(Self::Delete),
1109 other => Err(MemoryError::CorruptData {
1110 table: "pending_index_ops",
1111 row_id: item_key.to_string(),
1112 detail: format!("invalid op_kind '{other}'"),
1113 }),
1114 }
1115 }
1116}
1117
1118#[derive(Debug, Clone)]
1120pub(crate) struct PendingIndexOp {
1121 pub item_key: String,
1122 pub entity_type: String,
1123 pub op_kind: IndexOpKind,
1124 pub attempt_count: u32,
1125 pub last_error: Option<String>,
1126}
1127
1128pub fn with_transaction<F, T>(conn: &Connection, f: F) -> Result<T, MemoryError>
1130where
1131 F: FnOnce(&rusqlite::Transaction<'_>) -> Result<T, MemoryError>,
1132{
1133 let tx = conn.unchecked_transaction()?;
1134 let result = f(&tx)?;
1135 tx.commit()?;
1136 Ok(result)
1137}
1138
1139pub fn open_database(
1141 path: &Path,
1142 pool: &PoolConfig,
1143 limits: &MemoryLimits,
1144) -> Result<Connection, MemoryError> {
1145 open_database_internal(path, pool, limits.max_db_size_bytes, true)
1146}
1147
1148#[allow(dead_code)] pub fn open_database_connection(
1151 path: &Path,
1152 pool: &PoolConfig,
1153 limits: &MemoryLimits,
1154) -> Result<Connection, MemoryError> {
1155 open_database_internal(path, pool, limits.max_db_size_bytes, false)
1156}
1157
1158pub(crate) fn open_database_internal(
1159 path: &Path,
1160 pool: &PoolConfig,
1161 max_db_size_bytes: u64,
1162 run_schema_migrations: bool,
1163) -> Result<Connection, MemoryError> {
1164 create_parent_dirs(path)?;
1165 let conn = Connection::open(path)?;
1166 configure_connection(&conn, path, pool, max_db_size_bytes, false)?;
1167 if run_schema_migrations {
1168 run_migrations(&conn)?;
1169 }
1170 Ok(conn)
1171}
1172
1173pub(crate) fn open_pool_member_connection(
1174 path: &Path,
1175 pool: &PoolConfig,
1176 limits: &MemoryLimits,
1177 query_only: bool,
1178) -> Result<Connection, MemoryError> {
1179 create_parent_dirs(path)?;
1180 let flags = OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE;
1181 let conn = Connection::open_with_flags(path, flags)?;
1182 configure_connection(&conn, path, pool, limits.max_db_size_bytes, query_only)?;
1183 Ok(conn)
1184}
1185
1186fn create_parent_dirs(path: &Path) -> Result<(), MemoryError> {
1187 if let Some(parent) = path.parent() {
1188 if !parent.as_os_str().is_empty() {
1189 std::fs::create_dir_all(parent).map_err(|e| {
1190 MemoryError::StorageError(format!(
1191 "failed to create database directory {}: {}",
1192 parent.display(),
1193 e
1194 ))
1195 })?;
1196 }
1197 }
1198 Ok(())
1199}
1200
1201fn configure_connection(
1202 conn: &Connection,
1203 path: &Path,
1204 pool: &PoolConfig,
1205 max_db_size_bytes: u64,
1206 query_only: bool,
1207) -> Result<(), MemoryError> {
1208 let journal_mode = if pool.enable_wal { "WAL" } else { "DELETE" };
1209 conn.execute_batch(&format!(
1210 "PRAGMA journal_mode = {};
1211 PRAGMA foreign_keys = ON;
1212 PRAGMA busy_timeout = {};
1213 PRAGMA synchronous = NORMAL;
1214 PRAGMA temp_store = MEMORY;
1215 PRAGMA wal_autocheckpoint = {};
1216 PRAGMA cache_size = -25600;
1217 PRAGMA mmap_size = 268435456;",
1218 journal_mode, pool.busy_timeout_ms, pool.wal_autocheckpoint,
1219 ))?;
1220
1221 if query_only {
1222 conn.execute_batch("PRAGMA query_only = ON;")?;
1223 }
1224
1225 let actual_journal_mode: String =
1226 conn.query_row("PRAGMA journal_mode", [], |row| row.get(0))?;
1227 let expected_journal_mode = if pool.enable_wal { "wal" } else { "delete" };
1228 if actual_journal_mode.to_lowercase() != expected_journal_mode {
1229 return Err(MemoryError::StorageError(format!(
1230 "SQLite journal mode mismatch for {}: requested {}, got {}",
1231 path.display(),
1232 expected_journal_mode,
1233 actual_journal_mode
1234 )));
1235 }
1236
1237 if max_db_size_bytes > 0 {
1238 let page_size: u64 = conn.query_row("PRAGMA page_size", [], |row| row.get(0))?;
1239 let max_page_count = max_db_size_bytes.div_ceil(page_size);
1240
1241 const MAX_SQLITE_PAGE_COUNT: u64 = 1_073_741_823; const MIN_SQLITE_PAGE_COUNT: u64 = 1;
1244 if !(MIN_SQLITE_PAGE_COUNT..=MAX_SQLITE_PAGE_COUNT).contains(&max_page_count) {
1245 return Err(MemoryError::StorageError(format!(
1246 "Invalid max_page_count {}: must be between {} and {}",
1247 max_page_count, MIN_SQLITE_PAGE_COUNT, MAX_SQLITE_PAGE_COUNT
1248 )));
1249 }
1250
1251 let actual_max_page_count: u64 = conn.query_row(
1252 &format!("PRAGMA max_page_count = {}", max_page_count),
1253 [],
1254 |row| row.get(0),
1255 )?;
1256 let page_count: u64 = conn.query_row("PRAGMA page_count", [], |row| row.get(0))?;
1257
1258 if page_count > actual_max_page_count {
1259 return Err(MemoryError::DatabaseSizeLimitExceeded {
1260 current: page_count.saturating_mul(page_size),
1261 limit: max_db_size_bytes,
1262 });
1263 }
1264 }
1265
1266 let foreign_keys_enabled: bool = conn.query_row("PRAGMA foreign_keys", [], |row| row.get(0))?;
1268 if !foreign_keys_enabled {
1269 return Err(MemoryError::StorageError(
1270 "PRAGMA foreign_keys failed to enable after configuration".to_string(),
1271 ));
1272 }
1273
1274 Ok(())
1275}
1276
1277pub fn run_migrations(conn: &Connection) -> Result<(), MemoryError> {
1279 let user_version: u32 = conn
1280 .query_row("PRAGMA user_version", [], |row| row.get(0))
1281 .map_err(|e| MemoryError::MigrationFailed {
1282 version: 0,
1283 reason: format!("failed to read PRAGMA user_version: {e}"),
1284 })?;
1285
1286 if user_version > MAX_SCHEMA_VERSION {
1287 return Err(MemoryError::SchemaAhead {
1288 found: user_version,
1289 supported: MAX_SCHEMA_VERSION,
1290 });
1291 }
1292
1293 conn.execute_batch(
1294 "CREATE TABLE IF NOT EXISTS _schema_version (
1295 version INTEGER PRIMARY KEY,
1296 applied_at TEXT NOT NULL DEFAULT (datetime('now'))
1297 );",
1298 )?;
1299
1300 for &(version, sql) in MIGRATIONS {
1301 let current_version: u32 = conn
1302 .query_row(
1303 "SELECT COALESCE(MAX(version), 0) FROM _schema_version",
1304 [],
1305 |row| row.get(0),
1306 )
1307 .unwrap_or(0);
1308
1309 if current_version >= version {
1310 continue;
1311 }
1312
1313 with_transaction(conn, |tx| {
1314 match version {
1315 9 => run_migration_v9(tx).map_err(|e| MemoryError::MigrationFailed {
1316 version,
1317 reason: e.to_string(),
1318 })?,
1319 16 => run_migration_v16(tx).map_err(|e| MemoryError::MigrationFailed {
1320 version,
1321 reason: e.to_string(),
1322 })?,
1323 17 => run_migration_v17(tx).map_err(|e| MemoryError::MigrationFailed {
1324 version,
1325 reason: e.to_string(),
1326 })?,
1327 20 => run_migration_v20(tx).map_err(|e| MemoryError::MigrationFailed {
1328 version,
1329 reason: e.to_string(),
1330 })?,
1331 21 => run_migration_v21(tx).map_err(|e| MemoryError::MigrationFailed {
1332 version,
1333 reason: e.to_string(),
1334 })?,
1335 26 => run_migration_v26(tx).map_err(|e| MemoryError::MigrationFailed {
1336 version,
1337 reason: e.to_string(),
1338 })?,
1339 28 => run_migration_v28(tx).map_err(|e| MemoryError::MigrationFailed {
1340 version,
1341 reason: e.to_string(),
1342 })?,
1343 _ => tx
1344 .execute_batch(sql)
1345 .map_err(|e| MemoryError::MigrationFailed {
1346 version,
1347 reason: e.to_string(),
1348 })?,
1349 }
1350 tx.execute(
1351 "INSERT INTO _schema_version (version) VALUES (?1)",
1352 params![version],
1353 )
1354 .map_err(|e| MemoryError::MigrationFailed {
1355 version,
1356 reason: e.to_string(),
1357 })?;
1358 Ok(())
1359 })?;
1360
1361 tracing::info!("Applied migration V{}", version);
1362 }
1363
1364 with_transaction(conn, |tx| {
1368 tx.execute_batch(MIGRATION_V29)
1369 .map_err(MemoryError::Database)
1370 })?;
1371 with_transaction(conn, |tx| {
1372 tx.execute_batch(MIGRATION_V30)
1373 .map_err(MemoryError::Database)
1374 })?;
1375 with_transaction(conn, |tx| {
1376 tx.execute_batch(MIGRATION_V31)
1377 .map_err(MemoryError::Database)
1378 })?;
1379
1380 let final_version: u32 = conn
1381 .query_row(
1382 "SELECT COALESCE(MAX(version), 0) FROM _schema_version",
1383 [],
1384 |row| row.get(0),
1385 )
1386 .unwrap_or(0);
1387 conn.execute_batch(&format!("PRAGMA user_version = {};", final_version))?;
1388
1389 Ok(())
1390}
1391
1392fn run_migration_v16(conn: &Connection) -> Result<(), rusqlite::Error> {
1393 add_column_if_missing(conn, "projection_import_log", "kernel_payload_json", "TEXT")?;
1394 add_column_if_missing(
1395 conn,
1396 "projection_import_failures",
1397 "kernel_payload_json",
1398 "TEXT",
1399 )?;
1400 Ok(())
1401}
1402
1403fn run_migration_v17(conn: &Connection) -> Result<(), rusqlite::Error> {
1404 add_column_if_missing(conn, "projection_import_log", "episode_bundle_id", "TEXT")?;
1405 add_column_if_missing(conn, "projection_import_log", "episode_bundle_json", "TEXT")?;
1406 add_column_if_missing(
1407 conn,
1408 "projection_import_log",
1409 "execution_context_json",
1410 "TEXT",
1411 )?;
1412 add_column_if_missing(
1413 conn,
1414 "projection_import_failures",
1415 "episode_bundle_id",
1416 "TEXT",
1417 )?;
1418 add_column_if_missing(
1419 conn,
1420 "projection_import_failures",
1421 "episode_bundle_json",
1422 "TEXT",
1423 )?;
1424 add_column_if_missing(
1425 conn,
1426 "projection_import_failures",
1427 "execution_context_json",
1428 "TEXT",
1429 )?;
1430 Ok(())
1431}
1432
1433fn run_migration_v20(conn: &Connection) -> Result<(), rusqlite::Error> {
1434 add_column_if_missing(conn, "derived_vector_artifacts", "encoded_digest", "TEXT")?;
1435 conn.execute(
1436 "UPDATE derived_vector_artifacts
1437 SET encoded_digest = artifact_digest
1438 WHERE encoded_digest IS NULL OR encoded_digest = ''",
1439 [],
1440 )?;
1441 add_column_if_missing(
1442 conn,
1443 "derived_vector_artifacts",
1444 "encoding",
1445 "TEXT NOT NULL DEFAULT 'turbo_code_wire_v1'",
1446 )?;
1447 add_column_if_missing(
1448 conn,
1449 "derived_vector_artifacts",
1450 "dim",
1451 "INTEGER NOT NULL DEFAULT 0",
1452 )?;
1453 add_column_if_missing(
1454 conn,
1455 "derived_vector_artifacts",
1456 "status",
1457 "TEXT NOT NULL DEFAULT 'active'",
1458 )?;
1459 conn.execute_batch(
1460 "CREATE INDEX IF NOT EXISTS idx_derived_vector_artifacts_profile
1461 ON derived_vector_artifacts(codec_family, codec_profile_digest, status);
1462 CREATE INDEX IF NOT EXISTS idx_derived_vector_artifacts_source_digest
1463 ON derived_vector_artifacts(source_embedding_digest);",
1464 )?;
1465 Ok(())
1466}
1467
1468fn run_migration_v21(conn: &Connection) -> Result<(), rusqlite::Error> {
1469 conn.execute_batch(MIGRATION_V21)?;
1470 add_column_if_missing(conn, "derived_vector_artifacts", "generation_id", "TEXT")?;
1471 conn.execute_batch(
1472 "CREATE INDEX IF NOT EXISTS idx_derived_vector_artifacts_generation
1473 ON derived_vector_artifacts(generation_id, status);",
1474 )?;
1475 Ok(())
1476}
1477
1478const SEARCH_RECEIPT_SCHEMA_VERSION: &str = "vector_search_receipt_v1";
1479
1480#[derive(Debug, Serialize, Deserialize)]
1481struct StoredVectorSearchReceiptV1 {
1482 #[serde(default = "default_search_receipt_schema_version")]
1483 schema_version: String,
1484 receipt_id: String,
1485 evaluation_time: DateTime<Utc>,
1486 #[serde(default)]
1487 receipt_digest: Option<String>,
1488 #[serde(default)]
1489 trace_id: Option<String>,
1490 #[serde(default)]
1491 attempt_family_id: Option<String>,
1492 #[serde(default)]
1493 attempt_id: Option<String>,
1494 #[serde(default)]
1495 replay_of: Option<String>,
1496 query_embedding_digest: Option<String>,
1497 #[serde(default)]
1498 query_text_digest: Option<String>,
1499 #[serde(default)]
1500 query_input_digest: Option<String>,
1501 #[serde(default)]
1502 filter_digest: Option<String>,
1503 #[serde(default)]
1504 redaction_state: Option<String>,
1505 #[serde(default)]
1506 budget_id: Option<String>,
1507 #[serde(default)]
1508 deadline_at: Option<DateTime<Utc>>,
1509 search_profile: String,
1510 candidate_backend: String,
1511 codec_family: Option<String>,
1512 codec_profile_digest: Option<String>,
1513 #[serde(default)]
1514 artifact_profile_digest: Option<String>,
1515 #[serde(default)]
1516 artifact_count: Option<u64>,
1517 #[serde(default)]
1518 artifact_corruption_count: Option<u64>,
1519 #[serde(default)]
1520 artifact_missing_count: Option<u64>,
1521 #[serde(default)]
1522 vector_artifact_manifest_digest: Option<String>,
1523 #[serde(default)]
1524 artifact_generation_id: Option<String>,
1525 #[serde(default)]
1526 approximate_scanned_count: Option<u64>,
1527 #[serde(default)]
1528 approximate_returned_count: Option<u64>,
1529 #[serde(default)]
1530 raw_rows_loaded_count: Option<u64>,
1531 #[serde(default)]
1532 filter_strategy: Option<String>,
1533 #[serde(default)]
1534 vector_artifact_count: Option<u64>,
1535 #[serde(default)]
1536 vector_artifact_missing_count: Option<u64>,
1537 #[serde(default)]
1538 vector_artifact_stale_count: Option<u64>,
1539 #[serde(default)]
1540 exact_rerank_count: Option<u64>,
1541 #[serde(default)]
1542 approximate_candidate_count: Option<u64>,
1543 #[serde(default)]
1544 fallback_reason: Option<String>,
1545 approximate: bool,
1546 requested_candidates: u64,
1547 returned_candidates: u64,
1548 post_filter_candidates: u64,
1549 #[serde(default)]
1550 sparse_enabled: bool,
1551 #[serde(default)]
1552 sparse_weight: Option<f64>,
1553 #[serde(default)]
1554 sparse_query_nonzero_count: Option<u64>,
1555 #[serde(default)]
1556 sparse_candidate_count: Option<u64>,
1557 #[serde(default)]
1558 sparse_representations: Vec<String>,
1559 #[serde(default)]
1560 sparse_result_ranks: Vec<SparseRankReceiptV1>,
1561 fallback: Option<String>,
1562 exact_rerank: bool,
1563 result_ids: Vec<String>,
1564 degradations: Vec<String>,
1565}
1566
1567fn default_search_receipt_schema_version() -> String {
1568 SEARCH_RECEIPT_SCHEMA_VERSION.to_string()
1569}
1570
1571fn b3_digest(bytes: &[u8]) -> String {
1572 format!("blake3:{}", ContentDigest::compute(bytes).hex())
1573}
1574
1575#[cfg(feature = "turbo-quant-codec")]
1577#[derive(Debug, Clone)]
1578pub(crate) struct DerivedVectorArtifactRow {
1579 pub item_key: String,
1580 pub generation_id: Option<String>,
1581 pub codec_family: String,
1582 pub codec_profile_digest: String,
1583 pub source_embedding_digest: String,
1584 pub encoded_digest: String,
1585 pub encoding: String,
1586 pub dim: usize,
1587 pub status: String,
1588 pub encoded: Vec<u8>,
1589 pub codec_governance_receipt_id: Option<String>,
1591 pub codec_profile: Option<String>,
1592 pub degradation_budget: Option<f64>,
1593 pub raw_source_artifact_id: Option<String>,
1594}
1595
1596#[cfg(feature = "turbo-quant-codec")]
1598#[derive(Debug, Clone)]
1599#[allow(dead_code)]
1600pub(crate) struct DerivedVectorArtifactGenerationRow {
1601 pub generation_id: String,
1602 pub codec_family: String,
1603 pub codec_profile_digest: String,
1604 pub source_snapshot_digest: String,
1605 pub source_row_count: usize,
1606 pub artifact_count: usize,
1607 pub dim: usize,
1608 pub encoding: String,
1609 pub artifact_manifest_digest: String,
1610 pub status: String,
1611}
1612
1613#[cfg(feature = "turbo-quant-codec")]
1615pub(crate) fn source_embedding_digest(
1616 blob: &[u8],
1617 expected_dim: usize,
1618) -> Result<String, MemoryError> {
1619 validate_vector_blob_len(blob, expected_dim)?;
1620 let mut builder = DigestBuilder::new();
1621 builder
1622 .update_str("semantic-memory.source_embedding.v1")
1623 .separator()
1624 .update(&(expected_dim as u64).to_le_bytes())
1625 .separator()
1626 .update(blob);
1627 Ok(format!("blake3:{}", builder.finalize().hex()))
1628}
1629
1630#[cfg(feature = "turbo-quant-codec")]
1631fn source_snapshot_digest(rows: &[DerivedVectorArtifactRow], dim: usize) -> String {
1632 let mut entries = rows
1633 .iter()
1634 .map(|row| (row.item_key.as_str(), row.source_embedding_digest.as_str()))
1635 .collect::<Vec<_>>();
1636 entries.sort_unstable();
1637
1638 let mut builder = DigestBuilder::new();
1639 builder
1640 .update_str("semantic-memory.vector_source_snapshot.v1")
1641 .separator()
1642 .update(&(dim as u64).to_le_bytes())
1643 .separator();
1644 for (item_key, source_embedding_digest) in entries {
1645 builder
1646 .update_str(item_key)
1647 .separator()
1648 .update_str(source_embedding_digest)
1649 .separator();
1650 }
1651 format!("blake3:{}", builder.finalize().hex())
1652}
1653
1654#[cfg(feature = "turbo-quant-codec")]
1655pub(crate) fn current_source_snapshot_digest(
1656 conn: &Connection,
1657 dim: usize,
1658) -> Result<(String, usize), MemoryError> {
1659 let mut stmt = conn.prepare(
1660 "SELECT 'fact:' || id AS item_key, embedding FROM facts WHERE embedding IS NOT NULL
1661 UNION ALL
1662 SELECT 'chunk:' || id AS item_key, embedding FROM chunks WHERE embedding IS NOT NULL
1663 UNION ALL
1664 SELECT 'msg:' || id AS item_key, embedding FROM messages WHERE embedding IS NOT NULL
1665 UNION ALL
1666 SELECT 'episode:' || episode_id AS item_key, embedding FROM episodes WHERE embedding IS NOT NULL",
1667 )?;
1668 let rows = stmt.query_map([], |row| {
1669 Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?))
1670 })?;
1671
1672 let mut entries = Vec::new();
1673 for row in rows {
1674 let (item_key, blob) = row?;
1675 entries.push((item_key, source_embedding_digest(&blob, dim)?));
1676 }
1677 entries.sort_unstable();
1678
1679 let mut builder = DigestBuilder::new();
1680 builder
1681 .update_str("semantic-memory.vector_source_snapshot.v1")
1682 .separator()
1683 .update(&(dim as u64).to_le_bytes())
1684 .separator();
1685 for (item_key, source_embedding_digest) in &entries {
1686 builder
1687 .update_str(item_key)
1688 .separator()
1689 .update_str(source_embedding_digest)
1690 .separator();
1691 }
1692 Ok((
1693 format!("blake3:{}", builder.finalize().hex()),
1694 entries.len(),
1695 ))
1696}
1697
1698#[cfg(feature = "turbo-quant-codec")]
1699fn derived_artifact_manifest_digest(rows: &[DerivedVectorArtifactRow]) -> String {
1700 let mut entries = rows
1701 .iter()
1702 .map(|row| {
1703 (
1704 row.item_key.as_str(),
1705 row.source_embedding_digest.as_str(),
1706 row.encoded_digest.as_str(),
1707 )
1708 })
1709 .collect::<Vec<_>>();
1710 entries.sort_unstable();
1711
1712 let mut builder = DigestBuilder::new();
1713 builder
1714 .update_str("semantic-memory.vector_artifact_manifest.v1")
1715 .separator();
1716 for (item_key, source_embedding_digest, encoded_digest) in entries {
1717 builder
1718 .update_str(item_key)
1719 .separator()
1720 .update_str(source_embedding_digest)
1721 .separator()
1722 .update_str(encoded_digest)
1723 .separator();
1724 }
1725 format!("blake3:{}", builder.finalize().hex())
1726}
1727
1728#[cfg(feature = "turbo-quant-codec")]
1729pub(crate) fn upsert_derived_vector_artifact(
1730 conn: &Connection,
1731 row: &DerivedVectorArtifactRow,
1732) -> Result<(), MemoryError> {
1733 conn.execute(
1734 "INSERT OR REPLACE INTO derived_vector_artifacts
1735 (item_key, generation_id, codec_family, codec_profile_digest, source_embedding_digest,
1736 encoded_digest, artifact_digest, encoding, dim, encoded, created_at, status,
1737 codec_governance_receipt_id, codec_profile, degradation_budget, raw_source_artifact_id)
1738 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6, ?7, ?8, ?9, datetime('now'), ?10, ?11, ?12, ?13, ?14)",
1739 params![
1740 row.item_key,
1741 row.generation_id.as_deref(),
1742 row.codec_family,
1743 row.codec_profile_digest,
1744 row.source_embedding_digest,
1745 row.encoded_digest,
1746 row.encoding,
1747 i64::try_from(row.dim)
1748 .map_err(|err| MemoryError::Other(format!("artifact dim overflow: {err}")))?,
1749 row.encoded,
1750 row.status,
1751 row.codec_governance_receipt_id.as_deref(),
1752 row.codec_profile.as_deref(),
1753 row.degradation_budget,
1754 row.raw_source_artifact_id.as_deref(),
1755 ],
1756 )?;
1757 Ok(())
1758}
1759
1760#[allow(dead_code)] pub fn delete_derived_vector_artifact(
1762 conn: &Connection,
1763 item_key: &str,
1764) -> Result<(), MemoryError> {
1765 conn.execute(
1766 "DELETE FROM derived_vector_artifacts WHERE item_key = ?1",
1767 params![item_key],
1768 )?;
1769 Ok(())
1770}
1771
1772pub fn invalidate_derived_vector_artifact(
1773 conn: &Connection,
1774 item_key: &str,
1775) -> Result<(), MemoryError> {
1776 conn.execute(
1777 "UPDATE derived_vector_artifacts
1778 SET status = 'invalidated'
1779 WHERE item_key = ?1 AND status = 'active'",
1780 params![item_key],
1781 )?;
1782 conn.execute(
1783 "UPDATE derived_vector_artifact_generations
1784 SET status = 'invalidated'
1785 WHERE status = 'active'",
1786 [],
1787 )?;
1788 Ok(())
1789}
1790
1791#[cfg(feature = "turbo-quant-codec")]
1792#[allow(dead_code)]
1793pub(crate) fn load_derived_vector_artifacts_by_profile(
1794 conn: &Connection,
1795 codec_family: &str,
1796 codec_profile_digest: &str,
1797) -> Result<Vec<DerivedVectorArtifactRow>, MemoryError> {
1798 let mut stmt = conn.prepare(
1799 "SELECT item_key, generation_id, codec_family, codec_profile_digest, source_embedding_digest,
1800 encoded_digest, encoding, dim, status, encoded,
1801 codec_governance_receipt_id, codec_profile, degradation_budget, raw_source_artifact_id
1802 FROM derived_vector_artifacts
1803 WHERE codec_family = ?1 AND codec_profile_digest = ?2 AND status = 'active'",
1804 )?;
1805 let rows = stmt.query_map(params![codec_family, codec_profile_digest], |row| {
1806 let dim_i64: i64 = row.get(7)?;
1807 Ok(DerivedVectorArtifactRow {
1808 item_key: row.get(0)?,
1809 generation_id: row.get(1)?,
1810 codec_family: row.get(2)?,
1811 codec_profile_digest: row.get(3)?,
1812 source_embedding_digest: row.get(4)?,
1813 encoded_digest: row.get(5)?,
1814 encoding: row.get(6)?,
1815 dim: usize::try_from(dim_i64).map_err(|err| {
1816 rusqlite::Error::FromSqlConversionFailure(
1817 7,
1818 rusqlite::types::Type::Integer,
1819 Box::new(err),
1820 )
1821 })?,
1822 status: row.get(8)?,
1823 encoded: row.get(9)?,
1824 codec_governance_receipt_id: row.get(10)?,
1825 codec_profile: row.get(11)?,
1826 degradation_budget: row.get(12)?,
1827 raw_source_artifact_id: row.get(13)?,
1828 })
1829 })?;
1830
1831 let mut artifacts = Vec::new();
1832 for row in rows {
1833 artifacts.push(row?);
1834 }
1835 Ok(artifacts)
1836}
1837
1838#[cfg(feature = "turbo-quant-codec")]
1839pub(crate) fn load_derived_vector_artifacts_by_generation(
1840 conn: &Connection,
1841 generation_id: &str,
1842) -> Result<Vec<DerivedVectorArtifactRow>, MemoryError> {
1843 let mut stmt = conn.prepare(
1844 "SELECT item_key, generation_id, codec_family, codec_profile_digest, source_embedding_digest,
1845 encoded_digest, encoding, dim, status, encoded,
1846 codec_governance_receipt_id, codec_profile, degradation_budget, raw_source_artifact_id
1847 FROM derived_vector_artifacts
1848 WHERE generation_id = ?1 AND status = 'active'",
1849 )?;
1850 let rows = stmt.query_map(params![generation_id], |row| {
1851 let dim_i64: i64 = row.get(7)?;
1852 Ok(DerivedVectorArtifactRow {
1853 item_key: row.get(0)?,
1854 generation_id: row.get(1)?,
1855 codec_family: row.get(2)?,
1856 codec_profile_digest: row.get(3)?,
1857 source_embedding_digest: row.get(4)?,
1858 encoded_digest: row.get(5)?,
1859 encoding: row.get(6)?,
1860 dim: usize::try_from(dim_i64).map_err(|err| {
1861 rusqlite::Error::FromSqlConversionFailure(
1862 7,
1863 rusqlite::types::Type::Integer,
1864 Box::new(err),
1865 )
1866 })?,
1867 status: row.get(8)?,
1868 encoded: row.get(9)?,
1869 codec_governance_receipt_id: row.get(10)?,
1870 codec_profile: row.get(11)?,
1871 degradation_budget: row.get(12)?,
1872 raw_source_artifact_id: row.get(13)?,
1873 })
1874 })?;
1875
1876 let mut artifacts = Vec::new();
1877 for row in rows {
1878 artifacts.push(row?);
1879 }
1880 Ok(artifacts)
1881}
1882
1883#[cfg(feature = "turbo-quant-codec")]
1884pub(crate) fn current_derived_vector_generation(
1885 conn: &Connection,
1886 codec_family: &str,
1887 codec_profile_digest: &str,
1888) -> Result<Option<DerivedVectorArtifactGenerationRow>, MemoryError> {
1889 conn.query_row(
1890 "SELECT generation_id, codec_family, codec_profile_digest, source_snapshot_digest,
1891 source_row_count, artifact_count, dim, encoding, artifact_manifest_digest, status
1892 FROM derived_vector_artifact_generations
1893 WHERE codec_family = ?1 AND codec_profile_digest = ?2 AND status = 'active'
1894 ORDER BY created_at DESC
1895 LIMIT 1",
1896 params![codec_family, codec_profile_digest],
1897 |row| {
1898 let source_row_count: i64 = row.get(4)?;
1899 let artifact_count: i64 = row.get(5)?;
1900 let dim: i64 = row.get(6)?;
1901 Ok(DerivedVectorArtifactGenerationRow {
1902 generation_id: row.get(0)?,
1903 codec_family: row.get(1)?,
1904 codec_profile_digest: row.get(2)?,
1905 source_snapshot_digest: row.get(3)?,
1906 source_row_count: usize::try_from(source_row_count).map_err(|err| {
1907 rusqlite::Error::FromSqlConversionFailure(
1908 4,
1909 rusqlite::types::Type::Integer,
1910 Box::new(err),
1911 )
1912 })?,
1913 artifact_count: usize::try_from(artifact_count).map_err(|err| {
1914 rusqlite::Error::FromSqlConversionFailure(
1915 5,
1916 rusqlite::types::Type::Integer,
1917 Box::new(err),
1918 )
1919 })?,
1920 dim: usize::try_from(dim).map_err(|err| {
1921 rusqlite::Error::FromSqlConversionFailure(
1922 6,
1923 rusqlite::types::Type::Integer,
1924 Box::new(err),
1925 )
1926 })?,
1927 encoding: row.get(7)?,
1928 artifact_manifest_digest: row.get(8)?,
1929 status: row.get(9)?,
1930 })
1931 },
1932 )
1933 .optional()
1934 .map_err(MemoryError::from)
1935}
1936
1937#[allow(dead_code)] pub fn count_derived_vector_artifacts(
1939 conn: &Connection,
1940 codec_family: &str,
1941 codec_profile_digest: &str,
1942) -> Result<usize, MemoryError> {
1943 let count: i64 = conn.query_row(
1944 "SELECT COUNT(*) FROM derived_vector_artifacts
1945 WHERE codec_family = ?1 AND codec_profile_digest = ?2 AND status = 'active'",
1946 params![codec_family, codec_profile_digest],
1947 |row| row.get(0),
1948 )?;
1949 usize::try_from(count)
1950 .map_err(|err| MemoryError::Other(format!("derived artifact count overflow: {err}")))
1951}
1952
1953#[cfg(feature = "turbo-quant-codec")]
1954pub(crate) fn rebuild_turbo_quant_artifacts(
1955 conn: &Connection,
1956 dim: usize,
1957 bits: u8,
1958 projections: usize,
1959 seed: u64,
1960) -> Result<VectorArtifactBuildReceiptV1, MemoryError> {
1961 use crate::vector_codec::{TurboQuantCodec, VectorCodec};
1962
1963 let started = std::time::Instant::now();
1964 let codec = TurboQuantCodec::new(dim, bits, projections, seed)?;
1965 let codec_profile_digest = codec.profile().digest();
1966 let generation_id = uuid::Uuid::new_v4().to_string();
1967 let mut source_row_count = 0usize;
1968 let mut artifact_count = 0usize;
1969 let mut skipped_row_count = 0usize;
1970 let mut degradations = Vec::new();
1971
1972 let mut stmt = conn.prepare(
1973 "SELECT 'fact:' || id AS item_key, embedding FROM facts WHERE embedding IS NOT NULL
1974 UNION ALL
1975 SELECT 'chunk:' || id AS item_key, embedding FROM chunks WHERE embedding IS NOT NULL
1976 UNION ALL
1977 SELECT 'msg:' || id AS item_key, embedding FROM messages WHERE embedding IS NOT NULL
1978 UNION ALL
1979 SELECT 'episode:' || episode_id AS item_key, embedding FROM episodes WHERE embedding IS NOT NULL",
1980 )?;
1981 let rows = stmt.query_map([], |row| {
1982 Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?))
1983 })?;
1984
1985 let mut pending = Vec::new();
1986 for row in rows {
1987 let (item_key, blob) = row?;
1988 source_row_count += 1;
1989 let embedding = match decode_f32_le(&blob, dim) {
1990 Ok(embedding) => embedding,
1991 Err(err) => {
1992 skipped_row_count += 1;
1993 degradations.push(format!(
1994 "skipped {item_key}: invalid authoritative embedding: {err}"
1995 ));
1996 continue;
1997 }
1998 };
1999 let artifact = match codec.encode(&embedding) {
2000 Ok(artifact) => artifact,
2001 Err(err) => {
2002 skipped_row_count += 1;
2003 degradations.push(format!("skipped {item_key}: encode failed: {err}"));
2004 continue;
2005 }
2006 };
2007 pending.push(DerivedVectorArtifactRow {
2008 item_key,
2009 generation_id: Some(generation_id.clone()),
2010 codec_family: "turbo_quant".to_string(),
2011 codec_profile_digest: codec_profile_digest.clone(),
2012 source_embedding_digest: source_embedding_digest(&blob, dim)?,
2013 encoded_digest: artifact.artifact_digest,
2014 encoding: "turbo_code_wire_v1".to_string(),
2015 dim,
2016 status: "active".to_string(),
2017 encoded: artifact.encoded,
2018 codec_governance_receipt_id: None,
2021 codec_profile: None,
2022 degradation_budget: None,
2023 raw_source_artifact_id: None,
2024 });
2025 }
2026 drop(stmt);
2027
2028 let build_receipt_id = uuid::Uuid::new_v4().to_string();
2029 let source_snapshot_digest = source_snapshot_digest(&pending, dim);
2030 let artifact_manifest_digest = derived_artifact_manifest_digest(&pending);
2031 let source_tables = vec![
2032 "facts".to_string(),
2033 "chunks".to_string(),
2034 "messages".to_string(),
2035 "episodes".to_string(),
2036 ];
2037 let generation_manifest = DerivedVectorArtifactGenerationV1 {
2038 schema_version: "derived_vector_artifact_generation_v1".to_string(),
2039 generation_id: generation_id.clone(),
2040 codec_family: "turbo_quant".to_string(),
2041 codec_profile_digest: codec_profile_digest.clone(),
2042 source_snapshot_digest: source_snapshot_digest.clone(),
2043 source_row_count,
2044 artifact_count: pending.len(),
2045 source_tables,
2046 dim,
2047 encoding: "turbo_code_wire_v1".to_string(),
2048 created_at: Utc::now(),
2049 build_receipt_id: Some(build_receipt_id.clone()),
2050 artifact_manifest_digest: artifact_manifest_digest.clone(),
2051 status: if skipped_row_count == 0 {
2052 "active".to_string()
2053 } else {
2054 "failed".to_string()
2055 },
2056 degradations: degradations.clone(),
2057 };
2058
2059 with_transaction(conn, |tx| {
2060 tx.execute(
2061 "UPDATE derived_vector_artifact_generations
2062 SET status = 'superseded'
2063 WHERE codec_family = ?1 AND codec_profile_digest = ?2 AND status = 'active'",
2064 params!["turbo_quant", &codec_profile_digest],
2065 )?;
2066 tx.execute(
2067 "DELETE FROM derived_vector_artifacts
2068 WHERE codec_family = ?1 AND codec_profile_digest = ?2",
2069 params!["turbo_quant", &codec_profile_digest],
2070 )?;
2071 tx.execute(
2072 "INSERT INTO derived_vector_artifact_generations
2073 (generation_id, schema_version, codec_family, codec_profile_digest,
2074 source_snapshot_digest, source_row_count, artifact_count, source_tables_json,
2075 dim, encoding, created_at, build_receipt_id, artifact_manifest_digest,
2076 status, degradations_json)
2077 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
2078 params![
2079 generation_manifest.generation_id,
2080 generation_manifest.schema_version,
2081 generation_manifest.codec_family,
2082 generation_manifest.codec_profile_digest,
2083 generation_manifest.source_snapshot_digest,
2084 i64::try_from(generation_manifest.source_row_count).map_err(|err| {
2085 MemoryError::Other(format!("source row count overflow: {err}"))
2086 })?,
2087 i64::try_from(generation_manifest.artifact_count).map_err(|err| {
2088 MemoryError::Other(format!("artifact count overflow: {err}"))
2089 })?,
2090 serde_json::to_string(&generation_manifest.source_tables)
2091 .map_err(|err| MemoryError::Other(err.to_string()))?,
2092 i64::try_from(generation_manifest.dim)
2093 .map_err(|err| MemoryError::Other(format!("artifact dim overflow: {err}")))?,
2094 generation_manifest.encoding,
2095 generation_manifest.created_at.to_rfc3339(),
2096 generation_manifest.build_receipt_id,
2097 generation_manifest.artifact_manifest_digest,
2098 generation_manifest.status,
2099 serde_json::to_string(&generation_manifest.degradations)
2100 .map_err(|err| MemoryError::Other(err.to_string()))?,
2101 ],
2102 )?;
2103 for row in &pending {
2104 upsert_derived_vector_artifact(tx, row)?;
2105 artifact_count += 1;
2106 }
2107 Ok(())
2108 })?;
2109
2110 Ok(VectorArtifactBuildReceiptV1 {
2111 schema_version: "vector_artifact_build_receipt_v1".to_string(),
2112 codec_family: "turbo_quant".to_string(),
2113 codec_profile_digest,
2114 source_row_count,
2115 artifact_count,
2116 generation_id: Some(generation_id),
2117 source_snapshot_digest: Some(source_snapshot_digest),
2118 artifact_manifest_digest: Some(artifact_manifest_digest),
2119 build_receipt_id: Some(build_receipt_id),
2120 skipped_row_count,
2121 elapsed_ms: started.elapsed().as_millis(),
2122 created_at: Utc::now(),
2123 degradations,
2124 })
2125}
2126
2127fn receipt_count_to_u64(value: usize, field: &'static str) -> Result<u64, MemoryError> {
2128 u64::try_from(value).map_err(|err| MemoryError::Other(format!("{field} is too large: {err}")))
2129}
2130
2131fn receipt_count_to_i64(value: u64, field: &'static str) -> Result<i64, MemoryError> {
2132 i64::try_from(value).map_err(|err| MemoryError::Other(format!("{field} is too large: {err}")))
2133}
2134
2135fn receipt_count_to_usize(
2136 value: u64,
2137 receipt_id: &str,
2138 field: &'static str,
2139) -> Result<usize, MemoryError> {
2140 usize::try_from(value).map_err(|err| MemoryError::CorruptData {
2141 table: "search_receipts",
2142 row_id: receipt_id.to_string(),
2143 detail: format!("{field} does not fit this platform: {err}"),
2144 })
2145}
2146
2147fn stored_search_receipt(
2148 receipt: &VectorSearchReceiptV1,
2149) -> Result<StoredVectorSearchReceiptV1, MemoryError> {
2150 Ok(StoredVectorSearchReceiptV1 {
2151 schema_version: SEARCH_RECEIPT_SCHEMA_VERSION.to_string(),
2152 receipt_id: receipt.receipt_id.clone(),
2153 evaluation_time: receipt.evaluation_time,
2154 receipt_digest: receipt.receipt_digest.clone(),
2155 trace_id: receipt.trace_id.clone(),
2156 attempt_family_id: receipt.attempt_family_id.clone(),
2157 attempt_id: receipt.attempt_id.clone(),
2158 replay_of: receipt.replay_of.clone(),
2159 query_embedding_digest: receipt.query_embedding_digest.clone(),
2160 query_text_digest: receipt.query_text_digest.clone(),
2161 query_input_digest: receipt.query_input_digest.clone(),
2162 filter_digest: receipt.filter_digest.clone(),
2163 redaction_state: receipt.redaction_state.clone(),
2164 budget_id: receipt.budget_id.clone(),
2165 deadline_at: receipt.deadline_at,
2166 search_profile: receipt.search_profile.clone(),
2167 candidate_backend: receipt.candidate_backend.clone(),
2168 codec_family: receipt.codec_family.clone(),
2169 codec_profile_digest: receipt.codec_profile_digest.clone(),
2170 artifact_profile_digest: receipt.artifact_profile_digest.clone(),
2171 artifact_count: receipt
2172 .artifact_count
2173 .map(|value| receipt_count_to_u64(value, "artifact_count"))
2174 .transpose()?,
2175 artifact_corruption_count: receipt
2176 .artifact_corruption_count
2177 .map(|value| receipt_count_to_u64(value, "artifact_corruption_count"))
2178 .transpose()?,
2179 artifact_missing_count: receipt
2180 .artifact_missing_count
2181 .map(|value| receipt_count_to_u64(value, "artifact_missing_count"))
2182 .transpose()?,
2183 vector_artifact_manifest_digest: receipt.vector_artifact_manifest_digest.clone(),
2184 artifact_generation_id: receipt.artifact_generation_id.clone(),
2185 approximate_scanned_count: receipt
2186 .approximate_scanned_count
2187 .map(|value| receipt_count_to_u64(value, "approximate_scanned_count"))
2188 .transpose()?,
2189 approximate_returned_count: receipt
2190 .approximate_returned_count
2191 .map(|value| receipt_count_to_u64(value, "approximate_returned_count"))
2192 .transpose()?,
2193 raw_rows_loaded_count: receipt
2194 .raw_rows_loaded_count
2195 .map(|value| receipt_count_to_u64(value, "raw_rows_loaded_count"))
2196 .transpose()?,
2197 filter_strategy: receipt.filter_strategy.clone(),
2198 vector_artifact_count: receipt
2199 .vector_artifact_count
2200 .map(|value| receipt_count_to_u64(value, "vector_artifact_count"))
2201 .transpose()?,
2202 vector_artifact_missing_count: receipt
2203 .vector_artifact_missing_count
2204 .map(|value| receipt_count_to_u64(value, "vector_artifact_missing_count"))
2205 .transpose()?,
2206 vector_artifact_stale_count: receipt
2207 .vector_artifact_stale_count
2208 .map(|value| receipt_count_to_u64(value, "vector_artifact_stale_count"))
2209 .transpose()?,
2210 exact_rerank_count: receipt
2211 .exact_rerank_count
2212 .map(|value| receipt_count_to_u64(value, "exact_rerank_count"))
2213 .transpose()?,
2214 approximate_candidate_count: receipt
2215 .approximate_candidate_count
2216 .map(|value| receipt_count_to_u64(value, "approximate_candidate_count"))
2217 .transpose()?,
2218 fallback_reason: receipt.fallback_reason.clone(),
2219 approximate: receipt.approximate,
2220 requested_candidates: receipt_count_to_u64(
2221 receipt.requested_candidates,
2222 "requested_candidates",
2223 )?,
2224 returned_candidates: receipt_count_to_u64(
2225 receipt.returned_candidates,
2226 "returned_candidates",
2227 )?,
2228 post_filter_candidates: receipt_count_to_u64(
2229 receipt.post_filter_candidates,
2230 "post_filter_candidates",
2231 )?,
2232 sparse_enabled: receipt.sparse_enabled,
2233 sparse_weight: receipt.sparse_weight,
2234 sparse_query_nonzero_count: receipt
2235 .sparse_query_nonzero_count
2236 .map(|value| receipt_count_to_u64(value, "sparse_query_nonzero_count"))
2237 .transpose()?,
2238 sparse_candidate_count: receipt
2239 .sparse_candidate_count
2240 .map(|value| receipt_count_to_u64(value, "sparse_candidate_count"))
2241 .transpose()?,
2242 sparse_representations: receipt.sparse_representations.clone(),
2243 sparse_result_ranks: receipt.sparse_result_ranks.clone(),
2244 fallback: receipt.fallback.clone(),
2245 exact_rerank: receipt.exact_rerank,
2246 result_ids: receipt.result_ids.clone(),
2247 degradations: receipt.degradations.clone(),
2248 })
2249}
2250
2251fn search_receipt_from_stored(
2252 stored: StoredVectorSearchReceiptV1,
2253) -> Result<VectorSearchReceiptV1, MemoryError> {
2254 if stored.schema_version != SEARCH_RECEIPT_SCHEMA_VERSION {
2255 return Err(MemoryError::CorruptData {
2256 table: "search_receipts",
2257 row_id: stored.receipt_id,
2258 detail: format!(
2259 "unsupported receipt schema version '{}'",
2260 stored.schema_version
2261 ),
2262 });
2263 }
2264
2265 Ok(VectorSearchReceiptV1 {
2266 schema_version: stored.schema_version.clone(),
2267 receipt_digest: stored.receipt_digest,
2268 receipt_id: stored.receipt_id.clone(),
2269 evaluation_time: stored.evaluation_time,
2270 trace_id: stored.trace_id,
2271 attempt_family_id: stored.attempt_family_id,
2272 attempt_id: stored.attempt_id,
2273 replay_of: stored.replay_of,
2274 query_embedding_digest: stored.query_embedding_digest,
2275 query_text_digest: stored.query_text_digest,
2276 query_input_digest: stored.query_input_digest,
2277 filter_digest: stored.filter_digest,
2278 redaction_state: stored.redaction_state,
2279 budget_id: stored.budget_id,
2280 deadline_at: stored.deadline_at,
2281 search_profile: stored.search_profile,
2282 candidate_backend: stored.candidate_backend,
2283 codec_family: stored.codec_family,
2284 codec_profile_digest: stored.codec_profile_digest,
2285 artifact_profile_digest: stored.artifact_profile_digest,
2286 artifact_count: stored
2287 .artifact_count
2288 .map(|value| receipt_count_to_usize(value, &stored.receipt_id, "artifact_count"))
2289 .transpose()?,
2290 artifact_corruption_count: stored
2291 .artifact_corruption_count
2292 .map(|value| {
2293 receipt_count_to_usize(value, &stored.receipt_id, "artifact_corruption_count")
2294 })
2295 .transpose()?,
2296 artifact_missing_count: stored
2297 .artifact_missing_count
2298 .map(|value| {
2299 receipt_count_to_usize(value, &stored.receipt_id, "artifact_missing_count")
2300 })
2301 .transpose()?,
2302 vector_artifact_manifest_digest: stored.vector_artifact_manifest_digest,
2303 artifact_generation_id: stored.artifact_generation_id,
2304 approximate_scanned_count: stored
2305 .approximate_scanned_count
2306 .map(|value| {
2307 receipt_count_to_usize(value, &stored.receipt_id, "approximate_scanned_count")
2308 })
2309 .transpose()?,
2310 approximate_returned_count: stored
2311 .approximate_returned_count
2312 .map(|value| {
2313 receipt_count_to_usize(value, &stored.receipt_id, "approximate_returned_count")
2314 })
2315 .transpose()?,
2316 raw_rows_loaded_count: stored
2317 .raw_rows_loaded_count
2318 .map(|value| receipt_count_to_usize(value, &stored.receipt_id, "raw_rows_loaded_count"))
2319 .transpose()?,
2320 filter_strategy: stored.filter_strategy,
2321 vector_artifact_count: stored
2322 .vector_artifact_count
2323 .map(|value| receipt_count_to_usize(value, &stored.receipt_id, "vector_artifact_count"))
2324 .transpose()?,
2325 vector_artifact_missing_count: stored
2326 .vector_artifact_missing_count
2327 .map(|value| {
2328 receipt_count_to_usize(value, &stored.receipt_id, "vector_artifact_missing_count")
2329 })
2330 .transpose()?,
2331 vector_artifact_stale_count: stored
2332 .vector_artifact_stale_count
2333 .map(|value| {
2334 receipt_count_to_usize(value, &stored.receipt_id, "vector_artifact_stale_count")
2335 })
2336 .transpose()?,
2337 exact_rerank_count: stored
2338 .exact_rerank_count
2339 .map(|value| receipt_count_to_usize(value, &stored.receipt_id, "exact_rerank_count"))
2340 .transpose()?,
2341 approximate_candidate_count: stored
2342 .approximate_candidate_count
2343 .map(|value| {
2344 receipt_count_to_usize(value, &stored.receipt_id, "approximate_candidate_count")
2345 })
2346 .transpose()?,
2347 fallback_reason: stored.fallback_reason,
2348 derived_candidate: None,
2349 approximate: stored.approximate,
2350 requested_candidates: receipt_count_to_usize(
2351 stored.requested_candidates,
2352 &stored.receipt_id,
2353 "requested_candidates",
2354 )?,
2355 returned_candidates: receipt_count_to_usize(
2356 stored.returned_candidates,
2357 &stored.receipt_id,
2358 "returned_candidates",
2359 )?,
2360 post_filter_candidates: receipt_count_to_usize(
2361 stored.post_filter_candidates,
2362 &stored.receipt_id,
2363 "post_filter_candidates",
2364 )?,
2365 sparse_enabled: stored.sparse_enabled,
2366 sparse_weight: stored.sparse_weight,
2367 sparse_query_nonzero_count: stored
2368 .sparse_query_nonzero_count
2369 .map(|value| {
2370 receipt_count_to_usize(value, &stored.receipt_id, "sparse_query_nonzero_count")
2371 })
2372 .transpose()?,
2373 sparse_candidate_count: stored
2374 .sparse_candidate_count
2375 .map(|value| {
2376 receipt_count_to_usize(value, &stored.receipt_id, "sparse_candidate_count")
2377 })
2378 .transpose()?,
2379 sparse_representations: stored.sparse_representations,
2380 sparse_result_ranks: stored.sparse_result_ranks,
2381 fallback: stored.fallback,
2382 exact_rerank: stored.exact_rerank,
2383 result_ids: stored.result_ids,
2384 degradations: stored.degradations,
2385 })
2386}
2387
2388pub fn store_search_receipt(
2393 conn: &Connection,
2394 receipt: &VectorSearchReceiptV1,
2395) -> Result<(), MemoryError> {
2396 let stored = stored_search_receipt(receipt)?;
2397 let receipt_json = serde_json::to_string(&stored)
2398 .map_err(|err| MemoryError::Other(format!("failed to serialize search receipt: {err}")))?;
2399 let receipt_digest = b3_digest(receipt_json.as_bytes());
2400
2401 let existing_digest: Option<String> = conn
2402 .query_row(
2403 "SELECT receipt_digest FROM search_receipts WHERE receipt_id = ?1",
2404 params![&stored.receipt_id],
2405 |row| row.get(0),
2406 )
2407 .optional()?;
2408 if let Some(existing_digest) = existing_digest {
2409 if existing_digest == receipt_digest {
2410 return Ok(());
2411 }
2412 return Err(MemoryError::SearchReceiptConflict {
2413 receipt_id: stored.receipt_id,
2414 });
2415 }
2416
2417 let result_ids_json = serde_json::to_string(&stored.result_ids).map_err(|err| {
2418 MemoryError::Other(format!(
2419 "failed to serialize search receipt result IDs: {err}"
2420 ))
2421 })?;
2422 conn.execute(
2423 "INSERT INTO search_receipts (
2424 receipt_id,
2425 schema_version,
2426 evaluation_time,
2427 search_profile,
2428 candidate_backend,
2429 approximate,
2430 exact_rerank,
2431 fallback,
2432 requested_candidates,
2433 returned_candidates,
2434 post_filter_candidates,
2435 result_ids_json,
2436 receipt_json,
2437 receipt_digest
2438 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
2439 params![
2440 &stored.receipt_id,
2441 SEARCH_RECEIPT_SCHEMA_VERSION,
2442 stored.evaluation_time.to_rfc3339(),
2443 &stored.search_profile,
2444 &stored.candidate_backend,
2445 if stored.approximate { 1_i64 } else { 0_i64 },
2446 if stored.exact_rerank { 1_i64 } else { 0_i64 },
2447 &stored.fallback,
2448 receipt_count_to_i64(stored.requested_candidates, "requested_candidates")?,
2449 receipt_count_to_i64(stored.returned_candidates, "returned_candidates")?,
2450 receipt_count_to_i64(stored.post_filter_candidates, "post_filter_candidates")?,
2451 &result_ids_json,
2452 &receipt_json,
2453 &receipt_digest,
2454 ],
2455 )?;
2456 Ok(())
2457}
2458
2459pub fn get_search_receipt(
2461 conn: &Connection,
2462 receipt_id: &str,
2463) -> Result<Option<VectorSearchReceiptV1>, MemoryError> {
2464 let row: Option<(String, String, String)> = conn
2465 .query_row(
2466 "SELECT schema_version, receipt_json, receipt_digest
2467 FROM search_receipts
2468 WHERE receipt_id = ?1",
2469 params![receipt_id],
2470 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
2471 )
2472 .optional()?;
2473
2474 let Some((schema_version, receipt_json, receipt_digest)) = row else {
2475 return Ok(None);
2476 };
2477 if schema_version != SEARCH_RECEIPT_SCHEMA_VERSION {
2478 return Err(MemoryError::CorruptData {
2479 table: "search_receipts",
2480 row_id: receipt_id.to_string(),
2481 detail: format!("unsupported receipt schema version '{schema_version}'"),
2482 });
2483 }
2484
2485 let stored: StoredVectorSearchReceiptV1 =
2486 serde_json::from_str(&receipt_json).map_err(|err| MemoryError::CorruptData {
2487 table: "search_receipts",
2488 row_id: receipt_id.to_string(),
2489 detail: format!("invalid receipt JSON: {err}"),
2490 })?;
2491 let mut receipt = search_receipt_from_stored(stored)?;
2492 receipt.receipt_digest = Some(receipt_digest);
2493 Ok(Some(receipt))
2494}
2495
2496pub(crate) struct ReplayInputs {
2498 pub query_text: String,
2499 pub namespaces: Option<Vec<String>>,
2500 pub source_types: Option<Vec<SearchSourceType>>,
2501}
2502
2503pub(crate) fn store_replay_inputs(
2505 conn: &Connection,
2506 receipt_id: &str,
2507 query_text: &str,
2508 namespaces: Option<&[&str]>,
2509 source_types: Option<&[SearchSourceType]>,
2510) -> Result<(), MemoryError> {
2511 let namespaces_json = namespaces
2512 .map(serde_json::to_string)
2513 .transpose()
2514 .map_err(|error| {
2515 MemoryError::Other(format!("failed to serialize replay namespaces: {error}"))
2516 })?;
2517 let source_types_json = source_types
2518 .map(serde_json::to_string)
2519 .transpose()
2520 .map_err(|error| {
2521 MemoryError::Other(format!("failed to serialize replay source types: {error}"))
2522 })?;
2523
2524 let existing: Option<(String, Option<String>, Option<String>)> = conn
2525 .query_row(
2526 "SELECT query_text, namespaces_json, source_types_json
2527 FROM replay_inputs WHERE receipt_id = ?1",
2528 params![receipt_id],
2529 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
2530 )
2531 .optional()?;
2532 if let Some(existing) = existing {
2533 if existing.0 == query_text
2534 && existing.1.as_ref() == namespaces_json.as_ref()
2535 && existing.2.as_ref() == source_types_json.as_ref()
2536 {
2537 return Ok(());
2538 }
2539 return Err(MemoryError::SearchReceiptConflict {
2540 receipt_id: receipt_id.to_string(),
2541 });
2542 }
2543
2544 conn.execute(
2545 "INSERT INTO replay_inputs
2546 (receipt_id, query_text, namespaces_json, source_types_json)
2547 VALUES (?1, ?2, ?3, ?4)",
2548 params![receipt_id, query_text, namespaces_json, source_types_json],
2549 )?;
2550 Ok(())
2551}
2552
2553pub(crate) fn get_replay_inputs(
2555 conn: &Connection,
2556 receipt_id: &str,
2557) -> Result<Option<ReplayInputs>, MemoryError> {
2558 let row: Option<(String, Option<String>, Option<String>)> = conn
2559 .query_row(
2560 "SELECT query_text, namespaces_json, source_types_json
2561 FROM replay_inputs WHERE receipt_id = ?1",
2562 params![receipt_id],
2563 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
2564 )
2565 .optional()?;
2566 let Some((query_text, namespaces_json, source_types_json)) = row else {
2567 return Ok(None);
2568 };
2569 let namespaces = namespaces_json
2570 .map(|json| serde_json::from_str(&json))
2571 .transpose()
2572 .map_err(|error| MemoryError::CorruptData {
2573 table: "replay_inputs",
2574 row_id: receipt_id.to_string(),
2575 detail: format!("invalid namespaces JSON: {error}"),
2576 })?;
2577 let source_types = source_types_json
2578 .map(|json| serde_json::from_str(&json))
2579 .transpose()
2580 .map_err(|error| MemoryError::CorruptData {
2581 table: "replay_inputs",
2582 row_id: receipt_id.to_string(),
2583 detail: format!("invalid source types JSON: {error}"),
2584 })?;
2585 Ok(Some(ReplayInputs {
2586 query_text,
2587 namespaces,
2588 source_types,
2589 }))
2590}
2591
2592fn add_column_if_missing(
2593 conn: &Connection,
2594 table: &str,
2595 column: &str,
2596 column_sql: &str,
2597) -> Result<(), rusqlite::Error> {
2598 let pragma = format!("PRAGMA table_info({table})");
2599 let mut stmt = conn.prepare(&pragma)?;
2600 let exists = stmt
2601 .query_map([], |row| row.get::<_, String>(1))?
2602 .collect::<Result<Vec<_>, _>>()?
2603 .into_iter()
2604 .any(|name| name == column);
2605
2606 if !exists {
2607 conn.execute(
2608 &format!("ALTER TABLE {table} ADD COLUMN {column} {column_sql}"),
2609 [],
2610 )?;
2611 }
2612
2613 Ok(())
2614}
2615
2616pub fn check_embedding_metadata(
2618 conn: &Connection,
2619 config: &EmbeddingConfig,
2620) -> Result<(), MemoryError> {
2621 let existing: Option<(String, usize)> = conn
2623 .query_row(
2624 "SELECT model_name, dimensions FROM embedding_metadata WHERE id = 1",
2625 [],
2626 |row| Ok((row.get(0)?, row.get(1)?)),
2627 )
2628 .ok();
2629
2630 match existing {
2631 Some((model, dims)) => {
2632 if model != config.model || dims != config.dimensions {
2633 tracing::warn!(
2634 stored_model = %model,
2635 stored_dims = dims,
2636 configured_model = %config.model,
2637 configured_dims = config.dimensions,
2638 "Embedding model changed. Existing embeddings are stale."
2639 );
2640 conn.execute(
2641 "UPDATE embedding_metadata
2642 SET model_name = ?1,
2643 dimensions = ?2,
2644 embeddings_dirty = 1,
2645 updated_at = datetime('now')
2646 WHERE id = 1",
2647 params![config.model, config.dimensions],
2648 )?;
2649 }
2650 }
2651 None => {
2652 conn.execute(
2653 "INSERT INTO embedding_metadata (id, model_name, dimensions) VALUES (1, ?1, ?2)",
2654 params![config.model, config.dimensions],
2655 )?;
2656 }
2657 }
2658
2659 Ok(())
2660}
2661
2662pub fn embedding_to_bytes(embedding: &[f32]) -> Vec<u8> {
2664 encode_f32_le(embedding)
2665}
2666
2667#[derive(Debug, Clone)]
2669pub(crate) struct SparseVectorRow {
2670 pub item_key: String,
2671 pub weights: SparseWeights,
2672 pub representation: String,
2673}
2674
2675pub(crate) fn store_sparse_vector(
2677 conn: &Connection,
2678 item_key: &str,
2679 weights: &SparseWeights,
2680 representation: &str,
2681) -> Result<(), MemoryError> {
2682 if item_key.split_once(':').is_none() || representation.trim().is_empty() {
2683 return Err(MemoryError::InvalidConfig {
2684 field: "sparse_vector",
2685 reason: "item_key must be canonical and representation must not be empty".to_string(),
2686 });
2687 }
2688 if weights
2689 .entries
2690 .iter()
2691 .any(|(_, weight)| !weight.is_finite())
2692 {
2693 return Err(MemoryError::InvalidConfig {
2694 field: "sparse_vector.entries",
2695 reason: "sparse weights must be finite".to_string(),
2696 });
2697 }
2698 let entries_json = serde_json::to_string(&weights.entries)
2699 .map_err(|error| MemoryError::Other(format!("serialize sparse weights: {error}")))?;
2700 conn.execute(
2701 "INSERT INTO sparse_vectors (item_key, entries_json, representation, updated_at)
2702 VALUES (?1, ?2, ?3, datetime('now'))
2703 ON CONFLICT(item_key) DO UPDATE SET
2704 entries_json = excluded.entries_json,
2705 representation = excluded.representation,
2706 updated_at = excluded.updated_at",
2707 params![item_key, entries_json, representation],
2708 )?;
2709 Ok(())
2710}
2711
2712pub(crate) fn search_sparse_vectors(
2714 conn: &Connection,
2715 query: &SparseWeights,
2716 candidate_limit: usize,
2717 min_score: f64,
2718) -> Result<Vec<(SparseVectorRow, f64)>, MemoryError> {
2719 if query.is_empty() || candidate_limit == 0 {
2720 return Ok(Vec::new());
2721 }
2722
2723 let mut sql = String::from("WITH query_sparse(dimension, weight) AS (VALUES ");
2724 for index in 0..query.entries.len() {
2725 if index > 0 {
2726 sql.push(',');
2727 }
2728 let dimension_param = index * 2 + 1;
2729 let weight_param = dimension_param + 1;
2730 sql.push_str(&format!("(?{dimension_param}, ?{weight_param})"));
2731 }
2732 let min_score_param = query.entries.len() * 2 + 1;
2733 let limit_param = min_score_param + 1;
2734 sql.push_str(&format!(
2735 ") SELECT sv.item_key, sv.entries_json, sv.representation,
2736 SUM(CAST(json_extract(entry.value, '$[1]') AS REAL) * query_sparse.weight) AS sparse_score
2737 FROM sparse_vectors sv
2738 JOIN json_each(sv.entries_json) AS entry
2739 JOIN query_sparse
2740 ON CAST(json_extract(entry.value, '$[0]') AS INTEGER) = query_sparse.dimension
2741 GROUP BY sv.item_key
2742 HAVING sparse_score >= ?{min_score_param}
2743 ORDER BY sparse_score DESC, sv.item_key ASC
2744 LIMIT ?{limit_param}"
2745 ));
2746
2747 let mut values: Vec<rusqlite::types::Value> = Vec::with_capacity(query.entries.len() * 2 + 2);
2748 for (dimension, weight) in &query.entries {
2749 values.push((*dimension as i64).into());
2750 values.push(f64::from(*weight).into());
2751 }
2752 values.push(min_score.into());
2753 values.push((candidate_limit as i64).into());
2754
2755 let mut statement = conn.prepare(&sql)?;
2756 let rows = statement.query_map(rusqlite::params_from_iter(values), |row| {
2757 Ok((
2758 row.get::<_, String>(0)?,
2759 row.get::<_, String>(1)?,
2760 row.get::<_, String>(2)?,
2761 row.get::<_, f64>(3)?,
2762 ))
2763 })?;
2764 let mut results = Vec::new();
2765 for row in rows {
2766 let (item_key, entries_json, representation, score) = row?;
2767 let entries: Vec<(usize, f32)> =
2768 serde_json::from_str(&entries_json).map_err(|error| MemoryError::CorruptData {
2769 table: "sparse_vectors",
2770 row_id: item_key.clone(),
2771 detail: format!("invalid sparse entries JSON: {error}"),
2772 })?;
2773 results.push((
2774 SparseVectorRow {
2775 item_key,
2776 weights: SparseWeights::from_entries(entries),
2777 representation,
2778 },
2779 score,
2780 ));
2781 }
2782 Ok(results)
2783}
2784
2785pub(crate) fn delete_sparse_vector(conn: &Connection, item_key: &str) -> Result<(), MemoryError> {
2787 conn.execute(
2788 "DELETE FROM sparse_vectors WHERE item_key = ?1",
2789 params![item_key],
2790 )?;
2791 Ok(())
2792}
2793
2794pub fn encode_f32_le(values: &[f32]) -> Vec<u8> {
2796 let mut bytes = Vec::with_capacity(values.len() * 4);
2797 for value in values {
2798 bytes.extend_from_slice(&value.to_le_bytes());
2799 }
2800 bytes
2801}
2802
2803pub(crate) fn validate_embedding(values: &[f32], expected_dim: usize) -> Result<(), MemoryError> {
2805 if values.len() != expected_dim {
2806 return Err(MemoryError::EmbeddingDimensionMismatch {
2807 expected: expected_dim,
2808 actual: values.len(),
2809 });
2810 }
2811 if let Some((index, _)) = values
2812 .iter()
2813 .enumerate()
2814 .find(|(_, value)| !value.is_finite())
2815 {
2816 return Err(MemoryError::NonFiniteEmbeddingValue { index });
2817 }
2818 Ok(())
2819}
2820
2821pub(crate) fn validate_embedding_batch(
2823 values: &[Vec<f32>],
2824 requested: usize,
2825 expected_dim: usize,
2826) -> Result<(), MemoryError> {
2827 if values.len() != requested {
2828 return Err(MemoryError::EmbeddingBatchCountMismatch {
2829 requested,
2830 returned: values.len(),
2831 });
2832 }
2833 for embedding in values {
2834 validate_embedding(embedding, expected_dim)?;
2835 }
2836 Ok(())
2837}
2838
2839pub(crate) fn validate_vector_blob_len(
2841 bytes: &[u8],
2842 expected_dim: usize,
2843) -> Result<(), MemoryError> {
2844 let expected_bytes = expected_dim
2845 .checked_mul(4)
2846 .ok_or_else(|| MemoryError::InvalidConfig {
2847 field: "embedding.dimensions",
2848 reason: "dimension byte length overflow".to_string(),
2849 })?;
2850 if bytes.len() != expected_bytes {
2851 return Err(MemoryError::VectorBlobLengthMismatch {
2852 expected_bytes,
2853 actual_bytes: bytes.len(),
2854 });
2855 }
2856 Ok(())
2857}
2858
2859#[allow(clippy::manual_is_multiple_of)]
2861pub fn decode_f32_le(bytes: &[u8], expected_dim: usize) -> Result<Vec<f32>, MemoryError> {
2862 validate_vector_blob_len(bytes, expected_dim)?;
2863 decode_f32_le_unchecked_dim(bytes)
2864}
2865
2866#[allow(clippy::manual_is_multiple_of)]
2868pub fn bytes_to_embedding(bytes: &[u8]) -> Result<Vec<f32>, MemoryError> {
2869 if bytes.len() % 4 != 0 {
2870 return Err(MemoryError::InvalidEmbedding {
2871 expected_bytes: bytes.len() - (bytes.len() % 4),
2872 actual_bytes: bytes.len(),
2873 });
2874 }
2875
2876 decode_f32_le_unchecked_dim(bytes)
2877}
2878
2879fn decode_f32_le_unchecked_dim(bytes: &[u8]) -> Result<Vec<f32>, MemoryError> {
2880 let mut embedding = Vec::with_capacity(bytes.len() / 4);
2881 for (index, chunk) in bytes.chunks_exact(4).enumerate() {
2882 let value = f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
2883 if !value.is_finite() {
2884 return Err(MemoryError::NonFiniteEmbeddingValue { index });
2885 }
2886 embedding.push(value);
2887 }
2888 Ok(embedding)
2889}
2890
2891pub fn is_embeddings_dirty(conn: &Connection) -> Result<bool, MemoryError> {
2892 let dirty: i32 = conn
2893 .query_row(
2894 "SELECT COALESCE(embeddings_dirty, 0) FROM embedding_metadata WHERE id = 1",
2895 [],
2896 |row| row.get(0),
2897 )
2898 .unwrap_or(0);
2899 Ok(dirty != 0)
2900}
2901
2902pub fn clear_embeddings_dirty(conn: &Connection) -> Result<(), MemoryError> {
2903 conn.execute(
2904 "UPDATE embedding_metadata SET embeddings_dirty = 0 WHERE id = 1",
2905 [],
2906 )?;
2907 Ok(())
2908}
2909
2910#[cfg(feature = "hnsw")]
2911pub(crate) fn queue_pending_index_op(
2912 tx: &rusqlite::Transaction<'_>,
2913 item_key: &str,
2914 entity_type: &str,
2915 op_kind: IndexOpKind,
2916) -> Result<(), MemoryError> {
2917 tx.execute(
2918 "INSERT INTO pending_index_ops (item_key, entity_type, op_kind, attempt_count, last_error, updated_at)
2919 VALUES (?1, ?2, ?3, 0, NULL, datetime('now'))
2920 ON CONFLICT(item_key) DO UPDATE SET
2921 entity_type = excluded.entity_type,
2922 op_kind = excluded.op_kind,
2923 attempt_count = 0,
2924 last_error = NULL,
2925 updated_at = datetime('now')",
2926 params![item_key, entity_type, op_kind.as_str()],
2927 )?;
2928 mark_sidecar_dirty(tx)?;
2929 Ok(())
2930}
2931
2932#[cfg(feature = "hnsw")]
2933pub(crate) use IndexOpKind as PendingIndexOpKind;
2934
2935#[cfg(feature = "hnsw")]
2936pub(crate) fn enqueue_pending_index_op(
2937 tx: &rusqlite::Transaction<'_>,
2938 item_key: &str,
2939 entity_type: &str,
2940 op_kind: PendingIndexOpKind,
2941) -> Result<(), MemoryError> {
2942 queue_pending_index_op(tx, item_key, entity_type, op_kind)
2943}
2944
2945pub(crate) fn list_pending_index_ops(
2946 conn: &Connection,
2947) -> Result<Vec<PendingIndexOp>, MemoryError> {
2948 let table_exists: bool = conn
2949 .query_row(
2950 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='pending_index_ops'",
2951 [],
2952 |row| row.get(0),
2953 )
2954 .unwrap_or(false);
2955 if !table_exists {
2956 return Ok(Vec::new());
2957 }
2958
2959 let mut stmt = conn.prepare(
2960 "SELECT item_key, entity_type, op_kind, attempt_count, last_error
2961 FROM pending_index_ops
2962 ORDER BY updated_at ASC, item_key ASC",
2963 )?;
2964 let rows = stmt
2965 .query_map([], |row| {
2966 let item_key: String = row.get(0)?;
2967 let op_kind: String = row.get(2)?;
2968 Ok(PendingIndexOp {
2969 item_key: item_key.clone(),
2970 entity_type: row.get(1)?,
2971 op_kind: IndexOpKind::parse(&op_kind, &item_key).map_err(|e| {
2972 rusqlite::Error::FromSqlConversionFailure(
2973 2,
2974 rusqlite::types::Type::Text,
2975 Box::new(e),
2976 )
2977 })?,
2978 attempt_count: row.get::<_, i64>(3)? as u32,
2979 last_error: row.get(4)?,
2980 })
2981 })?
2982 .collect::<Result<Vec<_>, _>>()?;
2983 Ok(rows)
2984}
2985
2986#[cfg(feature = "hnsw")]
2987pub(crate) fn pending_index_op_count(conn: &Connection) -> Result<usize, MemoryError> {
2988 let table_exists: bool = conn
2989 .query_row(
2990 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='pending_index_ops'",
2991 [],
2992 |row| row.get(0),
2993 )
2994 .unwrap_or(false);
2995 if !table_exists {
2996 return Ok(0);
2997 }
2998
2999 let count: i64 = conn.query_row("SELECT COUNT(*) FROM pending_index_ops", [], |row| {
3000 row.get(0)
3001 })?;
3002 Ok(count as usize)
3003}
3004
3005#[cfg(feature = "hnsw")]
3006pub(crate) fn mark_pending_index_ops_failed(
3007 conn: &Connection,
3008 item_keys: &[String],
3009 error: &str,
3010) -> Result<(), MemoryError> {
3011 with_transaction(conn, |tx| {
3012 for item_key in item_keys {
3013 tx.execute(
3014 "UPDATE pending_index_ops
3015 SET attempt_count = attempt_count + 1,
3016 last_error = ?1,
3017 updated_at = datetime('now')
3018 WHERE item_key = ?2",
3019 params![error, item_key],
3020 )?;
3021 }
3022 Ok(())
3023 })
3024}
3025
3026#[cfg(feature = "hnsw")]
3027pub(crate) fn clear_pending_index_ops(
3028 conn: &Connection,
3029 item_keys: &[String],
3030) -> Result<(), MemoryError> {
3031 with_transaction(conn, |tx| {
3032 for item_key in item_keys {
3033 tx.execute(
3034 "DELETE FROM pending_index_ops WHERE item_key = ?1",
3035 params![item_key],
3036 )?;
3037 }
3038 Ok(())
3039 })
3040}
3041
3042#[cfg(feature = "hnsw")]
3043pub(crate) fn clear_all_pending_index_ops(conn: &Connection) -> Result<(), MemoryError> {
3044 conn.execute("DELETE FROM pending_index_ops", [])?;
3045 Ok(())
3046}
3047
3048#[cfg(feature = "hnsw")]
3049pub(crate) fn load_embedding_for_index_key(
3050 conn: &Connection,
3051 item_key: &str,
3052) -> Result<Option<Vec<f32>>, MemoryError> {
3053 let Some((domain, raw_id)) = item_key.split_once(':') else {
3054 return Err(MemoryError::InvalidKey(item_key.to_string()));
3055 };
3056
3057 let blob_result: Result<Option<Vec<u8>>, rusqlite::Error> = match domain {
3058 "fact" => conn.query_row(
3059 "SELECT embedding FROM facts WHERE id = ?1",
3060 params![raw_id],
3061 |row| row.get(0),
3062 ),
3063 "chunk" => conn.query_row(
3064 "SELECT embedding FROM chunks WHERE id = ?1",
3065 params![raw_id],
3066 |row| row.get(0),
3067 ),
3068 "msg" => {
3069 let message_id = raw_id
3070 .parse::<i64>()
3071 .map_err(|e| MemoryError::InvalidKey(format!("{}: {e}", item_key)))?;
3072 conn.query_row(
3073 "SELECT embedding FROM messages WHERE id = ?1",
3074 params![message_id],
3075 |row| row.get(0),
3076 )
3077 }
3078 "episode" => conn.query_row(
3079 "SELECT embedding FROM episodes WHERE episode_id = ?1",
3080 params![raw_id],
3081 |row| row.get(0),
3082 ),
3083 _ => return Err(MemoryError::InvalidKey(item_key.to_string())),
3084 };
3085
3086 let blob = match blob_result {
3087 Ok(blob) => blob,
3088 Err(rusqlite::Error::QueryReturnedNoRows) => None,
3089 Err(err) => return Err(err.into()),
3090 };
3091
3092 blob.map(|bytes| bytes_to_embedding(&bytes)).transpose()
3093}
3094
3095#[cfg(feature = "hnsw")]
3096fn mark_sidecar_dirty(tx: &rusqlite::Transaction<'_>) -> Result<(), MemoryError> {
3097 tx.execute(
3098 "INSERT INTO hnsw_metadata (key, value) VALUES ('sidecar_dirty', '1')
3099 ON CONFLICT(key) DO UPDATE SET value = '1'",
3100 [],
3101 )?;
3102 Ok(())
3103}
3104
3105#[cfg(feature = "hnsw")]
3106pub(crate) fn is_sidecar_dirty(conn: &Connection) -> Result<bool, MemoryError> {
3107 let dirty: Option<String> = conn
3109 .query_row(
3110 "SELECT value FROM hnsw_metadata WHERE key = 'sidecar_dirty'",
3111 [],
3112 |row| row.get(0),
3113 )
3114 .ok();
3115 Ok(matches!(dirty.as_deref(), Some("1")))
3116}
3117
3118#[cfg(feature = "hnsw")]
3119pub(crate) fn set_sidecar_dirty(conn: &Connection, dirty: bool) -> Result<(), MemoryError> {
3120 conn.execute(
3121 "INSERT INTO hnsw_metadata (key, value) VALUES ('sidecar_dirty', ?1)
3122 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
3123 params![if dirty { "1" } else { "0" }],
3124 )?;
3125 Ok(())
3126}
3127
3128pub(crate) fn parse_optional_json(
3129 table: &'static str,
3130 row_id: &str,
3131 field: &'static str,
3132 raw: Option<&str>,
3133) -> Result<Option<serde_json::Value>, MemoryError> {
3134 match raw {
3135 Some(raw) => serde_json::from_str(raw)
3136 .map(Some)
3137 .map_err(|e| MemoryError::CorruptData {
3138 table,
3139 row_id: row_id.to_string(),
3140 detail: format!("invalid {field}: {e}"),
3141 }),
3142 None => Ok(None),
3143 }
3144}
3145
3146pub(crate) fn parse_string_list_json(
3147 table: &'static str,
3148 row_id: &str,
3149 field: &'static str,
3150 raw: &str,
3151) -> Result<Vec<String>, MemoryError> {
3152 serde_json::from_str(raw).map_err(|e| MemoryError::CorruptData {
3153 table,
3154 row_id: row_id.to_string(),
3155 detail: format!("invalid {field}: {e}"),
3156 })
3157}
3158
3159pub(crate) fn parse_role(
3160 table: &'static str,
3161 row_id: &str,
3162 raw: &str,
3163) -> Result<Role, MemoryError> {
3164 Role::from_str_value(raw).ok_or_else(|| MemoryError::CorruptData {
3165 table,
3166 row_id: row_id.to_string(),
3167 detail: format!("invalid role '{raw}'"),
3168 })
3169}
3170
3171pub(crate) fn parse_episode_outcome(
3172 row_id: &str,
3173 raw: &str,
3174) -> Result<EpisodeOutcome, MemoryError> {
3175 EpisodeOutcome::from_str_value(raw).ok_or_else(|| MemoryError::CorruptData {
3176 table: "episodes",
3177 row_id: row_id.to_string(),
3178 detail: format!("invalid outcome '{raw}'"),
3179 })
3180}
3181
3182pub(crate) fn parse_verification_status(
3183 row_id: &str,
3184 raw: &str,
3185) -> Result<VerificationStatus, MemoryError> {
3186 serde_json::from_str(raw).map_err(|e| MemoryError::CorruptData {
3187 table: "episodes",
3188 row_id: row_id.to_string(),
3189 detail: format!("invalid verification_status: {e}"),
3190 })
3191}
3192
3193pub fn verify_integrity_sync(
3195 conn: &Connection,
3196 mode: VerifyMode,
3197) -> Result<IntegrityReport, MemoryError> {
3198 let mut issues = Vec::new();
3199
3200 let schema_version: u32 = conn
3201 .query_row("PRAGMA user_version", [], |row| row.get(0))
3202 .unwrap_or_else(|e| {
3203 issues.push(format!("failed to read schema version: {e}"));
3204 0
3205 });
3206 if schema_version > MAX_SCHEMA_VERSION {
3207 issues.push(format!(
3208 "schema version {} is ahead of supported {}",
3209 schema_version, MAX_SCHEMA_VERSION
3210 ));
3211 }
3212
3213 let fact_count: usize = conn
3214 .query_row("SELECT COUNT(*) FROM facts", [], |row| row.get(0))
3215 .unwrap_or_else(|e| {
3216 issues.push(format!("failed to count facts: {e}"));
3217 0
3218 });
3219 let chunk_count: usize = conn
3220 .query_row("SELECT COUNT(*) FROM chunks", [], |row| row.get(0))
3221 .unwrap_or_else(|e| {
3222 issues.push(format!("failed to count chunks: {e}"));
3223 0
3224 });
3225 let message_count: usize = conn
3226 .query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))
3227 .unwrap_or_else(|e| {
3228 issues.push(format!("failed to count messages: {e}"));
3229 0
3230 });
3231 let episode_count: usize = conn
3232 .query_row("SELECT COUNT(*) FROM episodes", [], |row| row.get(0))
3233 .unwrap_or_else(|e| {
3234 issues.push(format!("failed to count episodes: {e}"));
3235 0
3236 });
3237
3238 let facts_missing_embeddings: usize = conn
3239 .query_row(
3240 "SELECT COUNT(*) FROM facts WHERE embedding IS NULL",
3241 [],
3242 |row| row.get(0),
3243 )
3244 .unwrap_or_else(|e| {
3245 issues.push(format!("failed to count facts missing embeddings: {e}"));
3246 0
3247 });
3248 let chunks_missing_embeddings: usize = conn
3249 .query_row(
3250 "SELECT COUNT(*) FROM chunks WHERE embedding IS NULL",
3251 [],
3252 |row| row.get(0),
3253 )
3254 .unwrap_or_else(|e| {
3255 issues.push(format!("failed to count chunks missing embeddings: {e}"));
3256 0
3257 });
3258 let episodes_missing_embeddings: usize = conn
3259 .query_row(
3260 "SELECT COUNT(*) FROM episodes WHERE embedding IS NULL",
3261 [],
3262 |row| row.get(0),
3263 )
3264 .unwrap_or_else(|e| {
3265 issues.push(format!("failed to count episodes missing embeddings: {e}"));
3266 0
3267 });
3268
3269 if facts_missing_embeddings > 0 {
3270 issues.push(format!(
3271 "{} facts missing embeddings",
3272 facts_missing_embeddings
3273 ));
3274 }
3275 if chunks_missing_embeddings > 0 {
3276 issues.push(format!(
3277 "{} chunks missing embeddings",
3278 chunks_missing_embeddings
3279 ));
3280 }
3281 if episodes_missing_embeddings > 0 {
3282 issues.push(format!(
3283 "{} episodes missing embeddings",
3284 episodes_missing_embeddings
3285 ));
3286 }
3287
3288 let pending_ops = list_pending_index_ops(conn).unwrap_or_default();
3289 if !pending_ops.is_empty() {
3290 issues.push(format!(
3291 "{} pending HNSW sidecar ops queued in SQLite",
3292 pending_ops.len()
3293 ));
3294 for op in pending_ops.iter().take(5) {
3295 let op_kind = op.op_kind.as_str();
3296 let detail = match &op.last_error {
3297 Some(last_error) => format!(
3298 "{} {} {} (attempts: {}, last_error: {})",
3299 op.entity_type,
3300 op.op_kind.as_str(),
3301 op.item_key,
3302 op.attempt_count,
3303 last_error
3304 ),
3305 None => format!(
3306 "{} {} {} (attempts: {})",
3307 op.entity_type, op_kind, op.item_key, op.attempt_count
3308 ),
3309 };
3310 issues.push(format!("pending sidecar op: {detail}"));
3311 }
3312 }
3313
3314 if mode == VerifyMode::Full {
3315 let dims: usize = conn
3316 .query_row(
3317 "SELECT dimensions FROM embedding_metadata WHERE id = 1",
3318 [],
3319 |row| row.get(0),
3320 )
3321 .unwrap_or_else(|e| {
3322 issues.push(format!("failed to read embedding dimensions: {e}"));
3323 0
3324 });
3325
3326 verify_fts_drift(conn, "facts", "facts_rowid_map", fact_count, &mut issues);
3327 verify_fts_drift(conn, "chunks", "chunks_rowid_map", chunk_count, &mut issues);
3328 verify_fts_drift(
3329 conn,
3330 "messages",
3331 "messages_rowid_map",
3332 message_count,
3333 &mut issues,
3334 );
3335 verify_fts_drift(
3336 conn,
3337 "episodes",
3338 "episodes_rowid_map",
3339 episode_count,
3340 &mut issues,
3341 );
3342
3343 verify_blob_table(conn, "facts", "id", "embedding", dims, &mut issues)?;
3344 verify_blob_table(conn, "chunks", "id", "embedding", dims, &mut issues)?;
3345 verify_blob_table(conn, "messages", "id", "embedding", dims, &mut issues)?;
3346 verify_blob_table(
3347 conn,
3348 "episodes",
3349 "episode_id",
3350 "embedding",
3351 dims,
3352 &mut issues,
3353 )?;
3354
3355 verify_quantized_table(conn, "facts", "id", dims, &mut issues)?;
3356 verify_quantized_table(conn, "chunks", "id", dims, &mut issues)?;
3357 verify_quantized_table(conn, "messages", "id", dims, &mut issues)?;
3358 verify_quantized_table(conn, "episodes", "episode_id", dims, &mut issues)?;
3359
3360 verify_session_rows(conn, &mut issues)?;
3361 verify_message_rows(conn, &mut issues)?;
3362 verify_fact_rows(conn, &mut issues)?;
3363 verify_document_rows(conn, &mut issues)?;
3364 verify_episode_rows(conn, &mut issues)?;
3365
3366 let integrity_check: String = conn
3367 .query_row("PRAGMA integrity_check", [], |row| row.get(0))
3368 .unwrap_or_else(|_| "error".to_string());
3369 if integrity_check != "ok" {
3370 issues.push(format!("SQLite integrity_check: {}", integrity_check));
3371 }
3372 }
3373
3374 Ok(IntegrityReport {
3375 ok: issues.is_empty(),
3376 schema_version,
3377 fact_count,
3378 chunk_count,
3379 message_count,
3380 facts_missing_embeddings,
3381 chunks_missing_embeddings,
3382 issues,
3383 })
3384}
3385
3386pub fn reconcile_fts(conn: &Connection) -> Result<(), MemoryError> {
3388 with_transaction(conn, |tx| {
3389 tx.execute_batch("DROP TABLE IF EXISTS facts_fts")?;
3390 tx.execute_batch("DELETE FROM facts_rowid_map")?;
3391 tx.execute_batch(
3392 "CREATE VIRTUAL TABLE facts_fts USING fts5(
3393 content,
3394 content='',
3395 content_rowid='rowid',
3396 tokenize='porter unicode61'
3397 )",
3398 )?;
3399 tx.execute_batch("INSERT INTO facts_rowid_map (fact_id) SELECT id FROM facts")?;
3400 tx.execute_batch(
3401 "INSERT INTO facts_fts (rowid, content)
3402 SELECT rm.rowid, f.content
3403 FROM facts_rowid_map rm
3404 JOIN facts f ON f.id = rm.fact_id",
3405 )?;
3406
3407 tx.execute_batch("DROP TABLE IF EXISTS chunks_fts")?;
3408 tx.execute_batch("DELETE FROM chunks_rowid_map")?;
3409 tx.execute_batch(
3410 "CREATE VIRTUAL TABLE chunks_fts USING fts5(
3411 content,
3412 content='',
3413 content_rowid='rowid',
3414 tokenize='porter unicode61'
3415 )",
3416 )?;
3417 tx.execute_batch("INSERT INTO chunks_rowid_map (chunk_id) SELECT id FROM chunks")?;
3418 tx.execute_batch(
3419 "INSERT INTO chunks_fts (rowid, content)
3420 SELECT rm.rowid, c.content
3421 FROM chunks_rowid_map rm
3422 JOIN chunks c ON c.id = rm.chunk_id",
3423 )?;
3424
3425 tx.execute_batch("DROP TABLE IF EXISTS messages_fts")?;
3426 tx.execute_batch("DELETE FROM messages_rowid_map")?;
3427 tx.execute_batch(
3428 "CREATE VIRTUAL TABLE messages_fts USING fts5(
3429 content,
3430 content='',
3431 content_rowid='rowid',
3432 tokenize='porter unicode61'
3433 )",
3434 )?;
3435 tx.execute_batch("INSERT INTO messages_rowid_map (message_id) SELECT id FROM messages")?;
3436 tx.execute_batch(
3437 "INSERT INTO messages_fts (rowid, content)
3438 SELECT rm.rowid, m.content
3439 FROM messages_rowid_map rm
3440 JOIN messages m ON m.id = rm.message_id",
3441 )?;
3442
3443 tx.execute_batch("DROP TABLE IF EXISTS episodes_fts")?;
3444 tx.execute_batch("DELETE FROM episodes_rowid_map")?;
3445 tx.execute_batch(
3446 "CREATE VIRTUAL TABLE episodes_fts USING fts5(
3447 content,
3448 content='',
3449 content_rowid='rowid',
3450 tokenize='porter unicode61'
3451 )",
3452 )?;
3453 tx.execute_batch(
3454 "INSERT INTO episodes_rowid_map (episode_id, document_id) SELECT episode_id, document_id FROM episodes",
3455 )?;
3456 tx.execute_batch(
3457 "INSERT INTO episodes_fts (rowid, content)
3458 SELECT rm.rowid, e.search_text
3459 FROM episodes_rowid_map rm
3460 JOIN episodes e ON e.episode_id = rm.episode_id",
3461 )?;
3462
3463 Ok(())
3464 })?;
3465
3466 tracing::info!("FTS indexes reconciled");
3467 Ok(())
3468}
3469
3470fn verify_fts_drift(
3471 conn: &Connection,
3472 label: &str,
3473 map_table: &str,
3474 source_count: usize,
3475 issues: &mut Vec<String>,
3476) {
3477 let table_exists: bool = conn
3478 .query_row(
3479 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name = ?1",
3480 params![map_table],
3481 |row| row.get(0),
3482 )
3483 .unwrap_or(false);
3484 if !table_exists {
3485 if source_count > 0 {
3486 issues.push(format!("{} rows exist but {} is missing", label, map_table));
3487 }
3488 return;
3489 }
3490
3491 let sql = format!("SELECT COUNT(*) FROM {}", map_table);
3492 let indexed_count: usize = conn.query_row(&sql, [], |row| row.get(0)).unwrap_or(0);
3493 if indexed_count != source_count {
3494 issues.push(format!(
3495 "FTS {} index drift: {} rows in map vs {} source rows",
3496 label, indexed_count, source_count
3497 ));
3498 }
3499}
3500
3501fn verify_blob_table(
3502 conn: &Connection,
3503 table: &'static str,
3504 id_column: &'static str,
3505 blob_column: &'static str,
3506 expected_dims: usize,
3507 issues: &mut Vec<String>,
3508) -> Result<(), MemoryError> {
3509 if expected_dims == 0 {
3510 return Ok(());
3511 }
3512
3513 let sql = format!(
3514 "SELECT CAST({id_column} AS TEXT), {blob_column} FROM {table} WHERE {blob_column} IS NOT NULL"
3515 );
3516 let mut stmt = conn.prepare(&sql)?;
3517 let rows = stmt.query_map([], |row| {
3518 Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?))
3519 })?;
3520
3521 for row in rows {
3522 let (row_id, blob) = row?;
3523 match bytes_to_embedding(&blob) {
3524 Ok(embedding) if embedding.len() != expected_dims => issues.push(format!(
3525 "{}({}) has embedding dimension {} but expected {}",
3526 table,
3527 row_id,
3528 embedding.len(),
3529 expected_dims
3530 )),
3531 Ok(_) => {}
3532 Err(err) => issues.push(format!(
3533 "{}({}) invalid embedding blob: {}",
3534 table, row_id, err
3535 )),
3536 }
3537 }
3538
3539 Ok(())
3540}
3541
3542fn verify_quantized_table(
3543 conn: &Connection,
3544 table: &'static str,
3545 id_column: &'static str,
3546 expected_dims: usize,
3547 issues: &mut Vec<String>,
3548) -> Result<(), MemoryError> {
3549 if expected_dims == 0 {
3550 return Ok(());
3551 }
3552
3553 let sql = format!(
3554 "SELECT CAST({id_column} AS TEXT), embedding_q8 FROM {table} WHERE embedding IS NOT NULL"
3555 );
3556 let mut stmt = conn.prepare(&sql)?;
3557 let rows = stmt.query_map([], |row| {
3558 Ok((row.get::<_, String>(0)?, row.get::<_, Option<Vec<u8>>>(1)?))
3559 })?;
3560
3561 for row in rows {
3562 let (row_id, blob) = row?;
3563 match blob {
3564 Some(blob) => {
3565 if let Err(err) = unpack_quantized(&blob, expected_dims) {
3566 issues.push(format!(
3567 "{}({}) invalid quantized embedding: {}",
3568 table, row_id, err
3569 ));
3570 }
3571 }
3572 None => issues.push(format!("{}({}) missing quantized embedding", table, row_id)),
3573 }
3574 }
3575
3576 Ok(())
3577}
3578
3579fn verify_session_rows(conn: &Connection, issues: &mut Vec<String>) -> Result<(), MemoryError> {
3580 let mut stmt = conn.prepare("SELECT id, metadata FROM sessions WHERE metadata IS NOT NULL")?;
3581 let rows = stmt.query_map([], |row| {
3582 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
3583 })?;
3584 for row in rows {
3585 let (id, metadata) = row?;
3586 if let Err(err) = parse_optional_json("sessions", &id, "metadata", Some(&metadata)) {
3587 issues.push(err.to_string());
3588 }
3589 }
3590 Ok(())
3591}
3592
3593fn verify_message_rows(conn: &Connection, issues: &mut Vec<String>) -> Result<(), MemoryError> {
3594 let mut stmt = conn.prepare("SELECT id, role, metadata FROM messages")?;
3595 let rows = stmt.query_map([], |row| {
3596 Ok((
3597 row.get::<_, i64>(0)?,
3598 row.get::<_, String>(1)?,
3599 row.get::<_, Option<String>>(2)?,
3600 ))
3601 })?;
3602 for row in rows {
3603 let (id, role, metadata) = row?;
3604 let row_id = id.to_string();
3605 if let Err(err) = parse_role("messages", &row_id, &role) {
3606 issues.push(err.to_string());
3607 }
3608 if let Err(err) = parse_optional_json("messages", &row_id, "metadata", metadata.as_deref())
3609 {
3610 issues.push(err.to_string());
3611 }
3612 }
3613 Ok(())
3614}
3615
3616fn verify_fact_rows(conn: &Connection, issues: &mut Vec<String>) -> Result<(), MemoryError> {
3617 let mut stmt = conn.prepare("SELECT id, metadata FROM facts WHERE metadata IS NOT NULL")?;
3618 let rows = stmt.query_map([], |row| {
3619 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
3620 })?;
3621 for row in rows {
3622 let (id, metadata) = row?;
3623 if let Err(err) = parse_optional_json("facts", &id, "metadata", Some(&metadata)) {
3624 issues.push(err.to_string());
3625 }
3626 }
3627 Ok(())
3628}
3629
3630fn verify_document_rows(conn: &Connection, issues: &mut Vec<String>) -> Result<(), MemoryError> {
3631 let mut stmt = conn.prepare("SELECT id, metadata FROM documents WHERE metadata IS NOT NULL")?;
3632 let rows = stmt.query_map([], |row| {
3633 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
3634 })?;
3635 for row in rows {
3636 let (id, metadata) = row?;
3637 if let Err(err) = parse_optional_json("documents", &id, "metadata", Some(&metadata)) {
3638 issues.push(err.to_string());
3639 }
3640 }
3641 Ok(())
3642}
3643
3644fn verify_episode_rows(conn: &Connection, issues: &mut Vec<String>) -> Result<(), MemoryError> {
3645 let mut stmt = conn.prepare(
3646 "SELECT episode_id, cause_ids, outcome, verification_status
3647 FROM episodes",
3648 )?;
3649 let rows = stmt.query_map([], |row| {
3650 Ok((
3651 row.get::<_, String>(0)?,
3652 row.get::<_, String>(1)?,
3653 row.get::<_, String>(2)?,
3654 row.get::<_, String>(3)?,
3655 ))
3656 })?;
3657 for row in rows {
3658 let (episode_id, cause_ids, outcome, verification_status) = row?;
3659 if let Err(err) = parse_string_list_json("episodes", &episode_id, "cause_ids", &cause_ids) {
3660 issues.push(err.to_string());
3661 }
3662 if let Err(err) = parse_episode_outcome(&episode_id, &outcome) {
3663 issues.push(err.to_string());
3664 }
3665 if let Err(err) = parse_verification_status(&episode_id, &verification_status) {
3666 issues.push(err.to_string());
3667 }
3668 }
3669 Ok(())
3670}
3671
3672#[derive(Debug, Clone)]
3673pub(crate) struct ProveKvPoolGenerationRow {
3674 pub generation: ProveKvPoolGenerationV1,
3675}
3676
3677#[allow(dead_code)] fn parse_provekv_status(value: &str) -> ProveKvPoolGenerationStatus {
3679 match value {
3680 "disabled" => ProveKvPoolGenerationStatus::Disabled,
3681 "missing" => ProveKvPoolGenerationStatus::Missing,
3682 "building" => ProveKvPoolGenerationStatus::Building,
3683 "ready" => ProveKvPoolGenerationStatus::Ready,
3684 "stale" => ProveKvPoolGenerationStatus::Stale,
3685 "failed" => ProveKvPoolGenerationStatus::Failed,
3686 _ => ProveKvPoolGenerationStatus::Failed,
3687 }
3688}
3689
3690fn provekv_generation_from_row(
3691 row: &rusqlite::Row<'_>,
3692) -> rusqlite::Result<ProveKvPoolGenerationRow> {
3693 let created_at: String = row.get(9)?;
3694 Ok(ProveKvPoolGenerationRow {
3695 generation: ProveKvPoolGenerationV1 {
3696 schema_version: "semantic_memory_provekv_pool_generation_v1".to_string(),
3697 generation_id: row.get(0)?,
3698 embedding_snapshot_digest: row.get(1)?,
3699 source_digest: row.get(2)?,
3700 pool_manifest_digest: row.get(3)?,
3701 codec_family: row.get(4)?,
3702 codec_profile: row.get(5)?,
3703 vector_dim: row.get::<_, i64>(6)? as usize,
3704 item_count: row.get::<_, i64>(7)? as usize,
3705 payload_bytes: row.get::<_, i64>(8)? as u64,
3706 created_at: DateTime::parse_from_rfc3339(&created_at)
3707 .map(|dt| dt.with_timezone(&Utc))
3708 .map_err(|err| {
3709 rusqlite::Error::FromSqlConversionFailure(
3710 9,
3711 rusqlite::types::Type::Text,
3712 Box::new(err),
3713 )
3714 })?,
3715 },
3716 })
3717}
3718
3719#[allow(dead_code)]
3720pub(crate) fn insert_provekv_pool_generation(
3721 conn: &Connection,
3722 generation: &ProveKvPoolGenerationV1,
3723 payload: &[u8],
3724 item_map: &[ProveKvPoolItemMapEntryV1],
3725) -> Result<(), MemoryError> {
3726 let tx = conn.unchecked_transaction()?;
3727 tx.execute(
3728 "INSERT OR REPLACE INTO provekv_pool_generations
3729 (generation_id, embedding_snapshot_digest, source_digest, pool_manifest_digest,
3730 codec_family, codec_profile, vector_dim, item_count, payload_bytes, payload,
3731 status, failure_reason, created_at)
3732 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 'ready', NULL, ?11)",
3733 params![
3734 generation.generation_id,
3735 generation.embedding_snapshot_digest,
3736 generation.source_digest,
3737 generation.pool_manifest_digest,
3738 generation.codec_family,
3739 generation.codec_profile,
3740 generation.vector_dim as i64,
3741 generation.item_count as i64,
3742 generation.payload_bytes as i64,
3743 payload,
3744 generation.created_at.to_rfc3339(),
3745 ],
3746 )?;
3747 tx.execute(
3748 "DELETE FROM provekv_pool_item_map WHERE generation_id = ?1",
3749 params![generation.generation_id],
3750 )?;
3751 for entry in item_map {
3752 tx.execute(
3753 "INSERT INTO provekv_pool_item_map
3754 (generation_id, item_id, source_type, pool_index, embedding_digest)
3755 VALUES (?1, ?2, ?3, ?4, ?5)",
3756 params![
3757 entry.generation_id,
3758 entry.item_id,
3759 entry.source_type,
3760 entry.pool_index as i64,
3761 entry.embedding_digest,
3762 ],
3763 )?;
3764 }
3765 tx.commit()?;
3766 Ok(())
3767}
3768
3769pub(crate) fn latest_ready_provekv_pool_generation(
3770 conn: &Connection,
3771) -> Result<Option<ProveKvPoolGenerationRow>, MemoryError> {
3772 conn.query_row(
3773 "SELECT generation_id, embedding_snapshot_digest, source_digest, pool_manifest_digest,
3774 codec_family, codec_profile, vector_dim, item_count, payload_bytes, created_at
3775 FROM provekv_pool_generations
3776 WHERE status = 'ready'
3777 ORDER BY created_at DESC
3778 LIMIT 1",
3779 [],
3780 provekv_generation_from_row,
3781 )
3782 .optional()
3783 .map_err(MemoryError::from)
3784}
3785
3786pub(crate) fn load_provekv_pool_payload(
3787 conn: &Connection,
3788 generation_id: &str,
3789) -> Result<Vec<u8>, MemoryError> {
3790 conn.query_row(
3791 "SELECT payload FROM provekv_pool_generations WHERE generation_id = ?1",
3792 params![generation_id],
3793 |row| row.get(0),
3794 )
3795 .map_err(MemoryError::from)
3796}
3797
3798pub(crate) fn load_provekv_pool_item_map(
3799 conn: &Connection,
3800 generation_id: &str,
3801) -> Result<Vec<ProveKvPoolItemMapEntryV1>, MemoryError> {
3802 let mut stmt = conn.prepare(
3803 "SELECT generation_id, item_id, source_type, pool_index, embedding_digest
3804 FROM provekv_pool_item_map
3805 WHERE generation_id = ?1
3806 ORDER BY pool_index ASC",
3807 )?;
3808 let rows = stmt.query_map(params![generation_id], |row| {
3809 Ok(ProveKvPoolItemMapEntryV1 {
3810 generation_id: row.get(0)?,
3811 item_id: row.get(1)?,
3812 source_type: row.get(2)?,
3813 pool_index: row.get::<_, i64>(3)? as usize,
3814 embedding_digest: row.get(4)?,
3815 })
3816 })?;
3817 let mut entries = Vec::new();
3818 for row in rows {
3819 entries.push(row?);
3820 }
3821 Ok(entries)
3822}
3823
3824#[allow(dead_code)]
3825pub(crate) fn mark_provekv_pool_generation_failed(
3826 conn: &Connection,
3827 generation_id: &str,
3828 reason: &str,
3829) -> Result<(), MemoryError> {
3830 conn.execute(
3831 "UPDATE provekv_pool_generations SET status = 'failed', failure_reason = ?2 WHERE generation_id = ?1",
3832 params![generation_id, reason],
3833 )?;
3834 Ok(())
3835}
3836
3837#[allow(dead_code)] pub(crate) fn provekv_pool_artifact_status(
3839 conn: &Connection,
3840) -> Result<ProveKvPoolArtifactStatusV1, MemoryError> {
3841 let row = conn
3842 .query_row(
3843 "SELECT generation_id, embedding_snapshot_digest, pool_manifest_digest,
3844 item_count, payload_bytes, status, failure_reason
3845 FROM provekv_pool_generations
3846 ORDER BY created_at DESC
3847 LIMIT 1",
3848 [],
3849 |row| {
3850 Ok((
3851 row.get::<_, String>(0)?,
3852 row.get::<_, String>(1)?,
3853 row.get::<_, String>(2)?,
3854 row.get::<_, i64>(3)?,
3855 row.get::<_, i64>(4)?,
3856 row.get::<_, String>(5)?,
3857 row.get::<_, Option<String>>(6)?,
3858 ))
3859 },
3860 )
3861 .optional()?;
3862 Ok(match row {
3863 Some((
3864 generation_id,
3865 snapshot_digest,
3866 manifest_digest,
3867 item_count,
3868 payload_bytes,
3869 status,
3870 reason,
3871 )) => ProveKvPoolArtifactStatusV1 {
3872 status: parse_provekv_status(&status),
3873 generation_id: Some(generation_id),
3874 embedding_snapshot_digest: Some(snapshot_digest),
3875 pool_manifest_digest: Some(manifest_digest),
3876 item_count: item_count as usize,
3877 payload_bytes: payload_bytes as u64,
3878 reason,
3879 },
3880 None => ProveKvPoolArtifactStatusV1 {
3881 status: ProveKvPoolGenerationStatus::Missing,
3882 generation_id: None,
3883 embedding_snapshot_digest: None,
3884 pool_manifest_digest: None,
3885 item_count: 0,
3886 payload_bytes: 0,
3887 reason: Some("provekv_pool_generation_not_materialized".into()),
3888 },
3889 })
3890}
3891
3892#[cfg(test)]
3893mod provekv_pool_generation_db_tests {
3894 use super::*;
3895
3896 fn test_conn() -> Connection {
3897 let conn = Connection::open_in_memory().expect("in-memory db opens");
3898 conn.execute_batch(MIGRATION_V24)
3899 .expect("proveKV schema migration applies");
3900 conn
3901 }
3902
3903 fn generation(id: &str) -> ProveKvPoolGenerationV1 {
3904 ProveKvPoolGenerationV1 {
3905 schema_version: "semantic_memory_provekv_pool_generation_v1".to_string(),
3906 generation_id: id.to_string(),
3907 embedding_snapshot_digest: "blake3:snapshot".to_string(),
3908 source_digest: "blake3:source".to_string(),
3909 pool_manifest_digest: "blake3:manifest".to_string(),
3910 codec_family: "provekv_pool".to_string(),
3911 codec_profile: "semantic-memory-f32-derived-candidate-v1".to_string(),
3912 vector_dim: 4,
3913 item_count: 2,
3914 payload_bytes: 3,
3915 created_at: Utc::now(),
3916 }
3917 }
3918
3919 #[test]
3920 fn provekv_pool_generation_roundtrips_and_cascades_item_map() {
3921 let conn = test_conn();
3922 let gen = generation("gen-1");
3923 let item_map = vec![
3924 ProveKvPoolItemMapEntryV1 {
3925 generation_id: gen.generation_id.clone(),
3926 item_id: "fact-1".to_string(),
3927 source_type: "fact".to_string(),
3928 pool_index: 0,
3929 embedding_digest: "blake3:item-1".to_string(),
3930 },
3931 ProveKvPoolItemMapEntryV1 {
3932 generation_id: gen.generation_id.clone(),
3933 item_id: "fact-2".to_string(),
3934 source_type: "fact".to_string(),
3935 pool_index: 1,
3936 embedding_digest: "blake3:item-2".to_string(),
3937 },
3938 ];
3939 insert_provekv_pool_generation(&conn, &gen, &[1, 2, 3], &item_map).unwrap();
3940
3941 let latest = latest_ready_provekv_pool_generation(&conn)
3942 .unwrap()
3943 .expect("latest ready generation");
3944 assert_eq!(latest.generation.generation_id, gen.generation_id);
3945 assert_eq!(
3946 load_provekv_pool_payload(&conn, "gen-1").unwrap(),
3947 vec![1, 2, 3]
3948 );
3949 assert_eq!(
3950 load_provekv_pool_item_map(&conn, "gen-1").unwrap(),
3951 item_map
3952 );
3953
3954 mark_provekv_pool_generation_failed(&conn, "gen-1", "boom").unwrap();
3955 let status = provekv_pool_artifact_status(&conn).unwrap();
3956 assert_eq!(status.status, ProveKvPoolGenerationStatus::Failed);
3957 assert_eq!(status.reason.as_deref(), Some("boom"));
3958
3959 conn.execute(
3960 "DELETE FROM provekv_pool_generations WHERE generation_id = 'gen-1'",
3961 [],
3962 )
3963 .unwrap();
3964 let count: i64 = conn
3965 .query_row("SELECT COUNT(*) FROM provekv_pool_item_map", [], |row| {
3966 row.get(0)
3967 })
3968 .unwrap();
3969 assert_eq!(count, 0);
3970 }
3971}