Skip to main content

dejadb_store/
lib.rs

1//! dejadb-store — the embedded Turso-backed store for DejaDB.
2//!
3//! Implements the store schema: dictionary-encoded 2½-permutation
4//! triple indexes (SPO + POS mandatory, OSP selective for entity-valued
5//! relations), `entity_latest` materialization, op-log + HLC + tombstones,
6//! thread index, and the vaais operation profile (add / recall / batch /
7//! supersede / forget) plus bounded graph ops and two-axis `entity_at`.
8//!
9//! Sync facade over the async turso crate: `DejaDB` owns a current-thread
10//! runtime; point ops measured at µs-class through this path in M0.
11
12use std::collections::{HashMap, HashSet, VecDeque};
13use std::time::{SystemTime, UNIX_EPOCH};
14
15use dejadb_core::error::{Hash, DejaDbError, Result};
16use dejadb_core::format::deserialize::{deserialize_blob, DeserializedGrain};
17use dejadb_core::format::serialize::serialize_grain;
18use dejadb_core::types::Grain;
19use turso::{Builder, Connection, Value};
20use zeroize::Zeroize;
21
22/// Op-log operation kinds.
23pub const OP_ADD: i64 = 1;
24pub const OP_SUPERSEDE: i64 = 2;
25pub const OP_FORGET: i64 = 3; // tombstone
26
27/// Temporal axis for `entity_at`.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum Axis {
30    /// "What was true in the world at T" — `valid_from`/`valid_to`.
31    World,
32    /// "What did the agent know at T" — supersession chain walk.
33    Knowledge,
34}
35
36/// One op-log record, the change-feed unit.
37#[derive(Debug, Clone)]
38pub struct OpRecord {
39    pub op_seq: i64,
40    pub hlc: i64,
41    pub op: i64,
42    pub hash: Hash,
43}
44
45/// Traversal direction for `related`. `In` uses the
46/// selective OSP index, so it only sees entity-valued relations.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum Direction {
49    Out,
50    In,
51    Both,
52}
53
54/// Result of `bundle_since` — the git-shaped incremental backup (§5.10).
55#[derive(Debug, Clone)]
56pub struct BundleStats {
57    pub ops: usize,
58    pub bytes: u64,
59    pub last_op_seq: i64,
60}
61
62/// Result of `import_bundle`.
63#[derive(Debug, Clone, Default)]
64pub struct ImportStats {
65    pub applied: usize,
66    pub skipped: usize,
67}
68
69/// Pluggable embedding backend. The host owns the model;
70/// multilingual recall quality comes from choosing a multilingual model
71/// (e.g. bge-m3 / multilingual-e5) — text reaches the backend as
72/// NFC-normalized UTF-8, script untouched (Arabic/Mandarin/English alike).
73pub trait EmbedBackend: Send + Sync {
74    fn dim(&self) -> usize;
75    fn embed(&self, text: &str) -> Result<Vec<f32>>;
76    /// Model identifier recorded as file provenance (e.g. "bge-m3").
77    /// Backends should override this; it lets a later open detect that the
78    /// stored vectors came from a different model.
79    fn model(&self) -> &str {
80        "unspecified"
81    }
82}
83
84/// [`EmbedBackend`] that shells out to a host-supplied command per call: the
85/// text goes to the child's stdin, stdout must be a JSON array of numbers.
86/// This is the dependency-free way to give every surface (CLI `--embed-cmd`,
87/// MCP serve, bindings) a real vector leg — the host owns the model, the
88/// engine still ships none. One process spawn per embed: fine for turn-level
89/// recall and imports, not for the voice per-frame path.
90pub struct CommandEmbed {
91    argv: Vec<String>,
92    dim: usize,
93    model: String,
94}
95
96impl CommandEmbed {
97    /// `cmd` is split on whitespace (no shell interpretation). The command is
98    /// probed once here to learn the vector dimension, so a broken command
99    /// fails loudly at setup rather than mid-recall.
100    pub fn new(cmd: &str, model: Option<&str>) -> Result<Self> {
101        let argv: Vec<String> = cmd.split_whitespace().map(str::to_string).collect();
102        if argv.is_empty() {
103            return Err(DejaDbError::Validation("embed command is empty".into()));
104        }
105        let mut ce = CommandEmbed {
106            argv,
107            dim: 0,
108            model: model.unwrap_or("command").to_string(),
109        };
110        let probe = ce.run("dimension probe")?;
111        if probe.is_empty() {
112            return Err(DejaDbError::Validation(
113                "embed command returned an empty vector".into(),
114            ));
115        }
116        ce.dim = probe.len();
117        Ok(ce)
118    }
119
120    fn run(&self, text: &str) -> Result<Vec<f32>> {
121        use std::io::Write;
122        use std::process::{Command, Stdio};
123        let cmd_err = |e: std::io::Error| {
124            DejaDbError::Storage(format!("embed command '{}': {e}", self.argv[0]))
125        };
126        let mut child = Command::new(&self.argv[0])
127            .args(&self.argv[1..])
128            .stdin(Stdio::piped())
129            .stdout(Stdio::piped())
130            .stderr(Stdio::inherit())
131            .spawn()
132            .map_err(cmd_err)?;
133        {
134            let mut stdin = child.stdin.take().expect("stdin piped");
135            stdin.write_all(text.as_bytes()).map_err(cmd_err)?;
136            // dropping stdin closes the pipe so the child sees EOF
137        }
138        let out = child.wait_with_output().map_err(cmd_err)?;
139        if !out.status.success() {
140            return Err(DejaDbError::Storage(format!(
141                "embed command '{}' exited with {}",
142                self.argv[0], out.status
143            )));
144        }
145        serde_json::from_slice::<Vec<f32>>(&out.stdout).map_err(|e| {
146            DejaDbError::Validation(format!(
147                "embed command output must be a JSON array of numbers: {e}"
148            ))
149        })
150    }
151}
152
153impl EmbedBackend for CommandEmbed {
154    fn dim(&self) -> usize {
155        self.dim
156    }
157    fn embed(&self, text: &str) -> Result<Vec<f32>> {
158        let v = self.run(text)?;
159        if v.len() != self.dim {
160            return Err(DejaDbError::Validation(format!(
161                "embed command returned {} dims, expected {}",
162                v.len(),
163                self.dim
164            )));
165        }
166        Ok(v)
167    }
168    fn model(&self) -> &str {
169        &self.model
170    }
171}
172
173/// Pluggable cross-encoder reranker (Tier-2
174/// retrieval). Like `EmbedBackend`, the host owns the model: inject a local
175/// candle/ONNX cross-encoder (or any scorer) — the engine ships no model and
176/// takes no ML dependency. Off by default; with no reranker installed recall
177/// behaves exactly as before. Reranking is a **turn-level** refinement (tens
178/// of ms), never on the voice per-frame path; `recall_hybrid_tuned` only
179/// invokes it inside the deadline and falls back to fusion order otherwise.
180pub trait RerankBackend: Send + Sync {
181    /// Relevance score for each `(query, doc)` pair, positionally aligned with
182    /// `docs`. Higher = more relevant. Scores are only ever compared among
183    /// themselves, so raw cross-encoder logits are fine (no normalization
184    /// required). Must return exactly `docs.len()` scores.
185    fn rerank(&self, query: &str, docs: &[&str]) -> Result<Vec<f32>>;
186    /// Model identifier for observability (e.g. "ms-marco-MiniLM-L-6-v2").
187    fn model(&self) -> &str {
188        "unspecified"
189    }
190}
191
192/// Pluggable rule-based query expander (Tier-1 retrieval). No LLM, no network.
193/// Given a query it returns additional query *variants*; the caller runs one
194/// extra BM25 leg per variant and fuses them via RRF, bridging vocabulary gaps
195/// ("cell" ↔ "mobile" ↔ "phone") — the poor-man's semantic bridge for the
196/// edge/BM25-only profile where no embedder is installed. The built-in
197/// [`EnglishExpander`] is **English-only**; multilingual deployments install
198/// their own or leave expansion off (it is opt-in per query).
199pub trait QueryExpander: Send + Sync {
200    /// Query variants to also search, NOT including the original. Empty = no
201    /// expansion. Implementations should keep this small and bounded.
202    fn expand(&self, query: &str) -> Vec<String>;
203}
204
205/// Built-in English query expander: synonym substitution + naive suffix
206/// stemming, capped to a handful of variants. Deterministic and allocation-
207/// light. English-only by design (see [`QueryExpander`]).
208pub struct EnglishExpander {
209    /// Cap on the number of variants returned (default 4).
210    max_variants: usize,
211}
212
213impl Default for EnglishExpander {
214    fn default() -> Self {
215        Self { max_variants: 4 }
216    }
217}
218
219impl EnglishExpander {
220    pub fn new(max_variants: usize) -> Self {
221        Self { max_variants: max_variants.clamp(1, 16) }
222    }
223
224    /// Synonyms for a lowercased token (both directions of each group).
225    fn synonyms(token: &str) -> &'static [&'static str] {
226        // Small, deterministic map. Each group lists the *other* members.
227        match token {
228            "cell" | "cellphone" => &["mobile", "phone"],
229            "mobile" => &["cell", "phone"],
230            "phone" => &["cell", "mobile", "telephone"],
231            "email" | "e-mail" => &["mail"],
232            "buy" | "bought" | "purchased" => &["purchase"],
233            "purchase" => &["buy"],
234            "car" | "automobile" => &["vehicle"],
235            "vehicle" => &["car"],
236            "doctor" | "physician" => &["doctor", "physician"],
237            "kid" | "kids" | "child" => &["child", "children"],
238            "spouse" => &["wife", "husband", "partner"],
239            "job" => &["work", "employer"],
240            "home" | "house" => &["residence", "address"],
241            "birthday" => &["birthdate", "born"],
242            "big" => &["large"],
243            "small" => &["little"],
244            _ => &[],
245        }
246    }
247
248    /// Naive English suffix stemmer for a single token: strips one common
249    /// inflection. Returns the stem only when it differs and stays ≥3 chars.
250    fn stem(token: &str) -> Option<String> {
251        let lower = token;
252        for suf in ["ing", "ed", "es", "s"] {
253            if lower.len() > suf.len() + 2 && lower.ends_with(suf) {
254                let stem = &lower[..lower.len() - suf.len()];
255                if stem.len() >= 3 {
256                    return Some(stem.to_string());
257                }
258            }
259        }
260        None
261    }
262}
263
264impl QueryExpander for EnglishExpander {
265    fn expand(&self, query: &str) -> Vec<String> {
266        let tokens: Vec<String> = query
267            .split_whitespace()
268            .map(|t| t.trim_matches(|c: char| !c.is_alphanumeric()).to_lowercase())
269            .filter(|t| !t.is_empty())
270            .collect();
271        if tokens.is_empty() {
272            return Vec::new();
273        }
274        let mut variants: Vec<String> = Vec::new();
275        let mut seen: HashSet<String> = HashSet::new();
276        let original = tokens.join(" ");
277        seen.insert(original.clone());
278
279        // 1. Synonym substitution: one variant per (position, synonym).
280        for (i, tok) in tokens.iter().enumerate() {
281            for syn in Self::synonyms(tok) {
282                let mut v = tokens.clone();
283                v[i] = (*syn).to_string();
284                let s = v.join(" ");
285                if seen.insert(s.clone()) {
286                    variants.push(s);
287                    if variants.len() >= self.max_variants {
288                        return variants;
289                    }
290                }
291            }
292        }
293
294        // 2. A fully-stemmed variant (all tokens stemmed where possible).
295        let stemmed: Vec<String> = tokens
296            .iter()
297            .map(|t| Self::stem(t).unwrap_or_else(|| t.clone()))
298            .collect();
299        let s = stemmed.join(" ");
300        if seen.insert(s.clone()) {
301            variants.push(s);
302        }
303
304        variants.truncate(self.max_variants);
305        variants
306    }
307}
308
309/// Post-fusion recall refinements. All default off — a bare
310/// `recall_hybrid` behaves exactly as before. Applied inside the recall
311/// deadline; each stage degrades to plain fusion order when its backend or
312/// data is unavailable (fail-open, never an error).
313#[derive(Debug, Clone, Copy, Default)]
314pub struct RecallTuning {
315    /// Tier-1: run rule-based query expansion (extra BM25 legs, RRF-fused).
316    /// Uses the installed [`QueryExpander`], or the built-in [`EnglishExpander`].
317    pub query_expansion: bool,
318    /// Tier-2: cross-encoder rerank the fused candidate pool via the installed
319    /// [`RerankBackend`]. Takes precedence over `diversity_lambda`.
320    pub rerank: bool,
321    /// Tier-1: MMR diversity reorder. `lambda` in `[0,1]` — 1.0 = pure
322    /// relevance, 0.0 = maximum diversity. Requires an embedder + stored
323    /// vectors; silently skipped otherwise.
324    pub diversity_lambda: Option<f32>,
325}
326
327/// One extracted fact from a `remember()` extraction callback.
328#[derive(Debug, Clone)]
329pub struct FactDraft {
330    pub subject: String,
331    pub relation: String,
332    pub object: String,
333    pub confidence: f64,
334}
335
336/// Result of `DejaDB::remember`.
337#[derive(Debug, Clone)]
338pub struct RememberResult {
339    pub observation: Hash,
340    pub facts: Vec<Hash>,
341}
342
343/// Integrity report (`DejaDB::verify`).
344#[derive(Debug, Clone)]
345pub struct VerifyReport {
346    pub integrity: String,
347    /// Benign notes from Turso's experimental FTS internal indexes
348    /// (integrity_check miscounts them; not data corruption).
349    pub fts_notes: Vec<String>,
350    pub grains: usize,
351    pub hash_mismatches: usize,
352    pub undecodable: usize,
353}
354
355/// Store statistics (`DejaDB::stats`).
356#[derive(Debug, Clone)]
357pub struct StoreStats {
358    pub grains: usize,
359    pub current: usize,
360    pub triples: usize,
361    pub terms: usize,
362    pub ops: usize,
363    pub events_indexed: usize,
364}
365
366/// One version in a supersession chain (`DejaDB::history`, newest first).
367#[derive(Debug, Clone)]
368pub struct HistoryEntry {
369    pub hash: Hash,
370    pub object: String,
371    pub created_at: i64,
372    pub confidence: f64,
373    pub superseded_by: Option<Hash>,
374}
375
376/// An open fork: a `(namespace, subject, relation)` that has more than one
377/// live head, because two writers superseded the same value concurrently
378/// (e.g. edits synced from two edges). The tips coexist — nothing is lost —
379/// until an explicit merge closes the fork. `heads[0]` is the deterministic
380/// provisional head every node agrees on.
381#[derive(Debug, Clone)]
382pub struct ForkGroup {
383    pub namespace: String,
384    pub subject: String,
385    pub relation: String,
386    pub heads: Vec<Hash>,
387}
388
389const BUNDLE_MAGIC: &[u8; 4] = b"MGB1";
390
391/// RRF fusion constant used by `recall_hybrid` (the standard k0 = 60).
392/// Exported so observability surfaces can report the effective value.
393pub const RRF_K0: f64 = 60.0;
394
395/// Absolute cap on the candidate pool a refinement stage (rerank / MMR)
396/// considers. Bounds cross-encoder cost and the MMR pairwise-similarity join
397/// regardless of how far a caller over-fetches; a larger requested `k` still
398/// widens the pool to at least `k`.
399const REFINE_POOL: usize = 64;
400
401/// Open options.
402pub struct DejaDbOptions {
403    /// Relations whose objects are entities (get OSP reverse-index rows).
404    /// Defaults to the OMS `mg:` entity-valued vocabulary.
405    pub entity_relations: HashSet<String>,
406    /// Populate the FTS text column (BM25 leg). Turso's experimental FTS
407    /// costs ~150ms per write txn on segment commits — voice/edge deployments
408    /// set this false (structural + vector legs still serve recall; §6).
409    pub index_text: bool,
410    /// Encryption-at-rest key: 32 bytes → AES-256-GCM via Turso's page cipher.
411    /// `None` = plaintext. Host-supplied capability, never persisted in the
412    /// file — a bare `open()` cannot supply it, so
413    /// encrypted files must be opened with `open_with`/`open_encrypted`.
414    /// Destroying the key destroys the memory (crypto-erasure). Covers the
415    /// memory database (grains, indexes, op-log, WAL); the `.blobs` CAS
416    /// sidecar is not yet encrypted.
417    pub encryption_key: Option<[u8; 32]>,
418}
419
420impl Default for DejaDbOptions {
421    fn default() -> Self {
422        let ents = [
423            "mg:delegates_to",
424            "mg:owned_by",
425            "mg:assigned_to",
426            "mg:depends_on",
427            "mg:handed_off_to",
428            "mg:capable_of",
429            "delegates_to",
430            "reports_to",
431            "part_of",
432            "knows",
433        ];
434        DejaDbOptions {
435            entity_relations: ents.iter().map(|s| s.to_string()).collect(),
436            index_text: true,
437            encryption_key: None,
438        }
439    }
440}
441
442const SCHEMA: &[&str] = &[
443    "CREATE TABLE IF NOT EXISTS meta(k TEXT PRIMARY KEY, v TEXT)",
444    "CREATE TABLE IF NOT EXISTS terms(id INTEGER PRIMARY KEY, term TEXT UNIQUE)",
445    "CREATE TABLE IF NOT EXISTS grains(
446        seq INTEGER PRIMARY KEY,
447        hash BLOB,
448        ns INTEGER, gtype INTEGER, created_at INTEGER,
449        s INTEGER, p INTEGER, o INTEGER,
450        vf INTEGER, vt INTEGER,
451        svf INTEGER, svt INTEGER,
452        superseded_by BLOB, supersedes BLOB,
453        text TEXT,
454        blob BLOB NOT NULL)",
455    "CREATE UNIQUE INDEX IF NOT EXISTS idx_grains_hash ON grains(hash)",
456    "CREATE TABLE IF NOT EXISTS embeddings(seq INTEGER PRIMARY KEY, vec BLOB)",
457    "CREATE TABLE IF NOT EXISTS triples(ns INTEGER, s INTEGER, p INTEGER, o INTEGER, seq INTEGER, cur INTEGER)",
458    "CREATE INDEX IF NOT EXISTS idx_spo ON triples(ns,s,p,o,seq)",
459    "CREATE INDEX IF NOT EXISTS idx_pos ON triples(ns,p,o,s,seq)",
460    "CREATE INDEX IF NOT EXISTS idx_triples_seq ON triples(seq)",
461    "CREATE TABLE IF NOT EXISTS osp(ns INTEGER, o INTEGER, s INTEGER, p INTEGER, seq INTEGER, cur INTEGER)",
462    "CREATE INDEX IF NOT EXISTS idx_osp ON osp(ns,o,s)",
463    "CREATE INDEX IF NOT EXISTS idx_osp_seq ON osp(seq)",
464    "CREATE TABLE IF NOT EXISTS entity_latest(ns INTEGER, s INTEGER, p INTEGER, o INTEGER, seq INTEGER, hash BLOB, PRIMARY KEY(ns,s,p))",
465    "CREATE TABLE IF NOT EXISTS heads(ns INTEGER, s INTEGER, p INTEGER, seq INTEGER, hash BLOB, created_at INTEGER, PRIMARY KEY(ns,s,p,seq))",
466    "CREATE TABLE IF NOT EXISTS oplog(op_seq INTEGER PRIMARY KEY, hlc INTEGER, op INTEGER, hash BLOB)",
467    "CREATE TABLE IF NOT EXISTS thread_idx(ns INTEGER, session INTEGER, seq INTEGER)",
468    "CREATE INDEX IF NOT EXISTS idx_thread ON thread_idx(ns, session, seq)",
469];
470
471fn pi(x: i64) -> Value {
472    Value::Integer(x)
473}
474fn pb(b: Vec<u8>) -> Value {
475    Value::Blob(b)
476}
477fn pt(s: &str) -> Value {
478    Value::Text(s.to_string())
479}
480fn opt_i(v: Option<i64>) -> Value {
481    match v {
482        Some(x) => Value::Integer(x),
483        None => Value::Null,
484    }
485}
486
487/// Hex-encode a 32-byte key for Turso `PRAGMA hexkey`.
488fn hex32(k: &[u8; 32]) -> String {
489    use std::fmt::Write;
490    let mut s = String::with_capacity(64);
491    for b in k {
492        let _ = write!(s, "{b:02x}");
493    }
494    s
495}
496
497// ---- passphrase key derivation (Argon2id) -------------------------------
498
499/// Argon2id parameters for passphrase-derived encryption keys (OWASP 2024).
500const KDF_M_COST: u32 = 19_456; // memory in KiB (19 MiB)
501const KDF_T_COST: u32 = 2; // iterations
502const KDF_P_COST: u32 = 1; // parallelism
503const KDF_SALT_LEN: usize = 16;
504
505fn kdf_err<E: std::fmt::Display>(e: E) -> DejaDbError {
506    DejaDbError::CryptoError(e.to_string())
507}
508
509/// Load the KDF salt/params sidecar at `<db>.kdf`, creating it with a fresh
510/// random salt if absent. The salt is not secret, but it must travel with the
511/// database file so the same passphrase re-derives the same key.
512fn load_or_create_kdf_sidecar(sidecar: &str) -> Result<([u8; KDF_SALT_LEN], u32, u32, u32)> {
513    match std::fs::read_to_string(sidecar) {
514        Ok(text) => parse_kdf_sidecar(&text, sidecar),
515        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
516            let mut salt = [0u8; KDF_SALT_LEN];
517            getrandom::getrandom(&mut salt).map_err(kdf_err)?;
518            let line = format!(
519                "v1 argon2id {} {KDF_M_COST} {KDF_T_COST} {KDF_P_COST}\n",
520                hex::encode(salt)
521            );
522            // Atomic create: if another process wrote the sidecar first, do not
523            // clobber it — re-read so both derive from the same persisted salt.
524            match std::fs::OpenOptions::new().write(true).create_new(true).open(sidecar) {
525                Ok(mut f) => {
526                    use std::io::Write;
527                    f.write_all(line.as_bytes()).map_err(kdf_err)?;
528                    Ok((salt, KDF_M_COST, KDF_T_COST, KDF_P_COST))
529                }
530                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
531                    let text = std::fs::read_to_string(sidecar).map_err(kdf_err)?;
532                    parse_kdf_sidecar(&text, sidecar)
533                }
534                Err(e) => Err(kdf_err(e)),
535            }
536        }
537        Err(e) => Err(kdf_err(e)),
538    }
539}
540
541/// Parse and sanity-check a KDF sidecar's contents.
542fn parse_kdf_sidecar(text: &str, sidecar: &str) -> Result<([u8; KDF_SALT_LEN], u32, u32, u32)> {
543    let toks: Vec<&str> = text.split_whitespace().collect();
544    if toks.len() != 6 || toks[0] != "v1" || toks[1] != "argon2id" {
545        return Err(kdf_err(format!("malformed KDF sidecar: {sidecar}")));
546    }
547    let salt_bytes = hex::decode(toks[2]).map_err(kdf_err)?;
548    if salt_bytes.len() != KDF_SALT_LEN {
549        return Err(kdf_err("KDF salt has wrong length"));
550    }
551    let mut salt = [0u8; KDF_SALT_LEN];
552    salt.copy_from_slice(&salt_bytes);
553    let m = toks[3].parse::<u32>().map_err(kdf_err)?;
554    let t = toks[4].parse::<u32>().map_err(kdf_err)?;
555    let p = toks[5].parse::<u32>().map_err(kdf_err)?;
556    // Reject absurd parameters (e.g. a tampered sidecar forcing a multi-GiB
557    // allocation → OOM). Bounds are generous but finite.
558    if !(8..=1_048_576).contains(&m) || !(1..=16).contains(&t) || !(1..=16).contains(&p) {
559        return Err(kdf_err("KDF parameters out of range"));
560    }
561    Ok((salt, m, t, p))
562}
563
564impl DejaDB {
565    /// Derive a 32-byte AES-256 key from a passphrase using Argon2id. The salt
566    /// and cost parameters live in a non-secret `<path>.kdf` sidecar created on
567    /// first use. The returned key zeroizes on drop.
568    ///
569    /// Losing the passphrase destroys the key (crypto-erasure); losing the
570    /// `.kdf` sidecar means the passphrase can no longer re-derive the key, so
571    /// back it up alongside the database.
572    pub fn derive_key_for(path: &str, passphrase: &str) -> Result<zeroize::Zeroizing<[u8; 32]>> {
573        if passphrase.trim().is_empty() {
574            return Err(kdf_err("passphrase must not be empty or whitespace-only"));
575        }
576        let sidecar = format!("{path}.kdf");
577        let (salt, m, t, p) = load_or_create_kdf_sidecar(&sidecar)?;
578        let params = argon2::Params::new(m, t, p, Some(32)).map_err(kdf_err)?;
579        let argon = argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
580        let mut key = zeroize::Zeroizing::new([0u8; 32]);
581        argon
582            .hash_password_into(passphrase.as_bytes(), &salt, &mut key[..])
583            .map_err(kdf_err)?;
584        Ok(key)
585    }
586
587    /// Open (or create) an encrypted memory using a passphrase-derived key
588    /// (Argon2id + AES-256-GCM at rest). Convenience over
589    /// [`DejaDB::derive_key_for`] + [`DejaDB::open_with`].
590    pub fn open_with_passphrase(path: &str, passphrase: &str) -> Result<Self> {
591        let key = Self::derive_key_for(path, passphrase)?;
592        Self::open_with(
593            path,
594            DejaDbOptions { encryption_key: Some(*key), ..DejaDbOptions::default() },
595        )
596    }
597}
598
599
600fn vec_to_json(v: &[f32]) -> String {
601    let mut s = String::with_capacity(v.len() * 8);
602    s.push('[');
603    for (i, x) in v.iter().enumerate() {
604        if i > 0 { s.push(','); }
605        s.push_str(&format!("{x}"));
606    }
607    s.push(']');
608    s
609}
610
611fn now_ms() -> i64 {
612    SystemTime::now()
613        .duration_since(UNIX_EPOCH)
614        .map(|d| d.as_millis() as i64)
615        .unwrap_or(0)
616}
617
618fn db_err<E: std::fmt::Display>(e: E) -> DejaDbError {
619    DejaDbError::Storage(e.to_string())
620}
621
622fn v_i64(v: &Value) -> Option<i64> {
623    match v {
624        Value::Integer(i) => Some(*i),
625        _ => None,
626    }
627}
628
629fn v_blob(v: &Value) -> Option<Vec<u8>> {
630    match v {
631        Value::Blob(b) => Some(b.clone()),
632        _ => None,
633    }
634}
635
636fn v_f64(v: &Value) -> Option<f64> {
637    match v {
638        Value::Real(r) => Some(*r),
639        Value::Integer(i) => Some(*i as f64),
640        _ => None,
641    }
642}
643
644/// Comma-separated list of i64 seqs for an inline `IN (...)` clause. Safe:
645/// the values are engine-internal seq ids, never user text.
646fn seq_csv(seqs: &[i64]) -> String {
647    let mut s = String::with_capacity(seqs.len() * 6);
648    for (i, x) in seqs.iter().enumerate() {
649        if i > 0 {
650            s.push(',');
651        }
652        s.push_str(&x.to_string());
653    }
654    s
655}
656
657/// A grain fully prepared for insertion (serialized + extracted + encoded).
658struct GrainPrep {
659    blob: Vec<u8>,
660    hash: Hash,
661    ns_id: i64,
662    s: Option<i64>,
663    p: Option<i64>,
664    o: Option<i64>,
665    osp: bool,
666    session: Option<i64>,
667    vf: Option<i64>,
668    vt: Option<i64>,
669    created: i64,
670    gtype: i64,
671    text: Option<String>,
672    embedding: Option<Vec<f32>>,
673}
674
675/// Extracted index-relevant fields of a grain about to be stored.
676struct GrainView {
677    ns: String,
678    subject: Option<String>,
679    relation: Option<String>,
680    object: Option<String>,
681    session: Option<String>,
682    vf: Option<i64>,
683    vt: Option<i64>,
684    created_at: i64,
685    gtype: u8,
686}
687
688fn extract_view(view: &DeserializedGrain) -> GrainView {
689    GrainView {
690        ns: view.get_str("namespace").unwrap_or("shared").to_string(),
691        subject: view.get_str("subject").map(str::to_string),
692        relation: view.get_str("relation").map(str::to_string),
693        object: view.get_str("object").map(str::to_string),
694        session: view.get_str("session_id").map(str::to_string),
695        vf: view.get_i64("valid_from"),
696        vt: view.get_i64("valid_to"),
697        created_at: view.get_i64("created_at").unwrap_or_else(now_ms),
698        gtype: view.grain_type as u8,
699    }
700}
701
702/// The single text projection the FTS and vector legs index: the grain's
703/// explicit `embedding_text` override when present (its documented contract —
704/// import pipelines set it to preserve original prose), else
705/// "subject relation object" plus any top-level `content`. `None` = nothing
706/// to index. Used by the write path, the reranker's candidate text, and the
707/// `rebuild_text_index` backfill so all three stay in lockstep.
708fn projected_text(view: &DeserializedGrain) -> Option<String> {
709    if let Some(et) = view.get_str("embedding_text") {
710        if !et.trim().is_empty() {
711            return Some(et.to_string());
712        }
713    }
714    let mut parts: Vec<String> = Vec::new();
715    if let (Some(s), Some(r), Some(o)) = (
716        view.get_str("subject"),
717        view.get_str("relation"),
718        view.get_str("object"),
719    ) {
720        parts.push(format!("{s} {r} {o}"));
721    }
722    if let Some(c) = view.get_str("content") {
723        parts.push(c.to_string());
724    }
725    if parts.is_empty() {
726        None
727    } else {
728        Some(parts.join(" "))
729    }
730}
731
732/// The embedded DejaDB store handle — one file per memory.
733pub struct DejaDB {
734    rt: tokio::runtime::Runtime,
735    _db: turso::Database,
736    conn: Connection,
737    dict: HashMap<String, i64>,
738    next_term: i64,
739    next_seq: i64,
740    next_op: i64,
741    hlc_last: i64,
742    entity_rels: HashSet<String>,
743    index_text: bool,
744    embedder: Option<Box<dyn EmbedBackend>>,
745    /// Optional cross-encoder reranker (Tier-2). Host-supplied, off by default.
746    reranker: Option<Box<dyn RerankBackend>>,
747    /// Optional query expander (Tier-1). `None` falls back to the built-in
748    /// English expander when `RecallTuning::query_expansion` is set.
749    expander: Option<Box<dyn QueryExpander>>,
750    /// Embedding provenance declared by the file (meta table): model + dim
751    /// of the vectors already stored, recorded when the first backend is
752    /// installed.
753    meta_embed: Option<(String, usize)>,
754    /// Reconciliation notes from open / set_embedder (file declarations vs
755    /// what this session supplied). Never fatal; surfaced by hosts.
756    warnings: Vec<String>,
757    blob_dir: std::path::PathBuf,
758    // cached hot-path statements (lazily prepared)
759    st_probe_sp: Option<turso::Statement>,
760    st_probe_s: Option<turso::Statement>,
761    st_fetch_seq: Option<turso::Statement>,
762    st_latest: Option<turso::Statement>,
763}
764
765async fn ensure_stmt<'a>(
766    slot: &'a mut Option<turso::Statement>,
767    conn: &Connection,
768    sql: &str,
769) -> Result<&'a mut turso::Statement> {
770    if slot.is_none() {
771        *slot = Some(conn.prepare(sql).await.map_err(db_err)?);
772    }
773    Ok(slot.as_mut().unwrap())
774}
775
776impl DejaDB {
777    /// Open honoring the file's own declarations (`meta` table) when
778    /// present. A fresh file is stamped with the defaults. This is the
779    /// file-truth path: settings like `text_index` travel with the file,
780    /// so the same memory behaves identically on any host.
781    pub fn open(path: &str) -> Result<Self> {
782        Self::open_internal(path, None)
783    }
784
785    /// Open with explicit options. Explicit options are deliberate: they
786    /// re-stamp the file's declarations, and a change to an existing
787    /// declaration is recorded in `open_warnings()`.
788    pub fn open_with(path: &str, opts: DejaDbOptions) -> Result<Self> {
789        Self::open_internal(path, Some(opts))
790    }
791
792    /// Open (or create) an encrypted memory: AES-256-GCM at rest with a
793    /// host-supplied 32-byte key (Turso page cipher). The key lives only in
794    /// the caller's process — never written to the file — so a bare `open()`
795    /// of this path cannot read it, and destroying the key destroys the
796    /// memory (crypto-erasure). Default index/relation options otherwise.
797    pub fn open_encrypted(path: &str, key: [u8; 32]) -> Result<Self> {
798        Self::open_with(
799            path,
800            DejaDbOptions { encryption_key: Some(key), ..DejaDbOptions::default() },
801        )
802    }
803
804    fn open_internal(path: &str, explicit: Option<DejaDbOptions>) -> Result<Self> {
805        let rt = tokio::runtime::Builder::new_current_thread()
806            .enable_all()
807            .build()
808            .map_err(db_err)?;
809        // Keep the AEAD key only in a Zeroizing buffer for the duration of the
810        // open; the raw copies (options + this local) are wiped once turso has
811        // ingested it, so no unzeroized key bytes linger on the heap.
812        let enc_key = explicit
813            .as_ref()
814            .and_then(|o| o.encryption_key)
815            .map(zeroize::Zeroizing::new);
816        let (db, conn) = rt.block_on(async {
817            let mut b = Builder::new_local(path).experimental_index_method(true);
818            if let Some(k) = &enc_key {
819                // Provide the AEAD key at BUILD time so the encrypted file
820                // header decrypts on the first read. (PRAGMA-after-connect
821                // works for create but not reopen — the header is read at
822                // connect, before a PRAGMA could set the key.)
823                // Wipe our hex rendering of the key after the builder copies it;
824                // the storage engine necessarily retains its own copy while the
825                // database is open.
826                let hexkey = zeroize::Zeroizing::new(hex32(k));
827                b = b.experimental_encryption(true).with_encryption(turso::EncryptionOpts {
828                    cipher: "aes256gcm".to_string(),
829                    hexkey: (*hexkey).clone(),
830                });
831            }
832            let db = b.build().await.map_err(db_err)?;
833            let conn = db.connect().map_err(db_err)?;
834            for sql in SCHEMA {
835                conn.execute(sql, ()).await.map_err(db_err)?;
836            }
837            Ok::<_, DejaDbError>((db, conn))
838        })?;
839
840        // ---- file-carried declarations (meta k/v) --------------------
841        let meta: HashMap<String, String> = rt.block_on(async {
842            let mut m = HashMap::new();
843            let mut rows = conn.query("SELECT k, v FROM meta", ()).await.map_err(db_err)?;
844            while let Some(row) = rows.next().await.map_err(db_err)? {
845                if let (Value::Text(k), Value::Text(v)) = (
846                    row.get_value(0).map_err(db_err)?,
847                    row.get_value(1).map_err(db_err)?,
848                ) {
849                    m.insert(k, v);
850                }
851            }
852            Ok::<_, DejaDbError>(m)
853        })?;
854        let declared_text = meta.get("text_index").map(|v| v == "1");
855        let declared_rels: Option<HashSet<String>> = meta
856            .get("entity_relations")
857            .and_then(|v| serde_json::from_str::<Vec<String>>(v).ok())
858            .map(|v| v.into_iter().collect());
859        let meta_embed = match (
860            meta.get("embedding_model"),
861            meta.get("embedding_dim").and_then(|d| d.parse::<usize>().ok()),
862        ) {
863            (Some(m), Some(d)) => Some((m.clone(), d)),
864            _ => None,
865        };
866
867        let mut warnings: Vec<String> = Vec::new();
868        if enc_key.is_some() {
869            warnings.push(
870                "encryption-at-rest ON (AES-256-GCM): the memory database is encrypted; the \
871                 .blobs CAS sidecar is NOT yet encrypted — keep sensitive media out of this file \
872                 (or avoid put_blob) until blob encryption lands"
873                    .into(),
874            );
875        }
876        let mut opts = match explicit {
877            Some(o) => {
878                if let Some(d) = declared_text {
879                    if d != o.index_text {
880                        warnings.push(format!(
881                            "file declared text_index={}; explicit open changed it to {} (re-stamped) — \
882                             grains written under the old setting keep their old indexing",
883                            if d { "on" } else { "off" },
884                            if o.index_text { "on" } else { "off" },
885                        ));
886                    }
887                }
888                if let Some(ref d) = declared_rels {
889                    if *d != o.entity_relations {
890                        warnings.push(
891                            "file-declared entity_relations differ from explicit options (re-stamped) — \
892                             OSP rows indexed under the old set are unchanged"
893                                .into(),
894                        );
895                    }
896                }
897                o
898            }
899            None => DejaDbOptions {
900                index_text: declared_text.unwrap_or(true),
901                entity_relations: declared_rels
902                    .unwrap_or_else(|| DejaDbOptions::default().entity_relations),
903                encryption_key: None,
904            },
905        };
906        // The plaintext key now lives only inside the storage engine (turso keeps
907        // its own copy while the database is open); wipe the copy carried in the
908        // open options so no unzeroized key bytes linger.
909        opts.encryption_key.zeroize();
910
911        // Stamp declarations + create the FTS index if wanted.
912        rt.block_on(async {
913            conn.execute(
914                "INSERT OR REPLACE INTO meta(k, v) VALUES ('text_index', ?1)",
915                (pt(if opts.index_text { "1" } else { "0" }),),
916            )
917            .await
918            .map_err(db_err)?;
919            let mut rels: Vec<&String> = opts.entity_relations.iter().collect();
920            rels.sort();
921            let rels = serde_json::to_string(&rels).unwrap_or_else(|_| "[]".into());
922            conn.execute(
923                "INSERT OR REPLACE INTO meta(k, v) VALUES ('entity_relations', ?1)",
924                (pt(&rels),),
925            )
926            .await
927            .map_err(db_err)?;
928            if opts.index_text {
929                // FTS index only when the BM25 leg is wanted: Turso's
930                // experimental tantivy index costs ~150ms per write txn in
931                // commit bookkeeping even for NULL text rows (measured in
932                // the voice-loop bench) — voice/edge profiles skip it.
933                conn.execute("CREATE INDEX IF NOT EXISTS idx_fts ON grains USING fts (text)", ())
934                    .await
935                    .map_err(db_err)?;
936            }
937            Ok::<_, DejaDbError>(())
938        })?;
939
940        // Load dictionary + counters.
941        let (dict, next_term, next_seq, next_op, hlc_last) = rt.block_on(async {
942            let mut dict = HashMap::new();
943            let mut next_term = 1i64;
944            {
945                let mut rows = conn.query("SELECT id, term FROM terms", ()).await.map_err(db_err)?;
946                while let Some(row) = rows.next().await.map_err(db_err)? {
947                    let id = v_i64(&row.get_value(0).map_err(db_err)?).unwrap_or(0);
948                    if let Value::Text(t) = row.get_value(1).map_err(db_err)? {
949                        dict.insert(t, id);
950                    }
951                    next_term = next_term.max(id + 1);
952                }
953            }
954            let one = |sql: &'static str| {
955                let conn = conn.clone();
956                async move {
957                    let mut rows = conn.query(sql, ()).await.map_err(db_err)?;
958                    let v = match rows.next().await.map_err(db_err)? {
959                        Some(row) => v_i64(&row.get_value(0).map_err(db_err)?).unwrap_or(0),
960                        None => 0,
961                    };
962                    Ok::<i64, DejaDbError>(v)
963                }
964            };
965            let next_seq = one("SELECT COALESCE(MAX(seq),0) FROM grains").await? + 1;
966            let next_op = one("SELECT COALESCE(MAX(op_seq),0) FROM oplog").await? + 1;
967            let hlc_last = one("SELECT COALESCE(MAX(hlc),0) FROM oplog").await?;
968            Ok::<_, DejaDbError>((dict, next_term, next_seq, next_op, hlc_last))
969        })?;
970
971        let blob_dir = std::path::PathBuf::from(format!("{}.blobs", path));
972        std::fs::create_dir_all(&blob_dir).map_err(db_err)?;
973
974        Ok(DejaDB {
975            rt,
976            _db: db,
977            conn,
978            dict,
979            next_term,
980            next_seq,
981            next_op,
982            hlc_last,
983            entity_rels: opts.entity_relations,
984            index_text: opts.index_text,
985            embedder: None,
986            reranker: None,
987            expander: None,
988            meta_embed,
989            warnings,
990            blob_dir,
991            st_probe_sp: None,
992            st_probe_s: None,
993            st_fetch_seq: None,
994            st_latest: None,
995        })
996    }
997
998    /// Install an embedding backend; subsequent adds embed their text
999    /// and the vector leg joins hybrid recall.
1000    ///
1001    /// The first installed backend is recorded in the file's `meta` table
1002    /// as embedding provenance (model + dim). A later open that injects a
1003    /// different-dim backend gets a reconciliation warning instead of
1004    /// silently mixing vector spaces.
1005    pub fn set_embedder(&mut self, e: Box<dyn EmbedBackend>) {
1006        let (model, dim) = (e.model().to_string(), e.dim());
1007        match &self.meta_embed {
1008            Some((m, d)) => {
1009                if *d != dim {
1010                    self.warnings.push(format!(
1011                        "embedding mismatch: file vectors are {m}@{d}, injected backend is \
1012                         {model}@{dim} — vector recall may be degraded"
1013                    ));
1014                } else if *m != model && m != "unspecified" && model != "unspecified" {
1015                    self.warnings.push(format!(
1016                        "embedding model differs: file declares {m}, injected {model} (same dim {dim})"
1017                    ));
1018                }
1019            }
1020            None => {
1021                let conn = &self.conn;
1022                let ok = self.rt.block_on(async {
1023                    conn.execute(
1024                        "INSERT OR REPLACE INTO meta(k, v) VALUES ('embedding_model', ?1)",
1025                        (pt(&model),),
1026                    )
1027                    .await
1028                    .map_err(db_err)?;
1029                    conn.execute(
1030                        "INSERT OR REPLACE INTO meta(k, v) VALUES ('embedding_dim', ?1)",
1031                        (pt(&dim.to_string()),),
1032                    )
1033                    .await
1034                    .map_err(db_err)?;
1035                    Ok::<_, DejaDbError>(())
1036                });
1037                if ok.is_ok() {
1038                    self.meta_embed = Some((model, dim));
1039                }
1040            }
1041        }
1042        self.embedder = Some(e);
1043    }
1044
1045    /// Install a cross-encoder reranker (Tier-2). Opt-in per query via
1046    /// `RecallTuning::rerank`; with none installed, requesting rerank is a
1047    /// no-op (fusion order stands). Host owns the model — no ML dep in-engine.
1048    pub fn set_reranker(&mut self, r: Box<dyn RerankBackend>) {
1049        self.reranker = Some(r);
1050    }
1051
1052    /// Whether a reranker backend is installed.
1053    pub fn has_reranker(&self) -> bool {
1054        self.reranker.is_some()
1055    }
1056
1057    /// Install a custom query expander (Tier-1). When unset, requesting
1058    /// `RecallTuning::query_expansion` falls back to the built-in English
1059    /// [`EnglishExpander`]. Install your own for other languages/domains.
1060    pub fn set_query_expander(&mut self, e: Box<dyn QueryExpander>) {
1061        self.expander = Some(e);
1062    }
1063
1064    /// Whether the BM25 text index is populated on writes (file-declared,
1065    /// honored or re-stamped at open).
1066    pub fn index_text_enabled(&self) -> bool {
1067        self.index_text
1068    }
1069
1070    /// Drop the FTS index ahead of a bulk load. Turso's experimental FTS
1071    /// costs ~150ms of commit bookkeeping per write transaction while the
1072    /// index exists — a tax bulk imports cannot amortize. With the index
1073    /// dropped, the `text` column keeps populating at full write speed; call
1074    /// [`Self::rebuild_text_index`] after the load to re-create the index
1075    /// (Turso indexes all existing rows at CREATE INDEX time — milliseconds,
1076    /// not per-row). Crash-safe: if the process dies in between, the next
1077    /// open re-creates the index and backfills it.
1078    ///
1079    /// Index-layer only — stored blobs are never touched. Returns `false`
1080    /// when there was nothing to defer (text indexing off, or already
1081    /// deferred).
1082    pub fn defer_text_index(&mut self) -> Result<bool> {
1083        if !self.index_text {
1084            return Ok(false);
1085        }
1086        let conn = &self.conn;
1087        self.rt.block_on(async {
1088            match conn.execute("DROP INDEX idx_fts", ()).await {
1089                Ok(_) => Ok(true),
1090                Err(e) => {
1091                    // Already absent (e.g. defer called twice) is not an error.
1092                    let s = e.to_string().to_ascii_lowercase();
1093                    if s.contains("no such index") {
1094                        Ok(false)
1095                    } else {
1096                        Err(db_err(e))
1097                    }
1098                }
1099            }
1100        })
1101    }
1102
1103    /// (Re)build the FTS index. Backfills the `text` column for rows written
1104    /// while text indexing was off — deriving the same `projected_text`
1105    /// the inline write path uses — then re-creates the index, which indexes
1106    /// every existing row. Pairs with [`Self::defer_text_index`] around bulk
1107    /// loads, and turns a file that flipped `--index-text true` after the
1108    /// fact into a fully searchable one. Index-layer only — stored blobs are
1109    /// never touched; forgotten grains are gone from `grains` and cannot be
1110    /// resurrected. Returns the number of rows whose text was backfilled.
1111    ///
1112    /// Errors when the file declares text indexing off — reopen with
1113    /// `index_text: true` (CLI `--index-text true`) first.
1114    pub fn rebuild_text_index(&mut self) -> Result<usize> {
1115        if !self.index_text {
1116            return Err(DejaDbError::Validation(
1117                "text indexing is off for this file — reopen with --index-text true \
1118                 (open_with index_text) before rebuilding the FTS index"
1119                    .to_string(),
1120            ));
1121        }
1122        // 1) Backfill NULL text from the immutable blobs (cheap: no index yet
1123        //    or index about to be rebuilt; the projection is identical to the
1124        //    write path's).
1125        let conn = &self.conn;
1126        let rows: Vec<(i64, Vec<u8>)> = self.rt.block_on(async {
1127            let mut out = Vec::new();
1128            let mut rows = conn
1129                .query("SELECT seq, blob FROM grains WHERE text IS NULL", ())
1130                .await
1131                .map_err(db_err)?;
1132            while let Some(row) = rows.next().await.map_err(db_err)? {
1133                let seq = v_i64(&row.get_value(0).map_err(db_err)?).unwrap_or(0);
1134                if let Value::Blob(b) = row.get_value(1).map_err(db_err)? {
1135                    out.push((seq, b));
1136                }
1137            }
1138            Ok::<_, DejaDbError>(out)
1139        })?;
1140        let mut updates: Vec<(i64, String)> = Vec::new();
1141        for (seq, blob) in &rows {
1142            let view = deserialize_blob(blob)?;
1143            if let Some(t) = projected_text(&view) {
1144                updates.push((*seq, t));
1145            }
1146        }
1147        let backfilled = updates.len();
1148        self.rt.block_on(async {
1149            conn.execute("BEGIN", ()).await.map_err(db_err)?;
1150            let r = async {
1151                for (seq, t) in &updates {
1152                    conn.execute(
1153                        "UPDATE grains SET text = ?1 WHERE seq = ?2",
1154                        (pt(t), pi(*seq)),
1155                    )
1156                    .await
1157                    .map_err(db_err)?;
1158                }
1159                Ok::<_, DejaDbError>(())
1160            }
1161            .await;
1162            match r {
1163                Ok(()) => conn.execute("COMMIT", ()).await.map_err(db_err).map(|_| ()),
1164                Err(e) => {
1165                    let _ = conn.execute("ROLLBACK", ()).await;
1166                    Err(e)
1167                }
1168            }
1169        })?;
1170        // 2) Re-create the index — Turso backfills all existing rows here.
1171        self.rt.block_on(async {
1172            conn.execute(
1173                "CREATE INDEX IF NOT EXISTS idx_fts ON grains USING fts (text)",
1174                (),
1175            )
1176            .await
1177            .map_err(db_err)
1178        })?;
1179        Ok(backfilled)
1180    }
1181
1182    /// Dimension of the installed embedding backend, if any. `None` means
1183    /// the vector recall leg is off for this store.
1184    pub fn embedder_dim(&self) -> Option<usize> {
1185        self.embedder.as_ref().map(|e| e.dim())
1186    }
1187
1188    /// Embedding provenance declared by the file (model, dim), if any
1189    /// vectors were ever written.
1190    pub fn declared_embedding(&self) -> Option<(&str, usize)> {
1191        self.meta_embed.as_ref().map(|(m, d)| (m.as_str(), *d))
1192    }
1193
1194    /// Reconciliation warnings from open / set_embedder: file declarations
1195    /// vs what this session supplied. Empty when everything agrees.
1196    pub fn open_warnings(&self) -> &[String] {
1197        &self.warnings
1198    }
1199
1200    fn next_hlc(&mut self) -> i64 {
1201        let wall = now_ms() << 16;
1202        self.hlc_last = if wall > self.hlc_last { wall } else { self.hlc_last + 1 };
1203        self.hlc_last
1204    }
1205
1206    /// Dictionary-encode a term (cached; inserts on miss).
1207    fn term_id(&mut self, term: &str) -> Result<i64> {
1208        if let Some(id) = self.dict.get(term) {
1209            return Ok(*id);
1210        }
1211        let id = self.next_term;
1212        self.next_term += 1;
1213        let conn = &self.conn;
1214        self.rt.block_on(async {
1215            conn.execute("INSERT INTO terms(id, term) VALUES (?1, ?2)", (pi(id), pt(term)))
1216                .await
1217                .map_err(db_err)
1218        })?;
1219        self.dict.insert(term.to_string(), id);
1220        Ok(id)
1221    }
1222
1223    fn term_lookup(&self, term: &str) -> Option<i64> {
1224        self.dict.get(term).copied()
1225    }
1226
1227    fn term_str(&self, id: i64) -> Option<String> {
1228        self.dict
1229            .iter()
1230            .find(|(_, v)| **v == id)
1231            .map(|(k, _)| k.clone())
1232    }
1233
1234    // ----- write path -----
1235
1236    /// Add one grain (full txn). Returns its content address.
1237    pub fn add<G: Grain + 'static>(&mut self, grain: &G) -> Result<Hash> {
1238        self.add_batch_inner(std::slice::from_ref(&(grain as &dyn AddableDyn)))
1239            .map(|mut v| v.remove(0))
1240    }
1241
1242    /// Batched add — one txn for the whole slice (voice write-back path).
1243    pub fn add_batch(&mut self, grains: &[&dyn AddableDyn]) -> Result<Vec<Hash>> {
1244        self.add_batch_inner(grains)
1245    }
1246
1247    /// Value-level idempotent add. When the grain carries a full
1248    /// `(subject, relation, object)` triple and the current head for
1249    /// `(ns, subject, relation)` already holds this exact object, nothing is
1250    /// written and the existing head's hash is returned with `false`.
1251    /// Otherwise it behaves like [`add`](Self::add) and returns `true`.
1252    ///
1253    /// This collapses a re-learned *value*, not merely a byte-identical
1254    /// replay: unlike content addressing it ignores `created_at` and the rest
1255    /// of the envelope, keying only on `(ns, subject, relation, object)`
1256    /// against the current provisional head. Grains without a full triple
1257    /// always insert. Paraphrased near-duplicates are a *different* object and
1258    /// out of scope here — those need a host-side (embedding) novelty check.
1259    pub fn add_if_novel<G: Grain + 'static>(&mut self, grain: &G) -> Result<(Hash, bool)> {
1260        self.add_dyn_if_novel(grain as &dyn AddableDyn)
1261    }
1262
1263    fn add_dyn_if_novel(&mut self, grain: &dyn AddableDyn) -> Result<(Hash, bool)> {
1264        let (blob, _hash) = grain.serialize_dyn()?;
1265        let gv = extract_view(&deserialize_blob(&blob)?);
1266        if let (Some(sj), Some(rl), Some(ob)) =
1267            (gv.subject.as_deref(), gv.relation.as_deref(), gv.object.as_deref())
1268        {
1269            // All three terms must already exist for a prior head to match;
1270            // a never-seen object can't be a duplicate, so we skip the probe.
1271            if let (Some(ns_id), Some(s_id), Some(p_id), Some(o_id)) = (
1272                self.term_lookup(&gv.ns),
1273                self.term_lookup(sj),
1274                self.term_lookup(rl),
1275                self.term_lookup(ob),
1276            ) {
1277                if let Some(existing) = self.head_hash_for_object(ns_id, s_id, p_id, o_id)? {
1278                    return Ok((existing, false));
1279                }
1280            }
1281        }
1282        let h = self
1283            .add_batch_inner(std::slice::from_ref(&grain))?
1284            .remove(0);
1285        Ok((h, true))
1286    }
1287
1288    /// Hash of the current provisional head for `(ns, s, p)` iff its object is
1289    /// exactly `o` — the µs probe behind [`add_if_novel`](Self::add_if_novel).
1290    fn head_hash_for_object(&mut self, ns: i64, s: i64, p: i64, o: i64) -> Result<Option<Hash>> {
1291        let conn = &self.conn;
1292        let bytes = self.rt.block_on(async {
1293            let mut rows = conn
1294                .query(
1295                    "SELECT hash FROM entity_latest WHERE ns=?1 AND s=?2 AND p=?3 AND o=?4",
1296                    (pi(ns), pi(s), pi(p), pi(o)),
1297                )
1298                .await
1299                .map_err(db_err)?;
1300            Ok::<_, DejaDbError>(match rows.next().await.map_err(db_err)? {
1301                Some(row) => v_blob(&row.get_value(0).map_err(db_err)?),
1302                None => None,
1303            })
1304        })?;
1305        match bytes {
1306            Some(b) => Ok(Some(Hash::try_from_bytes(&b)?)),
1307            None => Ok(None),
1308        }
1309    }
1310
1311    /// Serialize-side preparation shared by `add_batch` and bundle import.
1312    fn prep_from_blob(&mut self, blob: Vec<u8>, hash: Hash) -> Result<GrainPrep> {
1313        let view = deserialize_blob(&blob)?;
1314        let gv = extract_view(&view);
1315        let ns_id = self.term_id(&gv.ns)?;
1316        let (mut s, mut p, mut o, mut osp) = (None, None, None, false);
1317        if let (Some(sj), Some(rl), Some(ob)) = (&gv.subject, &gv.relation, &gv.object) {
1318            s = Some(self.term_id(sj)?);
1319            p = Some(self.term_id(rl)?);
1320            o = Some(self.term_id(ob)?);
1321            osp = self.entity_rels.contains(rl.as_str());
1322        }
1323        let session = match &gv.session {
1324            Some(x) => Some(self.term_id(x)?),
1325            None => None,
1326        };
1327        let projected = projected_text(&view);
1328        let text = if self.index_text { projected.clone() } else { None };
1329        let embed_text = projected;
1330        let embedding = match (&self.embedder, &embed_text) {
1331            (Some(e), Some(t)) => Some(e.embed(t)?),
1332            _ => None,
1333        };
1334        Ok(GrainPrep {
1335            blob,
1336            hash,
1337            ns_id,
1338            s,
1339            p,
1340            o,
1341            osp,
1342            session,
1343            vf: gv.vf,
1344            vt: gv.vt,
1345            created: gv.created_at,
1346            gtype: gv.gtype as i64,
1347            text,
1348            embedding,
1349        })
1350    }
1351
1352    fn add_batch_inner(&mut self, grains: &[&dyn AddableDyn]) -> Result<Vec<Hash>> {
1353        // Serialize + extract + dictionary-encode before entering the txn.
1354        let mut preps = Vec::with_capacity(grains.len());
1355        for g in grains {
1356            let (blob, hash) = g.serialize_dyn()?;
1357            preps.push(self.prep_from_blob(blob, hash)?);
1358        }
1359
1360        let first_seq = self.next_seq;
1361        self.next_seq += preps.len() as i64;
1362        let first_op = self.next_op;
1363        self.next_op += preps.len() as i64;
1364        let hlc0 = self.next_hlc();
1365        self.hlc_last = hlc0 + preps.len() as i64 - 1;
1366
1367        let conn = &self.conn;
1368        let hashes: Vec<Hash> = preps.iter().map(|p| p.hash).collect();
1369        self.rt.block_on(async {
1370            conn.execute("BEGIN", ()).await.map_err(db_err)?;
1371            let r = async {
1372                let mut st_g = conn
1373                    .prepare(
1374                        "INSERT INTO grains(seq,hash,ns,gtype,created_at,s,p,o,vf,vt,svf,svt,superseded_by,supersedes,text,blob)
1375                         VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,NULL,NULL,NULL,?12,?13)",
1376                    )
1377                    .await
1378                    .map_err(db_err)?;
1379                let mut st_t = conn
1380                    .prepare("INSERT INTO triples(ns,s,p,o,seq,cur) VALUES (?1,?2,?3,?4,?5,1)")
1381                    .await
1382                    .map_err(db_err)?;
1383                let mut st_o = conn
1384                    .prepare("INSERT INTO osp(ns,o,s,p,seq,cur) VALUES (?1,?2,?3,?4,?5,1)")
1385                    .await
1386                    .map_err(db_err)?;
1387                let mut st_e = conn
1388                    .prepare("INSERT OR REPLACE INTO entity_latest(ns,s,p,o,seq,hash) VALUES (?1,?2,?3,?4,?5,?6)")
1389                    .await
1390                    .map_err(db_err)?;
1391                let mut st_l = conn
1392                    .prepare("INSERT INTO oplog(op_seq,hlc,op,hash) VALUES (?1,?2,?3,?4)")
1393                    .await
1394                    .map_err(db_err)?;
1395                let mut st_th = conn
1396                    .prepare("INSERT INTO thread_idx(ns,session,seq) VALUES (?1,?2,?3)")
1397                    .await
1398                    .map_err(db_err)?;
1399                for (i, pr) in preps.iter().enumerate() {
1400                    let seq = first_seq + i as i64;
1401                    st_g.execute((
1402                        pi(seq),
1403                        pb(pr.hash.as_bytes().to_vec()),
1404                        pi(pr.ns_id),
1405                        pi(pr.gtype),
1406                        pi(pr.created),
1407                        opt_i(pr.s),
1408                        opt_i(pr.p),
1409                        opt_i(pr.o),
1410                        opt_i(pr.vf),
1411                        opt_i(pr.vt),
1412                        pi(pr.created),
1413                        match &pr.text { Some(t) => pt(t), None => Value::Null },
1414                        pb(pr.blob.clone()),
1415                    ))
1416                    .await
1417                    .map_err(db_err)?;
1418                    if let (Some(s), Some(p), Some(o)) = (pr.s, pr.p, pr.o) {
1419                        st_t.execute((pi(pr.ns_id), pi(s), pi(p), pi(o), pi(seq)))
1420                            .await
1421                            .map_err(db_err)?;
1422                        if pr.osp {
1423                            st_o.execute((pi(pr.ns_id), pi(o), pi(s), pi(p), pi(seq)))
1424                                .await
1425                                .map_err(db_err)?;
1426                        }
1427                        st_e.execute((
1428                            pi(pr.ns_id),
1429                            pi(s),
1430                            pi(p),
1431                            pi(o),
1432                            pi(seq),
1433                            pb(pr.hash.as_bytes().to_vec()),
1434                        ))
1435                        .await
1436                        .map_err(db_err)?;
1437                        conn.execute(
1438                            "DELETE FROM heads WHERE ns=?1 AND s=?2 AND p=?3",
1439                            (pi(pr.ns_id), pi(s), pi(p)),
1440                        )
1441                        .await
1442                        .map_err(db_err)?;
1443                        conn.execute(
1444                            "INSERT INTO heads(ns,s,p,seq,hash,created_at) VALUES (?1,?2,?3,?4,?5,?6)",
1445                            (pi(pr.ns_id), pi(s), pi(p), pi(seq), pb(pr.hash.as_bytes().to_vec()), pi(pr.created)),
1446                        )
1447                        .await
1448                        .map_err(db_err)?;
1449                    }
1450                    if let Some(sess) = pr.session {
1451                        st_th
1452                            .execute((pi(pr.ns_id), pi(sess), pi(seq)))
1453                            .await
1454                            .map_err(db_err)?;
1455                    }
1456                    if let Some(ref emb) = pr.embedding {
1457                        conn.execute(
1458                            "INSERT INTO embeddings(seq, vec) VALUES (?1, vector32(?2))",
1459                            (pi(seq), pt(&vec_to_json(emb))),
1460                        )
1461                        .await
1462                        .map_err(db_err)?;
1463                    }
1464                    st_l.execute((
1465                        pi(first_op + i as i64),
1466                        pi(hlc0 + i as i64),
1467                        pi(OP_ADD),
1468                        pb(pr.hash.as_bytes().to_vec()),
1469                    ))
1470                    .await
1471                    .map_err(db_err)?;
1472                }
1473                Ok::<(), DejaDbError>(())
1474            }
1475            .await;
1476            match r {
1477                Ok(()) => conn.execute("COMMIT", ()).await.map_err(db_err).map(|_| ()),
1478                Err(e) => {
1479                    let _ = conn.execute("ROLLBACK", ()).await;
1480                    Err(e)
1481                }
1482            }
1483        })?;
1484        Ok(hashes)
1485    }
1486
1487    // ----- read path -----
1488
1489    /// Reverse provenance: every grain whose `derived_from` is exactly
1490    /// `parent`, newest first. This is the credit-assignment / episode-unlearn
1491    /// query — "which lessons were distilled from this observation?" or "what
1492    /// did the agent learn from this bad session?". Superseded versions are
1493    /// included so the full derived lineage is visible; the caller can revise
1494    /// or `forget` each hash. Provenance is not a hot path, so this scans
1495    /// stored grains rather than maintaining a dedicated index.
1496    pub fn grains_derived_from(&mut self, parent: &Hash) -> Result<Vec<DeserializedGrain>> {
1497        let parent_hex = parent.to_hex();
1498        let conn = &self.conn;
1499        let blobs = self.rt.block_on(async {
1500            let mut rows = conn
1501                .query("SELECT blob FROM grains ORDER BY seq DESC", ())
1502                .await
1503                .map_err(db_err)?;
1504            let mut out = Vec::new();
1505            while let Some(row) = rows.next().await.map_err(db_err)? {
1506                if let Some(b) = v_blob(&row.get_value(0).map_err(db_err)?) {
1507                    out.push(b);
1508                }
1509            }
1510            Ok::<_, DejaDbError>(out)
1511        })?;
1512        let mut result = Vec::new();
1513        for b in &blobs {
1514            let g = deserialize_blob(b)?;
1515            if g.get_str("derived_from") == Some(parent_hex.as_str()) {
1516                result.push(g);
1517            }
1518        }
1519        Ok(result)
1520    }
1521
1522    /// Recent grains in a namespace, newest first, bounded by `limit`. With
1523    /// `gtype = None`, every type is returned. This is the "reflect over recent
1524    /// experience" read path — recent Events / Observations that have no
1525    /// subject or free-text anchor to hang a structural or BM25 leg on.
1526    pub fn recent(
1527        &mut self,
1528        ns: &str,
1529        gtype: Option<dejadb_core::types::GrainType>,
1530        limit: usize,
1531    ) -> Result<Vec<DeserializedGrain>> {
1532        let ns_id = match self.term_lookup(ns) {
1533            Some(x) => x,
1534            None => return Ok(Vec::new()),
1535        };
1536        // The `gtype` column stores the enum ordinal (see `extract_view`:
1537        // `view.grain_type as u8`), not the .mg header type-byte.
1538        let gt_ord = gtype.map(|g| g as u8 as i64);
1539        let conn = &self.conn;
1540        let blobs = self.rt.block_on(async {
1541            let mut out = Vec::new();
1542            let mut rows = match gt_ord {
1543                Some(gt) => conn
1544                    .query(
1545                        "SELECT blob FROM grains WHERE ns=?1 AND gtype=?2 ORDER BY seq DESC LIMIT ?3",
1546                        (pi(ns_id), pi(gt), pi(limit as i64)),
1547                    )
1548                    .await
1549                    .map_err(db_err)?,
1550                None => conn
1551                    .query(
1552                        "SELECT blob FROM grains WHERE ns=?1 ORDER BY seq DESC LIMIT ?2",
1553                        (pi(ns_id), pi(limit as i64)),
1554                    )
1555                    .await
1556                    .map_err(db_err)?,
1557            };
1558            while let Some(row) = rows.next().await.map_err(db_err)? {
1559                if let Some(b) = v_blob(&row.get_value(0).map_err(db_err)?) {
1560                    out.push(b);
1561                }
1562            }
1563            Ok::<_, DejaDbError>(out)
1564        })?;
1565        blobs.iter().map(|b| deserialize_blob(b)).collect()
1566    }
1567
1568    /// Fetch a grain by content address.
1569    pub fn get(&mut self, hash: &Hash) -> Result<DeserializedGrain> {
1570        let conn = &self.conn;
1571        let blob = self.rt.block_on(async {
1572            let mut rows = conn
1573                .query(
1574                    "SELECT blob FROM grains WHERE hash = ?1",
1575                    (pb(hash.as_bytes().to_vec()),),
1576                )
1577                .await
1578                .map_err(db_err)?;
1579            match rows.next().await.map_err(db_err)? {
1580                Some(row) => v_blob(&row.get_value(0).map_err(db_err)?)
1581                    .ok_or_else(|| DejaDbError::Storage("blob column not a blob".into())),
1582                None => Err(DejaDbError::NotFound(*hash)),
1583            }
1584        })?;
1585        deserialize_blob(&blob)
1586    }
1587
1588    /// Structural recall: current grains about `subject` (optionally filtered
1589    /// by relation), newest first, k-bounded. The voice hot path.
1590    pub fn recall(
1591        &mut self,
1592        ns: &str,
1593        subject: &str,
1594        relation: Option<&str>,
1595        k: usize,
1596    ) -> Result<Vec<DeserializedGrain>> {
1597        let (ns_id, s_id) = match (self.term_lookup(ns), self.term_lookup(subject)) {
1598            (Some(a), Some(b)) => (a, b),
1599            _ => return Ok(Vec::new()),
1600        };
1601        let p_id = match relation {
1602            Some(r) => match self.term_lookup(r) {
1603                Some(x) => Some(x),
1604                None => return Ok(Vec::new()),
1605            },
1606            None => None,
1607        };
1608        let conn = &self.conn;
1609        let rt = &self.rt;
1610        let slot_sp = &mut self.st_probe_sp;
1611        let slot_s = &mut self.st_probe_s;
1612        let slot_f = &mut self.st_fetch_seq;
1613        let blobs = rt.block_on(async {
1614            let mut out = Vec::new();
1615            let mut seqs: Vec<i64> = Vec::new();
1616            match p_id {
1617                Some(p) => {
1618                    let st = ensure_stmt(
1619                        slot_sp,
1620                        conn,
1621                        "SELECT seq FROM triples WHERE ns=?1 AND s=?2 AND p=?3 AND cur=1 ORDER BY seq DESC LIMIT ?4",
1622                    )
1623                    .await?;
1624                    let mut rows = st
1625                        .query((pi(ns_id), pi(s_id), pi(p), pi(k as i64)))
1626                        .await
1627                        .map_err(db_err)?;
1628                    while let Some(row) = rows.next().await.map_err(db_err)? {
1629                        if let Some(x) = v_i64(&row.get_value(0).map_err(db_err)?) {
1630                            seqs.push(x);
1631                        }
1632                    }
1633                }
1634                None => {
1635                    let st = ensure_stmt(
1636                        slot_s,
1637                        conn,
1638                        "SELECT seq FROM triples WHERE ns=?1 AND s=?2 AND cur=1 ORDER BY seq DESC LIMIT ?3",
1639                    )
1640                    .await?;
1641                    let mut rows = st
1642                        .query((pi(ns_id), pi(s_id), pi(k as i64)))
1643                        .await
1644                        .map_err(db_err)?;
1645                    while let Some(row) = rows.next().await.map_err(db_err)? {
1646                        if let Some(x) = v_i64(&row.get_value(0).map_err(db_err)?) {
1647                            seqs.push(x);
1648                        }
1649                    }
1650                }
1651            }
1652            let st_f = ensure_stmt(slot_f, conn, "SELECT blob FROM grains WHERE seq = ?1").await?;
1653            for seq in seqs {
1654                let mut rows = st_f.query((pi(seq),)).await.map_err(db_err)?;
1655                if let Some(row) = rows.next().await.map_err(db_err)? {
1656                    if let Some(b) = v_blob(&row.get_value(0).map_err(db_err)?) {
1657                        out.push(b);
1658                    }
1659                }
1660            }
1661            Ok::<_, DejaDbError>(out)
1662        })?;
1663        blobs.iter().map(|b| deserialize_blob(b)).collect()
1664    }
1665
1666    /// Current value head for (subject, relation) — the µs point read.
1667    pub fn latest(&mut self, ns: &str, subject: &str, relation: &str) -> Result<Option<DeserializedGrain>> {
1668        let (ns_id, s_id, p_id) = match (
1669            self.term_lookup(ns),
1670            self.term_lookup(subject),
1671            self.term_lookup(relation),
1672        ) {
1673            (Some(a), Some(b), Some(c)) => (a, b, c),
1674            _ => return Ok(None),
1675        };
1676        let conn = &self.conn;
1677        let rt = &self.rt;
1678        let slot = &mut self.st_latest;
1679        let hash = rt.block_on(async {
1680            let st = ensure_stmt(
1681                slot,
1682                conn,
1683                "SELECT hash FROM entity_latest WHERE ns=?1 AND s=?2 AND p=?3",
1684            )
1685            .await?;
1686            let mut rows = st
1687                .query((pi(ns_id), pi(s_id), pi(p_id)))
1688                .await
1689                .map_err(db_err)?;
1690            Ok::<_, DejaDbError>(match rows.next().await.map_err(db_err)? {
1691                Some(row) => v_blob(&row.get_value(0).map_err(db_err)?),
1692                None => None,
1693            })
1694        })?;
1695        match hash {
1696            Some(h) => {
1697                let h = Hash::try_from_bytes(&h)?;
1698                Ok(Some(self.get(&h)?))
1699            }
1700            None => Ok(None),
1701        }
1702    }
1703
1704    /// Last `n` events of a session, oldest→newest (transcript tail).
1705    pub fn thread_tail(&mut self, ns: &str, session: &str, n: usize) -> Result<Vec<DeserializedGrain>> {
1706        let (ns_id, sess_id) = match (self.term_lookup(ns), self.term_lookup(session)) {
1707            (Some(a), Some(b)) => (a, b),
1708            _ => return Ok(Vec::new()),
1709        };
1710        let conn = &self.conn;
1711        let blobs = self.rt.block_on(async {
1712            let mut seqs = Vec::new();
1713            {
1714                let mut rows = conn
1715                    .query(
1716                        "SELECT seq FROM thread_idx WHERE ns=?1 AND session=?2 ORDER BY seq DESC LIMIT ?3",
1717                        (pi(ns_id), pi(sess_id), pi(n as i64)),
1718                    )
1719                    .await
1720                    .map_err(db_err)?;
1721                while let Some(row) = rows.next().await.map_err(db_err)? {
1722                    if let Some(x) = v_i64(&row.get_value(0).map_err(db_err)?) {
1723                        seqs.push(x);
1724                    }
1725                }
1726            }
1727            let mut out = Vec::new();
1728            for seq in seqs.into_iter().rev() {
1729                let mut rows = conn
1730                    .query("SELECT blob FROM grains WHERE seq = ?1", (pi(seq),))
1731                    .await
1732                    .map_err(db_err)?;
1733                if let Some(row) = rows.next().await.map_err(db_err)? {
1734                    if let Some(b) = v_blob(&row.get_value(0).map_err(db_err)?) {
1735                        out.push(b);
1736                    }
1737                }
1738            }
1739            Ok::<_, DejaDbError>(out)
1740        })?;
1741        blobs.iter().map(|b| deserialize_blob(b)).collect()
1742    }
1743
1744    // ----- evolution path -----
1745
1746    /// Supersede `old` with `new_grain` (atomic, OMS L2 semantics).
1747    /// Sets `derived_from` on the new grain; the old grain's blob is never
1748    /// touched — only its index-layer fields change.
1749    pub fn supersede<G: Grain + 'static>(&mut self, old: &Hash, new_grain: &mut G) -> Result<Hash> {
1750        // Old head must exist and be current.
1751        let conn = &self.conn;
1752        let old_row = self.rt.block_on(async {
1753            let mut rows = conn
1754                .query(
1755                    "SELECT seq, ns, s, p, svt FROM grains WHERE hash = ?1",
1756                    (pb(old.as_bytes().to_vec()),),
1757                )
1758                .await
1759                .map_err(db_err)?;
1760            match rows.next().await.map_err(db_err)? {
1761                Some(row) => {
1762                    let seq = v_i64(&row.get_value(0).map_err(db_err)?).unwrap_or(0);
1763                    let ns = v_i64(&row.get_value(1).map_err(db_err)?);
1764                    let s = v_i64(&row.get_value(2).map_err(db_err)?);
1765                    let p = v_i64(&row.get_value(3).map_err(db_err)?);
1766                    let svt = v_i64(&row.get_value(4).map_err(db_err)?);
1767                    Ok::<_, DejaDbError>(Some((seq, ns, s, p, svt)))
1768                }
1769                None => Ok(None),
1770            }
1771        })?;
1772        let (old_seq, _ns, old_s, old_p, old_svt) = match old_row {
1773            Some(x) => x,
1774            None => return Err(DejaDbError::NotFound(*old)),
1775        };
1776        if old_svt.is_some() {
1777            return Err(DejaDbError::SupersessionConflict(*old));
1778        }
1779
1780        new_grain.common_mut().derived_from = Some(old.to_hex());
1781        let new_hash = self.add(new_grain)?;
1782        let now = now_ms();
1783
1784        let op_seq = self.next_op;
1785        self.next_op += 1;
1786        let hlc = self.next_hlc();
1787        let conn = &self.conn;
1788        self.rt.block_on(async {
1789            conn.execute("BEGIN", ()).await.map_err(db_err)?;
1790            let r = async {
1791                conn.execute(
1792                    "UPDATE grains SET superseded_by=?1, svt=?2 WHERE seq=?3",
1793                    (pb(new_hash.as_bytes().to_vec()), pi(now), pi(old_seq)),
1794                )
1795                .await
1796                .map_err(db_err)?;
1797                conn.execute(
1798                    "UPDATE grains SET supersedes=?1 WHERE hash=?2",
1799                    (pb(old.as_bytes().to_vec()), pb(new_hash.as_bytes().to_vec())),
1800                )
1801                .await
1802                .map_err(db_err)?;
1803                conn.execute("UPDATE triples SET cur=0 WHERE seq=?1", (pi(old_seq),))
1804                    .await
1805                    .map_err(db_err)?;
1806                conn.execute("UPDATE osp SET cur=0 WHERE seq=?1", (pi(old_seq),))
1807                    .await
1808                    .map_err(db_err)?;
1809                conn.execute(
1810                    "INSERT INTO oplog(op_seq,hlc,op,hash) VALUES (?1,?2,?3,?4)",
1811                    (pi(op_seq), pi(hlc), pi(OP_SUPERSEDE), pb(new_hash.as_bytes().to_vec())),
1812                )
1813                .await
1814                .map_err(db_err)?;
1815                Ok::<(), DejaDbError>(())
1816            }
1817            .await;
1818            match r {
1819                Ok(()) => conn.execute("COMMIT", ()).await.map_err(db_err).map(|_| ()),
1820                Err(e) => {
1821                    let _ = conn.execute("ROLLBACK", ()).await;
1822                    Err(e)
1823                }
1824            }
1825        })?;
1826        let _ = (old_s, old_p);
1827        Ok(new_hash)
1828    }
1829
1830    /// Forget (erase from hot store) — writes a tombstone to the op-log.
1831    /// File-level crypto-erasure remains the strong path.
1832    pub fn forget(&mut self, hash: &Hash) -> Result<()> {
1833        let conn = &self.conn;
1834        let row = self.rt.block_on(async {
1835            let mut rows = conn
1836                .query(
1837                    "SELECT seq, ns, s, p FROM grains WHERE hash = ?1",
1838                    (pb(hash.as_bytes().to_vec()),),
1839                )
1840                .await
1841                .map_err(db_err)?;
1842            match rows.next().await.map_err(db_err)? {
1843                Some(row) => Ok::<_, DejaDbError>(Some((
1844                    v_i64(&row.get_value(0).map_err(db_err)?).unwrap_or(0),
1845                    v_i64(&row.get_value(1).map_err(db_err)?),
1846                    v_i64(&row.get_value(2).map_err(db_err)?),
1847                    v_i64(&row.get_value(3).map_err(db_err)?),
1848                ))),
1849                None => Ok(None),
1850            }
1851        })?;
1852        let (seq, ns, s, p) = match row {
1853            Some(x) => x,
1854            None => return Err(DejaDbError::NotFound(*hash)),
1855        };
1856        let op_seq = self.next_op;
1857        self.next_op += 1;
1858        let hlc = self.next_hlc();
1859        let conn = &self.conn;
1860        self.rt.block_on(async {
1861            conn.execute("BEGIN", ()).await.map_err(db_err)?;
1862            let r = async {
1863                conn.execute("DELETE FROM triples WHERE seq=?1", (pi(seq),))
1864                    .await
1865                    .map_err(db_err)?;
1866                conn.execute("DELETE FROM osp WHERE seq=?1", (pi(seq),))
1867                    .await
1868                    .map_err(db_err)?;
1869                conn.execute("DELETE FROM embeddings WHERE seq=?1", (pi(seq),))
1870                    .await
1871                    .map_err(db_err)?;
1872                conn.execute("DELETE FROM thread_idx WHERE seq=?1", (pi(seq),))
1873                    .await
1874                    .map_err(db_err)?;
1875                conn.execute("DELETE FROM grains WHERE seq=?1", (pi(seq),))
1876                    .await
1877                    .map_err(db_err)?;
1878                // entity_latest fallback: newest remaining current triple, if any.
1879                if let (Some(ns), Some(s), Some(p)) = (ns, s, p) {
1880                    conn.execute(
1881                        "DELETE FROM entity_latest WHERE ns=?1 AND s=?2 AND p=?3 AND seq=?4",
1882                        (pi(ns), pi(s), pi(p), pi(seq)),
1883                    )
1884                    .await
1885                    .map_err(db_err)?;
1886                    let mut rows = conn
1887                        .query(
1888                            "SELECT t.o, t.seq, g.hash FROM triples t JOIN grains g ON g.seq=t.seq
1889                             WHERE t.ns=?1 AND t.s=?2 AND t.p=?3 AND t.cur=1 ORDER BY t.seq DESC LIMIT 1",
1890                            (pi(ns), pi(s), pi(p)),
1891                        )
1892                        .await
1893                        .map_err(db_err)?;
1894                    if let Some(row) = rows.next().await.map_err(db_err)? {
1895                        let o = v_i64(&row.get_value(0).map_err(db_err)?).unwrap_or(0);
1896                        let sq = v_i64(&row.get_value(1).map_err(db_err)?).unwrap_or(0);
1897                        let h = v_blob(&row.get_value(2).map_err(db_err)?).unwrap_or_default();
1898                        conn.execute(
1899                            "INSERT OR REPLACE INTO entity_latest(ns,s,p,o,seq,hash) VALUES (?1,?2,?3,?4,?5,?6)",
1900                            (pi(ns), pi(s), pi(p), pi(o), pi(sq), pb(h)),
1901                        )
1902                        .await
1903                        .map_err(db_err)?;
1904                    }
1905                }
1906                conn.execute(
1907                    "INSERT INTO oplog(op_seq,hlc,op,hash) VALUES (?1,?2,?3,?4)",
1908                    (pi(op_seq), pi(hlc), pi(OP_FORGET), pb(hash.as_bytes().to_vec())),
1909                )
1910                .await
1911                .map_err(db_err)?;
1912                Ok::<(), DejaDbError>(())
1913            }
1914            .await;
1915            match r {
1916                Ok(()) => conn.execute("COMMIT", ()).await.map_err(db_err).map(|_| ()),
1917                Err(e) => {
1918                    let _ = conn.execute("ROLLBACK", ()).await;
1919                    Err(e)
1920                }
1921            }
1922        })
1923    }
1924
1925    // ----- graph ops (bounded, indexed, capped) -----
1926
1927    /// Bounded k-hop traversal over the given relations.
1928    /// Returns reached entity terms (excluding the start), BFS order.
1929    /// `Direction::In`/`Both` use the selective OSP index, so reverse
1930    /// expansion only sees entity-valued relations.
1931    pub fn related(
1932        &mut self,
1933        ns: &str,
1934        start: &str,
1935        relations: &[&str],
1936        dir: Direction,
1937        depth: usize,
1938        cap: usize,
1939    ) -> Result<Vec<String>> {
1940        let ns_id = match self.term_lookup(ns) {
1941            Some(x) => x,
1942            None => return Ok(Vec::new()),
1943        };
1944        let start_id = match self.term_lookup(start) {
1945            Some(x) => x,
1946            None => return Ok(Vec::new()),
1947        };
1948        let rel_ids: Vec<i64> = relations.iter().filter_map(|r| self.term_lookup(r)).collect();
1949        if rel_ids.is_empty() {
1950            return Ok(Vec::new());
1951        }
1952        let depth = depth.min(4);
1953        let cap = cap.min(512);
1954        let conn = &self.conn;
1955        let reached = self.rt.block_on(async {
1956            let mut seen: HashSet<i64> = HashSet::new();
1957            seen.insert(start_id);
1958            let mut order: Vec<i64> = Vec::new();
1959            let mut frontier = vec![start_id];
1960            for _ in 0..depth {
1961                let mut next = Vec::new();
1962                for node in &frontier {
1963                    for p in &rel_ids {
1964                        if matches!(dir, Direction::Out | Direction::Both) {
1965                            let mut rows = conn
1966                                .query(
1967                                    "SELECT o FROM triples WHERE ns=?1 AND s=?2 AND p=?3 AND cur=1 LIMIT 64",
1968                                    (pi(ns_id), pi(*node), pi(*p)),
1969                                )
1970                                .await
1971                                .map_err(db_err)?;
1972                            while let Some(row) = rows.next().await.map_err(db_err)? {
1973                                if let Some(o) = v_i64(&row.get_value(0).map_err(db_err)?) {
1974                                    if seen.insert(o) {
1975                                        order.push(o);
1976                                        next.push(o);
1977                                        if order.len() >= cap {
1978                                            return Ok::<_, DejaDbError>(order);
1979                                        }
1980                                    }
1981                                }
1982                            }
1983                        }
1984                        if matches!(dir, Direction::In | Direction::Both) {
1985                            let mut rows = conn
1986                                .query(
1987                                    "SELECT s FROM osp WHERE ns=?1 AND o=?2 AND p=?3 AND cur=1 LIMIT 64",
1988                                    (pi(ns_id), pi(*node), pi(*p)),
1989                                )
1990                                .await
1991                                .map_err(db_err)?;
1992                            while let Some(row) = rows.next().await.map_err(db_err)? {
1993                                if let Some(s) = v_i64(&row.get_value(0).map_err(db_err)?) {
1994                                    if seen.insert(s) {
1995                                        order.push(s);
1996                                        next.push(s);
1997                                        if order.len() >= cap {
1998                                            return Ok::<_, DejaDbError>(order);
1999                                        }
2000                                    }
2001                                }
2002                            }
2003                        }
2004                    }
2005                }
2006                if next.is_empty() {
2007                    break;
2008                }
2009                frontier = next;
2010            }
2011            Ok(order)
2012        })?;
2013        Ok(reached.into_iter().filter_map(|id| self.term_str(id)).collect())
2014    }
2015
2016    /// Bounded bidirectional-ish path search (forward BFS with parents).
2017    pub fn path(
2018        &mut self,
2019        ns: &str,
2020        from: &str,
2021        to: &str,
2022        relations: &[&str],
2023        max_depth: usize,
2024    ) -> Result<Option<Vec<String>>> {
2025        let ns_id = match self.term_lookup(ns) {
2026            Some(x) => x,
2027            None => return Ok(None),
2028        };
2029        let (a, b) = match (self.term_lookup(from), self.term_lookup(to)) {
2030            (Some(a), Some(b)) => (a, b),
2031            _ => return Ok(None),
2032        };
2033        let rel_ids: Vec<i64> = relations.iter().filter_map(|r| self.term_lookup(r)).collect();
2034        if rel_ids.is_empty() {
2035            return Ok(None);
2036        }
2037        let max_depth = max_depth.min(6);
2038        let conn = &self.conn;
2039        let parents = self.rt.block_on(async {
2040            let mut parent: HashMap<i64, i64> = HashMap::new();
2041            let mut q = VecDeque::from([a]);
2042            let mut found = false;
2043            let mut hops = 0usize;
2044            let mut visited: HashSet<i64> = HashSet::from([a]);
2045            'outer: while !q.is_empty() && hops < max_depth {
2046                let level: Vec<i64> = q.drain(..).collect();
2047                for node in level {
2048                    for p in &rel_ids {
2049                        let mut rows = conn
2050                            .query(
2051                                "SELECT o FROM triples WHERE ns=?1 AND s=?2 AND p=?3 AND cur=1 LIMIT 64",
2052                                (pi(ns_id), pi(node), pi(*p)),
2053                            )
2054                            .await
2055                            .map_err(db_err)?;
2056                        while let Some(row) = rows.next().await.map_err(db_err)? {
2057                            if let Some(o) = v_i64(&row.get_value(0).map_err(db_err)?) {
2058                                if visited.insert(o) {
2059                                    parent.insert(o, node);
2060                                    if o == b {
2061                                        found = true;
2062                                        break 'outer;
2063                                    }
2064                                    q.push_back(o);
2065                                    if visited.len() > 2048 {
2066                                        break 'outer;
2067                                    }
2068                                }
2069                            }
2070                        }
2071                    }
2072                }
2073                hops += 1;
2074            }
2075            Ok::<_, DejaDbError>(if found { Some(parent) } else { None })
2076        })?;
2077        Ok(parents.map(|parent| {
2078            let mut chain = vec![b];
2079            let mut cur = b;
2080            while let Some(pr) = parent.get(&cur) {
2081                chain.push(*pr);
2082                cur = *pr;
2083                if cur == a {
2084                    break;
2085                }
2086            }
2087            chain.reverse();
2088            chain.into_iter().filter_map(|id| self.term_str(id)).collect()
2089        }))
2090    }
2091
2092    /// Two-axis as-of read.
2093    pub fn entity_at(
2094        &mut self,
2095        ns: &str,
2096        subject: &str,
2097        relation: &str,
2098        t: i64,
2099        axis: Axis,
2100    ) -> Result<Option<DeserializedGrain>> {
2101        match axis {
2102            Axis::Knowledge => {
2103                // Walk the supersession chain backward from the head.
2104                let head = match self.latest(ns, subject, relation)? {
2105                    Some(g) => g.hash,
2106                    None => return Ok(None),
2107                };
2108                let mut cur = head;
2109                loop {
2110                    let conn = &self.conn;
2111                    let row = self.rt.block_on(async {
2112                        let mut rows = conn
2113                            .query(
2114                                "SELECT svf, supersedes, blob FROM grains WHERE hash = ?1",
2115                                (pb(cur.as_bytes().to_vec()),),
2116                            )
2117                            .await
2118                            .map_err(db_err)?;
2119                        match rows.next().await.map_err(db_err)? {
2120                            Some(row) => {
2121                                let svf = v_i64(&row.get_value(0).map_err(db_err)?);
2122                                let sup = v_blob(&row.get_value(1).map_err(db_err)?);
2123                                let blob = v_blob(&row.get_value(2).map_err(db_err)?);
2124                                Ok::<_, DejaDbError>(Some((svf, sup, blob)))
2125                            }
2126                            None => Ok(None),
2127                        }
2128                    })?;
2129                    let (svf, sup, blob) = match row {
2130                        Some(x) => x,
2131                        None => return Ok(None),
2132                    };
2133                    if svf.unwrap_or(i64::MIN) <= t {
2134                        return Ok(Some(deserialize_blob(&blob.unwrap_or_default())?));
2135                    }
2136                    match sup {
2137                        Some(prev) => cur = Hash::try_from_bytes(&prev)?,
2138                        None => return Ok(None),
2139                    }
2140                }
2141            }
2142            Axis::World => {
2143                // Current knowledge filtered by world validity at T.
2144                let (ns_id, s_id, p_id) = match (
2145                    self.term_lookup(ns),
2146                    self.term_lookup(subject),
2147                    self.term_lookup(relation),
2148                ) {
2149                    (Some(a), Some(b), Some(c)) => (a, b, c),
2150                    _ => return Ok(None),
2151                };
2152                let conn = &self.conn;
2153                let blob = self.rt.block_on(async {
2154                    let mut rows = conn
2155                        .query(
2156                            "SELECT g.blob FROM triples tr JOIN grains g ON g.seq = tr.seq
2157                             WHERE tr.ns=?1 AND tr.s=?2 AND tr.p=?3
2158                               AND g.svt IS NULL
2159                               AND (g.vf IS NULL OR g.vf <= ?4)
2160                               AND (g.vt IS NULL OR g.vt > ?4)
2161                             ORDER BY tr.seq DESC LIMIT 1",
2162                            (pi(ns_id), pi(s_id), pi(p_id), pi(t)),
2163                        )
2164                        .await
2165                        .map_err(db_err)?;
2166                    Ok::<_, DejaDbError>(match rows.next().await.map_err(db_err)? {
2167                        Some(row) => v_blob(&row.get_value(0).map_err(db_err)?),
2168                        None => None,
2169                    })
2170                })?;
2171                match blob {
2172                    Some(b) => Ok(Some(deserialize_blob(&b)?)),
2173                    None => Ok(None),
2174                }
2175            }
2176        }
2177    }
2178
2179    /// Whether a grain with this content address exists.
2180    pub fn has(&mut self, hash: &Hash) -> Result<bool> {
2181        self.has_grain(hash)
2182    }
2183
2184    /// BM25 leg: FTS `MATCH` over grain text (facts as "s r o", event
2185    /// content). Returns current-grain seqs in match order.
2186    pub fn search_text(&mut self, ns: &str, query: &str, k: usize) -> Result<Vec<i64>> {
2187        if !self.index_text {
2188            return Ok(Vec::new()); // BM25 leg disabled (edge profile)
2189        }
2190        let ns_id = match self.term_lookup(ns) {
2191            Some(x) => x,
2192            None => return Ok(Vec::new()),
2193        };
2194        let conn = &self.conn;
2195        self.rt.block_on(async {
2196            let mut out = Vec::new();
2197            let mut rows = conn
2198                .query(
2199                    "SELECT seq FROM grains WHERE text MATCH ?1 AND ns = ?2 AND svt IS NULL LIMIT ?3",
2200                    (pt(query), pi(ns_id), pi(k as i64)),
2201                )
2202                .await
2203                .map_err(db_err)?;
2204            while let Some(row) = rows.next().await.map_err(db_err)? {
2205                if let Some(s) = v_i64(&row.get_value(0).map_err(db_err)?) {
2206                    out.push(s);
2207                }
2208            }
2209            Ok(out)
2210        })
2211    }
2212
2213    /// Vector leg: cosine top-k over embedded grain text (brute force —
2214    /// exact search at per-memory scale, per M0 measurements).
2215    /// Semantic nearest-neighbours to `text` among current grains, optionally
2216    /// scoped to `(subject, relation)`, returned as `(hash, cosine_similarity)`
2217    /// most-similar first. This is the **advise** half of a write-time novelty
2218    /// gate: a reflection harness calls it before writing a distilled lesson
2219    /// and, if the top similarity clears its own threshold, *supersedes* the
2220    /// near-duplicate instead of adding a paraphrase — the paraphrase-rot the
2221    /// exact-value idempotent add (`add_if_novel`) can't catch. It never
2222    /// mutates: the host stays in control (advise, don't drop).
2223    ///
2224    /// Novelty is a vector operation, so this **requires an installed
2225    /// embedder** and errors loudly without one rather than silently returning
2226    /// nothing. `text` is embedded as-is and compared against each grain's
2227    /// stored embedding (subject·relation·object + content); scoping to
2228    /// `(subject, relation)` keeps the constant prefix out of the way so the
2229    /// object phrasing dominates the score.
2230    pub fn nearest_semantic(
2231        &mut self,
2232        ns: &str,
2233        subject: Option<&str>,
2234        relation: Option<&str>,
2235        text: &str,
2236        k: usize,
2237    ) -> Result<Vec<(Hash, f32)>> {
2238        let Some(embedder) = &self.embedder else {
2239            return Err(DejaDbError::Validation(
2240                "novelty check requires an embedder (e.g. --embed-cmd); none installed".into(),
2241            ));
2242        };
2243        let Some(ns_id) = self.term_lookup(ns) else {
2244            return Ok(Vec::new());
2245        };
2246        let qjson = vec_to_json(&embedder.embed(text)?);
2247        // A named subject/relation that was never interned can have no
2248        // neighbours — short-circuit rather than scan.
2249        let s_id = match subject {
2250            Some(s) => match self.term_lookup(s) {
2251                Some(x) => Some(x),
2252                None => return Ok(Vec::new()),
2253            },
2254            None => None,
2255        };
2256        let p_id = match relation {
2257            Some(r) => match self.term_lookup(r) {
2258                Some(x) => Some(x),
2259                None => return Ok(Vec::new()),
2260            },
2261            None => None,
2262        };
2263        let conn = &self.conn;
2264        self.rt.block_on(async {
2265            let base = "SELECT g.hash, vector_distance_cos(e.vec, vector32(?2)) AS dist \
2266                        FROM embeddings e JOIN grains g ON g.seq = e.seq \
2267                        WHERE g.ns = ?1 AND g.svt IS NULL";
2268            let mut rows = match (s_id, p_id) {
2269                (Some(s), Some(p)) => {
2270                    conn.query(
2271                        &format!("{base} AND g.s = ?3 AND g.p = ?4 ORDER BY dist LIMIT ?5"),
2272                        (pi(ns_id), pt(&qjson), pi(s), pi(p), pi(k as i64)),
2273                    )
2274                    .await
2275                }
2276                (Some(s), None) => {
2277                    conn.query(
2278                        &format!("{base} AND g.s = ?3 ORDER BY dist LIMIT ?4"),
2279                        (pi(ns_id), pt(&qjson), pi(s), pi(k as i64)),
2280                    )
2281                    .await
2282                }
2283                _ => {
2284                    conn.query(
2285                        &format!("{base} ORDER BY dist LIMIT ?3"),
2286                        (pi(ns_id), pt(&qjson), pi(k as i64)),
2287                    )
2288                    .await
2289                }
2290            }
2291            .map_err(db_err)?;
2292            let mut out = Vec::new();
2293            while let Some(row) = rows.next().await.map_err(db_err)? {
2294                let h = v_blob(&row.get_value(0).map_err(db_err)?)
2295                    .and_then(|b| Hash::try_from_bytes(&b).ok());
2296                // vector_distance_cos is cosine *distance* (1 − similarity).
2297                let dist = v_f64(&row.get_value(1).map_err(db_err)?).unwrap_or(1.0);
2298                if let Some(h) = h {
2299                    out.push((h, (1.0 - dist) as f32));
2300                }
2301            }
2302            Ok(out)
2303        })
2304    }
2305
2306    pub fn search_vector(&mut self, ns: &str, query: &str, k: usize) -> Result<Vec<i64>> {
2307        let (Some(embedder), Some(ns_id)) = (&self.embedder, self.term_lookup(ns)) else {
2308            return Ok(Vec::new());
2309        };
2310        let qv = embedder.embed(query)?;
2311        let qjson = vec_to_json(&qv);
2312        let conn = &self.conn;
2313        self.rt.block_on(async {
2314            let mut out = Vec::new();
2315            let mut rows = conn
2316                .query(
2317                    "SELECT e.seq FROM embeddings e JOIN grains g ON g.seq = e.seq
2318                     WHERE g.ns = ?1 AND g.svt IS NULL
2319                     ORDER BY vector_distance_cos(e.vec, vector32(?2)) LIMIT ?3",
2320                    (pi(ns_id), pt(&qjson), pi(k as i64)),
2321                )
2322                .await
2323                .map_err(db_err)?;
2324            while let Some(row) = rows.next().await.map_err(db_err)? {
2325                if let Some(s) = v_i64(&row.get_value(0).map_err(db_err)?) {
2326                    out.push(s);
2327                }
2328            }
2329            Ok(out)
2330        })
2331    }
2332
2333    /// Hybrid recall: structural leg + BM25 leg fused
2334    /// with Reciprocal Rank Fusion; optional deadline makes it fail-open
2335    /// (returns whatever is gathered when the budget expires). This is the
2336    /// plain path — see [`recall_hybrid_tuned`](Self::recall_hybrid_tuned) for
2337    /// the Tier-1/Tier-2 refinements (query expansion, MMR, rerank).
2338    pub fn recall_hybrid(
2339        &mut self,
2340        ns: &str,
2341        subject: Option<&str>,
2342        relation: Option<&str>,
2343        query: Option<&str>,
2344        k: usize,
2345        deadline: Option<std::time::Duration>,
2346    ) -> Result<Vec<DeserializedGrain>> {
2347        self.recall_hybrid_tuned(ns, subject, relation, query, k, deadline, RecallTuning::default())
2348    }
2349
2350    /// Hybrid recall with post-fusion refinements. Same three
2351    /// legs and RRF fusion as [`recall_hybrid`](Self::recall_hybrid), plus the
2352    /// opt-in `tuning` stages:
2353    ///
2354    /// - **query expansion** (Tier-1): extra BM25 legs from rule-based query
2355    ///   variants, RRF-fused — bridges vocabulary gaps with no embedder.
2356    /// - **rerank** (Tier-2): a cross-encoder re-scores a widened candidate
2357    ///   pool via the installed [`RerankBackend`]. Takes precedence over MMR.
2358    /// - **diversity** (Tier-1): MMR reorders the pool to cut near-duplicates,
2359    ///   using the query embedding + stored candidate vectors.
2360    ///
2361    /// Every stage is fail-open: past the deadline, or with its backend/data
2362    /// absent, it degrades to plain fusion order rather than erroring. All
2363    /// default off, so this is a strict superset of `recall_hybrid`.
2364    #[allow(clippy::too_many_arguments)] // tuning knobs are intentionally explicit params
2365    pub fn recall_hybrid_tuned(
2366        &mut self,
2367        ns: &str,
2368        subject: Option<&str>,
2369        relation: Option<&str>,
2370        query: Option<&str>,
2371        k: usize,
2372        deadline: Option<std::time::Duration>,
2373        tuning: RecallTuning,
2374    ) -> Result<Vec<DeserializedGrain>> {
2375        let start = std::time::Instant::now();
2376        let over = |start: &std::time::Instant| match deadline {
2377            Some(d) => start.elapsed() >= d,
2378            None => false,
2379        };
2380
2381        // A refinement stage reranks/reorders a candidate pool, so fetch a
2382        // wider net per leg when one is active.
2383        let refine = tuning.rerank || tuning.diversity_lambda.is_some();
2384        let leg_k = if refine {
2385            k.max(REFINE_POOL)
2386        } else {
2387            k.saturating_mul(2)
2388        };
2389
2390        // leg 1: structural (the voice hot path — always runs first)
2391        let structural: Vec<i64> = match subject {
2392            Some(s) => self.recall_seqs(ns, s, relation, leg_k)?,
2393            None => Vec::new(),
2394        };
2395        // leg 2: BM25 — plus Tier-1 query-expansion variant legs. Skipped when
2396        // the deadline is already spent.
2397        let mut fts_legs: Vec<Vec<i64>> = Vec::new();
2398        if let Some(q) = query {
2399            if !over(&start) {
2400                fts_legs.push(self.search_text(ns, q, leg_k)?);
2401                if tuning.query_expansion && self.index_text {
2402                    for variant in self.expand_query(q) {
2403                        if over(&start) {
2404                            break;
2405                        }
2406                        let hits = self.search_text(ns, &variant, leg_k)?;
2407                        if !hits.is_empty() {
2408                            fts_legs.push(hits);
2409                        }
2410                    }
2411                }
2412            }
2413        }
2414        // leg 3: vector (multilingual path — CJK text that whitespace
2415        // tokenization can't serve rides this leg)
2416        let vecs: Vec<i64> = match query {
2417            Some(q) if self.embedder.is_some() && !over(&start) => {
2418                self.search_vector(ns, q, leg_k)?
2419            }
2420            _ => Vec::new(),
2421        };
2422        if structural.is_empty() && fts_legs.iter().all(|l| l.is_empty()) && vecs.is_empty() {
2423            return Ok(Vec::new());
2424        }
2425
2426        // RRF fusion (k0 = 60, the standard constant) across every leg.
2427        let mut scores: HashMap<i64, f64> = HashMap::new();
2428        for (rank, seq) in structural.iter().enumerate() {
2429            *scores.entry(*seq).or_insert(0.0) += 1.0 / (RRF_K0 + rank as f64);
2430        }
2431        for leg in &fts_legs {
2432            for (rank, seq) in leg.iter().enumerate() {
2433                *scores.entry(*seq).or_insert(0.0) += 1.0 / (RRF_K0 + rank as f64);
2434            }
2435        }
2436        for (rank, seq) in vecs.iter().enumerate() {
2437            *scores.entry(*seq).or_insert(0.0) += 1.0 / (RRF_K0 + rank as f64);
2438        }
2439        let mut ranked: Vec<(i64, f64)> = scores.into_iter().collect();
2440        ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap().then(b.0.cmp(&a.0)));
2441
2442        // Refinement stage: rerank wins over diversity when both are asked for.
2443        let ordered: Vec<i64> = if let Some(q) =
2444            query.filter(|_| tuning.rerank && self.reranker.is_some() && !over(&start))
2445        {
2446            self.rerank_pool(q, &ranked, k)?
2447        } else if let (Some(lambda), Some(q)) = (tuning.diversity_lambda, query) {
2448            if self.embedder.is_some() && !over(&start) {
2449                self.mmr_pool(q, &ranked, lambda, k)?
2450            } else {
2451                ranked.iter().take(k).map(|(s, _)| *s).collect()
2452            }
2453        } else {
2454            ranked.iter().take(k).map(|(s, _)| *s).collect()
2455        };
2456
2457        let mut out = Vec::new();
2458        for seq in ordered {
2459            if over(&start) {
2460                break; // fail-open: partial results beat a blown budget
2461            }
2462            if let Some(b) = self.blob_by_seq(seq)? {
2463                out.push(deserialize_blob(&b)?);
2464            }
2465        }
2466        Ok(out)
2467    }
2468
2469    /// Query variants for Tier-1 expansion: the installed [`QueryExpander`],
2470    /// or the built-in [`EnglishExpander`] when none is set.
2471    fn expand_query(&self, q: &str) -> Vec<String> {
2472        match &self.expander {
2473            Some(e) => e.expand(q),
2474            None => EnglishExpander::default().expand(q),
2475        }
2476    }
2477
2478    /// Text used to rerank a candidate — the same [`projected_text`] shape
2479    /// the FTS/embed legs index, derived from the grain so it works even
2480    /// when `index_text` is off.
2481    fn candidate_text(&mut self, seq: i64) -> Result<String> {
2482        let Some(b) = self.blob_by_seq(seq)? else {
2483            return Ok(String::new());
2484        };
2485        let g = deserialize_blob(&b)?;
2486        Ok(projected_text(&g).unwrap_or_default())
2487    }
2488
2489    /// Tier-2: cross-encoder rerank a widened candidate pool. Fetches the
2490    /// top-N fused candidates' text, scores each `(query, doc)` pair via the
2491    /// installed reranker, and returns the top-`k` seqs by score. Fail-open —
2492    /// a backend error or a length mismatch falls back to fusion order.
2493    fn rerank_pool(&mut self, query: &str, ranked: &[(i64, f64)], k: usize) -> Result<Vec<i64>> {
2494        let pool_n = ranked.len().min(k.max(REFINE_POOL));
2495        let pool: Vec<i64> = ranked.iter().take(pool_n).map(|(s, _)| *s).collect();
2496        if pool.is_empty() {
2497            return Ok(pool);
2498        }
2499        let mut docs: Vec<String> = Vec::with_capacity(pool.len());
2500        for &seq in &pool {
2501            docs.push(self.candidate_text(seq)?);
2502        }
2503        let refs: Vec<&str> = docs.iter().map(|s| s.as_str()).collect();
2504        let reranker = self.reranker.as_ref().expect("caller checked reranker present");
2505        match reranker.rerank(query, &refs) {
2506            Ok(scores) if scores.len() == refs.len() => {
2507                let mut scored: Vec<(i64, f32)> = pool.iter().copied().zip(scores).collect();
2508                // stable-ish: higher score first, then lower seq for ties
2509                scored.sort_by(|a, b| {
2510                    b.1.partial_cmp(&a.1)
2511                        .unwrap_or(std::cmp::Ordering::Equal)
2512                        .then(a.0.cmp(&b.0))
2513                });
2514                Ok(scored.into_iter().take(k).map(|(s, _)| s).collect())
2515            }
2516            // Backend failed or returned the wrong shape: keep fusion order.
2517            _ => Ok(pool.into_iter().take(k).collect()),
2518        }
2519    }
2520
2521    /// Tier-1: MMR diversity reorder. Greedy Maximal Marginal Relevance over
2522    /// the embedded candidates in the fused pool — `lambda·rel − (1−lambda)·
2523    /// max_sim_to_selected`, where `rel` is cosine-to-query and `sim` is
2524    /// candidate-to-candidate cosine (both via `vector_distance_cos`).
2525    /// Candidates lacking vectors keep fusion order after the MMR set.
2526    fn mmr_pool(
2527        &mut self,
2528        query: &str,
2529        ranked: &[(i64, f64)],
2530        lambda: f32,
2531        k: usize,
2532    ) -> Result<Vec<i64>> {
2533        let lambda = lambda.clamp(0.0, 1.0);
2534        let pool_n = ranked.len().min(k.max(REFINE_POOL));
2535        let pool: Vec<i64> = ranked.iter().take(pool_n).map(|(s, _)| *s).collect();
2536        if pool.len() < 2 {
2537            return Ok(pool);
2538        }
2539        let qv = match &self.embedder {
2540            Some(e) => e.embed(query)?,
2541            None => return Ok(pool.into_iter().take(k).collect()),
2542        };
2543        let qjson = vec_to_json(&qv);
2544        let rel = self.vec_rel_map(&pool, &qjson)?;
2545        // MMR is only meaningful with ≥2 embedded candidates.
2546        let embedded: Vec<i64> = pool.iter().copied().filter(|s| rel.contains_key(s)).collect();
2547        if embedded.len() < 2 {
2548            return Ok(pool.into_iter().take(k).collect());
2549        }
2550        let sim = self.vec_pairwise_map(&embedded)?;
2551        let sim_of = |a: i64, b: i64| -> f32 {
2552            if a == b {
2553                1.0
2554            } else {
2555                let key = if a < b { (a, b) } else { (b, a) };
2556                *sim.get(&key).unwrap_or(&0.0)
2557            }
2558        };
2559
2560        let target = k.min(embedded.len());
2561        let mut selected: Vec<i64> = Vec::with_capacity(target);
2562        let mut remaining: Vec<i64> = embedded.clone();
2563        while selected.len() < target && !remaining.is_empty() {
2564            let mut best_idx = 0usize;
2565            let mut best_score = f32::MIN;
2566            for (i, &c) in remaining.iter().enumerate() {
2567                let relevance = *rel.get(&c).unwrap_or(&0.0);
2568                let max_sim = selected
2569                    .iter()
2570                    .map(|&s| sim_of(c, s))
2571                    .fold(0.0f32, f32::max);
2572                let mmr = lambda * relevance - (1.0 - lambda) * max_sim;
2573                if mmr > best_score {
2574                    best_score = mmr;
2575                    best_idx = i;
2576                }
2577            }
2578            selected.push(remaining.remove(best_idx));
2579        }
2580        // Fill remaining slots with non-embedded candidates in fusion order.
2581        for s in pool {
2582            if selected.len() >= k {
2583                break;
2584            }
2585            if !selected.contains(&s) {
2586                selected.push(s);
2587            }
2588        }
2589        selected.truncate(k);
2590        Ok(selected)
2591    }
2592
2593    /// Cosine relevance (1 − distance) of each embedded candidate to the query
2594    /// vector. Candidates without stored vectors are simply absent from the map.
2595    fn vec_rel_map(&mut self, seqs: &[i64], qjson: &str) -> Result<HashMap<i64, f32>> {
2596        if seqs.is_empty() {
2597            return Ok(HashMap::new());
2598        }
2599        let sql = format!(
2600            "SELECT seq, vector_distance_cos(vec, vector32(?1)) FROM embeddings WHERE seq IN ({})",
2601            seq_csv(seqs)
2602        );
2603        let conn = &self.conn;
2604        self.rt.block_on(async {
2605            let mut out = HashMap::new();
2606            let mut rows = conn.query(&sql, (pt(qjson),)).await.map_err(db_err)?;
2607            while let Some(row) = rows.next().await.map_err(db_err)? {
2608                let seq = v_i64(&row.get_value(0).map_err(db_err)?);
2609                let dist = v_f64(&row.get_value(1).map_err(db_err)?);
2610                if let (Some(s), Some(d)) = (seq, dist) {
2611                    out.insert(s, 1.0 - d as f32);
2612                }
2613            }
2614            Ok(out)
2615        })
2616    }
2617
2618    /// Pairwise cosine similarity (1 − distance) among embedded candidates,
2619    /// keyed `(a, b)` with `a < b`. One upper-triangle self-join query.
2620    fn vec_pairwise_map(&mut self, seqs: &[i64]) -> Result<HashMap<(i64, i64), f32>> {
2621        if seqs.len() < 2 {
2622            return Ok(HashMap::new());
2623        }
2624        let csv = seq_csv(seqs);
2625        let sql = format!(
2626            "SELECT a.seq, b.seq, vector_distance_cos(a.vec, b.vec) \
2627             FROM embeddings a JOIN embeddings b ON a.seq < b.seq \
2628             WHERE a.seq IN ({csv}) AND b.seq IN ({csv})"
2629        );
2630        let conn = &self.conn;
2631        self.rt.block_on(async {
2632            let mut out = HashMap::new();
2633            let mut rows = conn.query(&sql, ()).await.map_err(db_err)?;
2634            while let Some(row) = rows.next().await.map_err(db_err)? {
2635                let a = v_i64(&row.get_value(0).map_err(db_err)?);
2636                let b = v_i64(&row.get_value(1).map_err(db_err)?);
2637                let dist = v_f64(&row.get_value(2).map_err(db_err)?);
2638                if let (Some(a), Some(b), Some(d)) = (a, b, dist) {
2639                    out.insert((a, b), 1.0 - d as f32);
2640                }
2641            }
2642            Ok(out)
2643        })
2644    }
2645
2646    fn recall_seqs(&mut self, ns: &str, subject: &str, relation: Option<&str>, k: usize) -> Result<Vec<i64>> {
2647        let (ns_id, s_id) = match (self.term_lookup(ns), self.term_lookup(subject)) {
2648            (Some(a), Some(b)) => (a, b),
2649            _ => return Ok(Vec::new()),
2650        };
2651        let p_id = match relation {
2652            Some(r) => match self.term_lookup(r) {
2653                Some(x) => Some(x),
2654                None => return Ok(Vec::new()),
2655            },
2656            None => None,
2657        };
2658        let conn = &self.conn;
2659        let rt = &self.rt;
2660        let slot_sp = &mut self.st_probe_sp;
2661        let slot_s = &mut self.st_probe_s;
2662        rt.block_on(async {
2663            let mut seqs = Vec::new();
2664            let mut rows = match p_id {
2665                Some(p) => {
2666                    let st = ensure_stmt(
2667                        slot_sp,
2668                        conn,
2669                        "SELECT seq FROM triples WHERE ns=?1 AND s=?2 AND p=?3 AND cur=1 ORDER BY seq DESC LIMIT ?4",
2670                    )
2671                    .await?;
2672                    st.query((pi(ns_id), pi(s_id), pi(p), pi(k as i64))).await.map_err(db_err)?
2673                }
2674                None => {
2675                    let st = ensure_stmt(
2676                        slot_s,
2677                        conn,
2678                        "SELECT seq FROM triples WHERE ns=?1 AND s=?2 AND cur=1 ORDER BY seq DESC LIMIT ?3",
2679                    )
2680                    .await?;
2681                    st.query((pi(ns_id), pi(s_id), pi(k as i64))).await.map_err(db_err)?
2682                }
2683            };
2684            while let Some(row) = rows.next().await.map_err(db_err)? {
2685                if let Some(x) = v_i64(&row.get_value(0).map_err(db_err)?) {
2686                    seqs.push(x);
2687                }
2688            }
2689            Ok(seqs)
2690        })
2691    }
2692
2693    fn blob_by_seq(&mut self, seq: i64) -> Result<Option<Vec<u8>>> {
2694        let conn = &self.conn;
2695        let rt = &self.rt;
2696        let slot = &mut self.st_fetch_seq;
2697        rt.block_on(async {
2698            let st = ensure_stmt(slot, conn, "SELECT blob FROM grains WHERE seq = ?1").await?;
2699            let mut rows = st
2700                .query((pi(seq),))
2701                .await
2702                .map_err(db_err)?;
2703            Ok(match rows.next().await.map_err(db_err)? {
2704                Some(row) => v_blob(&row.get_value(0).map_err(db_err)?),
2705                None => None,
2706            })
2707        })
2708    }
2709
2710    /// Distinct subjects holding `relation` in `ns` (POS-index scan).
2711    /// Backs directory-style listings (memory-tool `view` on a dir).
2712    pub fn subjects_with_relation(&mut self, ns: &str, relation: &str) -> Result<Vec<String>> {
2713        let (ns_id, p_id) = match (self.term_lookup(ns), self.term_lookup(relation)) {
2714            (Some(a), Some(b)) => (a, b),
2715            _ => return Ok(Vec::new()),
2716        };
2717        let conn = &self.conn;
2718        let ids = self.rt.block_on(async {
2719            let mut out = Vec::new();
2720            let mut rows = conn
2721                .query(
2722                    "SELECT DISTINCT s FROM triples WHERE ns=?1 AND p=?2 AND cur=1",
2723                    (pi(ns_id), pi(p_id)),
2724                )
2725                .await
2726                .map_err(db_err)?;
2727            while let Some(row) = rows.next().await.map_err(db_err)? {
2728                if let Some(s) = v_i64(&row.get_value(0).map_err(db_err)?) {
2729                    out.push(s);
2730                }
2731            }
2732            Ok::<_, DejaDbError>(out)
2733        })?;
2734        let mut subjects: Vec<String> = ids.into_iter().filter_map(|id| self.term_str(id)).collect();
2735        subjects.sort();
2736        Ok(subjects)
2737    }
2738
2739    /// The `remember()` seam: store raw content as an
2740    /// Observation grain, run the caller-supplied extraction function
2741    /// (typically an LLM callback — the host owns the model relationship),
2742    /// and store each returned draft as a Fact with `derived_from`
2743    /// provenance back to the observation.
2744    #[allow(clippy::type_complexity)] // extractor is a plain callback; a type alias would not clarify
2745    pub fn remember(
2746        &mut self,
2747        ns: &str,
2748        content: &str,
2749        observer: &str,
2750        extractor: Option<&dyn Fn(&str) -> Vec<FactDraft>>,
2751    ) -> Result<RememberResult> {
2752        use dejadb_core::types::Observation;
2753        let mut obs = Observation::new(observer, "llm");
2754        obs.common.namespace = Some(ns.to_string());
2755        obs.common.context = Some(serde_json::json!({ "content": content }));
2756        let observation = self.add(&obs)?;
2757
2758        let mut facts = Vec::new();
2759        if let Some(f) = extractor {
2760            for draft in f(content) {
2761                let mut fact = dejadb_core::types::Fact::new(&draft.subject, &draft.relation, &draft.object);
2762                fact.common.confidence = draft.confidence.clamp(0.0, 1.0);
2763                fact.common.namespace = Some(ns.to_string());
2764                fact.common.derived_from = Some(observation.to_hex());
2765                fact.common.source_type = Some("derived".to_string());
2766                facts.push(self.add(&fact)?);
2767            }
2768        }
2769        Ok(RememberResult { observation, facts })
2770    }
2771
2772    /// Total number of grains in the hot store.
2773    pub fn count(&mut self) -> Result<usize> {
2774        let conn = &self.conn;
2775        self.rt.block_on(async {
2776            let mut rows = conn
2777                .query("SELECT COUNT(*) FROM grains", ())
2778                .await
2779                .map_err(db_err)?;
2780            Ok(match rows.next().await.map_err(db_err)? {
2781                Some(row) => v_i64(&row.get_value(0).map_err(db_err)?).unwrap_or(0) as usize,
2782                None => 0,
2783            })
2784        })
2785    }
2786
2787    /// Open supersession tips for (subject, relation) — normally one; more
2788    /// than one means a fork (v4 grain-git model). Ordered provisional-first.
2789    /// Enumerate every open fork in the file — each `(ns, subject, relation)`
2790    /// whose `heads` table holds more than one live tip. This is the honest
2791    /// structural conflict signal: a true fork only arises from concurrent
2792    /// supersession of the same value (typically edits synced from two
2793    /// writers). Recall never surfaces this to stay off the hot path; operators
2794    /// call `deja forks` to find and merge them. Not a hot path (scans the
2795    /// heads table + reverse term lookups).
2796    pub fn open_forks(&mut self) -> Result<Vec<ForkGroup>> {
2797        let conn = &self.conn;
2798        let groups: Vec<(i64, i64, i64)> = self.rt.block_on(async {
2799            let mut rows = conn
2800                .query(
2801                    "SELECT ns, s, p FROM heads GROUP BY ns, s, p HAVING COUNT(*) > 1",
2802                    (),
2803                )
2804                .await
2805                .map_err(db_err)?;
2806            let mut out = Vec::new();
2807            while let Some(row) = rows.next().await.map_err(db_err)? {
2808                let ns = v_i64(&row.get_value(0).map_err(db_err)?);
2809                let s = v_i64(&row.get_value(1).map_err(db_err)?);
2810                let p = v_i64(&row.get_value(2).map_err(db_err)?);
2811                if let (Some(ns), Some(s), Some(p)) = (ns, s, p) {
2812                    out.push((ns, s, p));
2813                }
2814            }
2815            Ok::<_, DejaDbError>(out)
2816        })?;
2817
2818        let mut forks = Vec::new();
2819        for (ns_id, s_id, p_id) in groups {
2820            let (Some(namespace), Some(subject), Some(relation)) =
2821                (self.term_str(ns_id), self.term_str(s_id), self.term_str(p_id))
2822            else {
2823                continue;
2824            };
2825            let heads = self
2826                .heads(&namespace, &subject, &relation)?
2827                .into_iter()
2828                .map(|(h, _)| h)
2829                .collect();
2830            forks.push(ForkGroup {
2831                namespace,
2832                subject,
2833                relation,
2834                heads,
2835            });
2836        }
2837        Ok(forks)
2838    }
2839
2840    pub fn heads(&mut self, ns: &str, subject: &str, relation: &str) -> Result<Vec<(Hash, i64)>> {
2841        let (Some(ns_id), Some(s_id), Some(p_id)) = (
2842            self.term_lookup(ns),
2843            self.term_lookup(subject),
2844            self.term_lookup(relation),
2845        ) else {
2846            return Ok(Vec::new());
2847        };
2848        let conn = &self.conn;
2849        self.rt.block_on(async {
2850            let mut out = Vec::new();
2851            let mut rows = conn
2852                .query(
2853                    "SELECT hash, created_at FROM heads WHERE ns=?1 AND s=?2 AND p=?3
2854                     ORDER BY created_at DESC, hash DESC",
2855                    (pi(ns_id), pi(s_id), pi(p_id)),
2856                )
2857                .await
2858                .map_err(db_err)?;
2859            while let Some(row) = rows.next().await.map_err(db_err)? {
2860                let h = v_blob(&row.get_value(0).map_err(db_err)?).unwrap_or_default();
2861                let c = v_i64(&row.get_value(1).map_err(db_err)?).unwrap_or(0);
2862                if let Ok(h) = Hash::try_from_bytes(&h) {
2863                    out.push((h, c));
2864                }
2865            }
2866            Ok(out)
2867        })
2868    }
2869
2870    /// Close a fork: write `merged` superseding EVERY open tip, with all
2871    /// parents recorded in the provenance chain (git merge commit).
2872    pub fn merge_heads<G: Grain + 'static>(
2873        &mut self,
2874        ns: &str,
2875        subject: &str,
2876        relation: &str,
2877        merged: &mut G,
2878    ) -> Result<Hash> {
2879        let tips = self.heads(ns, subject, relation)?;
2880        if tips.len() < 2 {
2881            return Err(DejaDbError::Validation(format!(
2882                "merge_heads needs an open fork; {} head(s) present",
2883                tips.len()
2884            )));
2885        }
2886        // parents: provisional head as derived_from; ALL tips recorded in
2887        // context.merge_parents (context is serialized into the .mg blob;
2888        // provenance_chain is index-layer in this port)
2889        merged.common_mut().derived_from = Some(tips[0].0.to_hex());
2890        let parents: Vec<String> = tips.iter().map(|(h, _)| h.to_hex()).collect();
2891        let mut ctx = match merged.common().context.clone() {
2892            Some(serde_json::Value::Object(m)) => m,
2893            _ => serde_json::Map::new(),
2894        };
2895        ctx.insert("merge_parents".into(), serde_json::json!(parents));
2896        merged.common_mut().context = Some(serde_json::Value::Object(ctx));
2897        let merge_hash = self.add(merged)?; // add() collapses heads to {merge}
2898        let now = now_ms();
2899        let conn = &self.conn;
2900        for (tip, _) in &tips {
2901            let seq = self.rt.block_on(async {
2902                let mut rows = conn
2903                    .query("SELECT seq, svt FROM grains WHERE hash=?1", (pb(tip.as_bytes().to_vec()),))
2904                    .await
2905                    .map_err(db_err)?;
2906                Ok::<_, DejaDbError>(match rows.next().await.map_err(db_err)? {
2907                    Some(row) => {
2908                        let seq = v_i64(&row.get_value(0).map_err(db_err)?).unwrap_or(0);
2909                        let svt = v_i64(&row.get_value(1).map_err(db_err)?);
2910                        (svt.is_none()).then_some(seq)
2911                    }
2912                    None => None,
2913                })
2914            })?;
2915            if let Some(seq) = seq {
2916                self.rt.block_on(async {
2917                    conn.execute(
2918                        "UPDATE grains SET superseded_by=?1, svt=?2 WHERE seq=?3",
2919                        (pb(merge_hash.as_bytes().to_vec()), pi(now), pi(seq)),
2920                    )
2921                    .await
2922                    .map_err(db_err)?;
2923                    conn.execute("UPDATE triples SET cur=0 WHERE seq=?1", (pi(seq),))
2924                        .await
2925                        .map_err(db_err)?;
2926                    conn.execute("UPDATE osp SET cur=0 WHERE seq=?1", (pi(seq),))
2927                        .await
2928                        .map_err(db_err)?;
2929                    Ok::<(), DejaDbError>(())
2930                })?;
2931            }
2932        }
2933        Ok(merge_hash)
2934    }
2935
2936    /// Supersession-chain history for (namespace, subject, relation),
2937    /// newest first — the HISTORY statement's backing read (§5.13).
2938    pub fn history(&mut self, ns: &str, subject: &str, relation: &str) -> Result<Vec<HistoryEntry>> {
2939        let head = match self.latest(ns, subject, relation)? {
2940            Some(g) => g.hash,
2941            None => return Ok(Vec::new()),
2942        };
2943        let mut out = Vec::new();
2944        let mut cur = Some(head);
2945        while let Some(h) = cur {
2946            let conn = &self.conn;
2947            let row = self.rt.block_on(async {
2948                let mut rows = conn
2949                    .query(
2950                        "SELECT blob, superseded_by, supersedes FROM grains WHERE hash = ?1",
2951                        (pb(h.as_bytes().to_vec()),),
2952                    )
2953                    .await
2954                    .map_err(db_err)?;
2955                Ok::<_, DejaDbError>(match rows.next().await.map_err(db_err)? {
2956                    Some(row) => Some((
2957                        v_blob(&row.get_value(0).map_err(db_err)?),
2958                        v_blob(&row.get_value(1).map_err(db_err)?),
2959                        v_blob(&row.get_value(2).map_err(db_err)?),
2960                    )),
2961                    None => None,
2962                })
2963            })?;
2964            let (blob, sup_by, supersedes) = match row {
2965                Some(x) => x,
2966                None => break,
2967            };
2968            if let Some(b) = blob {
2969                let g = deserialize_blob(&b)?;
2970                out.push(HistoryEntry {
2971                    hash: h,
2972                    object: g.get_str("object").unwrap_or_default().to_string(),
2973                    created_at: g.get_i64("created_at").unwrap_or(0),
2974                    confidence: g.get_f64("confidence").unwrap_or(0.0),
2975                    superseded_by: sup_by.and_then(|b| Hash::try_from_bytes(&b).ok()),
2976                });
2977            }
2978            cur = supersedes.and_then(|b| Hash::try_from_bytes(&b).ok());
2979            if out.len() > 512 {
2980                break; // chain-length safety cap
2981            }
2982        }
2983        Ok(out)
2984    }
2985
2986    /// Verify store integrity: Turso's own integrity check plus a full
2987    /// content-address re-verification (every blob re-hashed and compared
2988    /// to its stored hash — the tamper-evidence read).
2989    pub fn verify(&mut self) -> Result<VerifyReport> {
2990        let conn = &self.conn;
2991        let (integrity, fts_notes, rows) = self.rt.block_on(async {
2992            // Collect every integrity line; Turso's experimental FTS keeps
2993            // internal dir indexes that integrity_check miscounts — classify
2994            // those as benign notes (candidate upstream report), never as
2995            // corruption. Content-address verification below is the real
2996            // tamper-evidence check and is unaffected.
2997            let mut real: Vec<String> = Vec::new();
2998            let mut fts_notes: Vec<String> = Vec::new();
2999            {
3000                let mut rows = conn.query("PRAGMA integrity_check", ()).await.map_err(db_err)?;
3001                while let Some(row) = rows.next().await.map_err(db_err)? {
3002                    if let Value::Text(s) = row.get_value(0).map_err(db_err)? {
3003                        if s == "ok" {
3004                            continue;
3005                        } else if s.contains("__turso_internal_fts") {
3006                            fts_notes.push(s);
3007                        } else {
3008                            real.push(s);
3009                        }
3010                    }
3011                }
3012            }
3013            let integ = if real.is_empty() { "ok".to_string() } else { real.join("; ") };
3014            let mut out: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
3015            let mut rows = conn.query("SELECT hash, blob FROM grains", ()).await.map_err(db_err)?;
3016            while let Some(row) = rows.next().await.map_err(db_err)? {
3017                let h = v_blob(&row.get_value(0).map_err(db_err)?).unwrap_or_default();
3018                let b = v_blob(&row.get_value(1).map_err(db_err)?).unwrap_or_default();
3019                out.push((h, b));
3020            }
3021            Ok::<_, DejaDbError>((integ, fts_notes, out))
3022        })?;
3023        let mut report = VerifyReport {
3024            integrity,
3025            fts_notes,
3026            grains: rows.len(),
3027            hash_mismatches: 0,
3028            undecodable: 0,
3029        };
3030        for (stored, blob) in rows {
3031            match deserialize_blob(&blob) {
3032                Ok(g) => {
3033                    if g.hash.as_bytes().as_slice() != stored.as_slice() {
3034                        report.hash_mismatches += 1;
3035                    }
3036                }
3037                Err(_) => report.undecodable += 1,
3038            }
3039        }
3040        Ok(report)
3041    }
3042
3043    /// Store statistics (CLI `stats`).
3044    pub fn stats(&mut self) -> Result<StoreStats> {
3045        let conn = &self.conn;
3046        self.rt.block_on(async {
3047            let one = |sql: &'static str| {
3048                let conn = conn.clone();
3049                async move {
3050                    let mut rows = conn.query(sql, ()).await.map_err(db_err)?;
3051                    Ok::<i64, DejaDbError>(match rows.next().await.map_err(db_err)? {
3052                        Some(row) => v_i64(&row.get_value(0).map_err(db_err)?).unwrap_or(0),
3053                        None => 0,
3054                    })
3055                }
3056            };
3057            Ok(StoreStats {
3058                grains: one("SELECT COUNT(*) FROM grains").await? as usize,
3059                current: one("SELECT COUNT(*) FROM grains WHERE svt IS NULL").await? as usize,
3060                triples: one("SELECT COUNT(*) FROM triples").await? as usize,
3061                terms: one("SELECT COUNT(*) FROM terms").await? as usize,
3062                ops: one("SELECT COUNT(*) FROM oplog").await? as usize,
3063                events_indexed: one("SELECT COUNT(*) FROM thread_idx").await? as usize,
3064            })
3065        })
3066    }
3067
3068    /// Op-log cursor read — the change feed (backs sync + UIs).
3069    pub fn changes_since(&mut self, after_op_seq: i64, limit: usize) -> Result<Vec<OpRecord>> {
3070        let conn = &self.conn;
3071        self.rt.block_on(async {
3072            let mut out = Vec::new();
3073            let mut rows = conn
3074                .query(
3075                    "SELECT op_seq, hlc, op, hash FROM oplog WHERE op_seq > ?1 ORDER BY op_seq LIMIT ?2",
3076                    (pi(after_op_seq), pi(limit as i64)),
3077                )
3078                .await
3079                .map_err(db_err)?;
3080            while let Some(row) = rows.next().await.map_err(db_err)? {
3081                out.push(OpRecord {
3082                    op_seq: v_i64(&row.get_value(0).map_err(db_err)?).unwrap_or(0),
3083                    hlc: v_i64(&row.get_value(1).map_err(db_err)?).unwrap_or(0),
3084                    op: v_i64(&row.get_value(2).map_err(db_err)?).unwrap_or(0),
3085                    hash: Hash::try_from_bytes(&v_blob(&row.get_value(3).map_err(db_err)?).unwrap_or_default())?,
3086                });
3087            }
3088            Ok(out)
3089        })
3090    }
3091}
3092
3093impl DejaDB {
3094    // ----- CAS blob sidecar -----
3095
3096    fn blob_path(&self, hex: &str) -> std::path::PathBuf {
3097        self.blob_dir.join(&hex[..2]).join(&hex[2..])
3098    }
3099
3100    /// Store bytes in the per-memory CAS; returns the `cas://sha256:` URI.
3101    /// Idempotent — content addressing dedupes by construction.
3102    pub fn put_blob(&mut self, bytes: &[u8]) -> Result<String> {
3103        use sha2::{Digest, Sha256};
3104        let hex = hex::encode(Sha256::digest(bytes));
3105        let path = self.blob_path(&hex);
3106        if !path.exists() {
3107            std::fs::create_dir_all(path.parent().unwrap()).map_err(db_err)?;
3108            let tmp = path.with_extension("tmp");
3109            std::fs::write(&tmp, bytes).map_err(db_err)?;
3110            std::fs::rename(&tmp, &path).map_err(db_err)?;
3111        }
3112        Ok(format!("cas://sha256:{hex}"))
3113    }
3114
3115    /// Fetch bytes by `cas://sha256:` URI, verifying the hash on read.
3116    pub fn get_blob(&mut self, uri: &str) -> Result<Vec<u8>> {
3117        use sha2::{Digest, Sha256};
3118        let hex = uri
3119            .strip_prefix("cas://sha256:")
3120            .ok_or_else(|| DejaDbError::Validation(format!("not a cas uri: {uri}")))?;
3121        let bytes = std::fs::read(self.blob_path(hex))
3122            .map_err(|_| DejaDbError::Storage(format!("blob missing: {uri}")))?;
3123        if hex::encode(Sha256::digest(&bytes)) != hex {
3124            return Err(DejaDbError::Storage(format!("blob corrupt: {uri}")));
3125        }
3126        Ok(bytes)
3127    }
3128
3129    /// Remove CAS blobs not referenced by any live grain's `content_refs`.
3130    /// Returns the number of blobs removed.
3131    pub fn gc_blobs(&mut self) -> Result<usize> {
3132        // Collect referenced hashes from live grains.
3133        let conn = &self.conn;
3134        let blobs: Vec<Vec<u8>> = self.rt.block_on(async {
3135            let mut out = Vec::new();
3136            let mut rows = conn.query("SELECT blob FROM grains", ()).await.map_err(db_err)?;
3137            while let Some(row) = rows.next().await.map_err(db_err)? {
3138                if let Some(b) = v_blob(&row.get_value(0).map_err(db_err)?) {
3139                    out.push(b);
3140                }
3141            }
3142            Ok::<_, DejaDbError>(out)
3143        })?;
3144        let mut referenced: HashSet<String> = HashSet::new();
3145        for b in &blobs {
3146            if let Ok(view) = deserialize_blob(b) {
3147                if let Some(refs) = view.fields.get("content_refs").and_then(|v| v.as_array()) {
3148                    for r in refs {
3149                        // inner keys may be compact ("u") or expanded ("uri")
3150                        let uri = r
3151                            .get("uri")
3152                            .and_then(|u| u.as_str())
3153                            .or_else(|| r.get("u").and_then(|u| u.as_str()));
3154                        if let Some(hex) = uri.and_then(|u| u.strip_prefix("cas://sha256:")) {
3155                            referenced.insert(hex.to_string());
3156                        }
3157                    }
3158                }
3159            }
3160        }
3161        let mut removed = 0usize;
3162        if let Ok(shards) = std::fs::read_dir(&self.blob_dir) {
3163            for shard in shards.flatten() {
3164                let prefix = shard.file_name().to_string_lossy().to_string();
3165                if let Ok(files) = std::fs::read_dir(shard.path()) {
3166                    for f in files.flatten() {
3167                        let rest = f.file_name().to_string_lossy().to_string();
3168                        let hex = format!("{prefix}{rest}");
3169                        if !referenced.contains(&hex) && std::fs::remove_file(f.path()).is_ok() {
3170                            removed += 1;
3171                        }
3172                    }
3173                }
3174            }
3175        }
3176        Ok(removed)
3177    }
3178
3179    // ----- bundle: git-shaped incremental backup / fast-forward sync (§5.10) -----
3180
3181    /// Export all ops after `after_op_seq` to a bundle file.
3182    /// Record: op(u8) · hlc(i64 LE) · hash(32) · blob_len(u32 LE) · blob.
3183    /// Blobs of later-forgotten grains export as len 0 — the importer
3184    /// relies on the subsequent tombstone for net-equivalence.
3185    pub fn bundle_since(&mut self, after_op_seq: i64, path: &str) -> Result<BundleStats> {
3186        let ops = self.changes_since(after_op_seq, usize::MAX / 2)?;
3187        let conn = &self.conn;
3188        let rt = &self.rt;
3189        let mut out: Vec<u8> = Vec::with_capacity(64 * 1024);
3190        out.extend_from_slice(BUNDLE_MAGIC);
3191        let mut last = after_op_seq;
3192        for rec in &ops {
3193            let blob: Option<Vec<u8>> = if rec.op == OP_FORGET {
3194                None
3195            } else {
3196                rt.block_on(async {
3197                    let mut rows = conn
3198                        .query(
3199                            "SELECT blob FROM grains WHERE hash = ?1",
3200                            (pb(rec.hash.as_bytes().to_vec()),),
3201                        )
3202                        .await
3203                        .map_err(db_err)?;
3204                    Ok::<_, DejaDbError>(match rows.next().await.map_err(db_err)? {
3205                        Some(row) => v_blob(&row.get_value(0).map_err(db_err)?),
3206                        None => None,
3207                    })
3208                })?
3209            };
3210            out.push(rec.op as u8);
3211            out.extend_from_slice(&rec.hlc.to_le_bytes());
3212            out.extend_from_slice(rec.hash.as_bytes());
3213            let b = blob.unwrap_or_default();
3214            out.extend_from_slice(&(b.len() as u32).to_le_bytes());
3215            out.extend_from_slice(&b);
3216            last = rec.op_seq;
3217        }
3218        std::fs::write(path, &out).map_err(db_err)?;
3219        Ok(BundleStats {
3220            ops: ops.len(),
3221            bytes: std::fs::metadata(path).map(|m| m.len()).unwrap_or(0),
3222            last_op_seq: last,
3223        })
3224    }
3225
3226    fn blob_by_hash(&mut self, hash: &Hash) -> Result<Option<Vec<u8>>> {
3227        let conn = &self.conn;
3228        self.rt.block_on(async {
3229            let mut rows = conn
3230                .query(
3231                    "SELECT blob FROM grains WHERE hash = ?1",
3232                    (pb(hash.as_bytes().to_vec()),),
3233                )
3234                .await
3235                .map_err(db_err)?;
3236            Ok::<_, DejaDbError>(match rows.next().await.map_err(db_err)? {
3237                Some(row) => v_blob(&row.get_value(0).map_err(db_err)?),
3238                None => None,
3239            })
3240        })
3241    }
3242
3243    fn has_grain(&mut self, hash: &Hash) -> Result<bool> {
3244        let conn = &self.conn;
3245        self.rt.block_on(async {
3246            let mut rows = conn
3247                .query(
3248                    "SELECT 1 FROM grains WHERE hash = ?1",
3249                    (pb(hash.as_bytes().to_vec()),),
3250                )
3251                .await
3252                .map_err(db_err)?;
3253            Ok(rows.next().await.map_err(db_err)?.is_some())
3254        })
3255    }
3256
3257    /// Insert one already-serialized grain (bundle import path).
3258    fn insert_blob(&mut self, blob: Vec<u8>, hash: Hash, op: i64, hlc_in: i64) -> Result<()> {
3259        let pr = self.prep_from_blob(blob, hash)?;
3260        let seq = self.next_seq;
3261        self.next_seq += 1;
3262        let op_seq = self.next_op;
3263        self.next_op += 1;
3264        self.hlc_last = self.hlc_last.max(hlc_in);
3265        let conn = &self.conn;
3266        self.rt.block_on(async {
3267            conn.execute("BEGIN", ()).await.map_err(db_err)?;
3268            let r = async {
3269                conn.execute(
3270                    "INSERT INTO grains(seq,hash,ns,gtype,created_at,s,p,o,vf,vt,svf,svt,superseded_by,supersedes,text,blob)
3271                     VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,NULL,NULL,NULL,?12,?13)",
3272                    (
3273                        pi(seq),
3274                        pb(pr.hash.as_bytes().to_vec()),
3275                        pi(pr.ns_id),
3276                        pi(pr.gtype),
3277                        pi(pr.created),
3278                        opt_i(pr.s),
3279                        opt_i(pr.p),
3280                        opt_i(pr.o),
3281                        opt_i(pr.vf),
3282                        opt_i(pr.vt),
3283                        pi(pr.created),
3284                        match &pr.text { Some(t) => pt(t), None => Value::Null },
3285                        pb(pr.blob.clone()),
3286                    ),
3287                )
3288                .await
3289                .map_err(db_err)?;
3290                if let (Some(s), Some(p), Some(o)) = (pr.s, pr.p, pr.o) {
3291                    conn.execute(
3292                        "INSERT INTO triples(ns,s,p,o,seq,cur) VALUES (?1,?2,?3,?4,?5,1)",
3293                        (pi(pr.ns_id), pi(s), pi(p), pi(o), pi(seq)),
3294                    )
3295                    .await
3296                    .map_err(db_err)?;
3297                    if pr.osp {
3298                        conn.execute(
3299                            "INSERT INTO osp(ns,o,s,p,seq,cur) VALUES (?1,?2,?3,?4,?5,1)",
3300                            (pi(pr.ns_id), pi(o), pi(s), pi(p), pi(seq)),
3301                        )
3302                        .await
3303                        .map_err(db_err)?;
3304                    }
3305                    // import path: UNION into heads (never collapse other
3306                    // tips — that's the local single-writer semantic only)
3307                    conn.execute(
3308                        "INSERT OR REPLACE INTO heads(ns,s,p,seq,hash,created_at) VALUES (?1,?2,?3,?4,?5,?6)",
3309                        (pi(pr.ns_id), pi(s), pi(p), pi(seq), pb(pr.hash.as_bytes().to_vec()), pi(pr.created)),
3310                    )
3311                    .await
3312                    .map_err(db_err)?;
3313                    // provisional election for entity_latest: replace only if
3314                    // (created_at, hash) beats the current head — deterministic
3315                    // on every node, no coordination.
3316                    let cur = {
3317                        let mut rows = conn
3318                            .query(
3319                                "SELECT h.created_at, h.hash FROM heads h JOIN entity_latest e
3320                                 ON e.ns=h.ns AND e.s=h.s AND e.p=h.p AND e.seq=h.seq
3321                                 WHERE e.ns=?1 AND e.s=?2 AND e.p=?3",
3322                                (pi(pr.ns_id), pi(s), pi(p)),
3323                            )
3324                            .await
3325                            .map_err(db_err)?;
3326                        match rows.next().await.map_err(db_err)? {
3327                            Some(row) => Some((
3328                                v_i64(&row.get_value(0).map_err(db_err)?).unwrap_or(0),
3329                                v_blob(&row.get_value(1).map_err(db_err)?).unwrap_or_default(),
3330                            )),
3331                            None => None,
3332                        }
3333                    };
3334                    let wins = match &cur {
3335                        Some((c, h)) => (pr.created, pr.hash.as_bytes().as_slice()) > (*c, h.as_slice()),
3336                        None => true,
3337                    };
3338                    if wins {
3339                        conn.execute(
3340                            "INSERT OR REPLACE INTO entity_latest(ns,s,p,o,seq,hash) VALUES (?1,?2,?3,?4,?5,?6)",
3341                            (pi(pr.ns_id), pi(s), pi(p), pi(o), pi(seq), pb(pr.hash.as_bytes().to_vec())),
3342                        )
3343                        .await
3344                        .map_err(db_err)?;
3345                    }
3346                }
3347                if let Some(sess) = pr.session {
3348                    conn.execute(
3349                        "INSERT INTO thread_idx(ns,session,seq) VALUES (?1,?2,?3)",
3350                        (pi(pr.ns_id), pi(sess), pi(seq)),
3351                    )
3352                    .await
3353                    .map_err(db_err)?;
3354                }
3355                if let Some(ref emb) = pr.embedding {
3356                    conn.execute(
3357                        "INSERT INTO embeddings(seq, vec) VALUES (?1, vector32(?2))",
3358                        (pi(seq), pt(&vec_to_json(emb))),
3359                    )
3360                    .await
3361                    .map_err(db_err)?;
3362                }
3363                conn.execute(
3364                    "INSERT INTO oplog(op_seq,hlc,op,hash) VALUES (?1,?2,?3,?4)",
3365                    (pi(op_seq), pi(hlc_in), pi(op), pb(pr.hash.as_bytes().to_vec())),
3366                )
3367                .await
3368                .map_err(db_err)?;
3369                Ok::<(), DejaDbError>(())
3370            }
3371            .await;
3372            match r {
3373                Ok(()) => conn.execute("COMMIT", ()).await.map_err(db_err).map(|_| ()),
3374                Err(e) => {
3375                    let _ = conn.execute("ROLLBACK", ()).await;
3376                    Err(e)
3377                }
3378            }
3379        })
3380    }
3381
3382    /// Apply the index-layer supersession flip old → new (import path).
3383    /// Returns whether anything changed (false = idempotent no-op).
3384    fn apply_supersede_flip(&mut self, old: &Hash, new_hash: &Hash) -> Result<bool> {
3385        let conn = &self.conn;
3386        self.rt.block_on(async {
3387            let old_row = {
3388                let mut rows = conn
3389                    .query(
3390                        "SELECT seq, svt FROM grains WHERE hash = ?1",
3391                        (pb(old.as_bytes().to_vec()),),
3392                    )
3393                    .await
3394                    .map_err(db_err)?;
3395                match rows.next().await.map_err(db_err)? {
3396                    Some(row) => Some((
3397                        v_i64(&row.get_value(0).map_err(db_err)?).unwrap_or(0),
3398                        v_i64(&row.get_value(1).map_err(db_err)?),
3399                    )),
3400                    None => None,
3401                }
3402            };
3403            let (old_seq, old_svt) = match old_row {
3404                Some(x) => x,
3405                None => return Ok(false), // partial history — fast-forward tolerates
3406            };
3407            if old_svt.is_some() {
3408                // v4 grain-git: old head already superseded. Same superseder →
3409                // idempotent replay. Different superseder → a FORK: both tips
3410                // stay alive as heads; entity_latest gets the provisional head
3411                // (created_at, then hash — deterministic on every node).
3412                let existing = {
3413                    let mut rows = conn
3414                        .query("SELECT superseded_by FROM grains WHERE seq=?1", (pi(old_seq),))
3415                        .await
3416                        .map_err(db_err)?;
3417                    match rows.next().await.map_err(db_err)? {
3418                        Some(row) => v_blob(&row.get_value(0).map_err(db_err)?),
3419                        None => None,
3420                    }
3421                };
3422                if existing.as_deref() == Some(new_hash.as_bytes().as_slice()) {
3423                    return Ok(false); // same supersede — idempotent
3424                }
3425                // incoming tip row
3426                let inc = {
3427                    let mut rows = conn
3428                        .query(
3429                            "SELECT seq, ns, s, p, o, created_at FROM grains WHERE hash=?1",
3430                            (pb(new_hash.as_bytes().to_vec()),),
3431                        )
3432                        .await
3433                        .map_err(db_err)?;
3434                    match rows.next().await.map_err(db_err)? {
3435                        Some(row) => Some((
3436                            v_i64(&row.get_value(0).map_err(db_err)?).unwrap_or(0),
3437                            v_i64(&row.get_value(1).map_err(db_err)?).unwrap_or(0),
3438                            v_i64(&row.get_value(2).map_err(db_err)?).unwrap_or(0),
3439                            v_i64(&row.get_value(3).map_err(db_err)?).unwrap_or(0),
3440                            v_i64(&row.get_value(4).map_err(db_err)?).unwrap_or(0),
3441                            v_i64(&row.get_value(5).map_err(db_err)?).unwrap_or(0),
3442                        )),
3443                        None => None,
3444                    }
3445                };
3446                let Some((inc_seq, ns, s, p, o, inc_created)) = inc else { return Ok(false) };
3447                conn.execute(
3448                    "INSERT OR REPLACE INTO heads(ns,s,p,seq,hash,created_at) VALUES (?1,?2,?3,?4,?5,?6)",
3449                    (pi(ns), pi(s), pi(p), pi(inc_seq), pb(new_hash.as_bytes().to_vec()), pi(inc_created)),
3450                )
3451                .await
3452                .map_err(db_err)?;
3453                // provisional election vs current entity_latest head
3454                let cur = {
3455                    let mut rows = conn
3456                        .query(
3457                            "SELECT h.created_at, h.hash FROM heads h JOIN entity_latest e
3458                             ON e.ns=h.ns AND e.s=h.s AND e.p=h.p AND e.seq=h.seq
3459                             WHERE e.ns=?1 AND e.s=?2 AND e.p=?3",
3460                            (pi(ns), pi(s), pi(p)),
3461                        )
3462                        .await
3463                        .map_err(db_err)?;
3464                    match rows.next().await.map_err(db_err)? {
3465                        Some(row) => Some((
3466                            v_i64(&row.get_value(0).map_err(db_err)?).unwrap_or(0),
3467                            v_blob(&row.get_value(1).map_err(db_err)?).unwrap_or_default(),
3468                        )),
3469                        None => None,
3470                    }
3471                };
3472                let incoming_wins = match &cur {
3473                    Some((c_created, c_hash)) => {
3474                        (inc_created, new_hash.as_bytes().as_slice()) > (*c_created, c_hash.as_slice())
3475                    }
3476                    None => true,
3477                };
3478                if incoming_wins {
3479                    conn.execute(
3480                        "INSERT OR REPLACE INTO entity_latest(ns,s,p,o,seq,hash) VALUES (?1,?2,?3,?4,?5,?6)",
3481                        (pi(ns), pi(s), pi(p), pi(o), pi(inc_seq), pb(new_hash.as_bytes().to_vec())),
3482                    )
3483                    .await
3484                    .map_err(db_err)?;
3485                }
3486                return Ok(true); // fork registered
3487            }
3488            let now = now_ms();
3489            conn.execute(
3490                "UPDATE grains SET superseded_by=?1, svt=?2 WHERE seq=?3",
3491                (pb(new_hash.as_bytes().to_vec()), pi(now), pi(old_seq)),
3492            )
3493            .await
3494            .map_err(db_err)?;
3495            conn.execute(
3496                "UPDATE grains SET supersedes=?1 WHERE hash=?2",
3497                (pb(old.as_bytes().to_vec()), pb(new_hash.as_bytes().to_vec())),
3498            )
3499            .await
3500            .map_err(db_err)?;
3501            conn.execute("UPDATE triples SET cur=0 WHERE seq=?1", (pi(old_seq),))
3502                .await
3503                .map_err(db_err)?;
3504            conn.execute("UPDATE osp SET cur=0 WHERE seq=?1", (pi(old_seq),))
3505                .await
3506                .map_err(db_err)?;
3507            conn.execute("DELETE FROM heads WHERE seq=?1", (pi(old_seq),))
3508                .await
3509                .map_err(db_err)?;
3510            Ok::<bool, DejaDbError>(true)
3511        })
3512    }
3513
3514    /// Import a bundle (idempotent; fast-forward replay in op order).
3515    pub fn import_bundle(&mut self, path: &str) -> Result<ImportStats> {
3516        self.import_bundle_until(path, None)
3517    }
3518
3519    /// Import, applying only ops with `hlc <= max_hlc` when set — the
3520    /// point-in-time restore primitive (§5.10b): replay history to T.
3521    pub fn import_bundle_until(&mut self, path: &str, max_hlc: Option<i64>) -> Result<ImportStats> {
3522        let data = std::fs::read(path).map_err(db_err)?;
3523        if data.len() < 4 || &data[..4] != BUNDLE_MAGIC {
3524            return Err(DejaDbError::Format("not a MGB1 bundle".into()));
3525        }
3526        let mut stats = ImportStats::default();
3527        let mut i = 4usize;
3528        while i < data.len() {
3529            if i + 1 + 8 + 32 + 4 > data.len() {
3530                return Err(DejaDbError::Format("truncated bundle record".into()));
3531            }
3532            let op = data[i] as i64;
3533            i += 1;
3534            let hlc = i64::from_le_bytes(data[i..i + 8].try_into().unwrap());
3535            i += 8;
3536            let hash = Hash::try_from_bytes(&data[i..i + 32])?;
3537            i += 32;
3538            let len = u32::from_le_bytes(data[i..i + 4].try_into().unwrap()) as usize;
3539            i += 4;
3540            if i.checked_add(len).is_none_or(|end| end > data.len()) {
3541                return Err(DejaDbError::Format("truncated bundle blob".into()));
3542            }
3543            let blob = data[i..i + len].to_vec();
3544            i += len;
3545
3546            if let Some(t) = max_hlc {
3547                if hlc > t {
3548                    stats.skipped += 1;
3549                    continue; // beyond the requested point in time
3550                }
3551            }
3552            match op {
3553                OP_ADD => {
3554                    if self.has_grain(&hash)? || blob.is_empty() {
3555                        // exists already, or pruned (forgotten later at source)
3556                        stats.skipped += 1;
3557                        continue;
3558                    }
3559                    self.insert_blob(blob, hash, op, hlc)?;
3560                    stats.applied += 1;
3561                }
3562                OP_SUPERSEDE => {
3563                    // supersede() double-logs (OP_ADD for the new grain, then
3564                    // OP_SUPERSEDE); the grain may thus already exist here —
3565                    // the flip must still be applied idempotently.
3566                    let exists = self.has_grain(&hash)?;
3567                    let bytes: Option<Vec<u8>> = if !blob.is_empty() {
3568                        Some(blob)
3569                    } else if exists {
3570                        self.blob_by_hash(&hash)?
3571                    } else {
3572                        None
3573                    };
3574                    match bytes {
3575                        None => stats.skipped += 1,
3576                        Some(bb) => {
3577                            let mut changed = false;
3578                            if !exists {
3579                                self.insert_blob(bb.clone(), hash, op, hlc)?;
3580                                changed = true;
3581                            }
3582                            if let Ok(view) = deserialize_blob(&bb) {
3583                                if let Some(df) = view.get_str("derived_from") {
3584                                    if let Ok(old) = Hash::from_hex(df) {
3585                                        changed |= self.apply_supersede_flip(&old, &hash)?;
3586                                    }
3587                                }
3588                            }
3589                            if changed {
3590                                stats.applied += 1;
3591                            } else {
3592                                stats.skipped += 1;
3593                            }
3594                        }
3595                    }
3596                }
3597                OP_FORGET => match self.forget(&hash) {
3598                    Ok(()) => stats.applied += 1,
3599                    Err(DejaDbError::NotFound(_)) => stats.skipped += 1,
3600                    Err(e) => return Err(e),
3601                },
3602                _ => return Err(DejaDbError::Format(format!("unknown bundle op {op}"))),
3603            }
3604        }
3605        Ok(stats)
3606    }
3607}
3608
3609pub mod memory_tool;
3610pub mod migrate;
3611
3612/// Object-safe serialization adapter so `add_batch` can take mixed grain types.
3613pub trait AddableDyn {
3614    fn serialize_dyn(&self) -> Result<(Vec<u8>, Hash)>;
3615}
3616
3617impl<G: Grain + 'static> AddableDyn for G {
3618    fn serialize_dyn(&self) -> Result<(Vec<u8>, Hash)> {
3619        serialize_grain(self)
3620    }
3621}
3622
3623#[cfg(test)]
3624mod tests {
3625    //! Inline unit tests for the store's pure/internal helpers. These sit
3626    //! below the black-box integration suite in `tests/` and exercise the
3627    //! bits that never surface through the public API: dictionary interning,
3628    //! the HLC counter, RRF fusion math, the Value bridge, and the crypto KDF
3629    //! helpers. A `tests` child module can reach the crate root's private
3630    //! items (fns, methods, struct fields), so we test them directly.
3631    use super::*;
3632    use tempfile::TempDir;
3633
3634    // ---- pure string / format helpers ----------------------------------
3635
3636    #[test]
3637    fn hex32_roundtrips_to_64_chars() {
3638        assert_eq!(hex32(&[0u8; 32]), "0".repeat(64));
3639        assert_eq!(hex32(&[0xffu8; 32]), "f".repeat(64));
3640
3641        // A distinct byte-per-index pattern renders in order and round-trips.
3642        let mut key = [0u8; 32];
3643        for (i, b) in key.iter_mut().enumerate() {
3644            *b = i as u8;
3645        }
3646        let hexed = hex32(&key);
3647        assert_eq!(hexed.len(), 64);
3648        assert_eq!(&hexed[..2], "00");
3649        assert_eq!(&hexed[62..], "1f"); // byte 31 == 0x1f
3650        assert_eq!(hex::decode(&hexed).unwrap(), key.to_vec());
3651    }
3652
3653    #[test]
3654    fn seq_csv_formats_integer_lists() {
3655        assert_eq!(seq_csv(&[]), "");
3656        assert_eq!(seq_csv(&[42]), "42");
3657        assert_eq!(seq_csv(&[1, 2, 3]), "1,2,3");
3658        // i64s (incl. negatives) render verbatim — no quoting, no spaces.
3659        assert_eq!(seq_csv(&[-1, 0, 7]), "-1,0,7");
3660    }
3661
3662    #[test]
3663    fn vec_to_json_renders_float_arrays() {
3664        assert_eq!(vec_to_json(&[]), "[]");
3665        assert_eq!(vec_to_json(&[1.0]), "[1]");
3666        assert_eq!(vec_to_json(&[1.0, 2.5, -3.25]), "[1,2.5,-3.25]");
3667    }
3668
3669    // ---- Value bridge (SQL <-> Rust) -----------------------------------
3670
3671    #[test]
3672    fn value_bridge_roundtrips() {
3673        // encode helpers produce the right Value variant, decode helpers only
3674        // accept a matching one.
3675        assert_eq!(v_i64(&pi(42)), Some(42));
3676        assert_eq!(v_i64(&pt("x")), None);
3677        assert_eq!(v_blob(&pb(vec![1, 2, 3])), Some(vec![1, 2, 3]));
3678        assert_eq!(v_blob(&pi(1)), None);
3679        // opt_i: Some -> Integer, None -> SQL NULL.
3680        assert_eq!(v_i64(&opt_i(Some(7))), Some(7));
3681        assert!(matches!(opt_i(None), Value::Null));
3682        // v_f64 accepts both Real and Integer.
3683        assert!((v_f64(&Value::Real(1.5)).unwrap() - 1.5).abs() < 1e-12);
3684        assert!((v_f64(&pi(3)).unwrap() - 3.0).abs() < 1e-12);
3685        assert_eq!(v_f64(&pt("x")), None);
3686    }
3687
3688    // ---- KDF sidecar parsing (crypto-critical) -------------------------
3689
3690    fn kdf_line(salt: &[u8], m: u32, t: u32, p: u32) -> String {
3691        format!("v1 argon2id {} {m} {t} {p}", hex::encode(salt))
3692    }
3693
3694    #[test]
3695    fn parse_kdf_sidecar_accepts_valid() {
3696        let salt = [7u8; KDF_SALT_LEN];
3697        let text = kdf_line(&salt, KDF_M_COST, KDF_T_COST, KDF_P_COST);
3698        let (got_salt, m, t, p) = parse_kdf_sidecar(&text, "x.kdf").unwrap();
3699        assert_eq!(got_salt, salt);
3700        assert_eq!((m, t, p), (KDF_M_COST, KDF_T_COST, KDF_P_COST));
3701        // Tolerant of leading whitespace and a trailing newline.
3702        assert!(parse_kdf_sidecar(&format!("  {text}\n"), "x.kdf").is_ok());
3703        // Boundary params are accepted.
3704        let salt_hex = hex::encode([1u8; KDF_SALT_LEN]);
3705        assert!(parse_kdf_sidecar(&format!("v1 argon2id {salt_hex} 8 1 1"), "x").is_ok());
3706        assert!(parse_kdf_sidecar(&format!("v1 argon2id {salt_hex} 1048576 16 16"), "x").is_ok());
3707    }
3708
3709    #[test]
3710    fn parse_kdf_sidecar_rejects_malformed() {
3711        let salt = hex::encode([1u8; KDF_SALT_LEN]);
3712        // wrong token count (too few / too many)
3713        assert!(parse_kdf_sidecar("v1 argon2id", "x").is_err());
3714        assert!(parse_kdf_sidecar(&format!("v1 argon2id {salt} 1 2 3 4"), "x").is_err());
3715        // wrong version tag
3716        assert!(parse_kdf_sidecar(&format!("v2 argon2id {salt} 19456 2 1"), "x").is_err());
3717        // wrong algorithm
3718        assert!(parse_kdf_sidecar(&format!("v1 scrypt {salt} 19456 2 1"), "x").is_err());
3719        // non-hex salt / non-numeric params
3720        assert!(parse_kdf_sidecar("v1 argon2id zzzz 19456 2 1", "x").is_err());
3721        assert!(parse_kdf_sidecar(&format!("v1 argon2id {salt} m 2 1"), "x").is_err());
3722    }
3723
3724    #[test]
3725    fn parse_kdf_sidecar_rejects_wrong_salt_length() {
3726        // 8 bytes (too short) and 32 bytes (too long) both rejected; only the
3727        // 16-byte KDF_SALT_LEN is valid.
3728        let short = hex::encode([1u8; 8]);
3729        assert!(parse_kdf_sidecar(&format!("v1 argon2id {short} 19456 2 1"), "x").is_err());
3730        let long = hex::encode([1u8; 32]);
3731        assert!(parse_kdf_sidecar(&format!("v1 argon2id {long} 19456 2 1"), "x").is_err());
3732    }
3733
3734    #[test]
3735    fn parse_kdf_sidecar_rejects_out_of_range_params() {
3736        let salt = hex::encode([1u8; KDF_SALT_LEN]);
3737        // m outside [8, 1_048_576] (guards against a tampered multi-GiB cost)
3738        assert!(parse_kdf_sidecar(&format!("v1 argon2id {salt} 7 2 1"), "x").is_err());
3739        assert!(parse_kdf_sidecar(&format!("v1 argon2id {salt} 1048577 2 1"), "x").is_err());
3740        // t outside [1, 16]
3741        assert!(parse_kdf_sidecar(&format!("v1 argon2id {salt} 19456 0 1"), "x").is_err());
3742        assert!(parse_kdf_sidecar(&format!("v1 argon2id {salt} 19456 17 1"), "x").is_err());
3743        // p outside [1, 16]
3744        assert!(parse_kdf_sidecar(&format!("v1 argon2id {salt} 19456 2 0"), "x").is_err());
3745        assert!(parse_kdf_sidecar(&format!("v1 argon2id {salt} 19456 2 17"), "x").is_err());
3746    }
3747
3748    // ---- passphrase key derivation (Argon2id) --------------------------
3749
3750    #[test]
3751    fn derive_key_rejects_empty_or_whitespace_passphrase() {
3752        let dir = TempDir::new().unwrap();
3753        let path = dir.path().join("k.db");
3754        let path = path.to_str().unwrap();
3755        assert!(DejaDB::derive_key_for(path, "").is_err());
3756        assert!(DejaDB::derive_key_for(path, "   ").is_err());
3757        assert!(DejaDB::derive_key_for(path, "\t\n ").is_err());
3758        // A rejected passphrase must not leave a sidecar behind.
3759        assert!(!std::path::Path::new(&format!("{path}.kdf")).exists());
3760    }
3761
3762    #[test]
3763    fn derive_key_is_deterministic_for_same_salt() {
3764        let dir = TempDir::new().unwrap();
3765        let path = dir.path().join("k.db");
3766        let path = path.to_str().unwrap();
3767        // First call mints the .kdf sidecar (fresh salt); the second reuses it.
3768        let k1 = DejaDB::derive_key_for(path, "correct horse battery staple").unwrap();
3769        let k2 = DejaDB::derive_key_for(path, "correct horse battery staple").unwrap();
3770        assert_eq!(*k1, *k2);
3771        // Same salt, different passphrase -> different key.
3772        let k3 = DejaDB::derive_key_for(path, "a different passphrase").unwrap();
3773        assert_ne!(*k1, *k3);
3774    }
3775
3776    #[test]
3777    fn derive_key_differs_across_salts() {
3778        let dir = TempDir::new().unwrap();
3779        let p1 = dir.path().join("a.db");
3780        let p2 = dir.path().join("b.db");
3781        let (p1, p2) = (p1.to_str().unwrap(), p2.to_str().unwrap());
3782        // Same passphrase, independent sidecars -> independent random salts ->
3783        // different keys.
3784        let k1 = DejaDB::derive_key_for(p1, "same-pass").unwrap();
3785        let k2 = DejaDB::derive_key_for(p2, "same-pass").unwrap();
3786        assert_ne!(*k1, *k2);
3787        assert!(std::path::Path::new(&format!("{p1}.kdf")).exists());
3788        assert!(std::path::Path::new(&format!("{p2}.kdf")).exists());
3789    }
3790
3791    // ---- RRF fusion math -----------------------------------------------
3792
3793    /// Mirror of the inline reciprocal-rank fusion in `recall_hybrid_tuned`:
3794    /// each leg contributes `1/(RRF_K0 + rank)`, scores sum across legs, and
3795    /// ties break by seq id descending. Kept here (rather than extracted from
3796    /// production) so these tests pin the fusion contract without changing the
3797    /// recall path; if the inline formula drifts, these expectations should
3798    /// be updated in lockstep.
3799    fn rrf_fuse(legs: &[&[i64]]) -> Vec<(i64, f64)> {
3800        let mut scores: HashMap<i64, f64> = HashMap::new();
3801        for leg in legs {
3802            for (rank, seq) in leg.iter().enumerate() {
3803                *scores.entry(*seq).or_insert(0.0) += 1.0 / (RRF_K0 + rank as f64);
3804            }
3805        }
3806        let mut ranked: Vec<(i64, f64)> = scores.into_iter().collect();
3807        ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap().then(b.0.cmp(&a.0)));
3808        ranked
3809    }
3810
3811    #[test]
3812    fn rrf_k0_constant_is_pinned() {
3813        // The standard k0 = 60; observability surfaces export this value.
3814        assert!((RRF_K0 - 60.0).abs() < f64::EPSILON);
3815    }
3816
3817    #[test]
3818    fn rrf_single_leg_preserves_rank_order() {
3819        let leg = [10i64, 20, 30];
3820        let fused = rrf_fuse(&[&leg]);
3821        assert_eq!(fused.iter().map(|(s, _)| *s).collect::<Vec<_>>(), vec![10, 20, 30]);
3822        assert!((fused[0].1 - 1.0 / 60.0).abs() < 1e-12);
3823        assert!((fused[1].1 - 1.0 / 61.0).abs() < 1e-12);
3824        assert!((fused[2].1 - 1.0 / 62.0).abs() < 1e-12);
3825        // Contribution strictly decreases with rank.
3826        assert!(fused[0].1 > fused[1].1 && fused[1].1 > fused[2].1);
3827    }
3828
3829    #[test]
3830    fn rrf_rewards_agreement_across_legs() {
3831        // seq 1 tops both legs; seq 2 only in leg A; seq 3 only in leg B.
3832        let a = [1i64, 2];
3833        let b = [1i64, 3];
3834        let fused = rrf_fuse(&[&a, &b]);
3835        // seq 1 accrues 2/60 and must rank first.
3836        assert_eq!(fused[0].0, 1);
3837        let top = fused.iter().find(|(s, _)| *s == 1).unwrap().1;
3838        assert!((top - 2.0 / 60.0).abs() < 1e-12);
3839        // A doc in only one leg cannot beat the doc endorsed by both.
3840        let two = fused.iter().find(|(s, _)| *s == 2).unwrap().1;
3841        assert!(top > two);
3842    }
3843
3844    #[test]
3845    fn rrf_breaks_ties_by_seq_desc() {
3846        // Two seqs at rank 0 of their own leg -> equal scores; the larger seq
3847        // id sorts first, matching the production tie-break.
3848        let l1 = [5i64];
3849        let l2 = [9i64];
3850        let fused = rrf_fuse(&[&l1, &l2]);
3851        assert!((fused[0].1 - fused[1].1).abs() < 1e-12);
3852        assert_eq!(fused[0].0, 9);
3853        assert_eq!(fused[1].0, 5);
3854    }
3855
3856    // ---- rule-based query expansion (pure, deterministic) --------------
3857
3858    #[test]
3859    fn english_expander_substitutes_synonyms() {
3860        let ex = EnglishExpander::default();
3861        let v = ex.expand("cell");
3862        assert!(v.contains(&"mobile".to_string()));
3863        assert!(v.contains(&"phone".to_string()));
3864        // The original query is never echoed back as a variant.
3865        assert!(!v.contains(&"cell".to_string()));
3866    }
3867
3868    #[test]
3869    fn english_expander_stems_and_is_bounded() {
3870        let ex = EnglishExpander::new(4);
3871        // Plural -> singular stem bridges the vocabulary gap.
3872        assert!(ex.expand("cars").contains(&"car".to_string()));
3873        // Empty query yields no variants.
3874        assert!(ex.expand("").is_empty());
3875        // Variant count honors the cap.
3876        assert!(ex.expand("cell phone email car").len() <= 4);
3877    }
3878
3879    // ---- HLC monotonicity + dictionary (need a live store handle) ------
3880
3881    fn open_tmp() -> (DejaDB, TempDir) {
3882        let dir = TempDir::new().unwrap();
3883        let path = dir.path().join("unit.db");
3884        let db = DejaDB::open(path.to_str().unwrap()).unwrap();
3885        (db, dir)
3886    }
3887
3888    #[test]
3889    fn next_hlc_is_strictly_monotonic_within_one_ms() {
3890        let (mut db, _d) = open_tmp();
3891        // Force the "same wall-clock millisecond" branch deterministically:
3892        // seed hlc_last far in the future so `wall <= hlc_last` on every call,
3893        // proving the in-memory +1 counter alone keeps HLCs strictly
3894        // increasing without any wall-clock advance (hence no sleep needed).
3895        db.hlc_last = (now_ms() + 1_000_000) << 16;
3896        let a = db.next_hlc();
3897        let b = db.next_hlc();
3898        let c = db.next_hlc();
3899        assert_eq!(b, a + 1);
3900        assert_eq!(c, b + 1);
3901    }
3902
3903    #[test]
3904    fn next_hlc_tracks_wall_clock_when_it_advances() {
3905        let (mut db, _d) = open_tmp();
3906        let before = now_ms();
3907        db.hlc_last = 0;
3908        let first = db.next_hlc();
3909        let after = now_ms();
3910        // With a zero baseline the wall clock (ms << 16) dominates: the top
3911        // bits carry the millisecond of the call.
3912        assert!(first >> 16 >= before && first >> 16 <= after);
3913    }
3914
3915    #[test]
3916    fn next_hlc_never_repeats_over_many_calls() {
3917        let (mut db, _d) = open_tmp();
3918        let mut last = db.next_hlc();
3919        for _ in 0..5000 {
3920            let n = db.next_hlc();
3921            assert!(n > last, "HLC must strictly increase: {n} !> {last}");
3922            last = n;
3923        }
3924    }
3925
3926    #[test]
3927    fn term_id_interns_and_reverse_scans() {
3928        let (mut db, _d) = open_tmp();
3929        let a = db.term_id("alice").unwrap();
3930        let b = db.term_id("bob").unwrap();
3931        assert_ne!(a, b);
3932        // Re-interning a known term is a cache hit -> same id.
3933        assert_eq!(db.term_id("alice").unwrap(), a);
3934        // Forward lookup.
3935        assert_eq!(db.term_lookup("alice"), Some(a));
3936        assert_eq!(db.term_lookup("bob"), Some(b));
3937        assert_eq!(db.term_lookup("nobody"), None);
3938        // Reverse scan (id -> term).
3939        assert_eq!(db.term_str(a).as_deref(), Some("alice"));
3940        assert_eq!(db.term_str(b).as_deref(), Some("bob"));
3941        assert_eq!(db.term_str(999_999), None);
3942    }
3943
3944    #[test]
3945    fn term_ids_persist_and_continue_across_reopen() {
3946        let dir = TempDir::new().unwrap();
3947        let path = dir.path().join("dict.db");
3948        let path = path.to_str().unwrap();
3949        let (a, b);
3950        {
3951            let mut db = DejaDB::open(path).unwrap();
3952            a = db.term_id("x").unwrap();
3953            b = db.term_id("y").unwrap();
3954            assert!(b > a);
3955        }
3956        // Reopen: the dictionary reloads, existing terms keep their ids, and a
3957        // fresh term gets an id beyond the previous max (next_term continues).
3958        {
3959            let mut db = DejaDB::open(path).unwrap();
3960            assert_eq!(db.term_lookup("x"), Some(a));
3961            assert_eq!(db.term_lookup("y"), Some(b));
3962            let c = db.term_id("z").unwrap();
3963            assert!(c > b);
3964            assert_eq!(db.term_str(c).as_deref(), Some("z"));
3965        }
3966    }
3967}