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