Skip to main content

semantic_memory/
db.rs

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