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